@stevecastaneda/paseo-progress 1.12.1 → 1.13.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.13 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, with Markdown formatted, 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.
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, PDFs and text files the agent records open in a preview inside Paseo, with Markdown formatted, and a folder opens as a list of its files. Videos and PDFs show in the preview only in the desktop and web apps. A PDF in the Deliverables tab also has a button beside Copy that opens it in a Paseo tab, which is where it shows on an iPhone or iPad. Any PDF you open in a Paseo tab shows there too. 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. In Settings you choose what the agent does while it waits: stop, work around the question, or keep building on its recommendation.
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. You pick what the agent does while you decide in Paseo's Settings (Settings, Plugins, Progress, Settings). **Stop**: it stops all work until you answer. **Work around**, the default: it holds what your answer affects and keeps working on the rest. **Keep building**: it builds its recommendation and changes course if you pick another, except for big questions, which wait. A question is big when building ahead would send something off your computer, when the answer sets something people or programs outside the repo rely on, or when most of the remaining work would be built on it. Each open question shows what the agent is doing: "Agent stopped until you answer", "Waiting for you" (with the reason when it's big), or "Building with B: <its label>". 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.
16
+ - **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the option the agent recommends. You pick what the agent does while you decide in Paseo's Settings (Settings, Plugins, Progress, Settings). **Stop**: it stops all work until you answer. **Work around**, the default: it holds what your answer affects and keeps working on the rest. **Keep building**: it builds its recommendation and changes course if you pick another, except for big questions, which wait. A question is big when building ahead would send something off your computer, when the answer sets something people or programs outside the repo rely on, or when most of the remaining work would be built on it. Each open question shows what the agent is doing: "Agent stopped until you answer", "Waiting for you" (with the reason when it's big), or "Building with B: <its label>". 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, PDFs 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">
@@ -24,7 +24,7 @@ The panel lives in Explorer, beside the agent chat, so you can watch both. Open
24
24
 
25
25
  <img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
26
26
 
27
- On phones, Progress opens in Explorer too. On any phone, 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.
27
+ On phones, Progress opens in Explorer too. On any phone, the pill stays above the message box while a run is open, so there's always a way in. SVG files, videos and PDFs preview on desktop and in Paseo's web app; on phones they open on the daemon host. A PDF in the Deliverables tab also has a button beside Copy that opens it in a Paseo tab, which is where it shows on an iPhone or iPad.
28
28
 
29
29
  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.
30
30
 
@@ -120,7 +120,7 @@ paseo-progress finish "Snapshots ship for all four tables"
120
120
  - 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.
121
121
  - `question ask` needs `--recommend`, the agent's pick. The question dialog tags that option "Recommended". Its output tells the agent the question mode and what to do until the answer. `--big "<why>"` marks a big question, which waits for the answer even in Keep building. `start` and `show` also print the mode.
122
122
  - Paseo keeps the question mode in its own settings, which only the plugin's server can read, so the server copies it to `~/.local/state/paseo-progress/settings.json` for the command. Without that file, for example while the plugin is off, the command uses Work around.
123
- - 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. Markdown shows formatted, with links and images relative to the file's own folder; **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.
123
+ - 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. Markdown shows formatted, with links and images relative to the file's own folder; **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, and a PDF (up to 100 MB) shows there too. 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.
124
124
  - 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.
125
125
  - 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.
126
126
  - `start` begins a fresh dashboard. Earlier runs stay in the file. Question references stay unique across runs.
