@stevecastaneda/paseo-progress 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +1 -1
- package/README.md +10 -4
- package/client/attachments.tsx +1 -1
- package/client/motion.tsx +3 -1
- package/client/preview.tsx +123 -47
- package/client/video.tsx +90 -0
- package/index.server.ts +3 -2
- package/package.json +1 -1
- package/server/open.ts +43 -3
- package/shared/attachments.ts +20 -5
- package/shared/rpc.ts +16 -1
- package/skills/paseo-progress/SKILL.md +2 -2
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. Videos play only in the desktop app. Any other file opens in an app on the computer running the Paseo daemon, and so does a previewed file when you press Open. That's true from your phone too. The app opens on that computer, not on the phone.
|
|
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), 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. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser and folders in the file manager). On macOS, **Open** sends text files to your default browser. Only recorded deliverables inside the worktree open this way; otherwise the path is copied. The copy icon on each row copies the path.
|
|
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,17 @@ 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), and the first 256 KB of a text file. Other files open in their default app. A video the app can't decode shows the player's error, and **Open** still works.
|
|
124
124
|
- Stale warnings and overdue times use the daemon host's clock.
|
|
125
125
|
|
|
126
126
|
## Changelog
|
|
127
127
|
|
|
128
|
+
### 1.1.0
|
|
129
|
+
|
|
130
|
+
- 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.
|
|
131
|
+
- 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.
|
|
132
|
+
- 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.
|
|
133
|
+
|
|
128
134
|
### 1.0.0
|
|
129
135
|
|
|
130
136
|
- 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.
|
package/client/attachments.tsx
CHANGED
|
@@ -25,7 +25,7 @@ export function absolutePath(path: string, root: string): string {
|
|
|
25
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
|
|
28
|
+
// (the system browser where there is none); images, videos and text go to `onPreview` when the
|
|
29
29
|
// caller can show the preview dialog; every other local file opens with its
|
|
30
30
|
// default app on the daemon host.
|
|
31
31
|
export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview }: {
|
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.
|
|
@@ -35,6 +35,8 @@ export function useLastPresent<T>(value: T | null): T | null {
|
|
|
35
35
|
// A button that scales to 0.96 while pressed. `static` turns the scale off.
|
|
36
36
|
export function PressScale({ static: isStatic, style, outerStyle, children, ...props }: Omit<PressableProps, "style" | "children"> & {
|
|
37
37
|
static?: boolean;
|
|
38
|
+
// Reaches the pressable itself, e.g. to focus it.
|
|
39
|
+
ref?: React.Ref<View>;
|
|
38
40
|
// Layout for the pressable itself, e.g. alignSelf; `style` is what scales.
|
|
39
41
|
outerStyle?: StyleProp<ViewStyle>;
|
|
40
42
|
style?: StyleProp<ViewStyle> | ((state: { pressed: boolean; hovered: boolean }) => StyleProp<ViewStyle>);
|
package/client/preview.tsx
CHANGED
|
@@ -4,9 +4,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
4
4
|
import React, { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
|
|
6
6
|
import { type Attachment, attachmentIcon, isPreviewable } from "./attachments";
|
|
7
|
-
import {
|
|
7
|
+
import { previewKind } from "../shared/attachments";
|
|
8
|
+
import { previewDeliverable, readDeliverablePiece } from "../shared/rpc";
|
|
8
9
|
import { PressScale, useLastPresent } from "./motion";
|
|
9
10
|
import { imageOutline, raised } from "./surfaces";
|
|
11
|
+
import { loadVideo, VideoPlayer } from "./video";
|
|
10
12
|
|
|
11
13
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
14
|
|
|
@@ -71,31 +73,43 @@ export function PreviewBody(props: PreviewProps) {
|
|
|
71
73
|
// its own scrolling and the file scrolls with the actions pinned below.
|
|
72
74
|
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, backLabel, onBack, gallery, onLong }: PreviewProps & { onLong(long: boolean): void }) {
|
|
73
75
|
const fetchPreview = useRpc(previewDeliverable);
|
|
76
|
+
const readPiece = useRpc(readDeliverablePiece);
|
|
77
|
+
const queryClient = useQueryClient();
|
|
78
|
+
// How much of a video has arrived, for the line under the spinner.
|
|
79
|
+
const loadingKey = (target: Attachment) => ["progress", "preview-loading", workspaceId, target.ref, target.path];
|
|
74
80
|
const previewQuery = (target: Attachment) => ({
|
|
75
81
|
queryKey: ["progress", "preview", workspaceId, target.ref, target.path],
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
82
|
+
// An image or video's shape is measured here and cached with it, not held in
|
|
83
|
+
// this component: Paseo remounts it when `onLong` flips the host's scrolling,
|
|
84
|
+
// and a remount that forgot the shape fell back to 16:10, flipped `long` back,
|
|
85
|
+
// and so on, so a tall image made the dialog jump between two sizes until closed.
|
|
86
|
+
queryFn: async () => {
|
|
87
|
+
const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
|
|
88
|
+
if (data.kind === "image") return { ...data, ratio: await imageRatio(data.dataUri) };
|
|
89
|
+
if (data.kind !== "video") return data;
|
|
90
|
+
queryClient.setQueryData(loadingKey(target), 0);
|
|
91
|
+
const video = await loadVideo({
|
|
92
|
+
bytes: data.bytes,
|
|
93
|
+
mimeType: data.mimeType,
|
|
94
|
+
readPiece: (offset) => readPiece({ workspaceId, workspaceDirectory, ref: target.ref, offset }),
|
|
95
|
+
onProgress: (loaded) => queryClient.setQueryData(loadingKey(target), loaded / data.bytes),
|
|
96
|
+
});
|
|
97
|
+
return { ...data, video, ratio: video.ratio };
|
|
98
|
+
},
|
|
99
|
+
// A video isn't refetched while it's open: that would load it again and stop it playing.
|
|
100
|
+
staleTime: previewKind(target.path ?? "") === "video" ? Infinity : 30_000,
|
|
101
|
+
// Images arrive as data URIs up to ~13 MB and videos run to 200 MB; let them
|
|
102
|
+
// go soon after the dialog moves on.
|
|
79
103
|
gcTime: 30_000,
|
|
80
104
|
});
|
|
81
105
|
// Stepping to another file keeps this one on screen (dimmed) until the next loads,
|
|
82
106
|
// so the dialog doesn't collapse to a spinner and back.
|
|
83
107
|
const preview = useQuery({ ...previewQuery(attachment), placeholderData: (previous) => previous });
|
|
84
108
|
const stepping = preview.isPlaceholderData;
|
|
85
|
-
|
|
86
|
-
// report a size for data URIs on every platform; without it the box stays 16:10.
|
|
87
|
-
const imageUri = !stepping && preview.data?.kind === "image" ? preview.data.dataUri : null;
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
if (!imageUri) return;
|
|
90
|
-
let current = true;
|
|
91
|
-
Image.getSize(imageUri, (width, height) => {
|
|
92
|
-
if (current && width && height) setMeasured({ ref: attachment.ref, ratio: width / height });
|
|
93
|
-
}, () => {});
|
|
94
|
-
return () => { current = false; };
|
|
95
|
-
}, [imageUri, attachment.ref]);
|
|
109
|
+
const loading = useQuery({ queryKey: loadingKey(attachment), queryFn: () => 0, enabled: false }).data;
|
|
96
110
|
// Load the next file in the direction of travel ahead of time, so stepping is
|
|
97
|
-
// instant without holding both neighbors in memory.
|
|
98
|
-
|
|
111
|
+
// instant without holding both neighbors in memory. Not a video: it can be
|
|
112
|
+
// 200 MB, so it loads only when opened.
|
|
99
113
|
const items = gallery?.items ?? [];
|
|
100
114
|
const index = items.findIndex((item) => item.ref === attachment.ref);
|
|
101
115
|
const lastIndex = useRef(index);
|
|
@@ -103,20 +117,42 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
103
117
|
if (index < 0 || items.length < 2) return;
|
|
104
118
|
const step = (lastIndex.current - index + items.length) % items.length === 1 ? -1 : 1;
|
|
105
119
|
lastIndex.current = index;
|
|
106
|
-
|
|
120
|
+
const next = items[(index + step + items.length) % items.length];
|
|
121
|
+
if (previewKind(next.path ?? "") !== "video") void queryClient.prefetchQuery(previewQuery(next));
|
|
107
122
|
}, [attachment.ref, items.length]);
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
// Previous and Next, wrapping at the ends. Left and right arrows do the same.
|
|
124
|
+
const step = (offset: number) => gallery?.onSelect(items[(Math.max(0, index) + offset + items.length) % items.length]);
|
|
125
|
+
useArrowKeys(items.length > 1 ? step : null);
|
|
126
|
+
// Next has focus when the preview opens, so Enter or Space moves on too.
|
|
127
|
+
const nextButton = useRef<View>(null);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
(nextButton.current as { focus?(): void } | null)?.focus?.();
|
|
130
|
+
}, []);
|
|
131
|
+
// Screenshots and recordings vary from phone-tall to full-width; size to the real
|
|
132
|
+
// file. While stepping, the dimmed one on screen keeps its shape until the next arrives.
|
|
133
|
+
const aspectRatio = preview.data && "ratio" in preview.data && preview.data.ratio ? preview.data.ratio : 16 / 10;
|
|
134
|
+
// A video fits the dialog whole, so the controls along its bottom stay in view
|
|
135
|
+
// without scrolling. A tall image scrolls instead, to keep it readable.
|
|
136
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
137
|
+
const room = roomForContent(windowHeight);
|
|
138
|
+
const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
|
|
139
|
+
const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
|
|
112
140
|
// A long file gets its own scroll area so the actions below it stay in view.
|
|
113
141
|
// Short ones keep the dialog sized to them.
|
|
114
|
-
const
|
|
115
|
-
const long = estimatedHeight(preview.data, aspectRatio) > roomForContent(windowHeight);
|
|
142
|
+
const long = estimatedHeight(preview.data, aspectRatio, maxMediaHeight) > room;
|
|
116
143
|
useEffect(() => onLong(long), [long]);
|
|
144
|
+
// How far a video has loaded: under the spinner, or in place of the path while
|
|
145
|
+
// the last file stays on screen, so nothing moves.
|
|
146
|
+
const videoLoading = preview.isFetching && loading !== undefined && loading < 1 ? `Loading video, ${Math.floor(loading * 100)}%` : null;
|
|
147
|
+
const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
|
|
117
148
|
const content = (
|
|
118
149
|
<>
|
|
119
|
-
{preview.isPending ?
|
|
150
|
+
{preview.isPending ? (
|
|
151
|
+
<View style={{ alignItems: "center", gap: 8, padding: 24 }}>
|
|
152
|
+
<ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading preview" />
|
|
153
|
+
{videoLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text> : null}
|
|
154
|
+
</View>
|
|
155
|
+
) : null}
|
|
120
156
|
{preview.error ? (
|
|
121
157
|
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
|
|
122
158
|
Could not preview it: {preview.error.message}
|
|
@@ -135,17 +171,17 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
135
171
|
</View>
|
|
136
172
|
) : null}
|
|
137
173
|
{preview.data?.kind === "image" ? (
|
|
138
|
-
<Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain"
|
|
139
|
-
onLoad={(event) => {
|
|
140
|
-
const { width, height } = event.nativeEvent.source ?? {};
|
|
141
|
-
if (width && height && !stepping) setMeasured({ ref: attachment.ref, ratio: width / height });
|
|
142
|
-
}}
|
|
143
|
-
style={{ width: "100%", aspectRatio, maxHeight: 640, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 }} />
|
|
174
|
+
<Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
|
|
144
175
|
) : null}
|
|
176
|
+
{preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
|
|
145
177
|
{/* The path as a caption under the file, where it has the full width. */}
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
|
|
178
|
+
{!preview.isPending && videoLoading ? (
|
|
179
|
+
<Text style={[caption, { marginTop: -4, fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text>
|
|
180
|
+
) : (
|
|
181
|
+
<Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { marginTop: -4 }]}>
|
|
182
|
+
{attachment.path}
|
|
183
|
+
</Text>
|
|
184
|
+
)}
|
|
149
185
|
</>
|
|
150
186
|
);
|
|
151
187
|
// Back on the left, Previous and Next in the middle, Open on the right.
|
|
@@ -154,7 +190,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
154
190
|
<View style={{ flex: 1, alignItems: "flex-start" }}>
|
|
155
191
|
{onBack ? <BackButton colors={colors} label={backLabel ?? "Back"} onPress={onBack} /> : null}
|
|
156
192
|
</View>
|
|
157
|
-
{
|
|
193
|
+
{items.length > 1 ? <Stepper colors={colors} index={Math.max(0, index)} count={items.length} onStep={step} nextRef={nextButton} /> : null}
|
|
158
194
|
<View style={{ flex: 1, alignItems: "flex-end" }}>
|
|
159
195
|
<DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} />
|
|
160
196
|
</View>
|
|
@@ -179,13 +215,21 @@ const CONTENT_WIDTH = 472;
|
|
|
179
215
|
const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
|
|
180
216
|
|
|
181
217
|
// Roughly how tall the preview draws, including wrapped lines.
|
|
182
|
-
function estimatedHeight(data: { kind: "image" } | { kind: "text"; text: string } | undefined, aspectRatio: number): number {
|
|
218
|
+
function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | undefined, aspectRatio: number, maxMediaHeight: number): number {
|
|
183
219
|
if (!data) return 0;
|
|
184
|
-
if (data.kind
|
|
220
|
+
if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
|
|
185
221
|
const lines = data.text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0);
|
|
186
222
|
return lines * 18 + 20;
|
|
187
223
|
}
|
|
188
224
|
|
|
225
|
+
// An image's width over its height, or null if it can't be read. Read from the data
|
|
226
|
+
// because onLoad doesn't report a size for data URIs on every platform.
|
|
227
|
+
function imageRatio(uri: string): Promise<number | null> {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
Image.getSize(uri, (width, height) => resolve(width && height ? width / height : null), () => resolve(null));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
// Height the dialog has for the preview before it must scroll: Paseo caps the card
|
|
190
234
|
// at 85% of the window (inside a 24px margin), less its header and padding, the
|
|
191
235
|
// path caption (17px after an 8px gap) and the footer (30px after a 20px gap).
|
|
@@ -194,9 +238,9 @@ function roomForContent(windowHeight: number): number {
|
|
|
194
238
|
}
|
|
195
239
|
|
|
196
240
|
// A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
|
|
197
|
-
export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, outerStyle }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; outerStyle?: ViewStyle }) {
|
|
241
|
+
export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
|
|
198
242
|
return (
|
|
199
|
-
<PressScale accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
|
|
243
|
+
<PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
|
|
200
244
|
style={({ pressed }) => ({
|
|
201
245
|
flexDirection: "row", alignItems: "center", gap: 6,
|
|
202
246
|
paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
|
|
@@ -213,16 +257,48 @@ export function BackButton({ colors, label, onPress }: { colors: Colors; label:
|
|
|
213
257
|
return <DialogButton colors={colors} icon="ChevronLeft" label={label} onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
|
|
214
258
|
}
|
|
215
259
|
|
|
216
|
-
// Previous and next through a gallery,
|
|
217
|
-
function Stepper({ colors,
|
|
218
|
-
const { items } = gallery;
|
|
219
|
-
const index = Math.max(0, items.findIndex((item) => item.ref === current.ref));
|
|
220
|
-
const go = (step: number) => gallery.onSelect(items[(index + step + items.length) % items.length]);
|
|
260
|
+
// Previous and next through a gallery, with "2 of 3" between.
|
|
261
|
+
function Stepper({ colors, index, count, onStep, nextRef }: { colors: Colors; index: number; count: number; onStep(offset: number): void; nextRef: React.Ref<View> }) {
|
|
221
262
|
return (
|
|
222
263
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
223
|
-
<DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel="Previous file" onPress={() =>
|
|
224
|
-
<Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {
|
|
225
|
-
<DialogButton colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() =>
|
|
264
|
+
<DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel="Previous file" onPress={() => onStep(-1)} />
|
|
265
|
+
<Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {count}</Text>
|
|
266
|
+
<DialogButton ref={nextRef} colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() => onStep(1)} />
|
|
226
267
|
</View>
|
|
227
268
|
);
|
|
228
269
|
}
|
|
270
|
+
|
|
271
|
+
// Calls `onStep` with -1 or 1 for the left and right arrow keys, in the desktop
|
|
272
|
+
// app. Not while typing, nor on a focused video, whose controls seek with them.
|
|
273
|
+
function useArrowKeys(onStep: ((offset: number) => void) | null) {
|
|
274
|
+
const latest = useRef(onStep);
|
|
275
|
+
latest.current = onStep;
|
|
276
|
+
const listening = Platform.OS === "web" && onStep !== null;
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (!listening) return;
|
|
279
|
+
const window = globalThis as unknown as {
|
|
280
|
+
addEventListener(type: "keydown", listener: (event: KeyEvent) => void): void;
|
|
281
|
+
removeEventListener(type: "keydown", listener: (event: KeyEvent) => void): void;
|
|
282
|
+
};
|
|
283
|
+
const onKeyDown = (event: KeyEvent) => {
|
|
284
|
+
const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
|
|
285
|
+
if (!offset || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
286
|
+
if (/^(VIDEO|INPUT|TEXTAREA|SELECT)$/.test(event.target?.tagName ?? "") || event.target?.isContentEditable) return;
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
latest.current?.(offset);
|
|
289
|
+
};
|
|
290
|
+
window.addEventListener("keydown", onKeyDown);
|
|
291
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
292
|
+
}, [listening]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type KeyEvent = {
|
|
296
|
+
key: string;
|
|
297
|
+
altKey: boolean;
|
|
298
|
+
ctrlKey: boolean;
|
|
299
|
+
metaKey: boolean;
|
|
300
|
+
shiftKey: boolean;
|
|
301
|
+
defaultPrevented: boolean;
|
|
302
|
+
target: { tagName?: string; isContentEditable?: boolean } | null;
|
|
303
|
+
preventDefault(): void;
|
|
304
|
+
};
|
package/client/video.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View, type ViewStyle } from "react-native";
|
|
3
|
+
|
|
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
|
+
interface VideoElement {
|
|
8
|
+
preload: string;
|
|
9
|
+
src: string;
|
|
10
|
+
videoWidth: number;
|
|
11
|
+
videoHeight: number;
|
|
12
|
+
onloadedmetadata: (() => void) | null;
|
|
13
|
+
onerror: (() => void) | null;
|
|
14
|
+
}
|
|
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
|
+
// Fetches a video piece by piece into one blob, then reads its shape.
|
|
32
|
+
export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
|
|
33
|
+
bytes: number;
|
|
34
|
+
mimeType: string;
|
|
35
|
+
readPiece(offset: number): Promise<{ data: string; bytes: number }>;
|
|
36
|
+
onProgress(loaded: number): void;
|
|
37
|
+
}): Promise<LoadedVideo> {
|
|
38
|
+
const pieces: Uint8Array[] = [];
|
|
39
|
+
let loaded = 0;
|
|
40
|
+
while (loaded < bytes) {
|
|
41
|
+
const piece = await readPiece(loaded);
|
|
42
|
+
const decoded = decode(piece.data);
|
|
43
|
+
if (piece.bytes !== bytes || !decoded.length) throw new Error("the video changed while it was loading. Try again.");
|
|
44
|
+
pieces.push(decoded);
|
|
45
|
+
loaded += decoded.length;
|
|
46
|
+
onProgress(loaded);
|
|
47
|
+
}
|
|
48
|
+
const blob = new browser.Blob(pieces, { type: mimeType });
|
|
49
|
+
return { blob, ratio: await videoRatio(blob) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decode(data: string): Uint8Array {
|
|
53
|
+
const binary = browser.atob(data);
|
|
54
|
+
const bytes = new Uint8Array(binary.length);
|
|
55
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
56
|
+
return bytes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reads the size from the video's header without playing it. Gives up after a
|
|
60
|
+
// few seconds, so a video the app can't decode still shows its player and error.
|
|
61
|
+
function videoRatio(blob: VideoBlob): Promise<number | null> {
|
|
62
|
+
const url = browser.URL.createObjectURL(blob);
|
|
63
|
+
const video = browser.document.createElement("video");
|
|
64
|
+
return new Promise<number | null>((resolve) => {
|
|
65
|
+
setTimeout(() => resolve(null), 5_000);
|
|
66
|
+
video.onloadedmetadata = () => resolve(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : null);
|
|
67
|
+
video.onerror = () => resolve(null);
|
|
68
|
+
video.preload = "metadata";
|
|
69
|
+
video.src = url;
|
|
70
|
+
}).finally(() => {
|
|
71
|
+
video.onloadedmetadata = video.onerror = null;
|
|
72
|
+
video.src = "";
|
|
73
|
+
browser.URL.revokeObjectURL(url);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The browser's own player, with its controls, in a box styled like an image's.
|
|
78
|
+
export function VideoPlayer({ video, label, style }: { video: LoadedVideo; label: string; style: ViewStyle }) {
|
|
79
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const created = browser.URL.createObjectURL(video.blob);
|
|
82
|
+
setUrl(created);
|
|
83
|
+
return () => browser.URL.revokeObjectURL(created);
|
|
84
|
+
}, [video.blob]);
|
|
85
|
+
return (
|
|
86
|
+
<View style={[style, { overflow: "hidden" }]}>
|
|
87
|
+
{url ? <video src={url} controls playsInline aria-label={label} style={{ width: "100%", height: "100%", display: "block", objectFit: "contain" }} /> : null}
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
package/index.server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPanelOpened, handleSetHistoryFolder } from "./server/dashboard";
|
|
3
3
|
import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
|
|
4
|
-
import { handleOpenDeliverable, handlePreviewDeliverable } from "./server/open";
|
|
4
|
+
import { handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
|
|
5
5
|
import { handleInstallSkill, handleSkillStatus } from "./server/skill";
|
|
6
|
-
import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, setHistoryFolder } from "./shared/rpc";
|
|
6
|
+
import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
7
7
|
|
|
8
8
|
export default function contribute(server: PluginServerContext) {
|
|
9
9
|
server.handle(getDashboard, handleGetDashboard);
|
|
@@ -13,6 +13,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
13
13
|
server.handle(installLauncher, handleInstallLauncher);
|
|
14
14
|
server.handle(openDeliverable, handleOpenDeliverable);
|
|
15
15
|
server.handle(previewDeliverable, handlePreviewDeliverable);
|
|
16
|
+
server.handle(readDeliverablePiece, handleReadDeliverablePiece);
|
|
16
17
|
server.handle(getSkillStatus, handleSkillStatus);
|
|
17
18
|
server.handle(installSkill, handleInstallSkill);
|
|
18
19
|
server.handle(listFolders, handleListFolders);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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
|
@@ -4,7 +4,7 @@ import { open, readFile, realpath, stat } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, relative, resolve } from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
-
import { findAttachment, imageMimeType, previewKind } from "../shared/attachments.ts";
|
|
7
|
+
import { findAttachment, imageMimeType, previewKind, videoMimeType } from "../shared/attachments.ts";
|
|
8
8
|
import { readProgress } from "./progress-file.ts";
|
|
9
9
|
import { findRoot, outside } from "./paths.ts";
|
|
10
10
|
import { findStore } from "./store.ts";
|
|
@@ -68,8 +68,15 @@ export const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
|
|
68
68
|
// Text past this is cut off in the preview; the full file is a click away.
|
|
69
69
|
export const MAX_PREVIEW_TEXT_BYTES = 256 * 1024;
|
|
70
70
|
|
|
71
|
+
// A video travels in pieces, so it's capped by what the app can hold, not the socket.
|
|
72
|
+
export const MAX_PREVIEW_VIDEO_BYTES = 200 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
// Small enough that one piece doesn't hold up the agent output sharing the socket.
|
|
75
|
+
export const VIDEO_PIECE_BYTES = 4 * 1024 * 1024;
|
|
76
|
+
|
|
71
77
|
export type Preview =
|
|
72
78
|
| { kind: "image"; dataUri: string; bytes: number }
|
|
79
|
+
| { kind: "video"; mimeType: string; bytes: number }
|
|
73
80
|
| { kind: "text"; text: string; bytes: number; truncated: boolean };
|
|
74
81
|
|
|
75
82
|
// An image (as a data URI) or a text file's contents, so any client (desktop
|
|
@@ -77,8 +84,12 @@ export type Preview =
|
|
|
77
84
|
export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
|
|
78
85
|
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
79
86
|
const kind = previewKind(target);
|
|
80
|
-
if (!kind) throw new Error(`${path} isn't an image or text file, so it can't be previewed.`);
|
|
87
|
+
if (!kind) throw new Error(`${path} isn't an image, video or text file, so it can't be previewed.`);
|
|
81
88
|
const { size } = await stat(target);
|
|
89
|
+
if (kind === "video") {
|
|
90
|
+
if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
|
|
91
|
+
return { kind, mimeType: videoMimeType(target)!, bytes: size };
|
|
92
|
+
}
|
|
82
93
|
if (kind === "text") {
|
|
83
94
|
// Read only what's shown, so a huge log doesn't load whole.
|
|
84
95
|
const file = await open(target);
|
|
@@ -89,11 +100,33 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
89
100
|
await file.close();
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
|
-
if (size > MAX_PREVIEW_BYTES) throw new Error(
|
|
103
|
+
if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
|
|
93
104
|
const data = await readFile(target);
|
|
94
105
|
return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
function tooLarge(path: string, size: number): string {
|
|
109
|
+
return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// One piece of a video, from `offset`. Resolved by reference each time, like the
|
|
113
|
+
// preview, so a piece can only come from a video the agent recorded.
|
|
114
|
+
export async function readDeliverablePiece(directory: string, id: string, offset: number): Promise<{ data: string; bytes: number }> {
|
|
115
|
+
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
116
|
+
if (previewKind(target) !== "video") throw new Error(`${path} isn't a video.`);
|
|
117
|
+
const file = await open(target);
|
|
118
|
+
try {
|
|
119
|
+
const { size } = await file.stat();
|
|
120
|
+
if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
|
|
121
|
+
const length = Math.max(0, Math.min(VIDEO_PIECE_BYTES, size - offset));
|
|
122
|
+
if (!length) return { data: "", bytes: size };
|
|
123
|
+
const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(length), position: offset });
|
|
124
|
+
return { data: buffer.subarray(0, bytesRead).toString("base64"), bytes: size };
|
|
125
|
+
} finally {
|
|
126
|
+
await file.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
97
130
|
export async function handlePreviewDeliverable(
|
|
98
131
|
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
99
132
|
_context: PluginHandlerContext,
|
|
@@ -101,6 +134,13 @@ export async function handlePreviewDeliverable(
|
|
|
101
134
|
return previewDeliverable(input.workspaceDirectory, input.ref);
|
|
102
135
|
}
|
|
103
136
|
|
|
137
|
+
export async function handleReadDeliverablePiece(
|
|
138
|
+
input: { workspaceId: string; workspaceDirectory: string; ref: string; offset: number },
|
|
139
|
+
_context: PluginHandlerContext,
|
|
140
|
+
) {
|
|
141
|
+
return readDeliverablePiece(input.workspaceDirectory, input.ref, input.offset);
|
|
142
|
+
}
|
|
143
|
+
|
|
104
144
|
export async function handleOpenDeliverable(
|
|
105
145
|
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
106
146
|
_context: PluginHandlerContext,
|
package/shared/attachments.ts
CHANGED
|
@@ -55,6 +55,15 @@ const IMAGE_TYPES: Record<string, string> = {
|
|
|
55
55
|
".svg": "image/svg+xml",
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// What Paseo's desktop app (Chromium) plays. A .mov plays when it holds H.264;
|
|
59
|
+
// one that doesn't shows the player's error, and Open still works.
|
|
60
|
+
const VIDEO_TYPES: Record<string, string> = {
|
|
61
|
+
".mp4": "video/mp4",
|
|
62
|
+
".m4v": "video/mp4",
|
|
63
|
+
".mov": "video/quicktime",
|
|
64
|
+
".webm": "video/webm",
|
|
65
|
+
};
|
|
66
|
+
|
|
58
67
|
// Plain text shown as-is in the preview dialog, and opened in the browser on
|
|
59
68
|
// the host. HTML is left to the browser either way.
|
|
60
69
|
const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
|
|
@@ -68,18 +77,24 @@ export function imageMimeType(path: string): string | null {
|
|
|
68
77
|
return IMAGE_TYPES[extension(path) ?? ""] ?? null;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
// The video type of a path by its extension, or null when it isn't a video.
|
|
81
|
+
export function videoMimeType(path: string): string | null {
|
|
82
|
+
return VIDEO_TYPES[extension(path) ?? ""] ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function previewKind(path: string): "image" | "video" | "text" | null {
|
|
72
86
|
if (imageMimeType(path)) return "image";
|
|
87
|
+
if (videoMimeType(path)) return "video";
|
|
73
88
|
return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
// Whether the preview dialog can show it. Only a browser draws SVG
|
|
77
|
-
// desktop app can, the phone apps
|
|
91
|
+
// Whether the preview dialog can show it. Only a browser draws SVG and plays
|
|
92
|
+
// video: Paseo's desktop app can, the phone apps can't, so there they open on
|
|
78
93
|
// the host like any other file.
|
|
79
94
|
export function canPreview(attachment: Attachment, where: { browser: boolean }): boolean {
|
|
80
95
|
if (!attachment.path) return false;
|
|
81
96
|
const kind = previewKind(attachment.path);
|
|
82
|
-
if (kind === "image" && extension(attachment.path) === ".svg") return where.browser;
|
|
97
|
+
if (kind === "video" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
|
|
83
98
|
return kind !== null;
|
|
84
99
|
}
|
|
85
100
|
|
|
@@ -98,7 +113,7 @@ export function attachmentIcon(attachment: Attachment): string {
|
|
|
98
113
|
if (attachment.url) return "Globe";
|
|
99
114
|
if (!attachment.path) return "File";
|
|
100
115
|
const kind = previewKind(attachment.path);
|
|
101
|
-
return kind === "image" ? "Image" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
116
|
+
return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
// The short name people recognize: a file or folder's own name, or a link's site.
|
package/shared/rpc.ts
CHANGED
|
@@ -124,7 +124,8 @@ export const openDeliverable = defineRpc({
|
|
|
124
124
|
output: z.object({ opened: z.string() }),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// An image or text file's contents, for the preview dialog.
|
|
127
|
+
// An image or text file's contents, for the preview dialog. A video comes back
|
|
128
|
+
// as its type and size only; its bytes follow in pieces (readDeliverablePiece).
|
|
128
129
|
export const previewDeliverable = defineRpc({
|
|
129
130
|
name: "progress.deliverable.preview",
|
|
130
131
|
input: z.object({
|
|
@@ -135,6 +136,20 @@ export const previewDeliverable = defineRpc({
|
|
|
135
136
|
}),
|
|
136
137
|
output: z.discriminatedUnion("kind", [
|
|
137
138
|
z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
|
|
139
|
+
z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
|
|
138
140
|
z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
|
|
139
141
|
]),
|
|
140
142
|
});
|
|
143
|
+
|
|
144
|
+
// One piece of a video from `offset`, base64, with the file's size so the client
|
|
145
|
+
// knows when it has it all.
|
|
146
|
+
export const readDeliverablePiece = defineRpc({
|
|
147
|
+
name: "progress.deliverable.piece",
|
|
148
|
+
input: z.object({
|
|
149
|
+
workspaceId: z.string().min(1),
|
|
150
|
+
workspaceDirectory: z.string().min(1),
|
|
151
|
+
ref: z.string().min(1),
|
|
152
|
+
offset: z.number().int().min(0),
|
|
153
|
+
}),
|
|
154
|
+
output: z.object({ data: z.string(), bytes: z.number() }),
|
|
155
|
+
});
|
|
@@ -27,7 +27,7 @@ Record each change as it happens, on the ticket's id from `show` (T01, T02, ...)
|
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| You start a ticket | `ticket update T03 --status working --stage Build` |
|
|
29
29
|
| You move to the next stage | `ticket update T03 --stage Review`, then `Fixes`, then `Verify` |
|
|
30
|
-
| Something reviewable lands (a screenshot folder, a report, a preview URL) | `deliverable add "<what it shows>" <path or URL> --ticket T03` |
|
|
30
|
+
| Something reviewable lands (a screenshot folder, a screen recording, a report, a preview URL) | `deliverable add "<what it shows>" <path or URL> --ticket T03` |
|
|
31
31
|
| A milestone the user would want in the story | `activity add "<one line>" --ticket T03` |
|
|
32
32
|
| Between bigger events | `ticker set "<what you are doing right now>"` |
|
|
33
33
|
| The ticket is finished | `ticket update T03 --status done`, then start the next one |
|
|
@@ -66,7 +66,7 @@ paseo-progress question ask "<short title>" "<the question, in one or two senten
|
|
|
66
66
|
- Build no option before the user answers, not even the one you recommend. The point of asking is that the user decides before the work is done.
|
|
67
67
|
- Keep working on whatever the question doesn't affect: other tickets, or other parts of this one. If nothing else is left, stop, tell the user in chat which questions you're waiting on, and leave the run open.
|
|
68
68
|
- When the user replies ("Q7 A", or in their own words), run `question answer Q7 A --words "<their words>"`, then build what they chose.
|
|
69
|
-
- When a question is about how something looks, attach the screenshot, mockup or preview link with `--file`. The user opens it from the question: images preview in Paseo, links open in its browser. Add more later with `question update Q7 --file <path>=<what it shows>`.
|
|
69
|
+
- When a question is about how something looks, attach the screenshot, mockup or preview link with `--file`. The user opens it from the question: images and videos preview in Paseo, links open in its browser. Add more later with `question update Q7 --file <path>=<what it shows>`.
|
|
70
70
|
- When the user asks about a question, answer in chat and also put the explanation on the question with `question update Q7 --background "<text>"`, so the dashboard carries it too.
|
|
71
71
|
- `question remove Q7` withdraws a question that no longer applies.
|
|
72
72
|
|