@stevecastaneda/paseo-progress 1.6.0 → 1.8.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/README.md CHANGED
@@ -145,6 +145,16 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.8.0
149
+
150
+ - [New] **HTML reports open on a server with no screen.** An HTML report on a computer that can't open it, like a Linux server, used to fail and copy the file's path. In the desktop app it now opens across Paseo's main area, with its pictures, styles and scripts. Esc or Close takes you back. Where the computer can open it, it still opens in your browser.
151
+ - [Improved] **What's new banner.** The headline gets the banner's full width, with **See what's new** under it.
152
+
153
+ ### 1.7.0
154
+
155
+ - [New] **See screenshots full screen.** In an image preview, press **Full screen** or click the image to fill the window with it. Esc takes you back to the preview, still open. The arrow keys still move between images. A long screenshot also gets **Fit to width**, which fills the width so you can scroll down it.
156
+ - [Fixed] **A preview that can't load says why right away.** A missing file, one too large to preview, or one outside the worktree showed a spinner for several seconds before the reason.
157
+
148
158
  ### 1.6.0
149
159
 
150
160
  - [New] **Read the skill in Settings.** Progress settings has an Agent Skills section. **View skill** shows the instructions agents get, and **Install skill** appears there when the skill isn't installed.
@@ -2,10 +2,11 @@ import { openExternalUrl, type PluginWorkspacePanelProps, useRpc } from "@getpas
2
2
  import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
3
3
  import React from "react";
4
4
  import { Platform, Text, View } from "react-native";
5
- import { type Attachment, attachmentIcon, canPreview, shortName } from "../shared/attachments";
5
+ import { type Attachment, attachmentIcon, canPreview, opensAsPage, shortName } from "../shared/attachments";
6
+ import { openPage } from "./page-opener";
6
7
  import { errorText } from "./errors";
7
8
  import { PressableRow } from "./row";
8
- import { openDeliverable } from "../shared/rpc";
9
+ import { canOpenOnHost, openDeliverable } from "../shared/rpc";
9
10
  import { useProgressLanguage } from "./language";
10
11
 
11
12
  export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName, ticketSourceAttachment } from "../shared/attachments";
@@ -35,6 +36,7 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
35
36
  const { messages: m } = useProgressLanguage();
36
37
  const toast = useToast();
37
38
  const openOnHostRpc = useRpc(openDeliverable);
39
+ const checkHost = useRpc(canOpenOnHost);
38
40
  async function copyPath(attachment: Attachment, prefix = "") {
39
41
  try {
40
42
  await copyText(absolutePath(attachment.path!, workspaceDirectory));
@@ -61,6 +63,12 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
61
63
  }
62
64
  return;
63
65
  }
66
+ // A page opens on the computer holding it, like a double-click, and in
67
+ // Paseo only where that computer has no screen to open it on.
68
+ if (openPage && opensAsPage(attachment, { browser: Platform.OS === "web" }) && !(await checkHost({ workspaceId, workspaceDirectory }).then((result) => result.canOpen, () => false))) {
69
+ openPage({ workspaceId, workspaceDirectory, ref: attachment.ref, title: attachment.title });
70
+ return;
71
+ }
64
72
  if (onPreview && isPreviewable(attachment)) {
65
73
  onPreview(attachment);
66
74
  return;
@@ -62,7 +62,7 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
62
62
  ))}
63
63
  <ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
64
64
  <StackedDialog colors={colors} now={now} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory, navigation }}
65
- root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)) } : null} />
65
+ root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)), fullScreen: false } : null} />
66
66
  </View>
67
67
  );
68
68
  }
@@ -1,10 +1,13 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
2
  import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
3
3
  import React, { useEffect, useRef, useState } from "react";
4
+ import { Platform } from "react-native";
5
+ import { opensAsPage } from "../shared/attachments";
4
6
  import type { Dashboard, Question } from "../shared/dashboard";
5
7
  import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
6
8
  import { NarrowProvider } from "./narrow";
7
9
  import { BackButton, galleryFiles, PreviewContent } from "./preview";
10
+ import { previewKind } from "../shared/attachments";
8
11
  import { QuestionView, useCopy } from "./questions";
9
12
  import { TicketStoryView } from "./ticket-dialog";
10
13
  import { ProgressLanguageProvider } from "./language";
@@ -12,11 +15,13 @@ import { ProgressLanguageProvider } from "./language";
12
15
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
13
16
  type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
14
17
 
15
- // What the dialog is showing. A preview keeps the files beside it for Previous and Next.
18
+ // What the dialog is showing. A preview keeps the files beside it for Previous and Next,
19
+ // and whether its image fills the window. `leftFullScreen` puts focus back on the
20
+ // Full screen button; it lives here because leaving can remount the preview.
16
21
  export type Step =
17
22
  | { kind: "ticket"; id: string }
18
23
  | { kind: "question"; id: string }
19
- | { kind: "preview"; attachment: Attachment; group: Attachment[] };
24
+ | { kind: "preview"; attachment: Attachment; group: Attachment[]; fullScreen: boolean; leftFullScreen?: boolean };
20
25
 
21
26
  // Paseo gives each dialog its own backdrop, so opening a second one darkens
22
27
  // the screen while the first fades out; steps swap content inside this one instead.
@@ -43,11 +48,11 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
43
48
  const group = useRef<Attachment[]>([]);
44
49
  const push = (step: Step) => setSteps((current) => [...current, step]);
45
50
  const replaceTop = (step: Step) => setSteps((current) => [...current.slice(0, -1), step]);