@@ -140,11 +140,15 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
140
140
  - 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/`.
141
141
  - 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.
142
142
  - 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.
143
- - 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.
143
+ - Previews cover images up to 10 MB, videos up to 200 MB and PDFs up to 100 MB (desktop and web apps 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.
144
144
  - Stale warnings and overdue times use the daemon host's clock.
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.13.0
149
+
150
+ - [New] **PDF previews.** A PDF the agent records now opens in the preview in Paseo's desktop and web apps, with a loading percentage while it arrives. Any PDF you open in a Paseo tab now shows in that tab, up to 100 MB on desktop and 25 MB on a phone. A PDF deliverable also has a button beside Copy that opens it in a tab, which is how you see it on an iPhone or iPad.
151
+
148
152
  ### 1.12.1
149
153
 
150
154
  - [Fixed] **Long files fill the preview on a phone.** In Paseo's web app at phone width, a long file opened from a list of several files filled only the top half of the sheet, and the bottom half stayed empty. The file now fills the sheet down to its path and buttons.
@@ -1,9 +1,11 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/ui";
2
+ import { Icon, Markdown } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useState } from "react";
4
- import { Text, View } from "react-native";
4
+ import { Platform, Text, View } from "react-native";
5
5
  import type { Deliverable } from "../shared/dashboard";
6
- import { type Attachment, attachmentIcon, deliverableAttachment, shortName, useAttachmentOpener } from "./attachments";
6
+ import { opensInTab } from "../shared/attachments";
7
+ import { localLinkTarget } from "../shared/markdown";
8
+ import { type Attachment, absolutePath, attachmentIcon, deliverableAttachment, shortName, useAttachmentOpener } from "./attachments";
7
9
  import { formatClientAgo } from "./i18n";
8
10
  import { PressScale } from "./motion";
9
11
  import { StackedDialog } from "./dialog-stack";
@@ -17,6 +19,10 @@ export { absolutePath } from "./attachments";
17
19
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
18
20
  type Navigation = PluginWorkspacePanelProps["navigation"];
19
21
 
22
+ const ROW_GAP = 8;
23
+ const BUTTON_PAIR_GAP = 4;
24
+ const TAP_AREA_FILL = Array(8).fill("████").join(" ");
25
+
20
26
  // The Deliverables tab: newest first, 10 at a time (see usePaged).
21
27
  export function DeliverablesList({ colors, deliverables, now, workspaceId, workspaceDirectory, navigation }: {
22
28
  colors: Colors;
@@ -32,37 +38,63 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
32
38
  const paged = usePaged(deliverables);
33
39
  return (
34
40
  <View>
35
- {paged.shown.map((deliverable, index) => (
36
- <PressableRow key={deliverable.id} colors={colors} accessibilityRole={deliverable.url ? "link" : "button"}
37
- accessibilityLabel={m.openItem(deliverable.title)}
38
- onPress={() => void open(deliverableAttachment(deliverable))}
39
- style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
40
- {/* Optical: centers the 14px icon on the 18px title line. */}
41
- <View style={{ paddingTop: 2 }}><Icon name={attachmentIcon(deliverableAttachment(deliverable))} size={14} color={colors.foregroundMuted} /></View>
42
- <View style={{ flex: 1, minWidth: 0, gap: 1 }}>
43
- {/* What the agent found is the headline; the row itself is what opens. */}
44
- <Text numberOfLines={3} style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{deliverable.title}</Text>
45
- {/* Just the name, ticket and age. The full path is one press of Copy away. */}
46
- <Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
47
- {[shortName(deliverableAttachment(deliverable)), deliverable.ticketLabel ?? deliverable.ticketId, formatClientAgo(locale, deliverable.addedAt, now)].filter(Boolean).join(" · ")}
48
- </Text>
49
- </View>
50
- {deliverable.path ? (
51
- // Optical: the 22px button centers on the 18px title line.
52
- <PressScale accessibilityRole="button" accessibilityLabel={m.copyItemPath(deliverable.title)} hitSlop={6} outerStyle={{ marginTop: -2 }}
53
- onPress={() => void copyPath(deliverableAttachment(deliverable))}
54
- // A step above the row's own hover tint (surface1), so it reads as its own target.
55
- style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
56
- <Icon name="Copy" size={14} color={colors.foregroundMuted} />
57
- </PressScale>
58
- ) : (
59
- <View style={{ paddingTop: 2 }}><Icon name="ExternalLink" size={14} color={colors.foregroundMuted} /></View>
60
- )}
61
- </PressableRow>
62
- ))}
41
+ {paged.shown.map((deliverable, index) => {
42
+ const attachment = deliverableAttachment(deliverable);
43
+ const tab = opensInTab(attachment, { platform: Platform.OS });
44
+ return (
45
+ <PressableRow key={deliverable.id} colors={colors} accessibilityRole={deliverable.url ? "link" : "button"}
46
+ accessibilityLabel={m.openItem(deliverable.title)}
47
+ onPress={() => void open(attachment)}
48
+ style={{ flexDirection: "row", gap: ROW_GAP, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
49
+ {/* Optical: centers the 14px icon on the 18px title line. */}
50
+ <View style={{ paddingTop: 2 }}><Icon name={attachmentIcon(attachment)} size={14} color={colors.foregroundMuted} /></View>
51
+ <View style={{ flex: 1, minWidth: 0, gap: 1 }}>
52
+ {/* What the agent found is the headline; the row itself is what opens. */}
53
+ <Text numberOfLines={3} style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{deliverable.title}</Text>
54
+ {/* Just the name, ticket and age. The full path is one press of Copy away. */}
55
+ <Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
56
+ {[shortName(attachment), deliverable.ticketLabel ?? deliverable.ticketId, formatClientAgo(locale, deliverable.addedAt, now)].filter(Boolean).join(" · ")}
57
+ </Text>
58
+ </View>
59
+ {tab ? (
60
+ <TabButton colors={colors} label={m.openInTab(deliverable.title)} target={localLinkTarget(absolutePath(deliverable.path!, workspaceDirectory))} />
61
+ ) : null}
62
+ {deliverable.path ? (
63
+ // Optical: the 22px button centers on the 18px title line.
64
+ <PressScale accessibilityRole="button" accessibilityLabel={m.copyItemPath(deliverable.title)}
65
+ hitSlop={tab ? { top: 6, bottom: 6, left: BUTTON_PAIR_GAP / 2, right: 6 } : 6} outerStyle={{ marginTop: -2 }}
66
+ onPress={() => void copyPath(attachment)}
67
+ // A step above the row's own hover tint (surface1), so it reads as its own target.
68
+ style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
69
+ <Icon name="Copy" size={14} color={colors.foregroundMuted} />
70
+ </PressScale>
71
+ ) : (
72
+ <View style={{ paddingTop: 2 }}><Icon name="ExternalLink" size={14} color={colors.foregroundMuted} /></View>
73
+ )}
74
+ </PressableRow>
75
+ );
76
+ })}
63
77
  <ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
64
78
  <StackedDialog colors={colors} now={now} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory, navigation }}
65
79
  root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)), fullScreen: false } : null} />
66
80
  </View>
67
81
  );
68
82
  }
83
+
84
+ // A Paseo Markdown link to a local file is the only way a plugin can open a tab.
85
+ // A browser sends touches to a clear view; iOS ignores a view under 1% opaque.
86
+ function TabButton({ colors, label, target }: { colors: Colors; label: string; target: string }) {
87
+ const linkTakesFocus = Platform.OS === "web";
88
+ const keepPressFromRow = () => {};
89
+ return (
90
+ <PressScale accessibilityRole="button" accessibilityLabel={label} focusable={!linkTakesFocus}
91
+ outerStyle={{ marginTop: -2, marginEnd: BUTTON_PAIR_GAP - ROW_GAP, alignSelf: "flex-start" }}
92
+ onPress={keepPressFromRow}
93
+ style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, overflow: "hidden", backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
94
+ <Icon name="Maximize2" size={14} color={colors.foregroundMuted} />
95
+ <View style={{ position: "absolute", top: -24, start: -24, width: 72, height: 72, opacity: Platform.OS === "web" ? 0 : 0.011 }}>
96
+ <Markdown>{`[${TAP_AREA_FILL}](${target})`}</Markdown>
97
+ </View>
98
+ </PressScale>
99
+ );
100
+ }
@@ -103,7 +103,8 @@ export const messages: ProgressMessages = {
103
103
  exitFullScreen: "Exit full screen",
104
104
  fitToWidth: "Fit to width",
105
105
  fitToScreen: "Fit to screen",
106
- videoChanged: "the video changed while it was loading. Try again.",
106
+ fileChanged: "the file changed while it was loading. Try again.",
107
+ openInTab: (title: string) => `Open ${title} in a tab`,
107
108
  localHistory: "Run history stays on this computer.",
108
109
  historyStopped: "Runs will stay on this computer. Files already saved stay in the repo.",
109
110
  historyFailure: "Could not change where runs are saved.",
@@ -193,7 +194,8 @@ export const messages: ProgressMessages = {
193
194
  newVersion: (version: string) => `New in ${version}:`,
194
195
  updatedTo: (version: string) => `Updated to ${version}:`,
195
196
  whatsNewTitle: (version: string) => `What’s new in Progress ${version}`,
196
- loadingVideo: (percent: number) => `Loading video, ${percent}%`,
197
+ loadingPieces: (kind, percent: number) => `Loading ${kind === "pdf" ? "PDF" : "video"}, ${percent}%`,
198
+ pdfTooLarge: (megabytes: number) => `This PDF is ${megabytes} MB, too large to show here.`,
197
199
  previewError: (error: string) => `Could not preview it: ${error}`,
198
200
  emptyFolder: (title: string) => `Nothing in ${title} yet.`,
199
201
  firstEntries: (count: number) => `Showing the first ${count}.`,
@@ -103,7 +103,8 @@ export const messages: ProgressMessages = {
103
103
  exitFullScreen: "退出全屏",
104
104
  fitToWidth: "适应宽度",
105
105
  fitToScreen: "适应屏幕",
106
- videoChanged: "视频在加载过程中发生了变化。请重试。",
106
+ fileChanged: "文件在加载过程中发生了变化。请重试。",
107
+ openInTab: (title: string) => `在标签页中打开 ${title}`,
107
108
  localHistory: "运行记录保存在这台电脑上。",
108
109
  historyStopped: "运行记录将保存在这台电脑上。已保存的文件会保留在仓库中。",
109
110
  historyFailure: "无法更改运行记录的保存位置。",
@@ -193,7 +194,8 @@ export const messages: ProgressMessages = {
193
194
  newVersion: (version: string) => `${version} 的更新:`,
194
195
  updatedTo: (version: string) => `已更新到 ${version}:`,
195
196
  whatsNewTitle: (version: string) => `Progress ${version} 更新内容`,
196
- loadingVideo: (percent: number) => `正在加载视频,${percent}%`,
197
+ loadingPieces: (kind, percent: number) => `正在加载${kind === "pdf" ? " PDF" : "视频"},${percent}%`,
198
+ pdfTooLarge: (megabytes: number) => `此 PDF 有 ${megabytes} MB,太大,无法在这里显示。`,
197
199
  previewError: (error: string) => `无法预览:${error}`,
198
200
  emptyFolder: (title: string) => `${title} 中暂无内容。`,
199
201
  firstEntries: (count: number) => `仅显示前 ${count} 项。`,
@@ -1,3 +1,4 @@
1
+ import type { PiecedKind } from "../shared/attachments";
1
2
  import type { TicketStatus } from "../shared/events";
2
3
  import type { QuestionMode } from "../shared/question-modes";
3
4
  import type { NoteKind } from "../shared/whats-new";
@@ -85,7 +86,8 @@ export interface ProgressMessages {
85
86
  exitFullScreen: string;
86
87
  fitToWidth: string;
87
88
  fitToScreen: string;
88
- videoChanged: string;
89
+ fileChanged: string;
90
+ openInTab: (title: string) => string;
89
91
  localHistory: string;
90
92
  historyStopped: string;
91
93
  historyFailure: string;
@@ -175,7 +177,8 @@ export interface ProgressMessages {
175
177
  newVersion: (version: string) => string;
176
178
  updatedTo: (version: string) => string;
177
179
  whatsNewTitle: (version: string) => string;
178
- loadingVideo: (percent: number) => string;
180
+ loadingPieces: (kind: PiecedKind, percent: number) => string;
181
+ pdfTooLarge: (megabytes: number) => string;
179
182
  previewError: (error: string) => string;
180
183
  emptyFolder: (title: string) => string;
181
184
  firstEntries: (count: number) => string;
package/client/pdf.tsx ADDED
@@ -0,0 +1,43 @@
1
+ import { type PluginFile, type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client";
2
+ import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
3
+ import React from "react";
4
+ import { Text, View, type ViewStyle } from "react-native";
5
+ import { errorText } from "./errors";
6
+ import { useProgressLanguage } from "./language";
7
+ import { type FileBlob, useObjectUrl } from "./pieces";
8
+
9
+ type Colors = PluginFilePanelProps["theme"]["colors"];
10
+
11
+ export function PdfFrame({ blob, style }: { blob: FileBlob; style: ViewStyle }) {
12
+ const url = useObjectUrl(blob);
13
+ return <View style={[style, { overflow: "hidden" }]}>{url ? <DocumentFrame url={url} /> : null}</View>;
14
+ }
15
+
16
+ export const TAB_LIMIT_BYTES: Record<"web" | "native", number> = {
17
+ web: 100 * 1024 * 1024,
18
+ native: 25 * 1024 * 1024,
19
+ };
20
+
21
+ export function PdfPanel({ file, layout, theme }: PluginFilePanelProps) {
22
+ const { messages: m } = useProgressLanguage();
23
+ const limit = TAB_LIMIT_BYTES[layout.platform === "web" ? "web" : "native"];
24
+ if (file.size > limit) return <Note colors={theme.colors}>{m.pdfTooLarge(Math.round(file.size / 1024 / 1024))}</Note>;
25
+ return <PdfFile file={file} colors={theme.colors} />;
26
+ }
27
+
28
+ function PdfFile({ file, colors }: { file: PluginFile; colors: Colors }) {
29
+ const { messages: m } = useProgressLanguage();
30
+ const url = useFileUrl(file);
31
+ if (url.status === "loading") return <Loading />;
32
+ if (url.status === "error") return <Note colors={colors} alert>{m.previewError(errorText(url.error, m.unknownError))}</Note>;
33
+ return <View style={{ flex: 1, minHeight: 0 }}><DocumentFrame url={url.url} /></View>;
34
+ }
35
+
36
+ function Note({ colors, alert = false, children }: { colors: Colors; alert?: boolean; children: string }) {
37
+ return (
38
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }}>
39
+ <Text accessibilityRole={alert ? "alert" : undefined} selectable
40
+ style={{ maxWidth: 360, textAlign: "center", color: alert ? colors.statusDanger : colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{children}</Text>
41
+ </View>
42
+ );
43
+ }
@@ -0,0 +1,47 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export const browser = globalThis as unknown as {
4
+ atob(data: string): string;
5
+ Blob: new (parts: Uint8Array[], options: { type: string }) => FileBlob;
6
+ URL: { createObjectURL(blob: FileBlob): string; revokeObjectURL(url: string): void };
7
+ };
8
+
9
+ export type FileBlob = { readonly size: number };
10
+
11
+ export class FileChangedError extends Error {}
12
+
13
+ export async function loadPieces({ bytes, mimeType, readPiece, onProgress }: {
14
+ bytes: number;
15
+ mimeType: string;
16
+ readPiece(offset: number): Promise<{ data: string; bytes: number }>;
17
+ onProgress(loaded: number): void;
18
+ }): Promise<FileBlob> {
19
+ const pieces: Uint8Array[] = [];
20
+ let loaded = 0;
21
+ while (loaded < bytes) {
22
+ const piece = await readPiece(loaded);
23
+ const decoded = decode(piece.data);
24
+ if (piece.bytes !== bytes || !decoded.length) throw new FileChangedError("the file changed while it was loading. Try again.");
25
+ pieces.push(decoded);
26
+ loaded += decoded.length;
27
+ onProgress(loaded);
28
+ }
29
+ return new browser.Blob(pieces, { type: mimeType });
30
+ }
31
+
32
+ function decode(data: string): Uint8Array {
33
+ const binary = browser.atob(data);
34
+ const bytes = new Uint8Array(binary.length);
35
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
36
+ return bytes;
37
+ }
38
+
39
+ export function useObjectUrl(blob: FileBlob): string | null {
40
+ const [url, setUrl] = useState<string | null>(null);
41
+ useEffect(() => {
42
+ const created = browser.URL.createObjectURL(blob);
43
+ setUrl(created);
44
+ return () => browser.URL.revokeObjectURL(created);
45
+ }, [blob]);
46
+ return url;
47
+ }
@@ -5,11 +5,13 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
5
5
  import { ActivityIndicator, Image, type LayoutChangeEvent, Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
6
6
  import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
7
7
  import { errorText } from "./errors";
8
- import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
8
+ import { folderAttachments, isFolder, isPieced, PDF_MIME_TYPE, previewKind } from "../shared/attachments";
9
9
  import { canOpenOnHost, previewDeliverable, readDeliverablePiece } from "../shared/rpc";
10
10
  import { PressScale } from "./motion";
11
11
  import { imageOutline, raised } from "./surfaces";
12
- import { loadVideo, VideoChangedError, VideoPlayer } from "./video";
12
+ import { FileChangedError, loadPieces } from "./pieces";
13
+ import { PdfFrame } from "./pdf";
14
+ import { videoRatio, VideoPlayer } from "./video";
13
15
  import { useProgressLanguage } from "./language";
14
16
 
15
17
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
@@ -56,7 +58,6 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
56
58
  queryFn: () => checkHost({ workspaceId, workspaceDirectory }),
57
59
  staleTime: Infinity,
58
60
  }).data?.canOpen === true;
59
- // How much of a video has arrived, for the line under the spinner.
60
61
  const loadingKey = (target: Attachment) => ["progress", "preview-loading", workspaceId, target.ref, target.path];
61
62
  const previewQuery = (target: Attachment) => ({
62
63
  queryKey: ["progress", "preview", workspaceId, target.ref, target.path],
@@ -67,20 +68,17 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
67
68
  queryFn: async () => {
68
69
  const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
69
70
  if (data.kind === "image") return { ...data, ...await imageSize(data.dataUri) };
70
- if (data.kind !== "video") return data;
71
+ if (data.kind !== "video" && data.kind !== "pdf") return data;
71
72
  queryClient.setQueryData(loadingKey(target), 0);
72
- const video = await loadVideo({
73
+ const blob = await loadPieces({
73
74
  bytes: data.bytes,
74
- mimeType: data.mimeType,
75
+ mimeType: data.kind === "video" ? data.mimeType : PDF_MIME_TYPE,
75
76
  readPiece: (offset) => readPiece({ workspaceId, workspaceDirectory, ref: target.ref, offset }),
76
77
  onProgress: (loaded) => queryClient.setQueryData(loadingKey(target), loaded / data.bytes),
77
78
  });
78
- return { ...data, video, ratio: video.ratio };
79
+ return data.kind === "video" ? { ...data, blob, ratio: await videoRatio(blob) } : { ...data, blob };
79
80
  },
80
- // A video isn't refetched while it's open: that would load it again and stop it playing.
81
- staleTime: previewKind(target.path ?? "") === "video" ? Infinity : 30_000,
82
- // Images arrive as data URIs up to ~13 MB and videos run to 200 MB; let them
83
- // go soon after the dialog moves on.
81
+ staleTime: isPieced(previewKind(target.path ?? "")) ? Infinity : 30_000,
84
82
  gcTime: 30_000,
85
83
  // A file that's missing, too large or outside the worktree fails the same way
86
84
  // every time, so the reason shows at once instead of after seconds of retries.
@@ -92,8 +90,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
92
90
  const stepping = preview.isPlaceholderData;
93
91
  const loading = useQuery({ queryKey: loadingKey(attachment), queryFn: () => 0, enabled: false }).data;
94
92
  // Load the next file in the direction of travel ahead of time, so stepping is
95
- // instant without holding both neighbors in memory. Not a video: it can be
96
- // 200 MB, so it loads only when opened.
93
+ // instant without holding both neighbors in memory.
97
94
  const items = gallery?.items ?? [];
98
95
  const index = items.findIndex((item) => item.ref === attachment.ref);
99
96
  const lastIndex = useRef(index);
@@ -102,7 +99,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
102
99
  const step = (lastIndex.current - index + items.length) % items.length === 1 ? -1 : 1;
103
100
  lastIndex.current = index;
104
101
  const next = items[(index + step + items.length) % items.length];
105
- if (previewKind(next.path ?? "") !== "video") void queryClient.prefetchQuery(previewQuery(next));
102
+ if (!isPieced(previewKind(next.path ?? ""))) void queryClient.prefetchQuery(previewQuery(next));
106
103
  }, [attachment.ref, items.length]);
107
104
  // Previous and Next, wrapping at the ends. Left and right arrows do the same.
108
105
  const step = (offset: number) => gallery?.onSelect(items[(index + offset + items.length) % items.length]);
@@ -145,8 +142,9 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
145
142
  if (fullScreen && !preview.isPending && !showsLayer) onFullScreen(false);
146
143
  }, [fullScreen, preview.isPending, showsLayer]);
147
144
  const room = roomForContent(windowHeight);
148
- const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
149
- const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
145
+ const maxMediaHeight = preview.data?.kind === "video" || preview.data?.kind === "pdf" ? Math.min(640, Math.max(200, room)) : 640;
146
+ const mediaFrame = { borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
147
+ const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, ...mediaFrame } as const;
150
148
  // Markdown's height depends on the user's text size, which a plugin can't read,
151
149
  // so only a file far too tall for any size is long before it's drawn. Anything
152
150
  // else is measured, and once it's too tall it stays long: Paseo remounting the
@@ -167,14 +165,13 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
167
165
  const measure = !long && !stepping ? (event: LayoutChangeEvent) => {
168
166
  if (event.nativeEvent.layout.height > room) queryClient.setQueryData(longKey, true);
169
167
  } : undefined;
170
- // How far a video has loaded: under the spinner, or in place of the path while
171
- // the last file stays on screen, so nothing moves.
172
- const videoLoading = preview.isFetching && loading !== undefined && loading < 1 ? m.loadingVideo(Math.floor(loading * 100)) : null;
168
+ const fileKind = previewKind(attachment.path ?? "");
169
+ const piecesLoading = isPieced(fileKind) && preview.isFetching && loading !== undefined && loading < 1 ? m.loadingPieces(fileKind, Math.floor(loading * 100)) : null;
173
170
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
174
171
  const loadingState = (
175
172
  <View style={{ alignItems: "center", gap: 8, padding: 24 }}>
176
173
  <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingPreview} />
177
- {videoLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text> : null}
174
+ {piecesLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{piecesLoading}</Text> : null}
178
175
  </View>
179
176
  );
180
177
  const truncatedNote = (paddingTop: number) => <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop }}>{m.truncatedText}{canOpen ? m.openRest : ""}</Text>;
@@ -183,7 +180,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
183
180
  {preview.isPending ? loadingState : null}
184
181
  {preview.error ? (
185
182
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
186
- {m.previewError(preview.error instanceof VideoChangedError ? m.videoChanged : errorText(preview.error, m.unknownError))}
183
+ {m.previewError(preview.error instanceof FileChangedError ? m.fileChanged : errorText(preview.error, m.unknownError))}
187
184
  </Text>
188
185
  ) : null}
189
186
  {preview.data?.kind === "text" ? (
@@ -212,14 +209,15 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
212
209
  <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
213
210
  </Pressable>
214
211
  ) : null}
215
- {preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
212
+ {preview.data?.kind === "video" ? <VideoPlayer blob={preview.data.blob} label={attachment.title} style={mediaStyle} /> : null}
213
+ {preview.data?.kind === "pdf" ? <PdfFrame blob={preview.data.blob} style={{ width: "100%", height: maxMediaHeight, ...mediaFrame }} /> : null}
216
214
  </>
217
215
  );
218
216
  // The path as a caption under the file, where it has the full width, with Copy
219
217
  // beside it so it can go into a message without leaving the dialog. A long
220
218
  // file scrolls above it, so it stays in view with the actions.
221
- const path = !preview.isPending && videoLoading ? (
222
- <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text>
219
+ const path = !preview.isPending && piecesLoading ? (
220
+ <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{piecesLoading}</Text>
223
221
  ) : (
224
222
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
225
223
  <Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { flex: 1, minWidth: 0 }]}>
@@ -380,9 +378,10 @@ const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
380
378
  const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
381
379
 
382
380
  // Roughly how tall the preview draws, including wrapped lines.
383
- function estimatedHeight(data: { kind: "image" | "video" | "html" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
381
+ function estimatedHeight(data: { kind: "image" | "video" | "pdf" | "html" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
384
382
  if (!data) return 0;
385
383
  if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
384
+ if (data.kind === "pdf") return maxMediaHeight;
386
385
  if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
387
386
  const lines = data.text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0);
388
387
  return lines * 18 + 20;
package/client/video.tsx CHANGED
@@ -1,9 +1,7 @@
1
- import React, { useEffect, useState } from "react";
1
+ import React from "react";
2
2
  import { View, type ViewStyle } from "react-native";
3
+ import { browser, type FileBlob, useObjectUrl } from "./pieces";
3
4
 
4
- // Videos play only in Paseo's desktop app (see canPreview), which is a browser
5
- // underneath. The plugin compiles without the DOM's types, so these are just the
6
- // browser APIs used here.
7
5
  interface VideoElement {
8
6
  preload: string;
9
7
  src: string;
@@ -12,57 +10,13 @@ interface VideoElement {
12
10
  onloadedmetadata: (() => void) | null;
13
11
  onerror: (() => void) | null;
14
12
  }
15
- const browser = globalThis as unknown as {
16
- atob(data: string): string;
17
- Blob: new (parts: Uint8Array[], options: { type: string }) => VideoBlob;
18
- URL: { createObjectURL(blob: VideoBlob): string; revokeObjectURL(url: string): void };
19
- document: { createElement(tag: "video"): VideoElement };
20
- };
21
-
22
- // A whole video held in the app, ready to play.
23
- export type VideoBlob = { readonly size: number };
24
-
25
- export interface LoadedVideo {
26
- blob: VideoBlob;
27
- // Width over height, or null when the app can't read it (the box stays 16:10).
28
- ratio: number | null;
29
- }
30
-
31
- export class VideoChangedError extends Error {}
32
-
33
- // Fetches a video piece by piece into one blob, then reads its shape.
34
- export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
35
- bytes: number;
36
- mimeType: string;
37
- readPiece(offset: number): Promise<{ data: string; bytes: number }>;
38
- onProgress(loaded: number): void;
39
- }): Promise<LoadedVideo> {
40
- const pieces: Uint8Array[] = [];
41
- let loaded = 0;
42
- while (loaded < bytes) {
43
- const piece = await readPiece(loaded);
44
- const decoded = decode(piece.data);
45
- if (piece.bytes !== bytes || !decoded.length) throw new VideoChangedError("the video changed while it was loading. Try again.");
46
- pieces.push(decoded);
47
- loaded += decoded.length;
48
- onProgress(loaded);
49
- }
50
- const blob = new browser.Blob(pieces, { type: mimeType });
51
- return { blob, ratio: await videoRatio(blob) };
52
- }
53
-
54
- function decode(data: string): Uint8Array {
55
- const binary = browser.atob(data);
56
- const bytes = new Uint8Array(binary.length);
57
- for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
58
- return bytes;
59
- }
13
+ const page = globalThis as unknown as { document: { createElement(tag: "video"): VideoElement } };
60
14
 
61
15
  // Reads the size from the video's header without playing it. Gives up after a
62
16
  // few seconds, so a video the app can't decode still shows its player and error.
63
- function videoRatio(blob: VideoBlob): Promise<number | null> {
17
+ export function videoRatio(blob: FileBlob): Promise<number | null> {
64
18
  const url = browser.URL.createObjectURL(blob);
65
- const video = browser.document.createElement("video");
19
+ const video = page.document.createElement("video");
66
20
  return new Promise<number | null>((resolve) => {
67
21
  setTimeout(() => resolve(null), 5_000);
68
22
  video.onloadedmetadata = () => resolve(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : null);
@@ -77,13 +31,8 @@ function videoRatio(blob: VideoBlob): Promise<number | null> {
77
31
  }
78
32
 
79
33
  // The browser's own player, with its controls, in a box styled like an image's.
80
- export function VideoPlayer({ video, label, style }: { video: LoadedVideo; label: string; style: ViewStyle }) {
81
- const [url, setUrl] = useState<string | null>(null);
82
- useEffect(() => {
83
- const created = browser.URL.createObjectURL(video.blob);
84
- setUrl(created);
85
- return () => browser.URL.revokeObjectURL(created);
86
- }, [video.blob]);
34
+ export function VideoPlayer({ blob, label, style }: { blob: FileBlob; label: string; style: ViewStyle }) {
35
+ const url = useObjectUrl(blob);
87
36
  return (
88
37
  <View style={[style, { overflow: "hidden" }]}>
89
38
  {url ? <video src={url} controls playsInline aria-label={label} style={{ width: "100%", height: "100%", display: "block", objectFit: "contain" }} /> : null}
package/index.client.tsx CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { Platform } from "react-native";
2
3
  import { ProgressPanel } from "./client/panel";
3
4
  import { contributePills, progressPanel } from "./client/pills";
4
5
  import { QuestionSettings } from "./client/settings";
5
6
  import { ProgressLanguageProvider } from "./client/language";
6
7
  import { addPageScreen } from "./client/page-screen";
8
+ import { PdfPanel } from "./client/pdf";
9
+ import { showsPdfs } from "./shared/attachments";
7
10
 
8
11
  const SETTINGS_ID = "settings";
9
12
 
@@ -28,6 +31,13 @@ export default function contribute(client: PluginClientContext) {
28
31
  },
29
32
  });
30
33
 
34
+ const stopPdf = showsPdfs(Platform.OS) ? client.addFilePanel({
35
+ id: "pdf",
36
+ title: "PDF",
37
+ icon: "BookOpenText",
38
+ files: { extensions: ["pdf"] },
39
+ Component: (props) => <ProgressLanguageProvider><PdfPanel {...props} /></ProgressLanguageProvider>,
40
+ }) : () => {};
31
41
  const stopPage = addPageScreen(client);
32
42
  const stopSettings = client.addSettingsScreen({
33
43
  id: SETTINGS_ID,
@@ -38,6 +48,7 @@ export default function contribute(client: PluginClientContext) {
38
48
 
39
49
  return () => {
40
50
  stopPage();
51
+ void stopPdf();
41
52
  void stopSettings();
42
53
  void stopPanel();
43
54
  void stopCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.12.1",
3
+ "version": "1.13.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/open.ts CHANGED
@@ -5,7 +5,7 @@ import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
5
5
  import { homedir, hostname } from "node:os";
6
6
  import nodePath, { delimiter, dirname, join, relative, resolve } from "node:path";
7
7
  import { promisify } from "node:util";
8
- import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, textFormat, videoMimeType } from "../shared/attachments.ts";
8
+ import { findAttachment, type FolderEntry, imageMimeType, isPieced, type PiecedKind, previewKind, splitRef, textFormat, videoMimeType } from "../shared/attachments.ts";
9
9
  import { prepareMarkdown, previewText } from "../shared/markdown.ts";
10
10
  import { readProgress } from "./progress-file.ts";
11
11
  import { findRoot, outside } from "./paths.ts";
@@ -82,17 +82,20 @@ export const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
82
82
  // Text past this is cut off in the preview; the full file is a click away.
83
83
  export const MAX_PREVIEW_TEXT_BYTES = 256 * 1024;
84
84
 
85
- // A video travels in pieces, so it's capped by what the app can hold, not the socket.
86
- export const MAX_PREVIEW_VIDEO_BYTES = 200 * 1024 * 1024;
85
+ export const PIECED_LIMIT_BYTES: Record<PiecedKind, number> = {
86
+ video: 200 * 1024 * 1024,
87
+ pdf: 100 * 1024 * 1024,
88
+ };
87
89
 
88
90
  // Small enough that one piece doesn't hold up the agent output sharing the socket.
89
- export const VIDEO_PIECE_BYTES = 4 * 1024 * 1024;
91
+ export const PIECE_BYTES = 4 * 1024 * 1024;
90
92
 
91
93
  export const MAX_FOLDER_ENTRIES = 500;
92
94
 
93
95
  export type Preview =
94
96
  | { kind: "image"; dataUri: string; bytes: number }
95
97
  | { kind: "video"; mimeType: string; bytes: number }
98
+ | { kind: "pdf"; bytes: number }
96
99
  | { kind: "text"; text: string; bytes: number; truncated: boolean }
97
100
  | { kind: "markdown"; markdown: string; bytes: number; truncated: boolean }
98
101
  | { kind: "html"; html: string; bytes: number }
@@ -103,11 +106,11 @@ export async function previewDeliverable(directory: string, id: string): Promise
103
106
  const stats = await stat(realTarget);
104
107
  if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(realTarget)) };
105
108
  const kind = previewKind(realTarget);
106
- if (!kind) throw new Error(`${path} isn't an image, video, text or HTML file, so it can't be previewed.`);
109
+ if (!kind) throw new Error(`${path} isn't an image, video, PDF, text or HTML file, so it can't be previewed.`);
107
110
  const { size } = stats;
108
- if (kind === "video") {
109
- if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
110
- return { kind, mimeType: videoMimeType(realTarget)!, bytes: size };
111
+ if (isPieced(kind)) {
112
+ if (size > PIECED_LIMIT_BYTES[kind]) throw new Error(tooLarge(path, size));
113
+ return kind === "video" ? { kind, mimeType: videoMimeType(realTarget)!, bytes: size } : { kind, bytes: size };
111
114
  }
112
115
  if (kind === "text") {
113
116
  // Read only what's shown, so a huge log doesn't load whole.
@@ -195,16 +198,15 @@ function tooLarge(path: string, size: number): string {
195
198
  return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
196
199
  }
197
200
 
198
- // One piece of a video, from `offset`. Resolved by reference each time, like the
199
- // preview, so a piece can only come from a video the agent recorded.
200
201
  export async function readDeliverablePiece(directory: string, id: string, offset: number): Promise<{ data: string; bytes: number }> {
201
202
  const { path, realTarget } = await resolveAttachment(directory, id, "previewed");
202
- if (previewKind(realTarget) !== "video") throw new Error(`${path} isn't a video.`);
203
+ const kind = previewKind(realTarget);
204
+ if (!isPieced(kind)) throw new Error(`${path} isn't a video or PDF.`);
203
205
  const file = await open(realTarget);
