@stevecastaneda/paseo-progress 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 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.
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,24 @@ 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), and the first 256 KB of a text file. Other files open in their default app. A video the app can't decode shows the player's error, and **Open** still works.
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.3.0
129
+
130
+ - The file preview has a **Copy** button beside the path. It copies the full path, so you can paste it into your message to the agent without closing the preview. Before, the path was cut off when it was long, and you had to close the preview and copy it from the list.
131
+ - The path stays in view below a tall image or long file. Before, it scrolled with the file and you had to scroll to the bottom to see it.
132
+
133
+ ### 1.2.0
134
+
135
+ - 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`.
136
+ - A folder attached to a question lists its files the same way.
137
+ - Every **Back** button says just **Back**. Before, it named where it went, like **Back to Q3**.
138
+ - 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`.
139
+ - Error messages in the panel no longer end in Paseo's `requestType=… code=…` text.
140
+
128
141
  ### 1.1.0
129
142
 
130
143
  - 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.
@@ -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 instanceof Error ? error.message : "Could not open it"}. `);
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: 2 }}>
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: 3, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
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: 17 }}>{attachment.title}</Text>
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 }} />}
@@ -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 { PreviewDialog, previewable } from "./preview";
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
- // 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 });
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
- <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 }} />
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
  }
@@ -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, PreviewContent, previewable } from "./preview";
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
- // One dialog for a ticket or a question and everything opened from it. Paseo
22
- // gives each dialog its own backdrop, so opening a second one darkens the screen
23
- // while the first fades out; instead the content changes in place, and Back
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: Question[];
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
- // What was opened on top of the root, oldest first. Cleared when a new root opens.
38
- const [trail, setTrail] = useState<Step[]>([]);
39
- const rootKey = root && root.kind !== "preview" ? `${root.kind}:${root.id}` : null;
40
- const [trailFor, setTrailFor] = useState(rootKey);
41
- if (trailFor !== rootKey) {
42
- setTrailFor(rootKey);
43
- if (rootKey) setTrail([]);
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) => setTrail((current) => [...current, step]);
48
- const replaceTop = (step: Step) => setTrail((current) => [...current.slice(0, -1), step]);
49
- const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: previewable(group.current) }) });
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 steps: Step[] = root ? [root, ...trail] : [];
63
- // Keep drawing the last step while the dialog fades out.
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,10 @@ 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); }} backLabel={back?.label} onBack={back?.onPress}
91
- gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong} />
83
+ onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
84
+ onCopyPath={() => void opener.copyPath(preview.attachment)} onBack={back}
85
+ gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong}
86
+ onOpenEntry={openAttachment} />
92
87
  ) : (
93
88
  <>
94
89
  {ticket && dashboard ? (
@@ -99,7 +94,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
99
94
  <QuestionView colors={colors} question={question} now={now}
100
95
  onCopy={(letter) => { void copy(question, letter); onClose(); }}
101
96
  onOpenAttachment={openAttachment}>
102
- {back ? <BackButton colors={colors} label={back.label} onPress={back.onPress} /> : null}
97
+ {back ? <BackButton colors={colors} onPress={back} /> : null}
103
98
  </QuestionView>
104
99
  ) : null}
105
100
  </>
@@ -110,6 +105,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
110
105
  );
111
106
  }
112
107
 
113
- function stepName(step: Step): string {
114
- return step.kind === "preview" ? step.attachment.title : step.id;
108
+ function stepKey(step: Step): string {
109
+ return step.kind === "preview" ? `preview:${step.attachment.ref}` : `${step.kind}:${step.id}`;
115
110
  }
111
+
@@ -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
+ }
@@ -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 instanceof Error ? error.message : "Could not change where runs are saved."),
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.message}</Text> : null}
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 ? (
@@ -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 instanceof Error ? error.message : "Could not install the command."),
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 instanceof Error ? error.message : "Could not install the skill."),
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.message}
56
+ Could not read progress: {errorText(query.error, "something went wrong")}
56
57
  </Text>
57
58
  ) : null}
58
59
  <LauncherBanner colors={colors} host={host} />
@@ -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 { PreviewDialog, previewable } from "./preview";
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: previewable(group.current) }) });
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.message}` : "Loading…"}
56
+ {query.error ? `Could not read progress: ${errorText(query.error, "something went wrong")}` : "Loading…"}
55
57
  </Text>
56
58
  )}