46
- const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current) }) });
51
+ const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current), fullScreen: false }) });
47
52
  const openAttachment = (attachment: Attachment, from: Attachment[]) => {
48
53
  group.current = from;
49
- // A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
50
- if (attachment.url) onClose();
54
+ // A link or a page opens outside the dialog; close first so it isn't left behind it.
55
+ if (attachment.url || opensAsPage(attachment, { browser: Platform.OS === "web" })) onClose();
51
56
  void opener.open(attachment);
52
57
  };
53
58
  // Opens the agent's chat in its workspace, with the dialog out of the way.
@@ -62,6 +67,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
62
67
  const ticket = top?.kind === "ticket" ? dashboard?.tickets.find((candidate) => candidate.id === top.id) : undefined;
63
68
  const question = top?.kind === "question" ? questions.find((candidate) => candidate.id === top.id) : undefined;
64
69
  const preview = top?.kind === "preview" ? top : undefined;
70
+ const setFullScreen = (on: boolean) => preview && replaceTop({ ...preview, fullScreen: on, leftFullScreen: !on });
65
71
  // The ticket or question it opened on went away (answered elsewhere, or a new run): close rather than show a blank dialog.
66
72
  const rootMissing = root !== null && (root.kind === "ticket" ? !dashboard?.tickets.some((candidate) => candidate.id === root.id)
67
73
  : root.kind === "question" ? !questions.some((candidate) => candidate.id === root.id) : false);
@@ -74,9 +80,16 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
74
80
 
75
81
  return (
76
82
  <Modal title={title} icon={<Icon name={icon} size={16} color={colors.foregroundMuted} />}
77
- open={Boolean(root)} onOpenChange={(next) => { if (!next) onClose(); }}>
78
- {/* One Modal.Content for every step: replacing it would re-present the sheet on phones. */}
79
- <Modal.Content scrollable={!(preview && previewLong)}>
83
+ open={Boolean(root)} onOpenChange={(next) => {
84
+ if (next) return;
85
+ // Esc leaves full screen first; the dialog stays open under it.
86
+ if (preview?.fullScreen) setFullScreen(false);
87
+ else onClose();
88
+ }}>
89
+ {/* One Modal.Content for every step: replacing it would re-present the sheet on phones.
90
+ Full screen turns its scrolling off. The image's fixed position reaches the window only
91
+ when no ancestor has a transform, and a scroll view has one. */}
92
+ <Modal.Content scrollable={!(preview && (previewLong || preview.fullScreen))}>
80
93
  <ProgressLanguageProvider>
81
94
  {/* A dialog has room for clock times, however narrow the panel that opened it. */}
82
95
  <NarrowProvider value={false}>
@@ -84,7 +97,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
84
97
  <PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
85
98
  onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
86
99
  onCopyPath={() => void opener.copyPath(preview.attachment)} onBack={back}
87
- gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong}
100
+ gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment, fullScreen: preview.fullScreen && previewKind(attachment.path ?? "") === "image", leftFullScreen: false }) }}
101
+ onLong={setPreviewLong} fullScreen={preview.fullScreen} leftFullScreen={preview.leftFullScreen === true} onFullScreen={setFullScreen}
88
102
  onOpenEntry={openAttachment} />