204
206
  try {
205
207
  const { size } = await file.stat();
206
- if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
207
- const length = Math.max(0, Math.min(VIDEO_PIECE_BYTES, size - offset));
208
+ if (size > PIECED_LIMIT_BYTES[kind]) throw new Error(tooLarge(path, size));
209
+ const length = Math.max(0, Math.min(PIECE_BYTES, size - offset));
208
210
  if (!length) return { data: "", bytes: size };
209
211
  const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(length), position: offset });
210
212
  return { data: buffer.subarray(0, bytesRead).toString("base64"), bytes: size };
@@ -97,12 +97,25 @@ export function textFormat(path: string): "markdown" | "plain" | null {
97
97
  return TEXT_FORMATS[extension(path) ?? ""] ?? null;
98
98
  }
99
99
 
100
- export function previewKind(path: string): "image" | "video" | "text" | "html" | null {
100
+ export type PiecedKind = "video" | "pdf";
101
+ export type PreviewKind = "image" | PiecedKind | "text" | "html";
102
+
103
+ export function isPieced(kind: PreviewKind | null): kind is PiecedKind {
104
+ return kind === "video" || kind === "pdf";
105
+ }
106
+
107
+ export const PDF_MIME_TYPE = "application/pdf";
108
+
109
+ const DOCUMENT_KINDS: Record<string, "pdf" | "html"> = {
110
+ ".pdf": "pdf",
111
+ ".html": "html",
112
+ ".htm": "html",
113
+ };
114
+
115
+ export function previewKind(path: string): PreviewKind | null {
101
116
  if (imageMimeType(path)) return "image";
102
117
  if (videoMimeType(path)) return "video";
103
- const ext = extension(path) ?? "";
104
- if (ext === ".html" || ext === ".htm") return "html";
105
- return textFormat(path) ? "text" : null;
118
+ return DOCUMENT_KINDS[extension(path) ?? ""] ?? (textFormat(path) ? "text" : null);
106
119
  }
107
120
 
108
121
  export function isFolder(attachment: Attachment): boolean {
@@ -128,18 +141,26 @@ export function folderAttachments(folder: Attachment, entries: FolderEntry[]): A
128
141
  return entries.map((entry) => ({ ref: entryRef(folder.ref, entry.name), title: entry.name, path: `${base}${entry.name}${entry.folder ? "/" : ""}` }));
129
142
  }
130
143
 
131
- // Whether the preview dialog can show it. Only a browser draws SVG and plays
132
- // video: Paseo's desktop app can, the phone apps can't, so there they open on
133
- // the host like any other file.
144
+ // Whether the preview dialog can show it. Only a browser draws SVG, plays video
145
+ // and shows a PDF from memory: Paseo's desktop and web apps can, the phone apps
146
+ // can't, so there they open on the host like any other file.
134
147
  export function canPreview(attachment: Attachment, where: { browser: boolean }): boolean {
135
148
  if (!attachment.path) return false;
136
149
  if (isFolder(attachment)) return true;
137
150
  const kind = previewKind(attachment.path);
138
151
  if (kind === "html") return false;
139
- if (kind === "video" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
152
+ if (kind === "video" || kind === "pdf" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
140
153
  return kind !== null;
141
154
  }
142
155
 
156
+ const PREVIEW_ICON: Record<PreviewKind, string> = {
157
+ image: "Image",
158
+ video: "Video",
159
+ pdf: "BookOpenText",
160
+ text: "FileText",
161
+ html: "FileCode",
162
+ };
163
+
143
164
  const KIND_ICON: Record<NonNullable<Deliverable["kind"]>, string> = {
144
165
  file: "File",
145
166
  folder: "Folder",
@@ -155,7 +176,7 @@ export function attachmentIcon(attachment: Attachment): string {
155
176
  if (attachment.url) return "Globe";
156
177
  if (!attachment.path) return "File";
157
178
  const kind = previewKind(attachment.path);
158
- return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : kind === "html" ? "FileCode" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
179
+ return kind ? PREVIEW_ICON[kind] : /[\/]$/.test(attachment.path) ? "Folder" : "File";
159
180
  }
160
181
 
161
182
  // An HTML page is too wide for the dialog, so where the host can't open it the
@@ -164,6 +185,15 @@ export function opensAsPage(attachment: Attachment, where: { browser: boolean })
164
185
  return where.browser && Boolean(attachment.path) && previewKind(attachment.path!) === "html";
165
186
  }
166
187
 
188
+ // Android's viewer has no PDF support.
189
+ export function showsPdfs(platform: string): boolean {
190
+ return platform !== "android";
191
+ }
192
+
193
+ export function opensInTab(attachment: Attachment, where: { platform: string }): boolean {
194
+ return showsPdfs(where.platform) && previewKind(attachment.path ?? "") === "pdf";
195
+ }
196
+
167
197
  // The short name people recognize: a file or folder's own name, or a link's site.
168
198
  export function shortName(attachment: Attachment): string {
169
199
  if (attachment.url) {
@@ -348,6 +348,10 @@ function decode(text: string): string {
348
348
  }
349
349
  }
350
350
 
351
+ export function localLinkTarget(path: string): string {
352
+ return encodeTarget(/^[A-Za-z]:[\\/]/.test(path) ? path.replace(/\\/g, "/") : path);
353
+ }
354
+
351
355
  // A Windows drive's colon stays, so Paseo still sees `C:/`.
352
356
  function encodeTarget(path: string): string {
353
357
  const drive = /^[A-Za-z]:\//.exec(path)?.[0] ?? "";
package/shared/rpc.ts CHANGED
@@ -142,9 +142,7 @@ export const canOpenOnHost = defineRpc({
142
142
  output: z.object({ canOpen: z.boolean() }),
143
143
  });
144
144
 
145
- // An image, text, Markdown or HTML file's contents, for the preview dialog. A
146
- // video comes back as its type and size only; its bytes follow in pieces
147
- // (readDeliverablePiece).
145
+ // An image, text, Markdown or HTML file's contents, for the preview dialog.
148
146
  export const previewDeliverable = defineRpc({
149
147
  name: "progress.deliverable.preview",
150
148
  input: z.object({
@@ -156,6 +154,7 @@ export const previewDeliverable = defineRpc({
156
154
  output: z.discriminatedUnion("kind", [
157
155
  z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
158
156
  z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
157
+ z.object({ kind: z.literal("pdf"), bytes: z.number() }),
159
158
  z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
160
159
  z.object({ kind: z.literal("markdown"), markdown: z.string(), bytes: z.number(), truncated: z.boolean() }),
161
160
  z.object({ kind: z.literal("html"), html: z.string(), bytes: z.number() }),
@@ -163,8 +162,6 @@ export const previewDeliverable = defineRpc({
163
162
  ]),
164
163
  });
165
164
 
166
- // One piece of a video from `offset`, base64, with the file's size so the client
167
- // knows when it has it all.
168
165
  export const readDeliverablePiece = defineRpc({
169
166
  name: "progress.deliverable.piece",
170
167
  input: z.object({