57
- <PreviewDialog colors={colors} attachment={previewing?.attachment ?? null} workspaceId={workspaceId} workspaceDirectory={root}
58
- onClose={() => setPreviewing(null)} onOpenOnHost={(attachment) => void opener.openOnHost(attachment)}
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(); }}
@@ -1,43 +1,18 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, Modal, ScrollView } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import React, { useEffect, useRef, useState } from "react";
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, attachmentIcon, isPreviewable } from "./attachments";
7
- import { previewKind } from "../shared/attachments";
8
- import { previewDeliverable, readDeliverablePiece } from "../shared/rpc";
9
- import { PressScale, useLastPresent } from "./motion";
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
- // Keeps only what the preview can show, for a Gallery's items.
49
- export function previewable(group: Attachment[]): Attachment[] {
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,24 @@ type PreviewProps = {
56
30
  workspaceId: string;
57
31
  workspaceDirectory: string;
58
32
  onOpenOnHost(): void;
59
- backLabel?: string;
33
+ onCopyPath(): void;
60
34
  onBack?(): void;
61
35
  gallery?: Gallery;
36
+ onOpenEntry(attachment: Attachment, group: Attachment[]): void;
62
37
  };
63
38
 
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
39
  // `onLong` says when the file is too tall for the dialog, so the host turns off
73
40
  // its own scrolling and the file scrolls with the actions pinned below.
74
- export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, backLabel, onBack, gallery, onLong }: PreviewProps & { onLong(long: boolean): void }) {
41
+ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong }: PreviewProps & { onLong(long: boolean): void }) {
75
42
  const fetchPreview = useRpc(previewDeliverable);
76
43
  const readPiece = useRpc(readDeliverablePiece);
44
+ const checkHost = useRpc(canOpenOnHost);
77
45
  const queryClient = useQueryClient();
46
+ const canOpen = useQuery({
47
+ queryKey: ["progress", "can-open", workspaceId],
48
+ queryFn: () => checkHost({ workspaceId, workspaceDirectory }),
49
+ staleTime: Infinity,
50
+ }).data?.canOpen === true;
78
51
  // How much of a video has arrived, for the line under the spinner.
79
52
  const loadingKey = (target: Attachment) => ["progress", "preview-loading", workspaceId, target.ref, target.path];
80
53
  const previewQuery = (target: Attachment) => ({
@@ -121,8 +94,10 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
121
94
  if (previewKind(next.path ?? "") !== "video") void queryClient.prefetchQuery(previewQuery(next));
122
95
  }, [attachment.ref, items.length]);
123
96
  // Previous and Next, wrapping at the ends. Left and right arrows do the same.
124
- const step = (offset: number) => gallery?.onSelect(items[(Math.max(0, index) + offset + items.length) % items.length]);
125
- useArrowKeys(items.length > 1 ? step : null);
97
+ const step = (offset: number) => gallery?.onSelect(items[(index + offset + items.length) % items.length]);
98
+ const inGallery = index >= 0;
99
+ const canStep = inGallery && items.length > 1;
100
+ useArrowKeys(canStep ? step : null);
126
101
  // Next has focus when the preview opens, so Enter or Space moves on too.
127
102
  const nextButton = useRef<View>(null);
128
103
  useEffect(() => {
@@ -155,7 +130,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
155
130
  ) : null}
156
131
  {preview.error ? (
157
132
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
158
- Could not preview it: {preview.error.message}
133
+ Could not preview it: {errorText(preview.error, "something went wrong")}
159
134
  </Text>
160
135
  ) : null}
161
136
  {preview.data?.kind === "text" ? (
@@ -165,45 +140,63 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
165
140
  </Text>
166
141
  {preview.data.truncated ? (
167
142
  <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.
143
+ Showing the first 256 KB.{canOpen ? " Press Open to see the rest in its default app." : ""}
169
144
  </Text>
170
145
  ) : null}
171
146
  </View>
172
147
  ) : null}
148
+ {preview.data?.kind === "folder" ? (
149
+ <FolderList colors={colors} folder={attachment} entries={folderAttachments(attachment, preview.data.entries)} truncated={preview.data.truncated}
150
+ dimmed={stepping} onOpen={onOpenEntry} />
151
+ ) : null}
173
152
  {preview.data?.kind === "image" ? (
174
153
  <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
175
154
  ) : null}
176
155
  {preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
177
- {/* The path as a caption under the file, where it has the full width. */}
178
- {!preview.isPending && videoLoading ? (
179
- <Text style={[caption, { marginTop: -4, fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text>
180
- ) : (
181
- <Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { marginTop: -4 }]}>
182
- {attachment.path}
183
- </Text>
184
- )}
185
156
  </>
186
157
  );
158
+ // The path as a caption under the file, where it has the full width, with Copy
159
+ // beside it so it can go into a message without leaving the dialog. A long
160
+ // file scrolls above it, so it stays in view with the actions.
161
+ const path = !preview.isPending && videoLoading ? (
162
+ <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text>
163
+ ) : (
164
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
165
+ <Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { flex: 1, minWidth: 0 }]}>
166
+ {attachment.path}
167
+ </Text>
168
+ {/* Optical: the 22px button centers on the 17px caption line without growing it. */}
169
+ <PressScale accessibilityRole="button" accessibilityLabel={`Copy the path of ${attachment.title}`} hitSlop={6} outerStyle={{ marginVertical: -3 }}
170
+ onPress={onCopyPath}
171
+ style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
172
+ <Icon name="Copy" size={14} color={colors.foregroundMuted} />
173
+ </PressScale>
174
+ </View>
175
+ );
187
176
  // Back on the left, Previous and Next in the middle, Open on the right.