89
103
  ) : (
90
104
  <>
@@ -96,6 +96,10 @@ export const messages: ProgressMessages = {
96
96
  back: "Back",
97
97
  previousFile: "Previous file",
98
98
  nextFile: "Next file",
99
+ fullScreen: "Full screen",
100
+ exitFullScreen: "Exit full screen",
101
+ fitToWidth: "Fit to width",
102
+ fitToScreen: "Fit to screen",
99
103
  videoChanged: "the video changed while it was loading. Try again.",
100
104
  localHistory: "Run history stays on this computer.",
101
105
  historyStopped: "Runs will stay on this computer. Files already saved stay in the repo.",
@@ -96,6 +96,10 @@ export const messages: ProgressMessages = {
96
96
  back: "返回",
97
97
  previousFile: "上一个文件",
98
98
  nextFile: "下一个文件",
99
+ fullScreen: "全屏",
100
+ exitFullScreen: "退出全屏",
101
+ fitToWidth: "适应宽度",
102
+ fitToScreen: "适应屏幕",
99
103
  videoChanged: "视频在加载过程中发生了变化。请重试。",
100
104
  localHistory: "运行记录保存在这台电脑上。",
101
105
  historyStopped: "运行记录将保存在这台电脑上。已保存的文件会保留在仓库中。",
@@ -78,6 +78,10 @@ export interface ProgressMessages {
78
78
  back: string;
79
79
  previousFile: string;
80
80
  nextFile: string;
81
+ fullScreen: string;
82
+ exitFullScreen: string;
83
+ fitToWidth: string;
84
+ fitToScreen: string;
81
85
  videoChanged: string;
82
86
  localHistory: string;
83
87
  historyStopped: string;
@@ -0,0 +1,9 @@
1
+ export type PageParams = { workspaceId: string; workspaceDirectory: string; ref: string; title: string };
2
+
3
+ // Panels can't open a screen themselves, only the plugin's client can, so it
4
+ // sets this once it has added the page screen.
5
+ export let openPage: ((params: PageParams) => void) | null = null;
6
+
7
+ export function setPageOpener(opener: typeof openPage) {
8
+ openPage = opener;
9
+ }
@@ -0,0 +1,97 @@
1
+ import { type PluginClientContext, type PluginScreenProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import React, { useEffect, useRef, useState } from "react";
4
+ import { ActivityIndicator, Text, View } from "react-native";
5
+ import { previewDeliverable } from "../shared/rpc";
6
+ import { errorText } from "./errors";
7
+ import { ProgressLanguageProvider, useProgressLanguage } from "./language";
8
+ import { type PageParams, setPageOpener } from "./page-opener";
9
+
10
+ const PAGE_SCREEN_ID = "page";
11
+
12
+ export function addPageScreen(client: PluginClientContext) {
13
+ setPageOpener((params) => client.openScreen({ screenId: PAGE_SCREEN_ID, params }));
14
+ const stop = client.addScreen({
15
+ id: PAGE_SCREEN_ID,
16
+ title: (params) => params.title ?? "",
17
+ Component: (props) => <ProgressLanguageProvider><PageScreen {...props} /></ProgressLanguageProvider>,
18
+ });
19
+ return () => {
20
+ setPageOpener(null);
21
+ void stop();
22
+ };
23
+ }
24
+
25
+ // Keys pressed inside the page go to the page, so it tells Progress about
26
+ // Escape itself, unless one of its own handlers has used the key.
27
+ const ESCAPE = "progress:page-escape";
28
+ const ESCAPE_SCRIPT = `<script>addEventListener("keydown",function(e){if(e.key==="Escape"&&!e.defaultPrevented)parent.postMessage("${ESCAPE}","*")})</script>`;
29
+
30
+ // The plugin compiles without the DOM's types; these are the browser APIs used here.
31
+ type KeyEvent = { key: string; defaultPrevented: boolean };
32
+ type FrameMessage = { data: unknown; source: unknown };
33
+ type Frame = { contentWindow: unknown; getClientRects(): { length: number } };
34
+ const browser = globalThis as unknown as {
35
+ addEventListener(type: "keydown", listener: (event: KeyEvent) => void): void;
36
+ addEventListener(type: "message", listener: (event: FrameMessage) => void): void;
37
+ removeEventListener(type: "keydown" | "message", listener: (event: never) => void): void;
38
+ };
39
+
40
+ // Escape leaves the page, like the Close button in Paseo's header.
41
+ // Paseo can keep a screen mounted, hidden, after leaving it, so Escape only
42
+ // counts while the page is on screen.
43
+ function useEscapeToClose(frame: React.RefObject<Frame | null>, close: (() => void) | undefined) {
44
+ useEffect(() => {
45
+ if (!close || typeof browser.addEventListener !== "function") return;
46
+ const onKey = (event: KeyEvent) => {
47
+ if (event.key === "Escape" && !event.defaultPrevented && frame.current?.getClientRects().length) close();
48
+ };
49
+ const onMessage = (event: FrameMessage) => {
50
+ if (event.data === ESCAPE && event.source === frame.current?.contentWindow) close();
51
+ };
52
+ browser.addEventListener("keydown", onKey);
53
+ browser.addEventListener("message", onMessage);
54
+ return () => {
55
+ browser.removeEventListener("keydown", onKey);
56
+ browser.removeEventListener("message", onMessage);
57
+ };
58
+ }, [close]);
59
+ }
60
+
61
+ // An HTML deliverable across the whole screen, the width it was written for.
62
+ function PageScreen({ params, theme: { colors }, navigation }: PluginScreenProps) {
63
+ const { messages: m } = useProgressLanguage();
64
+ const fetchPreview = useRpc(previewDeliverable);
65
+ const { workspaceId, workspaceDirectory, ref } = params as PageParams;
66
+ const page = useQuery({
67
+ queryKey: ["progress", "page", workspaceId, ref],
68
+ queryFn: () => fetchPreview({ workspaceId, workspaceDirectory, ref }),
69
+ });
70
+ // The page stays hidden on Paseo's background until it has drawn, so opening it
71
+ // doesn't flash an empty white frame first.
72
+ const [shownHtml, setShownHtml] = useState<string | null>(null);
73
+ const frame = useRef<Frame | null>(null);
74
+ useEscapeToClose(frame, navigation ? () => navigation.openWorkspace({ workspaceId }) : undefined);
75
+ if (page.data?.kind === "html") {
76
+ const html = page.data.html + ESCAPE_SCRIPT;
77
+ return (
78
+ <View style={{ flex: 1, backgroundColor: colors.surface0 }}>
79
+ {/* `allow-scripts` alone, as in Paseo's own HTML preview: the page runs its scripts
80
+ (and loads them from the web, so no CSP) in an opaque origin that can't reach
81
+ Paseo's DOM or storage, navigate the app, open windows or submit forms. */}
82
+ <iframe ref={frame as never} srcDoc={html} sandbox="allow-scripts" referrerPolicy="no-referrer" title={params.title} onLoad={() => setShownHtml(html)}
83
+ style={{ flex: 1, width: "100%", height: "100%", display: "block", border: "none", backgroundColor: "white",
84
+ opacity: shownHtml === html ? 1 : 0, transition: "opacity 150ms ease-out" }} />
85
+ </View>
86
+ );
87
+ }
88
+ return (
89
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }}>
90
+ {page.error ? (
91
+ <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
92
+ {m.previewError(errorText(page.error, m.unknownError))}
93
+ </Text>
94
+ ) : <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingPreview} />}
95
+ </View>
96
+ );
97
+ }
@@ -59,7 +59,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
59
59
  </Text>
60
60
  )}
61
61
  <StackedDialog colors={colors} now={Date.now()} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory: root }}
62
- root={previewing ? { kind: "preview", ...previewing } : null} />
62
+ root={previewing ? { kind: "preview", ...previewing, fullScreen: false } : null} />
63
63
  <View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
64
64
  {/* A full 44pt touch target on phones; desktop keeps the compact button. */}
65
65
  <PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
@@ -1,8 +1,8 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
2
  import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import React, { useEffect, useRef } from "react";
