@stevecastaneda/paseo-progress 1.0.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 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 and text files the agent records open in a preview inside Paseo. 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 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">
@@ -22,7 +22,7 @@ The panel lives in Explorer, beside the agent chat, so you can watch both. Open
22
22
 
23
23
  <img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
24
24
 
25
- On phones, where Paseo has no Explorer pane, Progress opens as a tab of its own, and the pill stays above the message box while a run is open so there's always a way in. SVG files preview on desktop; on phones they open on the daemon host.
25
+ On phones, where Paseo has no Explorer pane, Progress opens as a tab of its own, and the pill stays above the message box while a run is open so there's always a way in. SVG files and videos preview on desktop; on phones they open on the daemon host.
26
26
 
27
27
  The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `progress-panel-opened` file next to the progress file and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
28
28
 
@@ -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. 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,25 @@ 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 and the first 256 KB of a text file. Other files open in their default app.
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
+
136
+ ### 1.1.0
137
+
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.
139
+ - The left and right arrow keys move to the previous and next file in a preview. **Next** has focus when the preview opens, so Enter moves on too.
140
+ - An image too tall for the preview, like a phone screenshot, could make it flicker between two sizes until you closed it. It now opens at its real shape and holds still.
141
+
128
142
  ### 1.0.0
129
143
 
130
144
  - Version 1.0. Nothing you use changes. From now on, a new first number means an update breaks something, a new middle number means a new feature, and a new last number means a fix.
@@ -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 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,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); }} 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); }} 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} label={back.label} onPress={back.onPress} /> : null}
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 stepName(step: Step): string {
114
- return step.kind === "preview" ? step.attachment.title : step.id;
107
+ function stepKey(step: Step): string {
108
+ return step.kind === "preview" ? `preview:${step.attachment.ref}` : `${step.kind}:${step.id}`;
115
109
  }
110
+
@@ -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
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
2
- import { AccessibilityInfo, Animated, Easing, Platform, Pressable, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
2
+ import { AccessibilityInfo, Animated, Easing, Platform, Pressable, type PressableProps, type StyleProp, type View, type ViewStyle } from "react-native";
3
3
 
4
4
  // Read at render time, not import time. The native driver can't run on web,
5
5
  // and web is the only place filter blur renders.
@@ -24,17 +24,11 @@ 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;
30
+ // Reaches the pressable itself, e.g. to focus it.
31
+ ref?: React.Ref<View>;
38
32
  // Layout for the pressable itself, e.g. alignSelf; `style` is what scales.
39
33
  outerStyle?: StyleProp<ViewStyle>;
40
34
  style?: StyleProp<ViewStyle> | ((state: { pressed: boolean; hovered: boolean }) => StyleProp<ViewStyle>);
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(); }}