188
177
  const actions = (
189
178
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
190
179
  <View style={{ flex: 1, alignItems: "flex-start" }}>
191
- {onBack ? <BackButton colors={colors} label={backLabel ?? "Back"} onPress={onBack} /> : null}
180
+ {onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
192
181
  </View>
193
- {items.length > 1 ? <Stepper colors={colors} index={Math.max(0, index)} count={items.length} onStep={step} nextRef={nextButton} /> : null}
182
+ {canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
194
183
  <View style={{ flex: 1, alignItems: "flex-end" }}>
195
- <DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} />
184
+ {canOpen ? <DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} /> : null}
196
185
  </View>
197
186
  </View>
198
187
  );
199
188
  return long ? (
200
189
  <>
201
190
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
191
+ <View style={{ marginTop: 8 }}>{path}</View>
202
192
  {actions}
203
193
  </>
204
194
  ) : (
205
195
  <View style={{ gap: 20 }}>
206
- <View style={{ gap: 12 }}>{content}</View>
196
+ <View style={{ gap: 8 }}>
197
+ <View style={{ gap: 12 }}>{content}</View>
198
+ {path}
199
+ </View>
207
200
  {actions}
208
201
  </View>
209
202
  );
@@ -214,14 +207,28 @@ const CONTENT_WIDTH = 472;
214
207
  // Characters of 12px monospace per line of the text box (10px padding each side).
215
208
  const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
216
209
 
210
+ const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
211
+
217
212
  // 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 {
213
+ function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
219
214
  if (!data) return 0;
215
+ if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
220
216
  if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
221
217
  const lines = data.text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0);
222
218
  return lines * 18 + 20;
223
219
  }
224
220
 
221
+ function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { colors: Colors; folder: Attachment; entries: Attachment[]; truncated: boolean; dimmed: boolean; onOpen(attachment: Attachment, group: Attachment[]): void }) {
222
+ const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
223
+ if (!entries.length) return <Text style={caption}>Nothing in {folder.title} yet.</Text>;
224
+ return (
225
+ <View style={{ gap: 8, opacity: dimmed ? 0.6 : 1 }}>
226
+ <AttachmentList colors={colors} attachments={entries} onOpen={onOpen} />
227
+ {truncated ? <Text style={caption}>Showing the first {entries.length}.</Text> : null}
228
+ </View>
229
+ );
230
+ }
231
+
225
232
  // An image's width over its height, or null if it can't be read. Read from the data
226
233
  // because onLoad doesn't report a size for data URIs on every platform.
227
234
  function imageRatio(uri: string): Promise<number | null> {
@@ -253,8 +260,8 @@ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress,
253
260
  }
254
261
 
255
262
  // Returns to the dialog this one was opened from. For surface1 dialogs.
256
- export function BackButton({ colors, label, onPress }: { colors: Colors; label: string; onPress(): void }) {
257
- return <DialogButton colors={colors} icon="ChevronLeft" label={label} onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
263
+ export function BackButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
264
+ return <DialogButton colors={colors} icon="ChevronLeft" label="Back" onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
258
265
  }
259
266
 
260
267
  // Previous and next through a gallery, with "2 of 3" between.
@@ -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.message}</Text> : null}
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 />
@@ -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.1.0",
3
+ "version": "1.3.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 { open, readFile, realpath, stat } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { join, relative, resolve } from "node:path";
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 const systemOpener: Opener = async (path) => {
38
- const browser = process.platform === "darwin" ? await defaultBrowser() : null;
39
- const [command, args] = openCommand(path, process.platform, browser);
40
- await promisify(execFile)(command, args);
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 path = findAttachment(dashboard, ref)?.path;
50
- if (!path) throw new Error(`No local ${ref.startsWith("Q") ? "attachment" : "deliverable"} ${ref} in this worktree.`);
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 } = await stat(target);
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, systemOpener);
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
  }
@@ -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