5
- import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
4
+ import React, { useEffect, useRef, useState } from "react";
5
+ import { ActivityIndicator, Image, 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
8
  import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
@@ -39,7 +39,12 @@ type PreviewProps = {
39
39
 
40
40
  // `onLong` says when the file is too tall for the dialog, so the host turns off
41
41
  // its own scrolling and the file scrolls with the actions pinned below.
42
- export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong }: PreviewProps & { onLong(long: boolean): void }) {
42
+ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong, fullScreen, leftFullScreen, onFullScreen }: PreviewProps & {
43
+ onLong(long: boolean): void;
44
+ fullScreen: boolean;
45
+ leftFullScreen: boolean;
46
+ onFullScreen(on: boolean): void;
47
+ }) {
43
48
  const { messages: m } = useProgressLanguage();
44
49
  const fetchPreview = useRpc(previewDeliverable);
45
50
  const readPiece = useRpc(readDeliverablePiece);
@@ -60,7 +65,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
60
65
  // and so on, so a tall image made the dialog jump between two sizes until closed.
61
66
  queryFn: async () => {
62
67
  const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
63
- if (data.kind === "image") return { ...data, ratio: await imageRatio(data.dataUri) };
68
+ if (data.kind === "image") return { ...data, ...await imageSize(data.dataUri) };
64
69
  if (data.kind !== "video") return data;
65
70
  queryClient.setQueryData(loadingKey(target), 0);
66
71
  const video = await loadVideo({
@@ -76,6 +81,9 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
76
81
  // Images arrive as data URIs up to ~13 MB and videos run to 200 MB; let them
77
82
  // go soon after the dialog moves on.
78
83
  gcTime: 30_000,
84
+ // A file that's missing, too large or outside the worktree fails the same way
85
+ // every time, so the reason shows at once instead of after seconds of retries.
86
+ retry: false,
79
87
  });
80
88
  // Stepping to another file keeps this one on screen (dimmed) until the next loads,
81
89
  // so the dialog doesn't collapse to a spinner and back.
@@ -102,15 +110,39 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
102
110
  useArrowKeys(canStep ? step : null);
103
111
  // Next has focus when the preview opens, so Enter or Space moves on too.
104
112
  const nextButton = useRef<View>(null);
113
+ const fullScreenButton = useRef<View>(null);
114
+ // Leaving full screen puts focus back on the Full screen button without the ring
115
+ // Esc would draw, which read as if the arrows now acted on it. Set during render so
116
+ // the ring never paints, and from `leftFullScreen` because leaving can remount this.
117
+ const [quiet, setQuiet] = useState(leftFullScreen && !fullScreen);
118
+ const [wasFullScreen, setWasFullScreen] = useState(fullScreen);
119
+ if (wasFullScreen !== fullScreen) {
120
+ setWasFullScreen(fullScreen);
121
+ setQuiet(!fullScreen && leftFullScreen);
122
+ }
105
123
  useEffect(() => {
106
- (nextButton.current as { focus?(): void } | null)?.focus?.();
107
- }, []);
124
+ if (fullScreen) return;
125
+ const restore = leftFullScreen && fullScreenButton.current !== null;
126
+ if (!restore) setQuiet(false);
127
+ focus(restore ? fullScreenButton : nextButton);
128
+ }, [fullScreen]);
108
129
  // Screenshots and recordings vary from phone-tall to full-width; size to the real
109
130
  // file. While stepping, the dimmed one on screen keeps its shape until the next arrives.
110
131
  const aspectRatio = preview.data && "ratio" in preview.data && preview.data.ratio ? preview.data.ratio : 16 / 10;
111
132
  // A video fits the dialog whole, so the controls along its bottom stay in view
112
133
  // without scrolling. A tall image scrolls instead, to keep it readable.
113
- const { height: windowHeight } = useWindowDimensions();
134
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
135
+ // Phones and narrow windows get Paseo's bottom sheet, whose transforms would
136
+ // keep the full-screen layer inside it.
137
+ const fullScreenFits = Platform.OS === "web" && windowWidth >= COMPACT_BELOW;
138
+ // By the file, not the data: while stepping, the data is still the last file's.
139
+ const canFullScreen = fullScreenFits && previewKind(attachment.path ?? "") === "image" && preview.data?.kind === "image";
140
+ // While stepping, the last image fills the layer until the next arrives. A file
141
+ // that fails to load has no data at all, so full screen ends with it.
142
+ const showsLayer = fullScreenFits && preview.data?.kind === "image";
143
+ useEffect(() => {
144
+ if (fullScreen && !preview.isPending && !showsLayer) onFullScreen(false);
145
+ }, [fullScreen, preview.isPending, showsLayer]);
114
146
  const room = roomForContent(windowHeight);
115
147
  const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
116
148
  const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
@@ -152,7 +184,11 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
152
184
  dimmed={stepping} onOpen={onOpenEntry} />
153
185
  ) : null}
154
186
  {preview.data?.kind === "image" ? (
155
- <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
187
+ // The Full screen button is the keyboard's way in, so the image isn't a second tab stop.
188
+ <Pressable disabled={!canFullScreen} focusable={false} onPress={() => onFullScreen(true)}
189
+ style={{ width: "100%", cursor: canFullScreen ? "zoom-in" as unknown as ViewStyle["cursor"] : undefined }}>
190
+ <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
191
+ </Pressable>
156
192
  ) : null}
