@stevecastaneda/paseo-progress 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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/client/attachments.tsx +95 -0
- package/client/dashboard-query.ts +32 -0
- package/client/deliverables.tsx +67 -0
- package/client/dialog-stack.tsx +104 -0
- package/client/directory.ts +90 -0
- package/client/launcher.tsx +111 -0
- package/client/motion.tsx +173 -0
- package/client/panel-opened.ts +13 -0
- package/client/panel.tsx +480 -0
- package/client/pill-popover.tsx +122 -0
- package/client/pill-state.ts +34 -0
- package/client/pills.tsx +235 -0
- package/client/preview.tsx +228 -0
- package/client/questions.tsx +248 -0
- package/client/row.tsx +34 -0
- package/client/section-title.tsx +24 -0
- package/client/show-more.tsx +37 -0
- package/client/spinner.tsx +35 -0
- package/client/status-badge.tsx +30 -0
- package/client/surfaces.ts +45 -0
- package/client/ticket-dialog.tsx +203 -0
- package/client/when.tsx +15 -0
- package/dist/server/cli.js +528 -0
- package/dist/server/launcher.js +90 -0
- package/dist/server/lock.js +34 -0
- package/dist/server/paths.js +6 -0
- package/dist/server/progress-file.js +123 -0
- package/dist/shared/attachments.js +86 -0
- package/dist/shared/dashboard.js +369 -0
- package/dist/shared/events.js +117 -0
- package/dist/shared/format.js +37 -0
- package/dist/shared/show.js +72 -0
- package/images/1-panel.png +0 -0
- package/images/2-ticket.png +0 -0
- package/images/3-question.png +0 -0
- package/images/4-pill.png +0 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +19 -0
- package/npm-shrinkwrap.json +4041 -0
- package/package.json +62 -0
- package/paseo-plugin.json +7 -0
- package/server/cli.ts +541 -0
- package/server/dashboard.ts +46 -0
- package/server/launcher.ts +97 -0
- package/server/lock.ts +32 -0
- package/server/open.ts +107 -0
- package/server/paths.ts +7 -0
- package/server/progress-file.ts +141 -0
- package/server/skill.ts +85 -0
- package/shared/attachments.ts +100 -0
- package/shared/dashboard.ts +491 -0
- package/shared/events.ts +131 -0
- package/shared/format.ts +39 -0
- package/shared/rpc.ts +109 -0
- package/shared/show.ts +68 -0
- package/shared/ticket-story.ts +93 -0
- package/skills/paseo-progress/SKILL.md +79 -0
- package/tsconfig.json +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Castaneda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Progress
|
|
2
|
+
|
|
3
|
+
A plugin for Paseo 0.9 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to `.scratch/progress.jsonl` in the worktree. The **Progress** panel draws the dashboard from that file and updates by itself.
|
|
4
|
+
|
|
5
|
+
<img src="images/1-panel.png" alt="The Progress panel in Explorer" width="400">
|
|
6
|
+
|
|
7
|
+
The dashboard shows:
|
|
8
|
+
|
|
9
|
+
- the run's title, a headline ("2 of 4 tickets done, 5 questions waiting for you, 1 stuck"), a one-line "now" ticker, and when it was last updated
|
|
10
|
+
- a "possibly stale" warning after 15 minutes with no update while tickets remain, unless Paseo shows an agent in the workspace still running (for example one waiting on its subagents), and never once every ticket is done or skipped or the run is finished; working items swap their spinner for a pulsing amber warning
|
|
11
|
+
- percent of estimated work done, time done of time estimated (like "1 h 30 min of 2 h"; once everything is done, how long it really took), and a bar with one segment per ticket
|
|
12
|
+
- **Stuck:** blocked tickets, working tickets with no update for longer than their estimate, and blockers the agent flags
|
|
13
|
+
- **Tickets:** estimate, status, and the working ticket's stage. A ticket that waits for another (`--waits-for T03`) shows an hourglass and "Waits for Ticket 03" until that one is done; waiting is the order of work, so it never counts as stuck, even if the agent also marked it blocked. Press a ticket for its story in a dialog: a timeline of its stages and statuses with how long each took, notes, time worked against the estimate, and the deliverables, questions and activity that belong to it. Items tagged with `--ticket` belong exactly; older, untagged ones are matched by when they happened and marked "by time"
|
|
14
|
+
- **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the default the agent is using. Press a question to open it in a dialog, then press **Copy Q2 B** to copy `Q2 (title): B` for your reply. Files and links the agent attached (`--file`) open like deliverables: images and text files preview in Paseo (with **Back** to the question), links open in Paseo's browser, and other files open in their default app. From the pill's popover, the preview opens over the popover, which stays open so you can go back to it. Answers stay listed under the same reference.
|
|
15
|
+
- **Activity**, **Answered** questions and **Deliverables** (press one to open it), sharing one card with a tab for each; the panel remembers the tab you picked. Each shows the newest 10, with **Show 10 older** below
|
|
16
|
+
|
|
17
|
+
The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. When questions are waiting or something is stuck, a pill above the message box shows it (`3 questions · 1 stuck`). Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
|
|
18
|
+
|
|
19
|
+
On phones, where Paseo has no Explorer pane, Progress opens as a tab of its own, and the pill stays above the message box while a run is open so there's always a way in. SVG files preview on desktop; on phones they open on the daemon host.
|
|
20
|
+
|
|
21
|
+
The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `.scratch/progress-panel-opened` and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
paseo plugin add npm:@stevecastaneda/paseo-progress
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or from GitHub: `paseo plugin add stevecastaneda/paseo-plugins --path progress`.
|
|
30
|
+
|
|
31
|
+
Enable plugins under **Settings → Plugins** on the Paseo host. You need Node.js 22.18 or later on the daemon host.
|
|
32
|
+
|
|
33
|
+
Then install the `paseo-progress` command agents run. Open **Progress** from Command Center and press **Install command** in the banner. It adds one file, `~/.local/bin/paseo-progress`, on the machine running the Paseo daemon, and the banner disappears once agents can run it. The plugin writes nothing until you press it, and it never replaces a file something else put at that path.
|
|
34
|
+
|
|
35
|
+
To install from a terminal instead, or to put the command somewhere else:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dir="$(paseo plugin ls progress --json | node -pe 'JSON.parse(require("fs").readFileSync(0))[0].path')"
|
|
39
|
+
node "$([ -f "$dir/dist/server/cli.js" ] && echo "$dir/dist/server/cli.js" || echo "$dir/server/cli.ts")" install-launcher [path]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If `paseo` isn't on your `PATH`, use `/Applications/Paseo.app/Contents/Resources/bin/paseo`. The command doesn't name a plugin folder: each run asks Paseo (`paseo plugin ls`) which copy of the plugin it is running and runs that copy, so it keeps working after updates and `npm run dev` switches. The npm package carries the command compiled to JavaScript in `dist/`, because Node won't run TypeScript from `node_modules`.
|
|
43
|
+
|
|
44
|
+
The plugin keeps each worktree's progress in `.scratch/progress.jsonl`. The first time it creates `.scratch/`, it also writes `.scratch/.gitignore` naming only its own files, so git ignores them without any change to your repository's `.gitignore`. If `.scratch/.gitignore` already exists, the plugin leaves it alone.
|
|
45
|
+
|
|
46
|
+
## Agent skill
|
|
47
|
+
|
|
48
|
+
The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions without stopping, flag stuck work, and finish. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. If Paseo later runs the plugin from somewhere else, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped.
|
|
49
|
+
|
|
50
|
+
From a terminal instead:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
skill="$(paseo plugin ls progress --json | node -pe 'JSON.parse(require("fs").readFileSync(0))[0].path')/skills/paseo-progress"
|
|
54
|
+
for dir in ~/.agents/skills ~/.claude/skills ~/.codex/skills; do mkdir -p "$dir" && ln -s "$skill" "$dir/paseo-progress"; done
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Recording progress
|
|
58
|
+
|
|
59
|
+
Run `paseo-progress --help` for every command, and `paseo-progress <command> --help` for one. Commands find the worktree root from the current directory.
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
paseo-progress start "Loan Options snapshots" --subtitle "4 tickets"
|
|
63
|
+
paseo-progress ticket add "Ticket 01: Saved table" --estimate 120 # Added T01
|
|
64
|
+
paseo-progress ticket update T01 --status working --stage Build
|
|
65
|
+
paseo-progress ticket update T01 --stage Fixes
|
|
66
|
+
paseo-progress ticket update T01 --status done
|
|
67
|
+
paseo-progress question ask "Row spacing" "Even out the card spacing?" \
|
|
68
|
+
--option "A=Even it out | Cards look balanced" \
|
|
69
|
+
--option "B=Leave it | No change" \
|
|
70
|
+
--default B --raised-by "Ticket 01 design review" # Asked Q1
|
|
71
|
+
paseo-progress question answer Q1 A --words "Even it out."
|
|
72
|
+
paseo-progress deliverable add "Browser check screenshots" .scratch/shots/ --ticket T01
|
|
73
|
+
paseo-progress activity add "Saved table matches the design now." --ticket T01
|
|
74
|
+
paseo-progress ticker set "Running the browser check"
|
|
75
|
+
paseo-progress stuck set "Staging is down" --ticket T01 # Flagged S1
|
|
76
|
+
paseo-progress show
|
|
77
|
+
paseo-progress finish "Snapshots ship for all four tables"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- Statuses are `not_started`, `working`, `blocked`, `done`, and `skipped`. Skipped tickets leave the totals.
|
|
81
|
+
- A working ticket is stuck once it goes longer than its estimate without an update, timed from its last status, stage or note change, or activity tagged to it. The ticket's dialog still shows total time worked against the estimate.
|
|
82
|
+
- `question ask --waits` marks a question the agent won't act on until you answer. Its default shows in amber.
|
|
83
|
+
- Deliverable paths are stored relative to the worktree root. Web links open in Paseo's browser. Pressing an image (PNG, JPEG, GIF, WebP, up to 10 MB) or text deliverable (Markdown, text, logs, JSON, YAML, CSV; the first 256 KB) previews it in a dialog inside Paseo, which also works from a phone; **Open** there opens the file in its default app. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser and folders in the file manager). On macOS, **Open** sends text files to your default browser. Only recorded deliverables inside the worktree open this way; otherwise the path is copied. The copy icon on each row copies the path.
|
|
84
|
+
- `start` begins a fresh dashboard. Earlier runs stay in the file. Question references stay unique across runs.
|
|
85
|
+
- `finish "<outcome>"` closes the run once every ticket is done or skipped, no question is open, and nothing is flagged stuck; otherwise it says what is left. The run stays on the dashboard, marked Finished with its outcome, and takes no more updates, so the next agent in the worktree starts a new run instead of adding to it.
|
|
86
|
+
|
|
87
|
+
## The progress file
|
|
88
|
+
|
|
89
|
+
`.scratch/progress.jsonl` holds one JSON event per line, each with a version (`v`), a UTC timestamp (`ts`) from the real clock, and a `type`. It is only ever appended to, so you can read or diff the history. A half-written last line is ignored. Other bad lines are skipped and listed in a small notice in the panel while the rest still renders. A lock keeps two commands from writing at once or handing out the same id.
|
|
90
|
+
|
|
91
|
+
## Limitations
|
|
92
|
+
|
|
93
|
+
- Nothing updates unless the agent runs `paseo-progress`. Agents need the skill, and an agent that skips the command leaves the dashboard behind.
|
|
94
|
+
- The command runs on the machine that hosts the Paseo daemon, and it needs Node.js 22.18 or later there.
|
|
95
|
+
- Each worktree keeps its own dashboard in `.scratch/progress.jsonl`. There's no view across worktrees.
|
|
96
|
+
- Paseo can't open a panel without switching you to its workspace. So a new run shows a "Progress" pill, and you open the panel yourself.
|
|
97
|
+
- Pills check busy worktrees every 5 seconds and quiet ones every 30. In a quiet worktree, a new question can take up to half a minute to show.
|
|
98
|
+
- Previews cover images up to 10 MB and the first 256 KB of a text file. Other files open in their default app.
|
|
99
|
+
- Stale warnings and overdue times use the daemon host's clock.
|
|
100
|
+
|
|
101
|
+
## Local development
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
cd progress
|
|
105
|
+
npm install
|
|
106
|
+
npm run dev
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`npm run dev` type-checks and tests the plugin, then points Paseo at this folder. The tests run the agent's commands in a throwaway worktree and read back the dashboard the panel would get.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { openExternalUrl, type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Platform, Text, View } from "react-native";
|
|
5
|
+
import { type Attachment, attachmentIcon, canPreview, shortName } from "../shared/attachments";
|
|
6
|
+
import { PressableRow } from "./row";
|
|
7
|
+
import { openDeliverable } from "../shared/rpc";
|
|
8
|
+
|
|
9
|
+
export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName } from "../shared/attachments";
|
|
10
|
+
|
|
11
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
|
+
type Navigation = PluginWorkspacePanelProps["navigation"];
|
|
13
|
+
|
|
14
|
+
// Shown in Paseo's preview dialog rather than handed to another app.
|
|
15
|
+
export function isPreviewable(attachment: Attachment): boolean {
|
|
16
|
+
return canPreview(attachment, { browser: Platform.OS === "web" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Full path on the daemon host for a stored path.
|
|
20
|
+
export function absolutePath(path: string, workspaceDirectory: string): string {
|
|
21
|
+
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return path;
|
|
22
|
+
const base = workspaceDirectory.replace(/[\\/]+$/, "");
|
|
23
|
+
return path === "./" ? `${base}/` : `${base}/${path}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Opens attachments the same way everywhere. Web links go to Paseo's browser
|
|
27
|
+
// (the system browser where there is none); images and text go to `onPreview` when the
|
|
28
|
+
// caller can show the preview dialog; every other local file opens with its
|
|
29
|
+
// default app on the daemon host.
|
|
30
|
+
export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview }: {
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
workspaceDirectory: string;
|
|
33
|
+
navigation?: Navigation;
|
|
34
|
+
onPreview?(attachment: Attachment): void;
|
|
35
|
+
}) {
|
|
36
|
+
const toast = useToast();
|
|
37
|
+
const openOnHostRpc = useRpc(openDeliverable);
|
|
38
|
+
async function copyPath(attachment: Attachment, prefix = "") {
|
|
39
|
+
try {
|
|
40
|
+
await copyText(absolutePath(attachment.path!, workspaceDirectory));
|
|
41
|
+
toast.show(`${prefix}Copied path`, { variant: prefix ? "warning" : "success" });
|
|
42
|
+
} catch {
|
|
43
|
+
toast.error("Could not copy. Select the path and use Copy.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function openOnHost(attachment: Attachment) {
|
|
47
|
+
try {
|
|
48
|
+
await openOnHostRpc({ workspaceId, workspaceDirectory, ref: attachment.ref });
|
|
49
|
+
toast.show(`Opened ${attachment.title}`, { variant: "success" });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await copyPath(attachment, `${error instanceof Error ? error.message : "Could not open it"}. `);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function open(attachment: Attachment) {
|
|
55
|
+
if (attachment.url) {
|
|
56
|
+
try {
|
|
57
|
+
if (navigation?.openBrowser) navigation.openBrowser({ url: attachment.url, workspaceId });
|
|
58
|
+
else await openExternalUrl(attachment.url);
|
|
59
|
+
} catch {
|
|
60
|
+
toast.error("Could not open this link.");
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (onPreview && isPreviewable(attachment)) {
|
|
65
|
+
onPreview(attachment);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await openOnHost(attachment);
|
|
69
|
+
}
|
|
70
|
+
return { open, openOnHost, copyPath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A question's attachments as pressable rows.
|
|
74
|
+
// Drawn in dialogs and the popover (surface1) unless `onSurface1` is false.
|
|
75
|
+
// `onOpen` also gets the whole list, so a preview can step through its siblings.
|
|
76
|
+
export function AttachmentList({ colors, attachments, onOpen, onSurface1 = true }: { colors: Colors; attachments: Attachment[]; onOpen(attachment: Attachment, group: Attachment[]): void; onSurface1?: boolean }) {
|
|
77
|
+
if (!attachments.length) return null;
|
|
78
|
+
return (
|
|
79
|
+
<View style={{ gap: 2 }}>
|
|
80
|
+
{attachments.map((attachment) => (
|
|
81
|
+
<PressableRow key={attachment.ref} colors={colors} onSurface1={onSurface1} accessibilityRole={attachment.url ? "link" : "button"} accessibilityLabel={`Open ${attachment.title}`}
|
|
82
|
+
onPress={() => onOpen(attachment, attachments)}
|
|
83
|
+
style={{ flexDirection: "row", gap: 6, alignItems: "center", paddingVertical: 3, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
|
|
84
|
+
{/* A 14px slot, like a section icon, so the title lines up with the heading above. */}
|
|
85
|
+
<View style={{ width: 14, alignItems: "center" }}><Icon name={attachmentIcon(attachment)} size={12} color={colors.foregroundMuted} /></View>
|
|
86
|
+
{/* The row is the target, so nothing in it is styled as a link. */}
|
|
87
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foreground, fontSize: 12, lineHeight: 17 }}>{attachment.title}</Text>
|
|
88
|
+
{shortName(attachment) !== attachment.title ? (
|
|
89
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>{shortName(attachment)}</Text>
|
|
90
|
+
) : <View style={{ flex: 1 }} />}
|
|
91
|
+
</PressableRow>
|
|
92
|
+
))}
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import type { RpcOutput } from "@getpaseo/plugin";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { type DashboardResult, getDashboard } from "../shared/rpc";
|
|
5
|
+
|
|
6
|
+
type FetchDashboard = (input: { workspaceId: string; workspaceDirectory: string; since?: string }) => Promise<RpcOutput<typeof getDashboard>>;
|
|
7
|
+
|
|
8
|
+
export const POLL_MS = 3_000;
|
|
9
|
+
|
|
10
|
+
// `previous` is what the cache already holds. Polls send its version, and an
|
|
11
|
+
// "unchanged" reply keeps that same object, so nothing re-renders.
|
|
12
|
+
export function dashboardQueryOptions(fetchDashboard: FetchDashboard, hostId: string, workspaceId: string, directory: string | null, previous?: () => DashboardResult | undefined) {
|
|
13
|
+
return {
|
|
14
|
+
queryKey: ["progress", "dashboard", hostId, workspaceId, directory],
|
|
15
|
+
queryFn: async (): Promise<DashboardResult> => {
|
|
16
|
+
const last = previous?.();
|
|
17
|
+
const result = await fetchDashboard({ workspaceId, workspaceDirectory: directory!, ...(last ? { since: last.version } : {}) });
|
|
18
|
+
return "unchanged" in result && last ? last : result as DashboardResult;
|
|
19
|
+
},
|
|
20
|
+
enabled: Boolean(directory),
|
|
21
|
+
staleTime: POLL_MS,
|
|
22
|
+
refetchInterval: POLL_MS,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useDashboard(hostId: string, workspaceId: string, directory: string | null) {
|
|
27
|
+
const fetchDashboard = useRpc(getDashboard);
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
const options = dashboardQueryOptions(fetchDashboard, hostId, workspaceId, directory,
|
|
30
|
+
() => queryClient.getQueryData<DashboardResult>(options.queryKey));
|
|
31
|
+
return useQuery(options);
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import type { Deliverable } from "../shared/dashboard";
|
|
6
|
+
import { type Attachment, attachmentIcon, deliverableAttachment, shortName, useAttachmentOpener } from "./attachments";
|
|
7
|
+
import { formatAgo } from "../shared/format";
|
|
8
|
+
import { PressScale } from "./motion";
|
|
9
|
+
import { PreviewDialog, previewable } from "./preview";
|
|
10
|
+
import { PressableRow } from "./row";
|
|
11
|
+
import { ShowMoreRow, usePaged } from "./show-more";
|
|
12
|
+
|
|
13
|
+
export { absolutePath } from "./attachments";
|
|
14
|
+
|
|
15
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
16
|
+
type Navigation = PluginWorkspacePanelProps["navigation"];
|
|
17
|
+
|
|
18
|
+
// The Deliverables tab: newest first, 10 at a time (see usePaged).
|
|
19
|
+
export function DeliverablesList({ colors, deliverables, now, workspaceId, workspaceDirectory, navigation }: {
|
|
20
|
+
colors: Colors;
|
|
21
|
+
now: number;
|
|
22
|
+
deliverables: Deliverable[];
|
|
23
|
+
workspaceId: string;
|
|
24
|
+
workspaceDirectory: string;
|
|
25
|
+
navigation: Navigation;
|
|
26
|
+
}) {
|
|
27
|
+
const [previewing, setPreviewing] = useState<Attachment | null>(null);
|
|
28
|
+
// Screenshots preview inside Paseo; links open in its browser; the rest open in their own app.
|
|
29
|
+
const { open, openOnHost, copyPath } = useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview: setPreviewing });
|
|
30
|
+
const paged = usePaged(deliverables);
|
|
31
|
+
return (
|
|
32
|
+
<View>
|
|
33
|
+
{paged.shown.map((deliverable, index) => (
|
|
34
|
+
<PressableRow key={deliverable.id} colors={colors} accessibilityRole={deliverable.url ? "link" : "button"}
|
|
35
|
+
accessibilityLabel={`Open ${deliverable.title}`}
|
|
36
|
+
onPress={() => void open(deliverableAttachment(deliverable))}
|
|
37
|
+
style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
38
|
+
{/* Optical: centers the 14px icon on the 18px title line. */}
|
|
39
|
+
<View style={{ paddingTop: 2 }}><Icon name={attachmentIcon(deliverableAttachment(deliverable))} size={14} color={colors.foregroundMuted} /></View>
|
|
40
|
+
<View style={{ flex: 1, minWidth: 0, gap: 1 }}>
|
|
41
|
+
{/* What the agent found is the headline; the row itself is what opens. */}
|
|
42
|
+
<Text numberOfLines={3} style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{deliverable.title}</Text>
|
|
43
|
+
{/* Just the name, ticket and age. The full path is one press of Copy away. */}
|
|
44
|
+
<Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
45
|
+
{[shortName(deliverableAttachment(deliverable)), deliverable.ticketLabel ?? deliverable.ticketId, formatAgo(deliverable.addedAt, now)].filter(Boolean).join(" · ")}
|
|
46
|
+
</Text>
|
|
47
|
+
</View>
|
|
48
|
+
{deliverable.path ? (
|
|
49
|
+
// Optical: the 22px button centers on the 18px title line.
|
|
50
|
+
<PressScale accessibilityRole="button" accessibilityLabel={`Copy the path of ${deliverable.title}`} hitSlop={6} outerStyle={{ marginTop: -2 }}
|
|
51
|
+
onPress={() => void copyPath(deliverableAttachment(deliverable))}
|
|
52
|
+
// A step above the row's own hover tint (surface1), so it reads as its own target.
|
|
53
|
+
style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
54
|
+
<Icon name="Copy" size={14} color={colors.foregroundMuted} />
|
|
55
|
+
</PressScale>
|
|
56
|
+
) : (
|
|
57
|
+
<View style={{ paddingTop: 2 }}><Icon name="ExternalLink" size={14} color={colors.foregroundMuted} /></View>
|
|
58
|
+
)}
|
|
59
|
+
</PressableRow>
|
|
60
|
+
))}
|
|
61
|
+
<ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
|
|
62
|
+
<PreviewDialog colors={colors} attachment={previewing} workspaceId={workspaceId} workspaceDirectory={workspaceDirectory}
|
|
63
|
+
onClose={() => setPreviewing(null)} onOpenOnHost={(attachment) => void openOnHost(attachment)}
|
|
64
|
+
gallery={{ items: previewable(deliverables.map(deliverableAttachment)), onSelect: setPreviewing }} />
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { Dashboard, Question } from "../shared/dashboard";
|
|
5
|
+
import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
|
|
6
|
+
import { useLastPresent } from "./motion";
|
|
7
|
+
import { BackButton, PreviewContent, previewable } from "./preview";
|
|
8
|
+
import { QuestionView, useCopy } from "./questions";
|
|
9
|
+
import { TicketStoryView } from "./ticket-dialog";
|
|
10
|
+
|
|
11
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
|
+
type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
|
|
13
|
+
|
|
14
|
+
// What the dialog is showing. A preview keeps the files beside it for Previous and Next.
|
|
15
|
+
export type Step =
|
|
16
|
+
| { kind: "ticket"; id: string }
|
|
17
|
+
| { kind: "question"; id: string }
|
|
18
|
+
| { kind: "preview"; attachment: Attachment; group: Attachment[] };
|
|
19
|
+
|
|
20
|
+
// One dialog for a ticket or a question and everything opened from it. Paseo
|
|
21
|
+
// gives each dialog its own backdrop, so opening a second one darkens the screen
|
|
22
|
+
// while the first fades out; instead the content changes in place, and Back
|
|
23
|
+
// returns to the step before.
|
|
24
|
+
export function StackedDialog({ colors, root, onClose, questions, dashboard, now, live = true, context }: {
|
|
25
|
+
colors: Colors;
|
|
26
|
+
// The ticket or question it opens on; null closes it.
|
|
27
|
+
root: Step | null;
|
|
28
|
+
onClose(): void;
|
|
29
|
+
questions: Question[];
|
|
30
|
+
// Needed only when it opens on a ticket.
|
|
31
|
+
dashboard?: Dashboard;
|
|
32
|
+
now: number;
|
|
33
|
+
live?: boolean;
|
|
34
|
+
context: AttachmentContext;
|
|
35
|
+
}) {
|
|
36
|
+
// What was opened on top of the root, oldest first. Cleared when a new root opens.
|
|
37
|
+
const [trail, setTrail] = useState<Step[]>([]);
|
|
38
|
+
const rootKey = root && root.kind !== "preview" ? `${root.kind}:${root.id}` : null;
|
|
39
|
+
const [trailFor, setTrailFor] = useState(rootKey);
|
|
40
|
+
if (trailFor !== rootKey) {
|
|
41
|
+
setTrailFor(rootKey);
|
|
42
|
+
if (rootKey) setTrail([]);
|
|
43
|
+
}
|
|
44
|
+
// The list an attachment was opened from, so its preview can step through the rest.
|
|
45
|
+
const group = useRef<Attachment[]>([]);
|
|
46
|
+
const push = (step: Step) => setTrail((current) => [...current, step]);
|
|
47
|
+
const replaceTop = (step: Step) => setTrail((current) => [...current.slice(0, -1), step]);
|
|
48
|
+
const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: previewable(group.current) }) });
|
|
49
|
+
const openAttachment = (attachment: Attachment, from: Attachment[]) => {
|
|
50
|
+
group.current = from;
|
|
51
|
+
void opener.open(attachment);
|
|
52
|
+
};
|
|
53
|
+
const copy = useCopy();
|
|
54
|
+
const steps: Step[] = root ? [root, ...trail] : [];
|
|
55
|
+
// Keep drawing the last step while the dialog fades out.
|
|
56
|
+
const shown = useLastPresent(steps.length ? { top: steps[steps.length - 1], previous: steps[steps.length - 2] } : null);
|
|
57
|
+
const back = shown?.previous ? { label: `Back to ${stepName(shown.previous)}`, onPress: () => setTrail((current) => current.slice(0, -1)) } : null;
|
|
58
|
+
|
|
59
|
+
const top = shown?.top;
|
|
60
|
+
const ticket = top?.kind === "ticket" ? dashboard?.tickets.find((candidate) => candidate.id === top.id) : undefined;
|
|
61
|
+
const question = top?.kind === "question" ? questions.find((candidate) => candidate.id === top.id) : undefined;
|
|
62
|
+
const preview = top?.kind === "preview" ? top : undefined;
|
|
63
|
+
// The ticket or question it opened on went away (answered elsewhere, or a new run): close rather than show a blank dialog.
|
|
64
|
+
const rootMissing = root !== null && (root.kind === "ticket" ? !dashboard?.tickets.some((candidate) => candidate.id === root.id)
|
|
65
|
+
: root.kind === "question" ? !questions.some((candidate) => candidate.id === root.id) : false);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (rootMissing) onClose();
|
|
68
|
+
}, [rootMissing]);
|
|
69
|
+
const [previewLong, setPreviewLong] = useState(false);
|
|
70
|
+
const title = ticket ? `${ticket.id} · ${ticket.title}` : question ? `${question.id} · ${question.title}` : preview ? preview.attachment.title : "";
|
|
71
|
+
const icon = ticket ? "ListChecks" : question ? "MessageCircleQuestion" : preview ? attachmentIcon(preview.attachment) : "ListChecks";
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Modal title={title} icon={<Icon name={icon} size={16} color={colors.foregroundMuted} />}
|
|
75
|
+
open={Boolean(root)} onOpenChange={(next) => { if (!next) onClose(); }}>
|
|
76
|
+
{/* One Modal.Content for every step: replacing it would re-present the sheet on phones. */}
|
|
77
|
+
<Modal.Content scrollable={!(preview && previewLong)}>
|
|
78
|
+
{preview ? (
|
|
79
|
+
<PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
|
|
80
|
+
onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }} backLabel={back?.label} onBack={back?.onPress}
|
|
81
|
+
gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong} />
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
{ticket && dashboard ? (
|
|
85
|
+
<TicketStoryView colors={colors} dashboard={dashboard} ticket={ticket} now={now} live={live}
|
|
86
|
+
onOpenAttachment={openAttachment} onOpenQuestion={(id) => push({ kind: "question", id })} />
|
|
87
|
+
) : null}
|
|
88
|
+
{question ? (
|
|
89
|
+
<QuestionView colors={colors} question={question} now={now}
|
|
90
|
+
onCopy={(letter) => { void copy(question, letter); onClose(); }}
|
|
91
|
+
onOpenAttachment={openAttachment}>
|
|
92
|
+
{back ? <BackButton colors={colors} label={back.label} onPress={back.onPress} /> : null}
|
|
93
|
+
</QuestionView>
|
|
94
|
+
) : null}
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</Modal.Content>
|
|
98
|
+
</Modal>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stepName(step: Step): string {
|
|
103
|
+
return step.kind === "preview" ? step.attachment.title : step.id;
|
|
104
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
|
2
|
+
|
|
3
|
+
type Page = {
|
|
4
|
+
entries: unknown[];
|
|
5
|
+
pageInfo: { hasMore: boolean; nextCursor?: string | null };
|
|
6
|
+
};
|
|
7
|
+
type Message = Parameters<SubscriptionObserver<unknown>["update"]>[0];
|
|
8
|
+
|
|
9
|
+
// Paseo 0.9 returns an owned subscription from list({ subscribe: {} }). It
|
|
10
|
+
// delivers the first page as a snapshot, again after every reconnect, then the
|
|
11
|
+
// directory updates. Releasing it ends the daemon observation.
|
|
12
|
+
export function observeDirectory<Result extends Page & { subscription?: OwnedSubscription<Result> }, Update>(options: {
|
|
13
|
+
list(options: { subscribe?: {}; page?: { cursor: string; limit: number } }): Promise<Result>;
|
|
14
|
+
select(message: Message): Update | undefined;
|
|
15
|
+
snapshot(entries: Result["entries"]): void;
|
|
16
|
+
update(update: Update): void;
|
|
17
|
+
}) {
|
|
18
|
+
let stopped = false;
|
|
19
|
+
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
let subscription: OwnedSubscription<Result> | undefined;
|
|
21
|
+
let generation = 0;
|
|
22
|
+
let buffered: Update[] | null = null;
|
|
23
|
+
const release = () => {
|
|
24
|
+
void subscription?.release().catch(() => undefined);
|
|
25
|
+
subscription = undefined;
|
|
26
|
+
};
|
|
27
|
+
const restart = (error: unknown) => {
|
|
28
|
+
generation++;
|
|
29
|
+
buffered = null;
|
|
30
|
+
release();
|
|
31
|
+
if (stopped) return;
|
|
32
|
+
console.error("Directory observation failed; retrying", error);
|
|
33
|
+
retry = setTimeout(() => void start(), 2_000);
|
|
34
|
+
};
|
|
35
|
+
const load = async (first: Result) => {
|
|
36
|
+
const run = ++generation;
|
|
37
|
+
buffered = [];
|
|
38
|
+
const entries = [...first.entries];
|
|
39
|
+
let page: Page = first;
|
|
40
|
+
try {
|
|
41
|
+
while (page.pageInfo.hasMore && page.pageInfo.nextCursor) {
|
|
42
|
+
page = await options.list({ page: { cursor: page.pageInfo.nextCursor, limit: 100 } });
|
|
43
|
+
if (stopped || run !== generation) return;
|
|
44
|
+
entries.push(...page.entries);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (run === generation) restart(error);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (stopped || run !== generation) return;
|
|
51
|
+
// Replay changes received during pagination after the snapshot so late
|
|
52
|
+
// reads cannot resurrect removed targets or overwrite newer updates.
|
|
53
|
+
const pending = buffered ?? [];
|
|
54
|
+
buffered = null;
|
|
55
|
+
options.snapshot(entries);
|
|
56
|
+
for (const update of pending) {
|
|
57
|
+
if (stopped || run !== generation) break;
|
|
58
|
+
options.update(update);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const start = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const result = await options.list({ subscribe: {} });
|
|
64
|
+
if (!result.subscription) throw new Error("Paseo did not return a directory subscription");
|
|
65
|
+
subscription = result.subscription;
|
|
66
|
+
if (stopped) return release();
|
|
67
|
+
subscription.subscribe({
|
|
68
|
+
snapshot: (first) => void load(first),
|
|
69
|
+
update: (message) => {
|
|
70
|
+
const update = options.select(message);
|
|
71
|
+
if (stopped || update === undefined) return;
|
|
72
|
+
if (buffered) buffered.push(update);
|
|
73
|
+
else options.update(update);
|
|
74
|
+
},
|
|
75
|
+
error: restart,
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
restart(error);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
void start();
|
|
82
|
+
return () => {
|
|
83
|
+
if (stopped) return;
|
|
84
|
+
stopped = true;
|
|
85
|
+
if (retry) clearTimeout(retry);
|
|
86
|
+
generation++;
|
|
87
|
+
buffered = null;
|
|
88
|
+
release();
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import { PressScale, Presence } from "./motion";
|
|
7
|
+
import { raised } from "./surfaces";
|
|
8
|
+
import { getLauncherStatus, getSkillStatus, installLauncher, installSkill, type LauncherStatus, type SkillStatus } from "../shared/rpc";
|
|
9
|
+
|
|
10
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
11
|
+
|
|
12
|
+
export function launcherQueryKey(hostId: string) {
|
|
13
|
+
return ["progress", "launcher", hostId];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function skillQueryKey(hostId: string) {
|
|
17
|
+
return ["progress", "skill", hostId];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// What the banner says, or null when agents can already run the command.
|
|
21
|
+
export function launcherNotice(status: LauncherStatus): { text: string; action?: string } | null {
|
|
22
|
+
switch (status.state) {
|
|
23
|
+
case "missing":
|
|
24
|
+
return { text: `Agents record progress with the paseo-progress command, which isn't installed yet. Installing adds one file: ${status.path}`, action: "Install command" };
|
|
25
|
+
case "outdated":
|
|
26
|
+
return { text: `An older paseo-progress command is installed at ${status.path}.`, action: "Update command" };
|
|
27
|
+
case "foreign":
|
|
28
|
+
return { text: `Something else already exists at ${status.path}, so the plugin won't install there. The README shows how to install it elsewhere.` };
|
|
29
|
+
case "current":
|
|
30
|
+
return status.onPath ? null : { text: `paseo-progress is installed, but its folder isn't on the PATH Paseo uses, so agents need the full path: ${status.path}` };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// What the skill banner says, or null when every agent folder has the skill.
|
|
35
|
+
export function skillNotice(status: SkillStatus): { text: string; action?: string } | null {
|
|
36
|
+
const folders = (state: string) => status.targets.filter((target) => target.state === state).map((target) => target.path.replace(/\/paseo-progress$/, ""));
|
|
37
|
+
const skipped = folders("foreign").length ? ` Something else is already at ${folders("foreign").join(", ")}, so the plugin leaves it alone.` : "";
|
|
38
|
+
switch (status.state) {
|
|
39
|
+
case "missing":
|
|
40
|
+
return { text: `Agents learn when to record progress from the paseo-progress skill, which isn't installed yet. Installing adds a link in ${folders("missing").join(", ")}.${skipped}`, action: "Install skill" };
|
|
41
|
+
case "outdated":
|
|
42
|
+
return { text: `The paseo-progress skill in ${folders("outdated").join(", ")} points to a copy of the plugin Paseo no longer runs.${skipped}`, action: "Update skill" };
|
|
43
|
+
case "foreign":
|
|
44
|
+
return { text: `Something else already exists at every paseo-progress skill path, so the plugin won't install the skill.` };
|
|
45
|
+
case "current":
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Nothing is written until the user presses the button.
|
|
51
|
+
export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
52
|
+
const fetchStatus = useRpc(getLauncherStatus);
|
|
53
|
+
const install = useRpc(installLauncher);
|
|
54
|
+
const queryClient = useQueryClient();
|
|
55
|
+
const toast = useToast();
|
|
56
|
+
const status = useQuery({ queryKey: launcherQueryKey(host.id), queryFn: () => fetchStatus({}), staleTime: 60_000 });
|
|
57
|
+
const mutation = useMutation({
|
|
58
|
+
mutationFn: () => install({}),
|
|
59
|
+
onSuccess: (next) => {
|
|
60
|
+
queryClient.setQueryData(launcherQueryKey(host.id), next);
|
|
61
|
+
toast.show(`Installed paseo-progress at ${next.path}`, { variant: "success" });
|
|
62
|
+
},
|
|
63
|
+
onError: (error) => toast.error(error instanceof Error ? error.message : "Could not install the command."),
|
|
64
|
+
});
|
|
65
|
+
const notice = status.data ? launcherNotice(status.data) : null;
|
|
66
|
+
return <SetupBanner colors={colors} icon="Terminal" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
70
|
+
const fetchStatus = useRpc(getSkillStatus);
|
|
71
|
+
const install = useRpc(installSkill);
|
|
72
|
+
const queryClient = useQueryClient();
|
|
73
|
+
const toast = useToast();
|
|
74
|
+
const status = useQuery({ queryKey: skillQueryKey(host.id), queryFn: () => fetchStatus({}), staleTime: 60_000 });
|
|
75
|
+
const mutation = useMutation({
|
|
76
|
+
mutationFn: () => install({}),
|
|
77
|
+
onSuccess: (next) => {
|
|
78
|
+
queryClient.setQueryData(skillQueryKey(host.id), next);
|
|
79
|
+
toast.show("Installed the paseo-progress skill. New agent sessions will use it.", { variant: "success" });
|
|
80
|
+
},
|
|
81
|
+
onError: (error) => toast.error(error instanceof Error ? error.message : "Could not install the skill."),
|
|
82
|
+
});
|
|
83
|
+
const notice = status.data ? skillNotice(status.data) : null;
|
|
84
|
+
return <SetupBanner colors={colors} icon="Sparkles" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function SetupBanner({ colors, icon, notice, pending, onPress }: {
|
|
88
|
+
colors: Colors;
|
|
89
|
+
icon: string;
|
|
90
|
+
notice: { text: string; action?: string } | null;
|
|
91
|
+
pending: boolean;
|
|
92
|
+
onPress(): void;
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<Presence show={Boolean(notice)}>{notice ? (
|
|
96
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
|
|
97
|
+
{/* Held on the first line: the text wraps to several lines in the Explorer pane. */}
|
|
98
|
+
<View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name={icon} size={14} color={colors.foregroundMuted} /></View>
|
|
99
|
+
<Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{notice.text}</Text>
|
|
100
|
+
{notice.action ? (
|
|
101
|
+
<PressScale accessibilityRole="button" disabled={pending} onPress={onPress}
|
|
102
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed || pending ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
103
|
+
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>
|
|
104
|
+
{pending ? "Installing…" : notice.action}
|
|
105
|
+
</Text>
|
|
106
|
+
</PressScale>
|
|
107
|
+
) : null}
|
|
108
|
+
</View>
|
|
109
|
+
) : null}</Presence>
|
|
110
|
+
);
|
|
111
|
+
}
|