@stevecastaneda/paseo-progress 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +1 -1
- package/README.md +11 -3
- package/client/attachments.tsx +7 -8
- package/client/deliverables.tsx +5 -6
- package/client/dialog-stack.tsx +24 -29
- package/client/errors.ts +6 -0
- package/client/history.tsx +3 -2
- package/client/launcher.tsx +3 -2
- package/client/motion.tsx +0 -8
- package/client/panel.tsx +2 -1
- package/client/pill-popover.tsx +7 -6
- package/client/preview.tsx +47 -55
- package/client/write-up.tsx +2 -1
- package/dist/server/cli.js +1 -1
- package/index.server.ts +3 -2
- package/package.json +1 -1
- package/server/cli.ts +1 -1
- package/server/open.ts +47 -16
- package/shared/attachments.ts +24 -0
- package/shared/rpc.ts +10 -0
package/OVERVIEW.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Progress gives each worktree a live dashboard for long agent jobs. The agent records its tickets or steps with a `paseo-progress` command, along with what's stuck, questions for you and finished work. The Progress panel in Explorer shows all of it. Needs Paseo 0.11 or later, and Node.js 22.18 or later on the computer running the Paseo daemon.
|
|
2
2
|
|
|
3
|
-
The panel shows how much of the estimated work is done, what the agent is doing right now, and where each ticket stands. A ticket turns stuck when it's blocked or runs past its estimate without an update. Press a ticket to see its timeline, activity, questions and deliverables. Images, videos and text files the agent records open in a preview inside Paseo. Videos play only in the desktop app. Any other file opens in an app on the computer running the Paseo daemon, and so does a previewed file when you press Open. That's true from your phone too. The app opens on that computer, not on the phone.
|
|
3
|
+
The panel shows how much of the estimated work is done, what the agent is doing right now, and where each ticket stands. A ticket turns stuck when it's blocked or runs past its estimate without an update. Press a ticket to see its timeline, activity, questions and deliverables. Images, videos and text files the agent records open in a preview inside Paseo, and a folder opens as a list of its files. Videos play only in the desktop app. Any other file opens in an app on the computer running the Paseo daemon, and so does a previewed file when you press Open. Open is hidden when that computer can't open files, like a Linux server with no desktop. That's true from your phone too. The app opens on that computer, not on the phone.
|
|
4
4
|
|
|
5
5
|
When the agent needs a decision, it asks a numbered question with lettered options and says which one it recommends. A pill above the message box counts open questions and stuck work. Each option has a Copy button. Paste the answer into the chat and the agent records it.
|
|
6
6
|
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The dashboard shows:
|
|
|
13
13
|
- percent of estimated work done, time done of time estimated (like "1h 30m of 2h"; once everything is done, how long it really took), and a bar with one segment per ticket
|
|
14
14
|
- **Stuck:** blocked tickets, working tickets with no update for longer than their estimate, and blockers the agent flags
|
|
15
15
|
- **Tickets:** estimate, status, and the working ticket's stage. A ticket that waits for another (`--waits-for T03`) shows "Waits for Ticket 03" under its title 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 summary on top shows its status and stage, time worked against the estimate as a bar that turns red past it, the agent's latest status line while it's working, and the agent on it. Tabs below hold the rest, each only when it has something: the ticket's write-up when it has a `--source` file (the start of it, with the rest a press away), a timeline of its stages and statuses with how long each took, and the activity, questions and deliverables that belong to it. The agent on a ticket shows by its chat title; pressing it opens that chat. Items tagged with `--ticket` belong exactly; older, untagged ones are matched by when they happened and marked "by time"
|
|
16
|
-
- **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the option the agent recommends. The agent builds nothing a question affects until you answer; it works on other things meanwhile, and stops when nothing else is left. 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, videos 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.
|
|
16
|
+
- **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the option the agent recommends. The agent builds nothing a question affects until you answer; it works on other things meanwhile, and stops when nothing else is left. 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, videos and text files preview in Paseo (with **Back** to the question), a folder lists its files there, 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.
|
|
17
17
|
- **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
|
|
18
18
|
|
|
19
19
|
<img src="images/2-ticket.png" alt="A ticket's story: its status, time against the estimate, the agent on it, and its timeline" width="400"> <img src="images/3-question.png" alt="A question waiting for an answer, with lettered options, the agent's recommendation and Copy buttons" width="400">
|
|
@@ -100,7 +100,7 @@ paseo-progress finish "Snapshots ship for all four tables"
|
|
|
100
100
|
- `ticket add` and `ticket update` take an optional `--source <path or link>[=<label>]`: where the ticket is written up, like its spec file or issue. It shows under the ticket's status in its dialog and opens like a deliverable. `--source ""` clears it. Tickets without one are fine.
|
|
101
101
|
- 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.
|
|
102
102
|
- `question ask` needs `--recommend`, the agent's pick. The question dialog tags that option "Recommended".
|
|
103
|
-
- 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. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser
|
|
103
|
+
- 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. **Open** shows only when the computer running Paseo can open files. A Linux server with no desktop can't, so there a file Paseo can't preview copies its path and says why. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing a folder lists its files in the same dialog. Press a file to preview it, or a folder inside to list that one, and **Back** returns to the list. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser). 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.
|
|
104
104
|
- Several agents can share one run. When another session adds a ticket, each agent sees it at the end of its next command's output (and in `show`), and the skill has it work that ticket in order after its current one. An agent only hears about tickets added since its own last update in the run, and only once.
|
|
105
105
|
- Subagents record their own progress. Since Paseo 0.11, an agent waiting on background subagents shows as idle, so only the subagents' updates show the work is still going. The skill has the agent give each subagent the worktree path and ticket id, and the subagent records its stages and a ticker line on that worktree's dashboard.
|
|
106
106
|
- `start` begins a fresh dashboard. Earlier runs stay in the file. Question references stay unique across runs.
|
|
@@ -120,11 +120,19 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
|
|
|
120
120
|
- Progress folders outside the repo stay behind when a worktree is deleted. They're small, and you can delete them from `~/.local/state/paseo-progress/`.
|
|
121
121
|
- 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.
|
|
122
122
|
- 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.
|
|
123
|
-
- Previews cover images up to 10 MB, videos up to 200 MB (desktop app only),
|
|
123
|
+
- Previews cover images up to 10 MB, videos up to 200 MB (desktop app only), the first 256 KB of a text file, and the first 500 items in a folder. The folder list leaves out hidden files. Other files open in their default app. A video the app can't decode shows the player's error, and **Open** still works.
|
|
124
124
|
- Stale warnings and overdue times use the daemon host's clock.
|
|
125
125
|
|
|
126
126
|
## Changelog
|
|
127
127
|
|
|
128
|
+
### 1.2.0
|
|
129
|
+
|
|
130
|
+
- A folder deliverable opens in Paseo as a list of its files. Press a file to preview it, or a folder inside to list that one; **Back** returns to the list. This works from a phone and when the worktree is on another computer. Before, Progress asked the computer running Paseo to open the folder. On a Linux server with no desktop, that failed with `spawn xdg-open ENOENT`.
|
|
131
|
+
- A folder attached to a question lists its files the same way.
|
|
132
|
+
- Every **Back** button says just **Back**. Before, it named where it went, like **Back to Q3**.
|
|
133
|
+
- The preview hides **Open** when the computer running Paseo can't open files, like a Linux server you reach over ssh. There, a file Paseo can't preview copies its path and says why: `Can't open files on my-server. Copied path`.
|
|
134
|
+
- Error messages in the panel no longer end in Paseo's `requestType=… code=…` text.
|
|
135
|
+
|
|
128
136
|
### 1.1.0
|
|
129
137
|
|
|
130
138
|
- Videos play in the preview. MP4, MOV and WebM files up to 200 MB play in Paseo's desktop app, with a loading percentage while they arrive. On a phone, a video opens on the computer running the Paseo daemon, as before.
|
package/client/attachments.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { Platform, Text, View } from "react-native";
|
|
5
5
|
import { type Attachment, attachmentIcon, canPreview, shortName } from "../shared/attachments";
|
|
6
|
+
import { errorText } from "./errors";
|
|
6
7
|
import { PressableRow } from "./row";
|
|
7
8
|
import { openDeliverable } from "../shared/rpc";
|
|
8
9
|
|
|
@@ -24,10 +25,6 @@ export function absolutePath(path: string, root: string): string {
|
|
|
24
25
|
return path === "./" ? `${base}/` : `${base}/${path}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
// Opens attachments the same way everywhere. Web links go to Paseo's browser
|
|
28
|
-
// (the system browser where there is none); images, videos and text go to `onPreview` when the
|
|
29
|
-
// caller can show the preview dialog; every other local file opens with its
|
|
30
|
-
// default app on the daemon host.
|
|
31
28
|
export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview }: {
|
|
32
29
|
workspaceId: string;
|
|
33
30
|
workspaceDirectory: string;
|
|
@@ -49,7 +46,7 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
|
|
|
49
46
|
await openOnHostRpc({ workspaceId, workspaceDirectory, ref: attachment.ref });
|
|
50
47
|
toast.show(`Opened ${attachment.title}`, { variant: "success" });
|
|
51
48
|
} catch (error) {
|
|
52
|
-
await copyPath(attachment, `${error
|
|
49
|
+
await copyPath(attachment, `${errorText(error, "Could not open it")}. `);
|
|
53
50
|
}
|
|
54
51
|
}
|
|
55
52
|
async function open(attachment: Attachment) {
|
|
@@ -71,21 +68,23 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
|
|
|
71
68
|
return { open, openOnHost, copyPath };
|
|
72
69
|
}
|
|
73
70
|
|
|
71
|
+
export const ATTACHMENT_ROW = { gap: 2, paddingVertical: 3, lineHeight: 17 };
|
|
72
|
+
|
|
74
73
|
// A question's attachments as pressable rows.
|
|
75
74
|
// Drawn in dialogs and the popover (surface1) unless `onSurface1` is false.
|
|
76
75
|
// `onOpen` also gets the whole list, so a preview can step through its siblings.
|
|
77
76
|
export function AttachmentList({ colors, attachments, onOpen, onSurface1 = true }: { colors: Colors; attachments: Attachment[]; onOpen(attachment: Attachment, group: Attachment[]): void; onSurface1?: boolean }) {
|
|
78
77
|
if (!attachments.length) return null;
|
|
79
78
|
return (
|
|
80
|
-
<View style={{ gap:
|
|
79
|
+
<View style={{ gap: ATTACHMENT_ROW.gap }}>
|
|
81
80
|
{attachments.map((attachment) => (
|
|
82
81
|
<PressableRow key={attachment.ref} colors={colors} onSurface1={onSurface1} accessibilityRole={attachment.url ? "link" : "button"} accessibilityLabel={`Open ${attachment.title}`}
|
|
83
82
|
onPress={() => onOpen(attachment, attachments)}
|
|
84
|
-
style={{ flexDirection: "row", gap: 6, alignItems: "center", paddingVertical:
|
|
83
|
+
style={{ flexDirection: "row", gap: 6, alignItems: "center", paddingVertical: ATTACHMENT_ROW.paddingVertical, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
|
|
85
84
|
{/* A 14px slot, like a section icon, so the title lines up with the heading above. */}
|
|
86
85
|
<View style={{ width: 14, alignItems: "center" }}><Icon name={attachmentIcon(attachment)} size={12} color={colors.foregroundMuted} /></View>
|
|
87
86
|
{/* The row is the target, so nothing in it is styled as a link. */}
|
|
88
|
-
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foreground, fontSize: 12, lineHeight:
|
|
87
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foreground, fontSize: 12, lineHeight: ATTACHMENT_ROW.lineHeight }}>{attachment.title}</Text>
|
|
89
88
|
{shortName(attachment) !== attachment.title ? (
|
|
90
89
|
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>{shortName(attachment)}</Text>
|
|
91
90
|
) : <View style={{ flex: 1 }} />}
|
package/client/deliverables.tsx
CHANGED
|
@@ -6,7 +6,8 @@ import type { Deliverable } from "../shared/dashboard";
|
|
|
6
6
|
import { type Attachment, attachmentIcon, deliverableAttachment, shortName, useAttachmentOpener } from "./attachments";
|
|
7
7
|
import { formatAgo } from "../shared/format";
|
|
8
8
|
import { PressScale } from "./motion";
|
|
9
|
-
import {
|
|
9
|
+
import { StackedDialog } from "./dialog-stack";
|
|
10
|
+
import { galleryFiles } from "./preview";
|
|
10
11
|
import { PressableRow } from "./row";
|
|
11
12
|
import { ShowMoreRow, usePaged } from "./show-more";
|
|
12
13
|
|
|
@@ -25,8 +26,7 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
|
|
|
25
26
|
navigation: Navigation;
|
|
26
27
|
}) {
|
|
27
28
|
const [previewing, setPreviewing] = useState<Attachment | null>(null);
|
|
28
|
-
|
|
29
|
-
const { open, openOnHost, copyPath } = useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview: setPreviewing });
|
|
29
|
+
const { open, copyPath } = useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview: setPreviewing });
|
|
30
30
|
const paged = usePaged(deliverables);
|
|
31
31
|
return (
|
|
32
32
|
<View>
|
|
@@ -59,9 +59,8 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
|
|
|
59
59
|
</PressableRow>
|
|
60
60
|
))}
|
|
61
61
|
<ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
gallery={{ items: previewable(deliverables.map(deliverableAttachment)), onSelect: setPreviewing }} />
|
|
62
|
+
<StackedDialog colors={colors} now={now} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory, navigation }}
|
|
63
|
+
root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)) } : null} />
|
|
65
64
|
</View>
|
|
66
65
|
);
|
|
67
66
|
}
|
package/client/dialog-stack.tsx
CHANGED
|
@@ -3,9 +3,8 @@ import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
|
|
|
3
3
|
import React, { useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { Dashboard, Question } from "../shared/dashboard";
|
|
5
5
|
import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
|
|
6
|
-
import { useLastPresent } from "./motion";
|
|
7
6
|
import { NarrowProvider } from "./narrow";
|
|
8
|
-
import { BackButton,
|
|
7
|
+
import { BackButton, galleryFiles, PreviewContent } from "./preview";
|
|
9
8
|
import { QuestionView, useCopy } from "./questions";
|
|
10
9
|
import { TicketStoryView } from "./ticket-dialog";
|
|
11
10
|
|
|
@@ -18,35 +17,32 @@ export type Step =
|
|
|
18
17
|
| { kind: "question"; id: string }
|
|
19
18
|
| { kind: "preview"; attachment: Attachment; group: Attachment[] };
|
|
20
19
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
// returns to the step before.
|
|
25
|
-
export function StackedDialog({ colors, root, onClose, questions, dashboard, now, live = true, context }: {
|
|
20
|
+
// Paseo gives each dialog its own backdrop, so opening a second one darkens
|
|
21
|
+
// the screen while the first fades out; steps swap content inside this one instead.
|
|
22
|
+
export function StackedDialog({ colors, root, onClose, questions = [], dashboard, now, live = true, context }: {
|
|
26
23
|
colors: Colors;
|
|
27
|
-
// The ticket or question it opens on; null closes it.
|
|
28
24
|
root: Step | null;
|
|
29
25
|
onClose(): void;
|
|
30
|
-
questions
|
|
26
|
+
questions?: Question[];
|
|
31
27
|
// Needed only when it opens on a ticket.
|
|
32
28
|
dashboard?: Dashboard;
|
|
33
29
|
now: number;
|
|
34
30
|
live?: boolean;
|
|
35
31
|
context: AttachmentContext;
|
|
36
32
|
}) {
|
|
37
|
-
//
|
|
38
|
-
const [
|
|
39
|
-
const rootKey = root
|
|
40
|
-
const [
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
if (
|
|
33
|
+
// Paseo's Modal keeps drawing its content while it fades out, so closing keeps the steps.
|
|
34
|
+
const [steps, setSteps] = useState<Step[]>(root ? [root] : []);
|
|
35
|
+
const rootKey = root ? stepKey(root) : null;
|
|
36
|
+
const [stepsFor, setStepsFor] = useState(rootKey);
|
|
37
|
+
if (stepsFor !== rootKey) {
|
|
38
|
+
setStepsFor(rootKey);
|
|
39
|
+
if (root) setSteps([root]);
|
|
44
40
|
}
|
|
45
41
|
// The list an attachment was opened from, so its preview can step through the rest.
|
|
46
42
|
const group = useRef<Attachment[]>([]);
|
|
47
|
-
const push = (step: Step) =>
|
|
48
|
-
const replaceTop = (step: Step) =>
|
|
49
|
-
const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group:
|
|
43
|
+
const push = (step: Step) => setSteps((current) => [...current, step]);
|
|
44
|
+
const replaceTop = (step: Step) => setSteps((current) => [...current.slice(0, -1), step]);
|
|
45
|
+
const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current) }) });
|
|
50
46
|
const openAttachment = (attachment: Attachment, from: Attachment[]) => {
|
|
51
47
|
group.current = from;
|
|
52
48
|
// A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
|
|
@@ -59,12 +55,9 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
|
|
|
59
55
|
context.navigation!.openAgent({ agentId });
|
|
60
56
|
} : undefined;
|
|
61
57
|
const copy = useCopy();
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const shown = useLastPresent(steps.length ? { top: steps[steps.length - 1], previous: steps[steps.length - 2] } : null);
|
|
65
|
-
const back = shown?.previous ? { label: `Back to ${stepName(shown.previous)}`, onPress: () => setTrail((current) => current.slice(0, -1)) } : null;
|
|
58
|
+
const top = steps.at(-1);
|
|
59
|
+
const back = steps.length > 1 ? () => setSteps((current) => current.slice(0, -1)) : undefined;
|
|
66
60
|
|
|
67
|
-
const top = shown?.top;
|
|
68
61
|
const ticket = top?.kind === "ticket" ? dashboard?.tickets.find((candidate) => candidate.id === top.id) : undefined;
|
|
69
62
|
const question = top?.kind === "question" ? questions.find((candidate) => candidate.id === top.id) : undefined;
|
|
70
63
|
const preview = top?.kind === "preview" ? top : undefined;
|
|
@@ -87,8 +80,9 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
|
|
|
87
80
|
<NarrowProvider value={false}>
|
|
88
81
|
{preview ? (
|
|
89
82
|
<PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
|
|
90
|
-
onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
|
|
91
|
-
gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong}
|
|
83
|
+
onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }} onBack={back}
|
|
84
|
+
gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong}
|
|
85
|
+
onOpenEntry={openAttachment} />
|
|
92
86
|
) : (
|
|
93
87
|
<>
|
|
94
88
|
{ticket && dashboard ? (
|
|
@@ -99,7 +93,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
|
|
|
99
93
|
<QuestionView colors={colors} question={question} now={now}
|
|
100
94
|
onCopy={(letter) => { void copy(question, letter); onClose(); }}
|
|
101
95
|
onOpenAttachment={openAttachment}>
|
|
102
|
-
{back ? <BackButton colors={colors}
|
|
96
|
+
{back ? <BackButton colors={colors} onPress={back} /> : null}
|
|
103
97
|
</QuestionView>
|
|
104
98
|
) : null}
|
|
105
99
|
</>
|
|
@@ -110,6 +104,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
|
|
|
110
104
|
);
|
|
111
105
|
}
|
|
112
106
|
|
|
113
|
-
function
|
|
114
|
-
return step.kind === "preview" ? step.attachment.
|
|
107
|
+
function stepKey(step: Step): string {
|
|
108
|
+
return step.kind === "preview" ? `preview:${step.attachment.ref}` : `${step.kind}:${step.id}`;
|
|
115
109
|
}
|
|
110
|
+
|
package/client/errors.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Paseo wraps a handler's error as "Request failed: <message> requestType=… code=…".
|
|
2
|
+
export function errorText(error: unknown, fallback: string): string {
|
|
3
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
4
|
+
if (typeof message !== "string") return fallback;
|
|
5
|
+
return message.replace(/^Request failed: /, "").replace(/ requestType=\S+(?: code=\S+)?$/, "").trim() || fallback;
|
|
6
|
+
}
|
package/client/history.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { ActivityIndicator, ScrollView, Text, TextInput, View } from "react-nati
|
|
|
6
6
|
import { PressScale } from "./motion";
|
|
7
7
|
import { PressableRow } from "./row";
|
|
8
8
|
import { listFolders, setHistoryFolder } from "../shared/rpc";
|
|
9
|
+
import { errorText } from "./errors";
|
|
9
10
|
|
|
10
11
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
11
12
|
|
|
@@ -27,7 +28,7 @@ export function HistoryRow({ colors, workspaceId, directory, savedTo }: {
|
|
|
27
28
|
queryClient.invalidateQueries({ queryKey: ["progress", "dashboard"] });
|
|
28
29
|
toast.show(next ? `Each run will be saved to ${next}.` : "Runs will stay on this computer. Files already saved stay in the repo.", { variant: "success" });
|
|
29
30
|
},
|
|
30
|
-
onError: (error) => toast.error(error
|
|
31
|
+
onError: (error) => toast.error(errorText(error, "Could not change where runs are saved.")),
|
|
31
32
|
});
|
|
32
33
|
// While saving, the button that started it says so.
|
|
33
34
|
const busy = mutation.isPending ? (mutation.variables === null ? "stop" : "pick") : null;
|
|
@@ -119,7 +120,7 @@ function FolderPicker({ colors, workspaceId, directory, open, start, onClose, on
|
|
|
119
120
|
{listed.data && !listed.data.folders.length ? (
|
|
120
121
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6 }}>No folders in here.</Text>
|
|
121
122
|
) : null}
|
|
122
|
-
{listed.error ? <Text accessibilityRole="alert" style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, paddingHorizontal: 8 }}>{listed.error.
|
|
123
|
+
{listed.error ? <Text accessibilityRole="alert" style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, paddingHorizontal: 8 }}>{errorText(listed.error, "Could not list the folders.")}</Text> : null}
|
|
123
124
|
</ScrollView>
|
|
124
125
|
|
|
125
126
|
{naming === null ? (
|
package/client/launcher.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Text, View } from "react-native";
|
|
|
6
6
|
import { PressScale, Presence } from "./motion";
|
|
7
7
|
import { raised } from "./surfaces";
|
|
8
8
|
import { getLauncherStatus, getSkillStatus, installLauncher, installSkill, type LauncherStatus, type SkillStatus } from "../shared/rpc";
|
|
9
|
+
import { errorText } from "./errors";
|
|
9
10
|
|
|
10
11
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
11
12
|
|
|
@@ -60,7 +61,7 @@ export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
60
61
|
queryClient.setQueryData(launcherQueryKey(host.id), next);
|
|
61
62
|
toast.show(`Installed paseo-progress at ${next.path}`, { variant: "success" });
|
|
62
63
|
},
|
|
63
|
-
onError: (error) => toast.error(error
|
|
64
|
+
onError: (error) => toast.error(errorText(error, "Could not install the command.")),
|
|
64
65
|
});
|
|
65
66
|
const notice = status.data ? launcherNotice(status.data) : null;
|
|
66
67
|
return <SetupBanner colors={colors} icon="Terminal" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
|
@@ -78,7 +79,7 @@ export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWork
|
|
|
78
79
|
queryClient.setQueryData(skillQueryKey(host.id), next);
|
|
79
80
|
toast.show("Installed the paseo-progress skill. New agent sessions will use it.", { variant: "success" });
|
|
80
81
|
},
|
|
81
|
-
onError: (error) => toast.error(error
|
|
82
|
+
onError: (error) => toast.error(errorText(error, "Could not install the skill.")),
|
|
82
83
|
});
|
|
83
84
|
const notice = status.data ? skillNotice(status.data) : null;
|
|
84
85
|
return <SetupBanner colors={colors} icon="Sparkles" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
package/client/motion.tsx
CHANGED
|
@@ -24,14 +24,6 @@ export function useReducedMotion(): boolean {
|
|
|
24
24
|
return reduced;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// The last non-null value, so a dialog keeps its content while it animates
|
|
28
|
-
// closed instead of collapsing mid-exit.
|
|
29
|
-
export function useLastPresent<T>(value: T | null): T | null {
|
|
30
|
-
const last = useRef(value);
|
|
31
|
-
if (value !== null) last.current = value;
|
|
32
|
-
return value ?? last.current;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
27
|
// A button that scales to 0.96 while pressed. `static` turns the scale off.
|
|
36
28
|
export function PressScale({ static: isStatic, style, outerStyle, children, ...props }: Omit<PressableProps, "style" | "children"> & {
|
|
37
29
|
static?: boolean;
|
package/client/panel.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import { TabStrip } from "./tabs";
|
|
|
23
23
|
import { ShowMoreRow, usePaged } from "./show-more";
|
|
24
24
|
import { notePanelOpened } from "./panel-opened";
|
|
25
25
|
import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
|
|
26
|
+
import { errorText } from "./errors";
|
|
26
27
|
|
|
27
28
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
28
29
|
|
|
@@ -52,7 +53,7 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
|
|
|
52
53
|
{query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading progress" style={{ padding: 12 }} /> : null}
|
|
53
54
|
{query.error ? (
|
|
54
55
|
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
|
|
55
|
-
Could not read progress: {query.error
|
|
56
|
+
Could not read progress: {errorText(query.error, "something went wrong")}
|
|
56
57
|
</Text>
|
|
57
58
|
) : null}
|
|
58
59
|
<LauncherBanner colors={colors} host={host} />
|
package/client/pill-popover.tsx
CHANGED
|
@@ -7,8 +7,10 @@ import { useDashboard } from "./dashboard-query";
|
|
|
7
7
|
import { progressPanel } from "./pills";
|
|
8
8
|
import { PressScale } from "./motion";
|
|
9
9
|
import { type Attachment, isPreviewable, useAttachmentOpener } from "./attachments";
|
|
10
|
-
import {
|
|
10
|
+
import { StackedDialog } from "./dialog-stack";
|
|
11
|
+
import { galleryFiles } from "./preview";
|
|
11
12
|
import { QuestionDetail, QuestionState, useCopy } from "./questions";
|
|
13
|
+
import { errorText } from "./errors";
|
|
12
14
|
import { raised } from "./surfaces";
|
|
13
15
|
|
|
14
16
|
type Colors = PluginButtonContentProps["theme"]["colors"];
|
|
@@ -35,7 +37,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
35
37
|
// Links and other files leave Paseo, so the popover closes for those.
|
|
36
38
|
const [previewing, setPreviewing] = useState<{ attachment: Attachment; group: Attachment[] } | null>(null);
|
|
37
39
|
const group = useRef<Attachment[]>([]);
|
|
38
|
-
const opener = useAttachmentOpener({ workspaceId, workspaceDirectory: root, onPreview: (attachment) => setPreviewing({ attachment, group:
|
|
40
|
+
const opener = useAttachmentOpener({ workspaceId, workspaceDirectory: root, onPreview: (attachment) => setPreviewing({ attachment, group: galleryFiles(group.current) }) });
|
|
39
41
|
return (
|
|
40
42
|
<View style={{ width: 360, maxWidth: "100%", gap: 8 }}>
|
|
41
43
|
{dashboard ? (
|
|
@@ -51,12 +53,11 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
51
53
|
</List>
|
|
52
54
|
) : (
|
|
53
55
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
54
|
-
{query.error ? `Could not read progress: ${query.error
|
|
56
|
+
{query.error ? `Could not read progress: ${errorText(query.error, "something went wrong")}` : "Loading…"}
|
|
55
57
|
</Text>
|
|
56
58
|
)}
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
gallery={previewing ? { items: previewing.group, onSelect: (attachment) => setPreviewing({ ...previewing, attachment }) } : undefined} />
|
|
59
|
+
<StackedDialog colors={colors} now={Date.now()} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory: root }}
|
|
60
|
+
root={previewing ? { kind: "preview", ...previewing } : null} />
|
|
60
61
|
<View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
61
62
|
{/* A full 44pt touch target on phones; desktop keeps the compact button. */}
|
|
62
63
|
<PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
|
package/client/preview.tsx
CHANGED
|
@@ -1,43 +1,18 @@
|
|
|
1
1
|
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
-
import { Icon,
|
|
2
|
+
import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import React, { useEffect, useRef
|
|
4
|
+
import React, { useEffect, useRef } from "react";
|
|
5
5
|
import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
|
|
6
|
-
import { type Attachment,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
|
|
7
|
+
import { errorText } from "./errors";
|
|
8
|
+
import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
|
|
9
|
+
import { canOpenOnHost, previewDeliverable, readDeliverablePiece } from "../shared/rpc";
|
|
10
|
+
import { PressScale } from "./motion";
|
|
10
11
|
import { imageOutline, raised } from "./surfaces";
|
|
11
12
|
import { loadVideo, VideoPlayer } from "./video";
|
|
12
13
|
|
|
13
14
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
14
15
|
|
|
15
|
-
// An image or text attachment shown in a dialog, with a way out to the host's own app.
|
|
16
|
-
// `onBack` returns to where the preview was opened from, e.g. a question's dialog.
|
|
17
|
-
export function PreviewDialog({ colors, attachment, workspaceId, workspaceDirectory, onClose, onOpenOnHost, backLabel, onBack, gallery }: {
|
|
18
|
-
colors: Colors;
|
|
19
|
-
attachment: Attachment | null;
|
|
20
|
-
workspaceId: string;
|
|
21
|
-
workspaceDirectory: string;
|
|
22
|
-
onClose(): void;
|
|
23
|
-
onOpenOnHost(attachment: Attachment): void;
|
|
24
|
-
backLabel?: string;
|
|
25
|
-
onBack?(): void;
|
|
26
|
-
gallery?: Gallery;
|
|
27
|
-
}) {
|
|
28
|
-
const open = Boolean(attachment);
|
|
29
|
-
attachment = useLastPresent(attachment);
|
|
30
|
-
return (
|
|
31
|
-
<Modal title={attachment?.title ?? "Preview"} icon={<Icon name={attachment ? attachmentIcon(attachment) : "FileText"} size={16} color={colors.foregroundMuted} />}
|
|
32
|
-
open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
|
|
33
|
-
{attachment ? (
|
|
34
|
-
<PreviewBody colors={colors} attachment={attachment} workspaceId={workspaceId} workspaceDirectory={workspaceDirectory}
|
|
35
|
-
onOpenOnHost={() => { onClose(); onOpenOnHost(attachment); }} backLabel={backLabel} onBack={onBack} gallery={gallery} />
|
|
36
|
-
) : <Modal.Content>{null}</Modal.Content>}
|
|
37
|
-
</Modal>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
16
|
// The other previewable files beside this one (a question's images, a ticket's
|
|
42
17
|
// screenshots), for Previous and Next. Only shown when there's more than one.
|
|
43
18
|
export interface Gallery {
|
|
@@ -45,9 +20,8 @@ export interface Gallery {
|
|
|
45
20
|
onSelect(attachment: Attachment): void;
|
|
46
21
|
}
|
|
47
22
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return group.filter(isPreviewable);
|
|
23
|
+
export function galleryFiles(group: Attachment[]): Attachment[] {
|
|
24
|
+
return group.filter((attachment) => isPreviewable(attachment) && !isFolder(attachment));
|
|
51
25
|
}
|
|
52
26
|
|
|
53
27
|
type PreviewProps = {
|
|
@@ -56,25 +30,23 @@ type PreviewProps = {
|
|
|
56
30
|
workspaceId: string;
|
|
57
31
|
workspaceDirectory: string;
|
|
58
32
|
onOpenOnHost(): void;
|
|
59
|
-
backLabel?: string;
|
|
60
33
|
onBack?(): void;
|
|
61
34
|
gallery?: Gallery;
|
|
35
|
+
onOpenEntry(attachment: Attachment, group: Attachment[]): void;
|
|
62
36
|
};
|
|
63
37
|
|
|
64
|
-
// The preview dialog's body in its own Modal.Content.
|
|
65
|
-
export function PreviewBody(props: PreviewProps) {
|
|
66
|
-
const [long, setLong] = useState(false);
|
|
67
|
-
return <Modal.Content scrollable={!long}><PreviewContent {...props} onLong={setLong} /></Modal.Content>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The preview without its Modal.Content, for a dialog that already has one (see
|
|
71
|
-
// StackedDialog): swapping Modal.Content itself would re-present the sheet on phones.
|
|
72
38
|
// `onLong` says when the file is too tall for the dialog, so the host turns off
|
|
73
39
|
// its own scrolling and the file scrolls with the actions pinned below.
|
|
74
|
-
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost,
|
|
40
|
+
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onBack, gallery, onOpenEntry, onLong }: PreviewProps & { onLong(long: boolean): void }) {
|
|
75
41
|
const fetchPreview = useRpc(previewDeliverable);
|
|
76
42
|
const readPiece = useRpc(readDeliverablePiece);
|
|
43
|
+
const checkHost = useRpc(canOpenOnHost);
|
|
77
44
|
const queryClient = useQueryClient();
|
|
45
|
+
const canOpen = useQuery({
|
|
46
|
+
queryKey: ["progress", "can-open", workspaceId],
|
|
47
|
+
queryFn: () => checkHost({ workspaceId, workspaceDirectory }),
|
|
48
|
+
staleTime: Infinity,
|
|
49
|
+
}).data?.canOpen === true;
|
|
78
50
|
// How much of a video has arrived, for the line under the spinner.
|
|
79
51
|
const loadingKey = (target: Attachment) => ["progress", "preview-loading", workspaceId, target.ref, target.path];
|
|
80
52
|
const previewQuery = (target: Attachment) => ({
|
|
@@ -121,8 +93,10 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
121
93
|
if (previewKind(next.path ?? "") !== "video") void queryClient.prefetchQuery(previewQuery(next));
|
|
122
94
|
}, [attachment.ref, items.length]);
|
|
123
95
|
// Previous and Next, wrapping at the ends. Left and right arrows do the same.
|
|
124
|
-
const step = (offset: number) => gallery?.onSelect(items[(
|
|
125
|
-
|
|
96
|
+
const step = (offset: number) => gallery?.onSelect(items[(index + offset + items.length) % items.length]);
|
|
97
|
+
const inGallery = index >= 0;
|
|
98
|
+
const canStep = inGallery && items.length > 1;
|
|
99
|
+
useArrowKeys(canStep ? step : null);
|
|
126
100
|
// Next has focus when the preview opens, so Enter or Space moves on too.
|
|
127
101
|
const nextButton = useRef<View>(null);
|
|
128
102
|
useEffect(() => {
|
|
@@ -155,7 +129,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
155
129
|
) : null}
|
|
156
130
|
{preview.error ? (
|
|
157
131
|
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
|
|
158
|
-
Could not preview it: {preview.error
|
|
132
|
+
Could not preview it: {errorText(preview.error, "something went wrong")}
|
|
159
133
|
</Text>
|
|
160
134
|
) : null}
|
|
161
135
|
{preview.data?.kind === "text" ? (
|
|
@@ -165,11 +139,15 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
165
139
|
</Text>
|
|
166
140
|
{preview.data.truncated ? (
|
|
167
141
|
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop: 8 }}>
|
|
168
|
-
Showing the first 256 KB. Press Open to see the rest in its default app.
|
|
142
|
+
Showing the first 256 KB.{canOpen ? " Press Open to see the rest in its default app." : ""}
|
|
169
143
|
</Text>
|
|
170
144
|
) : null}
|
|
171
145
|
</View>
|
|
172
146
|
) : null}
|
|
147
|
+
{preview.data?.kind === "folder" ? (
|
|
148
|
+
<FolderList colors={colors} folder={attachment} entries={folderAttachments(attachment, preview.data.entries)} truncated={preview.data.truncated}
|
|
149
|
+
dimmed={stepping} onOpen={onOpenEntry} />
|
|
150
|
+
) : null}
|
|
173
151
|
{preview.data?.kind === "image" ? (
|
|
174
152
|
<Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
|
|
175
153
|
) : null}
|
|
@@ -188,11 +166,11 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
188
166
|
const actions = (
|
|
189
167
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
190
168
|
<View style={{ flex: 1, alignItems: "flex-start" }}>
|
|
191
|
-
{onBack ? <BackButton colors={colors}
|
|
169
|
+
{onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
|
|
192
170
|
</View>
|
|
193
|
-
{
|
|
171
|
+
{canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
|
|
194
172
|
<View style={{ flex: 1, alignItems: "flex-end" }}>
|
|
195
|
-
<DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} />
|
|
173
|
+
{canOpen ? <DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} /> : null}
|
|
196
174
|
</View>
|
|
197
175
|
</View>
|
|
198
176
|
);
|
|
@@ -214,14 +192,28 @@ const CONTENT_WIDTH = 472;
|
|
|
214
192
|
// Characters of 12px monospace per line of the text box (10px padding each side).
|
|
215
193
|
const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
|
|
216
194
|
|
|
195
|
+
const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
|
|
196
|
+
|
|
217
197
|
// Roughly how tall the preview draws, including wrapped lines.
|
|
218
|
-
function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | undefined, aspectRatio: number, maxMediaHeight: number): number {
|
|
198
|
+
function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
|
|
219
199
|
if (!data) return 0;
|
|
200
|
+
if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
|
|
220
201
|
if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
|
|
221
202
|
const lines = data.text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0);
|
|
222
203
|
return lines * 18 + 20;
|
|
223
204
|
}
|
|
224
205
|
|
|
206
|
+
function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { colors: Colors; folder: Attachment; entries: Attachment[]; truncated: boolean; dimmed: boolean; onOpen(attachment: Attachment, group: Attachment[]): void }) {
|
|
207
|
+
const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
|
|
208
|
+
if (!entries.length) return <Text style={caption}>Nothing in {folder.title} yet.</Text>;
|
|
209
|
+
return (
|
|
210
|
+
<View style={{ gap: 8, opacity: dimmed ? 0.6 : 1 }}>
|
|
211
|
+
<AttachmentList colors={colors} attachments={entries} onOpen={onOpen} />
|
|
212
|
+
{truncated ? <Text style={caption}>Showing the first {entries.length}.</Text> : null}
|
|
213
|
+
</View>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
225
217
|
// An image's width over its height, or null if it can't be read. Read from the data
|
|
226
218
|
// because onLoad doesn't report a size for data URIs on every platform.
|
|
227
219
|
function imageRatio(uri: string): Promise<number | null> {
|
|
@@ -253,8 +245,8 @@ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress,
|
|
|
253
245
|
}
|
|
254
246
|
|
|
255
247
|
// Returns to the dialog this one was opened from. For surface1 dialogs.
|
|
256
|
-
export function BackButton({ colors,
|
|
257
|
-
return <DialogButton colors={colors} icon="ChevronLeft" label=
|
|
248
|
+
export function BackButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
|
|
249
|
+
return <DialogButton colors={colors} icon="ChevronLeft" label="Back" onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
|
|
258
250
|
}
|
|
259
251
|
|
|
260
252
|
// Previous and next through a gallery, with "2 of 3" between.
|
package/client/write-up.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { previewDeliverable } from "../shared/rpc";
|
|
|
8
8
|
import { type Block, parseWriteUp, type Span, withoutTitle, writeUpExcerpt } from "../shared/write-up";
|
|
9
9
|
import { type Attachment, attachmentIcon } from "./attachments";
|
|
10
10
|
import { PressableRow } from "./row";
|
|
11
|
+
import { errorText } from "./errors";
|
|
11
12
|
|
|
12
13
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
13
14
|
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
|
@@ -37,7 +38,7 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
|
|
|
37
38
|
return (
|
|
38
39
|
<View style={{ gap: 12, padding: 14, borderRadius: 8, backgroundColor: colors.surface0 }}>
|
|
39
40
|
{shown.length ? <View style={{ gap: 8 }}>{shown.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View> : null}
|
|
40
|
-
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Couldn't read it here: {preview.error
|
|
41
|
+
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Couldn't read it here: {errorText(preview.error, "something went wrong")}</Text> : null}
|
|
41
42
|
{/* The file on the left, opening it whole; expanding in place on the right. */}
|
|
42
43
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
43
44
|
<TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={`Open ${source.title}`} onPress={() => onOpen(source, [source])} grow />
|
package/dist/server/cli.js
CHANGED
|
@@ -20928,7 +20928,7 @@ var commands = {
|
|
|
20928
20928
|
function rootRelative(target, cwd, root) {
|
|
20929
20929
|
const absolute = resolve3(cwd, target);
|
|
20930
20930
|
const inside = relative(root, absolute);
|
|
20931
|
-
const suffix = /[\/]$/.test(target) ? "/" : "";
|
|
20931
|
+
const suffix = /[\/]$/.test(target) || statSync(absolute, { throwIfNoEntry: false })?.isDirectory() ? "/" : "";
|
|
20932
20932
|
if (!inside) return "./";
|
|
20933
20933
|
return outside(inside) ? absolute + suffix : inside + suffix;
|
|
20934
20934
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPanelOpened, handleSetHistoryFolder } from "./server/dashboard";
|
|
3
3
|
import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
|
|
4
|
-
import { handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
|
|
4
|
+
import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
|
|
5
5
|
import { handleInstallSkill, handleSkillStatus } from "./server/skill";
|
|
6
|
-
import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
6
|
+
import { canOpenOnHost, getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
7
7
|
|
|
8
8
|
export default function contribute(server: PluginServerContext) {
|
|
9
9
|
server.handle(getDashboard, handleGetDashboard);
|
|
@@ -12,6 +12,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
12
12
|
server.handle(getLauncherStatus, handleLauncherStatus);
|
|
13
13
|
server.handle(installLauncher, handleInstallLauncher);
|
|
14
14
|
server.handle(openDeliverable, handleOpenDeliverable);
|
|
15
|
+
server.handle(canOpenOnHost, handleCanOpenOnHost);
|
|
15
16
|
server.handle(previewDeliverable, handlePreviewDeliverable);
|
|
16
17
|
server.handle(readDeliverablePiece, handleReadDeliverablePiece);
|
|
17
18
|
server.handle(getSkillStatus, handleSkillStatus);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|
package/server/cli.ts
CHANGED
|
@@ -447,7 +447,7 @@ const commands: Record<string, Command> = {
|
|
|
447
447
|
function rootRelative(target: string, cwd: string, root: string): string {
|
|
448
448
|
const absolute = resolve(cwd, target);
|
|
449
449
|
const inside = relative(root, absolute);
|
|
450
|
-
const suffix = /[\/]$/.test(target) ? "/" : "";
|
|
450
|
+
const suffix = /[\/]$/.test(target) || statSync(absolute, { throwIfNoEntry: false })?.isDirectory() ? "/" : "";
|
|
451
451
|
if (!inside) return "./";
|
|
452
452
|
return outside(inside) ? absolute + suffix : inside + suffix;
|
|
453
453
|
}
|
package/server/open.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { delimiter, join, relative, resolve } from "node:path";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
|
-
import { findAttachment, imageMimeType, previewKind, videoMimeType } from "../shared/attachments.ts";
|
|
8
|
+
import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, videoMimeType } from "../shared/attachments.ts";
|
|
8
9
|
import { readProgress } from "./progress-file.ts";
|
|
9
10
|
import { findRoot, outside } from "./paths.ts";
|
|
10
11
|
import { findStore } from "./store.ts";
|
|
@@ -34,20 +35,32 @@ async function defaultBrowser(): Promise<string | null> {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
38
|
+
export function canOpenFiles(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
|
39
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
40
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return false;
|
|
41
|
+
return (env.PATH ?? "").split(delimiter).some((directory) => directory && existsSync(join(directory, "xdg-open")));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Opener {
|
|
45
|
+
return async (path) => {
|
|
46
|
+
if (!canOpenFiles(platform, env)) throw new Error(`Can't open files on ${hostname()}`);
|
|
47
|
+
const browser = platform === "darwin" ? await defaultBrowser() : null;
|
|
48
|
+
const [command, args] = openCommand(path, platform, browser);
|
|
49
|
+
await promisify(execFile)(command, args);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
// A deliverable or question attachment the agent recorded, found by reference,
|
|
44
54
|
// so the panel can never reach an arbitrary path. Only files inside the
|
|
45
55
|
// worktree resolve.
|
|
46
56
|
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
|
|
57
|
+
const { base, inner } = splitRef(ref);
|
|
47
58
|
const worktree = findRoot(directory);
|
|
48
59
|
const { dashboard } = await readProgress((await findStore(worktree)).directory);
|
|
49
|
-
const
|
|
50
|
-
if (!
|
|
60
|
+
const folder = findAttachment(dashboard, base)?.path;
|
|
61
|
+
if (!folder) throw new Error(`No local ${base.startsWith("Q") ? "attachment" : "deliverable"} ${base} in this worktree.`);
|
|
62
|
+
const path = inner ? join(folder, inner) : folder;
|
|
63
|
+
if (outside(relative(resolve(worktree, folder), resolve(worktree, path)))) throw new Error(`${path} is outside ${folder}, so it was not ${verb}.`);
|
|
51
64
|
const root = await realpath(worktree);
|
|
52
65
|
const target = await realpath(resolve(root, path)).catch(() => null);
|
|
53
66
|
if (!target) throw new Error(`${path} no longer exists.`);
|
|
@@ -74,18 +87,21 @@ export const MAX_PREVIEW_VIDEO_BYTES = 200 * 1024 * 1024;
|
|
|
74
87
|
// Small enough that one piece doesn't hold up the agent output sharing the socket.
|
|
75
88
|
export const VIDEO_PIECE_BYTES = 4 * 1024 * 1024;
|
|
76
89
|
|
|
90
|
+
export const MAX_FOLDER_ENTRIES = 500;
|
|
91
|
+
|
|
77
92
|
export type Preview =
|
|
78
93
|
| { kind: "image"; dataUri: string; bytes: number }
|
|
79
94
|
| { kind: "video"; mimeType: string; bytes: number }
|
|
80
|
-
| { kind: "text"; text: string; bytes: number; truncated: boolean }
|
|
95
|
+
| { kind: "text"; text: string; bytes: number; truncated: boolean }
|
|
96
|
+
| { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
|
|
81
97
|
|
|
82
|
-
// An image (as a data URI) or a text file's contents, so any client (desktop
|
|
83
|
-
// or phone) can show it without reaching the daemon host's disk.
|
|
84
98
|
export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
|
|
85
99
|
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
100
|
+
const stats = await stat(target);
|
|
101
|
+
if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(target)) };
|
|
86
102
|
const kind = previewKind(target);
|
|
87
103
|
if (!kind) throw new Error(`${path} isn't an image, video or text file, so it can't be previewed.`);
|
|
88
|
-
const { size } =
|
|
104
|
+
const { size } = stats;
|
|
89
105
|
if (kind === "video") {
|
|
90
106
|
if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
|
|
91
107
|
return { kind, mimeType: videoMimeType(target)!, bytes: size };
|
|
@@ -105,6 +121,17 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
105
121
|
return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
|
|
106
122
|
}
|
|
107
123
|
|
|
124
|
+
async function listFolder(target: string): Promise<{ entries: FolderEntry[]; truncated: boolean }> {
|
|
125
|
+
const entries: FolderEntry[] = [];
|
|
126
|
+
for (const entry of await readdir(target, { withFileTypes: true })) {
|
|
127
|
+
if (entry.name.startsWith(".")) continue;
|
|
128
|
+
const linkedFolder = entry.isSymbolicLink() && (await stat(join(target, entry.name)).catch(() => null))?.isDirectory();
|
|
129
|
+
entries.push({ name: entry.name, folder: entry.isDirectory() || Boolean(linkedFolder) });
|
|
130
|
+
}
|
|
131
|
+
entries.sort((a, b) => Number(b.folder) - Number(a.folder) || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
132
|
+
return { entries: entries.slice(0, MAX_FOLDER_ENTRIES), truncated: entries.length > MAX_FOLDER_ENTRIES };
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
function tooLarge(path: string, size: number): string {
|
|
109
136
|
return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
|
|
110
137
|
}
|
|
@@ -145,5 +172,9 @@ export async function handleOpenDeliverable(
|
|
|
145
172
|
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
146
173
|
_context: PluginHandlerContext,
|
|
147
174
|
) {
|
|
148
|
-
return openDeliverable(input.workspaceDirectory, input.ref,
|
|
175
|
+
return openDeliverable(input.workspaceDirectory, input.ref, hostOpener(process.platform, process.env));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function handleCanOpenOnHost(_input: { workspaceId: string; workspaceDirectory: string }, _context: PluginHandlerContext) {
|
|
179
|
+
return { canOpen: canOpenFiles(process.platform, process.env) };
|
|
149
180
|
}
|
package/shared/attachments.ts
CHANGED
|
@@ -88,11 +88,35 @@ export function previewKind(path: string): "image" | "video" | "text" | null {
|
|
|
88
88
|
return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export function isFolder(attachment: Attachment): boolean {
|
|
92
|
+
return attachment.kind === "folder" || /[\\/]$/.test(attachment.path ?? "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FolderEntry {
|
|
96
|
+
name: string;
|
|
97
|
+
folder: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function entryRef(folderRef: string, path: string): string {
|
|
101
|
+
return `${folderRef}/${path}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function splitRef(ref: string): { base: string; inner: string } {
|
|
105
|
+
const [base, ...rest] = ref.split("/");
|
|
106
|
+
return { base, inner: rest.join("/") };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function folderAttachments(folder: Attachment, entries: FolderEntry[]): Attachment[] {
|
|
110
|
+
const base = folder.path === "./" ? "" : (folder.path ?? "").replace(/[\\/]*$/, "/");
|
|
111
|
+
return entries.map((entry) => ({ ref: entryRef(folder.ref, entry.name), title: entry.name, path: `${base}${entry.name}${entry.folder ? "/" : ""}` }));
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
// Whether the preview dialog can show it. Only a browser draws SVG and plays
|
|
92
115
|
// video: Paseo's desktop app can, the phone apps can't, so there they open on
|
|
93
116
|
// the host like any other file.
|
|
94
117
|
export function canPreview(attachment: Attachment, where: { browser: boolean }): boolean {
|
|
95
118
|
if (!attachment.path) return false;
|
|
119
|
+
if (isFolder(attachment)) return true;
|
|
96
120
|
const kind = previewKind(attachment.path);
|
|
97
121
|
if (kind === "video" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
|
|
98
122
|
return kind !== null;
|
package/shared/rpc.ts
CHANGED
|
@@ -124,6 +124,15 @@ export const openDeliverable = defineRpc({
|
|
|
124
124
|
output: z.object({ opened: z.string() }),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
export const canOpenOnHost = defineRpc({
|
|
128
|
+
name: "progress.host.can-open",
|
|
129
|
+
input: z.object({
|
|
130
|
+
workspaceId: z.string().min(1),
|
|
131
|
+
workspaceDirectory: z.string().min(1),
|
|
132
|
+
}),
|
|
133
|
+
output: z.object({ canOpen: z.boolean() }),
|
|
134
|
+
});
|
|
135
|
+
|
|
127
136
|
// An image or text file's contents, for the preview dialog. A video comes back
|
|
128
137
|
// as its type and size only; its bytes follow in pieces (readDeliverablePiece).
|
|
129
138
|
export const previewDeliverable = defineRpc({
|
|
@@ -138,6 +147,7 @@ export const previewDeliverable = defineRpc({
|
|
|
138
147
|
z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
|
|
139
148
|
z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
|
|
140
149
|
z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
|
|
150
|
+
z.object({ kind: z.literal("folder"), entries: z.array(z.object({ name: z.string(), folder: z.boolean() })), truncated: z.boolean() }),
|
|
141
151
|
]),
|
|
142
152
|
});
|
|
143
153
|
|