157
193
  {preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
158
194
  </>
@@ -182,28 +218,136 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
182
218
  {onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
183
219
  </View>
184
220
  {canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
185
- <View style={{ flex: 1, alignItems: "flex-end" }}>
221
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
222
+ {canFullScreen ? (
223
+ // An inline outline beats Paseo's :focus-visible ring.
224
+ <DialogButton ref={fullScreenButton} colors={colors} icon="Maximize2" accessibilityLabel={m.fullScreen} onPress={() => onFullScreen(true)}
225
+ outerStyle={quiet ? { outlineWidth: 0 } : undefined} onBlur={() => setQuiet(false)} />
226
+ ) : null}
186
227
  {canOpen ? <DialogButton colors={colors} icon="ExternalLink" label={m.open} accessibilityLabel={m.openDefault} onPress={onOpenOnHost} /> : null}
187
228
  </View>
188
229
  </View>
189
230
  );
190
- return long ? (
231
+ const layerShown = fullScreen && showsLayer;
232
+ // An SVG stays sharp at any size, so only a bitmap stops at its own pixels. An
233
+ // icon's SVG can say it's 24px wide.
234
+ const fullScreenLayer = layerShown && preview.data?.kind === "image" ? (
235
+ <FullScreenImage title={attachment.title} uri={preview.data.dataUri} ratio={aspectRatio} dimmed={stepping} onExit={() => onFullScreen(false)}
236
+ naturalWidth={preview.data.dataUri.startsWith("data:image/svg+xml") ? null : preview.data.width} />
237
+ ) : null;
238
+ // Paseo's focus trap counts the controls under the layer, so Shift+Tab would
239
+ // reach a hidden Open. Inert takes them out until full screen ends.
240
+ const body = useRef<View>(null);
241
+ useEffect(() => {
242
+ if (!layerShown) return;
243
+ const element = body.current as unknown as { setAttribute?(name: string, value: string): void; removeAttribute?(name: string): void } | null;
244
+ element?.setAttribute?.("inert", "");
245
+ return () => element?.removeAttribute?.("inert");
246
+ }, [layerShown]);
247
+ return (
191
248
  <>
192
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
193
- <View style={{ marginTop: 8 }}>{path}</View>
194
- {actions}
249
+ {long ? (
250
+ <View ref={body} style={{ flex: 1 }}>
251
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
252
+ <View style={{ marginTop: 8 }}>{path}</View>
253
+ {actions}
254
+ </View>
255
+ ) : (
256
+ <View ref={body} style={{ gap: 20 }}>
257
+ <View style={{ gap: 8 }}>
258
+ <View style={{ gap: 12 }}>{content}</View>
259
+ {path}
260
+ </View>
261
+ {actions}
262
+ </View>
263
+ )}
264
+ {fullScreenLayer}
195
265
  </>
196
- ) : (
197
- <View style={{ gap: 20 }}>
198
- <View style={{ gap: 8 }}>
199
- <View style={{ gap: 12 }}>{content}</View>
200
- {path}
266
+ );
267
+ }
268
+
269
+ const FULL_SCREEN_MARGIN = 24;
270
+ const FULL_SCREEN_BAR = 52;
271
+
272
+ // The image over the whole window, never past its own pixels. It fits whole, or a
273
+ // long one can fill the width and scroll.
274
+ function FullScreenImage({ title, uri, naturalWidth, ratio, dimmed, onExit }: { title: string; uri: string; naturalWidth: number | null; ratio: number; dimmed: boolean; onExit(): void }) {
275
+ const { messages: m } = useProgressLanguage();
276
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
277
+ const closeButton = useRef<View>(null);
278
+ const fitButton = useRef<View>(null);
279
+ const scrollArea = useRef<React.ComponentRef<typeof ScrollView>>(null);
280
+ useEffect(() => focus(closeButton), []);
281
+ const [fit, setFit] = useState<"screen" | "width">("screen");
282
+ const room = windowHeight - 2 * FULL_SCREEN_MARGIN - FULL_SCREEN_BAR;
283
+ const widthFit = Math.max(0, Math.min(naturalWidth ?? Infinity, windowWidth - 2 * FULL_SCREEN_MARGIN));
284
+ const screenFit = Math.min(widthFit, room * ratio);
285
+ // Only an image that fills the width at least half again as large as it fits
286
+ // whole gets the choice. An ordinary window screenshot gains a little and reads
287
+ // fine whole; a phone screenshot or a long page doesn't.
288
+ const long = widthFit >= screenFit * 1.5;
289
+ const scrolls = long && fit === "width";
290
+ const toggle = () => setFit(scrolls ? "screen" : "width");
291
+ // Fit to width hands the keys to the scroll area, so the arrows, Page Up and
292
+ // Down, Home, End and Space scroll it. When the area goes, focus goes back to the bar.
293
+ const scrolled = useRef(false);
294
+ useEffect(() => {
295
+ if (scrolls) focus(scrollArea);
296
+ else if (scrolled.current) focus(long ? fitButton : closeButton);
297
+ scrolled.current = scrolls;
298
+ }, [scrolls, uri]);
299
+ const light = "rgba(255, 255, 255, 0.85)";
300
+ const barButton = ({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => ({
301
+ height: 36, alignItems: "center", justifyContent: "center", borderRadius: 6,
302
+ backgroundColor: pressed ? "rgba(255, 255, 255, 0.16)" : hovered ? "rgba(255, 255, 255, 0.1)" : "transparent",
303
+ }) as const;
304
+ // A press on the image stops there, so only the black around it exits.
305
+ // The fit button is the keyboard's way to the toggle, so the image isn't a tab stop.
306
+ const area = (
307
+ <Pressable accessible={false} focusable={false} onPress={onExit}
308
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: FULL_SCREEN_MARGIN }}>
309
+ <Pressable accessible={false} focusable={false} onPress={long ? toggle : () => {}}
310
+ style={{ cursor: (long ? (scrolls ? "zoom-out" : "zoom-in") : "default") as unknown as ViewStyle["cursor"] }}>
311
+ <Image accessibilityLabel={title} source={{ uri }} resizeMode="contain"
312
+ style={{ width: scrolls ? widthFit : screenFit, aspectRatio: ratio, borderRadius: 6, borderWidth: 1, borderColor: "rgba(255, 255, 255, 0.1)", opacity: dimmed ? 0.6 : 1 }} />
313
+ </Pressable>
314
+ </Pressable>
315
+ );
316
+ return (
317
+ <View style={{ position: "fixed" as unknown as ViewStyle["position"], top: 0, right: 0, bottom: 0, left: 0, zIndex: 1000, backgroundColor: "black" }}>
318
+ <View style={{ height: FULL_SCREEN_BAR, flexDirection: "row", alignItems: "center", gap: 12, paddingStart: FULL_SCREEN_MARGIN, paddingEnd: 12 }}>
319
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: light, fontSize: 13, lineHeight: 18 }}>{title}</Text>
320
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
321
+ {long ? (
322
+ <PressScale ref={fitButton} accessibilityRole="button" onPress={toggle}
323
+ style={(state) => ({ ...barButton(state), flexDirection: "row", gap: 6, paddingHorizontal: 10 })}>
324
+ <Icon name={scrolls ? "Shrink" : "MoveHorizontal"} size={16} color={light} />
325
+ <Text style={{ color: light, fontSize: 13, lineHeight: 18 }}>{scrolls ? m.fitToScreen : m.fitToWidth}</Text>
326
+ </PressScale>
327
+ ) : null}
328
+ <PressScale ref={closeButton} accessibilityRole="button" accessibilityLabel={m.exitFullScreen} onPress={onExit}
329
+ style={(state) => ({ ...barButton(state), width: 36 })}>
330
+ <Icon name="X" size={18} color={light} />
331
+ </PressScale>
332
+ </View>
201
333
  </View>
202
- {actions}
334
+ {/* Keyed so the next image starts at its top. The fit button stays the visible
335
+ keyboard control, so the scroll area draws no focus ring. */}
336
+ {scrolls ? (
337
+ <ScrollView key={uri} ref={scrollArea} focusable style={{ flex: 1, outlineWidth: 0 }} contentContainerStyle={{ flexGrow: 1 }}>{area}</ScrollView>
338
+ ) : area}
203
339
  </View>
204
340
  );
205
341
  }
206
342
 
343
+ // Only views on web have focus(); elsewhere this does nothing.
344
+ function focus(ref: React.RefObject<unknown>) {
345
+ (ref.current as { focus?(): void } | null)?.focus?.();
346
+ }
347
+
348
+ // Paseo draws dialogs as bottom sheets below this window width.
349
+ const COMPACT_BELOW = 720;
350
+
207
351
  // Width left for content: Paseo's 520px dialog less its 24px padding each side.
208
352
  const CONTENT_WIDTH = 472;
209
353
  // Characters of 12px monospace per line of the text box (10px padding each side).
@@ -212,7 +356,7 @@ const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
212
356
  const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
213
357
 
214
358
  // Roughly how tall the preview draws, including wrapped lines.
215
- function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
359
+ function estimatedHeight(data: { kind: "image" | "video" | "html" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
216
360
  if (!data) return 0;
217
361
  if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
218
362
  if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
@@ -232,11 +376,12 @@ function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { co
232
376
  );
233
377
  }
234
378
 
235
- // An image's width over its height, or null if it can't be read. Read from the data
236
- // because onLoad doesn't report a size for data URIs on every platform.
237
- function imageRatio(uri: string): Promise<number | null> {
379
+ // An image's pixel width and its width over its height, or nulls if they can't be read.
380
+ // Read from the data because onLoad doesn't report a size for data URIs on every platform.
381
+ function imageSize(uri: string): Promise<{ width: number | null; ratio: number | null }> {
238
382
  return new Promise((resolve) => {
239
- Image.getSize(uri, (width, height) => resolve(width && height ? width / height : null), () => resolve(null));
383
+ Image.getSize(uri, (width, height) => resolve(width && height ? { width, ratio: width / height } : { width: null, ratio: null }),
384
+ () => resolve({ width: null, ratio: null }));
240
385
  });
241
386
  }
242
387
 
@@ -248,9 +393,9 @@ function roomForContent(windowHeight: number): number {
248
393
  }
249
394
 
250
395
  // A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
251
- 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> }) {
396
+ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, onBlur, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; onBlur?(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
252
397
  return (
253
- <PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
398
+ <PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} onBlur={onBlur} outerStyle={outerStyle}
254
399
  style={({ pressed }) => ({
255
400
  flexDirection: "row", alignItems: "center", gap: 6,
256
401
  paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
@@ -39,22 +39,23 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
39
39
  return (
40
40
  <>
41
41
  <Presence show={Boolean(whatsNew)}>{whatsNew ? (
42
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
43
- <View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
44
- <Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
45
- <Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
46
- </Text>
47
- {/* 8px keeps the close button's 6px hit slop off the button beside it. */}
48
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
49
- <PressScale accessibilityRole="button" onPress={() => { setShown(whatsNew); setOpen(true); }}
50
- style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
51
- <Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.seeWhatsNew}</Text>
52
- </PressScale>
42
+ // The headline gets the full width, with the button under it on the text's edge.
43
+ <View style={{ gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
44
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
45
+ <View style={{ height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
46
+ <Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
47
+ <Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
48
+ </Text>
49
+ {/* Centered on the first line without making it taller. */}
53
50
  <PressScale accessibilityRole="button" accessibilityLabel={m.dismissWhatsNew} hitSlop={6} onPress={() => close(whatsNew.version)}
54
- style={({ pressed, hovered }) => ({ padding: 5, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
51
+ style={({ pressed, hovered }) => ({ marginVertical: -4.5, padding: 5, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
55
52
  <Icon name="X" size={16} color={colors.foregroundMuted} />
56
53
  </PressScale>
57
54
  </View>
55
+ <PressScale accessibilityRole="button" onPress={() => { setShown(whatsNew); setOpen(true); }}
56
+ style={({ pressed, hovered }) => ({ alignSelf: "flex-start", marginStart: 24, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
57
+ <Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.seeWhatsNew}</Text>
58
+ </PressScale>
58
59
  </View>
59
60
  ) : null}</Presence>
60
61
  <Modal title={!shown ? "" : shown.releases.length > 1 ? m.whatsNewAll : m.whatsNewTitle(shown.version)} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
package/index.client.tsx CHANGED
@@ -3,6 +3,7 @@ import { ProgressPanel } from "./client/panel";
3
3
  import { contributePills, progressPanel } from "./client/pills";
4
4
  import { QuestionSettings } from "./client/settings";
5
5
  import { ProgressLanguageProvider } from "./client/language";
6
+ import { addPageScreen } from "./client/page-screen";
6
7
 
7
8
  const SETTINGS_ID = "settings";
8
9
 
@@ -29,6 +30,7 @@ export default function contribute(client: PluginClientContext) {
29
30
  },
30
31
  });
31
32
 
33
+ const stopPage = addPageScreen(client);
32
34
  const stopSettings = client.addSettingsScreen({
33
35
  id: SETTINGS_ID,
34
36
  title: "Settings",
@@ -37,6 +39,7 @@ export default function contribute(client: PluginClientContext) {
37
39
  });
38
40
 
39
41
  return () => {
42
+ stopPage();
40
43
  void stopSettings();
41
44
  void stopPanel();
42
45
  void stopCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.6.0",
3
+ "version": "1.8.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
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
5
5
  import { homedir, hostname } from "node:os";
6
- import { delimiter, join, relative, resolve } from "node:path";
6
+ import { delimiter, dirname, join, relative, resolve } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, videoMimeType } from "../shared/attachments.ts";
9
9
  import { readProgress } from "./progress-file.ts";
@@ -53,7 +53,7 @@ export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): O
53
53
  // A deliverable or question attachment the agent recorded, found by reference,
54
54
  // so the panel can never reach an arbitrary path. Only files inside the
55
55
  // worktree resolve.
56
- async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
56
+ async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string; root: string }> {
57
57
  const { base, inner } = splitRef(ref);
58
58
  const worktree = findRoot(directory);
59
59
  const { dashboard } = await readProgress((await findStore(worktree)).directory);
@@ -66,7 +66,7 @@ async function resolveAttachment(directory: string, ref: string, verb: string):
66
66
  if (!target) throw new Error(`${path} no longer exists.`);
67
67
  const inside = relative(root, target);
68
68
  if (outside(inside)) throw new Error(`${path} is outside this worktree, so it was not ${verb}.`);
69
- return { path, target };
69
+ return { path, target, root };
70
70
  }
71
71
 
72
72
  export async function openDeliverable(directory: string, id: string, opener: Opener): Promise<{ opened: string }> {
@@ -93,14 +93,15 @@ export type Preview =
93
93
  | { kind: "image"; dataUri: string; bytes: number }
94
94
  | { kind: "video"; mimeType: string; bytes: number }
95
95
  | { kind: "text"; text: string; bytes: number; truncated: boolean }
96
+ | { kind: "html"; html: string; bytes: number }
96
97
  | { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
97
98
 
98
99
  export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
99
- const { path, target } = await resolveAttachment(directory, id, "previewed");
100
+ const { path, target, root } = await resolveAttachment(directory, id, "previewed");
100
101
  const stats = await stat(target);
101
102
  if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(target)) };
102
103
  const kind = previewKind(target);
103
- if (!kind) throw new Error(`${path} isn't an image, video or text file, so it can't be previewed.`);
104
+ if (!kind) throw new Error(`${path} isn't an image, video, text or HTML file, so it can't be previewed.`);
104
105
  const { size } = stats;
105
106
  if (kind === "video") {
106
107
  if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
@@ -117,6 +118,7 @@ export async function previewDeliverable(directory: string, id: string): Promise
117
118
  }
118
119
  }
119
120
  if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
121
+ if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(target, "utf8"), dirname(target), root), bytes: size };
120
122
  const data = await readFile(target);
121
123
  return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
122
124
  }
@@ -132,6 +134,57 @@ async function listFolder(target: string): Promise<{ entries: FolderEntry[]; tru
132
134
  return { entries: entries.slice(0, MAX_FOLDER_ENTRIES), truncated: entries.length > MAX_FOLDER_ENTRIES };
133
135
  }
134
136
 
137
+ // The preview gets only the page, so the files beside it that it loads (its
138
+ // screenshots, a script, a stylesheet) are written into it. Only files inside the
139
+ // worktree, up to MAX_PREVIEW_BYTES in all; anything else is left as written and
140
+ // fails to load. Web addresses are left alone.
141
+ async function inlineLocalFiles(html: string, directory: string, root: string): Promise<string> {
142
+ let room = MAX_PREVIEW_BYTES - Buffer.byteLength(html);
143
+ const inline = async (url: string, encode: (data: Buffer, file: string) => string): Promise<string | null> => {
144
+ if (!url || /^([a-z][a-z0-9+.-]*:|[/\\#?])/i.test(url)) return null;
145
+ try {
146
+ const file = await realpath(resolve(directory, decodeURI(url.replace(/[?#].*$/, ""))));
147
+ const stats = await stat(file);
148
+ if (outside(relative(root, file)) || !stats.isFile() || stats.size > room) return null;
149
+ const encoded = encode(await readFile(file), file);
150
+ if (encoded.length > room) return null;
151
+ room -= encoded.length;
152
+ return encoded;
153
+ } catch {
154
+ return null;
155
+ }
156
+ };
157
+ const dataUri = (data: Buffer, file: string) => `data:${imageMimeType(file) ?? videoMimeType(file) ?? "application/octet-stream"};base64,${data.toString("base64")}`;
158
+ // Its own end tag would close the element early.
159
+ const body = (tag: string) => (data: Buffer) => data.toString("utf8").replace(new RegExp(`</${tag}`, "gi"), `<\\/${tag}`);
160
+ html = await replaceEach(html, /<(?:img|source|video)\b[^>]*>/gi, (tag) =>
161
+ replaceEach(tag[0], /(?<=\s)(src|poster)(\s*=\s*)(["'])([^"']*)\3/gi, async ([, name, equals, quote, url]) => {
162
+ const uri = await inline(url, dataUri);
163
+ return uri && `${name}${equals}${quote}${uri}${quote}`;
164
+ }));
165
+ html = await replaceEach(html, /<link\b[^>]*>/gi, async ([tag]) => {
166
+ const href = tag.match(/\bhref\s*=\s*(["'])([^"']*)\1/i)?.[2];
167
+ if (!href || !/\brel\s*=\s*(["'])\s*stylesheet\s*\1/i.test(tag)) return null;
168
+ const css = await inline(href, body("style"));
169
+ return css === null ? null : `<style>${css}</style>`;
170
+ });
171
+ return replaceEach(html, /<script\b([^>]*?)\s+src\s*=\s*(["'])([^"']*)\2([^>]*)>\s*<\/script\s*>/gi, async ([, before, , src, after]) => {
172
+ const script = await inline(src, body("script"));
173
+ return script === null ? null : `<script${before}${after}>${script}</script>`;
174
+ });
175
+ }
176
+
177
+ // `replace` on each match in turn; a null keeps the match as it was.
178
+ async function replaceEach(text: string, pattern: RegExp, replace: (match: RegExpMatchArray) => Promise<string | null>): Promise<string> {
179
+ let result = "";
180
+ let last = 0;
181
+ for (const match of text.matchAll(pattern)) {
182
+ result += text.slice(last, match.index) + ((await replace(match)) ?? match[0]);
183
+ last = match.index! + match[0].length;
184
+ }
185
+ return result + text.slice(last);
186
+ }
187
+
135
188
  function tooLarge(path: string, size: number): string {
136
189
  return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
137
190
  }
@@ -65,7 +65,7 @@ const VIDEO_TYPES: Record<string, string> = {
65
65
  };
66
66
 
67
67
  // Plain text shown as-is in the preview dialog, and opened in the browser on
68
- // the host. HTML is left to the browser either way.
68
+ // the host.
69
69
  const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
70
70
 
71
71
  function extension(path: string): string | null {
@@ -82,10 +82,12 @@ export function videoMimeType(path: string): string | null {
82
82
  return VIDEO_TYPES[extension(path) ?? ""] ?? null;
83
83
  }
84
84
 
85
- export function previewKind(path: string): "image" | "video" | "text" | null {
85
+ export function previewKind(path: string): "image" | "video" | "text" | "html" | null {
86
86
  if (imageMimeType(path)) return "image";
87
87
  if (videoMimeType(path)) return "video";
88
- return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
88
+ const ext = extension(path) ?? "";
89
+ if (ext === ".html" || ext === ".htm") return "html";
90
+ return TEXT_EXTENSIONS.has(ext) ? "text" : null;
89
91
  }
90
92
 
91
93
  export function isFolder(attachment: Attachment): boolean {
@@ -118,6 +120,7 @@ export function canPreview(attachment: Attachment, where: { browser: boolean }):
118
120
  if (!attachment.path) return false;
119
121
  if (isFolder(attachment)) return true;
120
122
  const kind = previewKind(attachment.path);
123
+ if (kind === "html") return false;
121
124
  if (kind === "video" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
122
125
  return kind !== null;
123
126
  }
@@ -137,7 +140,13 @@ export function attachmentIcon(attachment: Attachment): string {
137
140
  if (attachment.url) return "Globe";
138
141
  if (!attachment.path) return "File";
139
142
  const kind = previewKind(attachment.path);
140
- return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
143
+ return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : kind === "html" ? "FileCode" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
144
+ }
145
+
146
+ // An HTML page is too wide for the dialog, so where the host can't open it the
147
+ // desktop app gives it a screen of its own. The phone apps can't draw one.
148
+ export function opensAsPage(attachment: Attachment, where: { browser: boolean }): boolean {
149
+ return where.browser && Boolean(attachment.path) && previewKind(attachment.path!) === "html";
141
150
  }
142
151
 
143
152
  // The short name people recognize: a file or folder's own name, or a link's site.
package/shared/rpc.ts CHANGED
@@ -142,8 +142,8 @@ export const canOpenOnHost = defineRpc({
142
142
  output: z.object({ canOpen: z.boolean() }),
143
143
  });
144
144
 
145
- // An image or text file's contents, for the preview dialog. A video comes back
146
- // as its type and size only; its bytes follow in pieces (readDeliverablePiece).
145
+ // An image, text or HTML file's contents, for the preview dialog. A video comes
146
+ // back as its type and size only; its bytes follow in pieces (readDeliverablePiece).
147
147
  export const previewDeliverable = defineRpc({
148
148
  name: "progress.deliverable.preview",
149
149
  input: z.object({
@@ -156,6 +156,7 @@ export const previewDeliverable = defineRpc({
156
156
  z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
157
157
  z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
158
158
  z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
159
+ z.object({ kind: z.literal("html"), html: z.string(), bytes: z.number() }),
159
160
  z.object({ kind: z.literal("folder"), entries: z.array(z.object({ name: z.string(), folder: z.boolean() })), truncated: z.boolean() }),
160
161
  ]),
161
162
  });