@stevecastaneda/paseo-progress 1.7.0 → 1.9.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
@@ -20,7 +20,7 @@ The dashboard shows:
20
20
 
21
21
  <img src="images/5-settings.png" alt="Progress settings in Paseo: Questions set to Work around, Language set to System, and the Agent Skills section with View skill" width="600">
22
22
 
23
- The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new once, including any versions you skipped. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
23
+ The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new once, including any versions you skipped. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with an open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. With more than one question, the arrows beside **Question 1 of 3** step through them. After you copy an answer, the popover opens on the next question. **Open Progress** at the bottom opens the panel in Explorer.
24
24
 
25
25
  <img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
26
26
 
@@ -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.9.0
149
+
150
+ - [Improved] **The pill's popover shows one question at a time.** You used to scroll past every open question to reach the one you wanted. Now **Question 2 of 3** sits at the top with back and next arrows. Copy an answer and the popover opens on the next question the next time you press the pill.
151
+ - [Improved] **A long question shows there's more below.** When a question runs past the bottom of the popover, its last lines fade out until you scroll to the end. Before, it stopped at the edge and looked finished.
152
+
153
+ ### 1.8.0
154
+
155
+ - [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.
156
+ - [Improved] **What's new banner.** The headline gets the banner's full width, with **See what's new** under it.
157
+
148
158
  ### 1.7.0
149
159
 
150
160
  - [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.
@@ -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;
@@ -1,6 +1,8 @@
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";
@@ -49,8 +51,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
49
51
  const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current), fullScreen: false }) });
50
52
  const openAttachment = (attachment: Attachment, from: Attachment[]) => {
51
53
  group.current = from;
52
- // A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
53
- 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();
54
56
  void opener.open(attachment);
55
57
  };
56
58
  // Opens the agent's chat in its workspace, with the dialog out of the way.
@@ -67,6 +67,9 @@ export const messages: ProgressMessages = {
67
67
  openProgress: "Open Progress",
68
68
  needsYou: "Progress needs you",
69
69
  nothingNeedsYou: "Nothing needs you right now.",
70
+ questionPosition: (total: number) => ["Question ", ` of ${total}`],
71
+ previousQuestion: "Previous question",
72
+ nextQuestion: "Next question",
70
73
  overdueAside: " (no update past its estimate)",
71
74
  writeUp: "Write-up",
72
75
  timeline: "Timeline",
@@ -67,6 +67,9 @@ export const messages: ProgressMessages = {
67
67
  openProgress: "打开 Progress",
68
68
  needsYou: "Progress 需要你处理",
69
69
  nothingNeedsYou: "目前没有需要你处理的事项。",
70
+ questionPosition: (total: number) => ["第 ", ` 个问题,共 ${total} 个`],
71
+ previousQuestion: "上一个问题",
72
+ nextQuestion: "下一个问题",
70
73
  overdueAside: "(超过预计时长未更新)",
71
74
  writeUp: "总结",
72
75
  timeline: "时间线",
@@ -49,6 +49,9 @@ export interface ProgressMessages {
49
49
  openProgress: string;
50
50
  needsYou: string;
51
51
  nothingNeedsYou: string;
52
+ questionPosition: (total: number) => Around;
53
+ previousQuestion: string;
54
+ nextQuestion: string;
52
55
  overdueAside: string;
53
56
  writeUp: string;
54
57
  timeline: 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
+ }
@@ -11,14 +11,20 @@ import { StackedDialog } from "./dialog-stack";
11
11
  import { galleryFiles } from "./preview";
12
12
  import { QuestionDetail, QuestionState, useCopy } from "./questions";
13
13
  import { errorText } from "./errors";
14
- import { raised } from "./surfaces";
14
+ import { raised, withAlpha } from "./surfaces";
15
15
  import { useProgressLanguage } from "./language";
16
16
 
17
17
  type Colors = PluginButtonContentProps["theme"]["colors"];
18
+ type Question = Dashboard["questions"]["open"][number];
18
19
 
19
20
  // On desktop, Paseo caps the popover at 440px and scrolls all of it, padding included. The
20
21
  // list scrolls on its own below that cap so Open Progress stays in view.
21
22
  const LIST_MAX_HEIGHT = 340;
23
+ const HEADER_GAP = 12;
24
+ const TOUCH_TARGET = 44;
25
+
26
+ // Paseo unmounts the popover when it closes, so reopening comes back to where the user was.
27
+ const shownQuestions = new Map<string, string>();
22
28
 
23
29
  // What the pill opens: the open questions with their Copy buttons, and anything
24
30
  // stuck, so the user can answer without leaving the chat.
@@ -43,8 +49,8 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
43
49
  return (
44
50
  <View style={{ width: 360, maxWidth: "100%", gap: 8 }}>
45
51
  {dashboard ? (
46
- <List compact={layout.compact}>
47
- <AttentionList colors={colors} dashboard={dashboard} onSurface1={onSurface1} onCopy={async (question, letter) => {
52
+ <AttentionList colors={colors} dashboard={dashboard} workspaceId={workspaceId} compact={layout.compact} touch={touch} onSurface1={onSurface1}
53
+ onCopy={async (question, letter) => {
48
54
  await copy(question, letter);
49
55
  close();
50
56
  }} onOpenAttachment={(attachment, from) => {
@@ -52,7 +58,6 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
52
58
  if (!isPreviewable(attachment)) close();
53
59
  void opener.open(attachment);
54
60
  }} />
55
- </List>
56
61
  ) : (
57
62
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
58
63
  {query.error ? m.readProgressError(errorText(query.error, m.unknownError)) : m.loading}
@@ -63,7 +68,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
63
68
  <View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
64
69
  {/* A full 44pt touch target on phones; desktop keeps the compact button. */}
65
70
  <PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
66
- style={({ pressed }) => ({ flexDirection: "row", justifyContent: "center", alignItems: "center", gap: touch ? 8 : 6, paddingVertical: 6, minHeight: touch ? 44 : undefined, borderRadius: touch ? 8 : 4, ...raised(colors),
71
+ style={({ pressed }) => ({ flexDirection: "row", justifyContent: "center", alignItems: "center", gap: touch ? 8 : 6, paddingVertical: 6, minHeight: touch ? TOUCH_TARGET : undefined, borderRadius: touch ? 8 : 4, ...raised(colors),
67
72
  backgroundColor: onSurface1 ? (pressed ? colors.surface1 : colors.surface2) : pressed ? colors.surface2 : colors.surface1 })}>
68
73
  {/* Beside the chat on desktop; on a phone it opens as its own tab, shown with the tab's icon. */}
69
74
  <Icon name={touch ? "ListChecks" : "PanelRight"} size={touch ? 16 : 12} color={colors.foreground} />
@@ -77,54 +82,140 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
77
82
  // On a phone the popover is a sheet that already scrolls, and sizes itself to
78
83
  // whichever scroll view reports last. Paseo 0.10.1 turns a ScrollView inside it
79
84
  // into one of those, so a short list would shrink the sheet to its title.
80
- function List({ compact, children }: { compact: boolean; children: React.ReactNode }) {
85
+ function List({ compact, maxHeight, surface, children }: { compact: boolean; maxHeight: number; surface: string; children: React.ReactNode }) {
86
+ const [moreBelow, setMoreBelow] = useState(false);
87
+ const size = useRef({ viewport: 0, content: 0, offset: 0 });
81
88
  if (compact) return <View style={{ gap: 12 }}>{children}</View>;
82
- return <ScrollView style={{ maxHeight: LIST_MAX_HEIGHT }} contentContainerStyle={{ gap: 12 }}>{children}</ScrollView>;
89
+ const measure = (next: Partial<typeof size.current>) => {
90
+ const s = Object.assign(size.current, next);
91
+ const below = s.offset + s.viewport < s.content - 1;
92
+ if (below !== moreBelow) setMoreBelow(below);
93
+ };
94
+ return (
95
+ <View style={{ position: "relative" }}>
96
+ <ScrollView style={{ maxHeight }} contentContainerStyle={{ gap: 12 }} scrollEventThrottle={16}
97
+ onLayout={(event) => measure({ viewport: event.nativeEvent.layout.height })}
98
+ onContentSizeChange={(_, height) => measure({ content: height })}
99
+ onScroll={(event) => measure({ offset: event.nativeEvent.contentOffset.y })}>
100
+ {children}
101
+ </ScrollView>
102
+ {moreBelow ? <BottomFade color={surface} /> : null}
103
+ </View>
104
+ );
105
+ }
106
+
107
+ const FADE_SLICES = 8;
108
+
109
+ // React Native Web 0.21 has no experimental_backgroundImage, so no single gradient style draws on both desktop and iPad.
110
+ function BottomFade({ color }: { color: string }) {
111
+ return (
112
+ <View pointerEvents="none" style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}>
113
+ {Array.from({ length: FADE_SLICES }, (_, i) => (
114
+ <View key={i} style={{ height: 3, backgroundColor: withAlpha(color, (0.9 * i) / (FADE_SLICES - 1)) ?? undefined }} />
115
+ ))}
116
+ </View>
117
+ );
83
118
  }
84
119
 
85
- export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAttachment }: {
120
+ export function AttentionList({ colors, dashboard, workspaceId, compact, touch, onSurface1, onCopy, onOpenAttachment }: {
86
121
  colors: Colors;
87
122
  dashboard: Dashboard;
123
+ workspaceId: string;
124
+ compact: boolean;
125
+ touch: boolean;
88
126
  onSurface1: boolean;
89
- onCopy(question: Dashboard["questions"]["open"][number], letter: string): void;
127
+ onCopy(question: Question, letter: string): void;
90
128
  onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
91
129
  }) {
92
130
  const { messages: m } = useProgressLanguage();
131
+ const [shownId, setShownId] = useState(() => shownQuestions.get(workspaceId));
93
132
  const now = Date.now();
133
+ const open = dashboard.questions.open;
134
+ const found = open.findIndex((question) => question.id === shownId);
135
+ const index = found === -1 ? 0 : found;
136
+ const question: Question | undefined = open[index];
94
137
  // The last question can be answered while the popover is open.
95
- if (!dashboard.questions.open.length && !dashboard.stuck.length) {
138
+ if (!question && !dashboard.stuck.length) {
96
139
  return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.nothingNeedsYou}</Text>;
97
140
  }
141
+ const show = (target: Question) => {
142
+ shownQuestions.set(workspaceId, target.id);
143
+ setShownId(target.id);
144
+ };
145
+ const advancePast = (copied: Question) => shownQuestions.set(workspaceId, (open[index + 1] ?? copied).id);
146
+ const step = { colors, touch, onSurface1, onPress: show };
147
+ const stepping = open.length > 1;
148
+ const position = m.questionPosition(open.length);
98
149
  return (
99
- <>
100
- {dashboard.questions.open.map((question) => (
101
- <View key={question.id} style={{ gap: 6 }}>
102
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
103
- <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19, fontWeight: "600" }}>{question.id}</Text>
104
- <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
105
- <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
106
- <QuestionState colors={colors} question={question} />
107
- </View>
150
+ <View style={{ gap: HEADER_GAP }}>
151
+ {stepping ? (
152
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
153
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontVariant: ["tabular-nums"] }}>
154
+ {position[0]}<Text style={{ color: colors.foreground, fontWeight: "600" }}>{index + 1}</Text>{position[1]}
155
+ </Text>
156
+ <View style={{ flexDirection: "row", gap: touch ? stepSlop * 2 : 4 }}>
157
+ <StepButton {...step} icon="ChevronLeft" label={m.previousQuestion} target={open[index - 1]} />
158
+ <StepButton {...step} icon="ChevronRight" label={m.nextQuestion} target={open[index + 1]} />
108
159
  </View>
109
- <QuestionDetail colors={colors} question={question} now={now} onSurface1={onSurface1} onCopy={(letter) => onCopy(question, letter)} onOpenAttachment={onOpenAttachment} />
110
160
  </View>
111
- ))}
112
- {dashboard.stuck.length ? (
113
- <View style={{ gap: 4 }}>
114
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
115
- <Icon name="OctagonAlert" size={12} color={colors.statusDanger} />
116
- <Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>{m.stuck}</Text>
161
+ ) : null}
162
+ <List key={question?.id} compact={compact} surface={onSurface1 ? colors.surface1 : colors.surface0} maxHeight={stepping ? LIST_MAX_HEIGHT - stepSize(touch) - HEADER_GAP : LIST_MAX_HEIGHT}>
163
+ {question ? (
164
+ <View style={{ gap: 6 }}>
165
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
166
+ <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19, fontWeight: "600" }}>{question.id}</Text>
167
+ <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
168
+ <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
169
+ <QuestionState colors={colors} question={question} />
170
+ </View>
171
+ </View>
172
+ <QuestionDetail colors={colors} question={question} now={now} onSurface1={onSurface1} onOpenAttachment={onOpenAttachment} onCopy={(letter) => {
173
+ advancePast(question);
174
+ onCopy(question, letter);
175
+ }} />
117
176
  </View>
118
- {dashboard.stuck.map((item) => (
119
- <Text key={item.key} style={{ color: colors.foreground, fontSize: 12, lineHeight: 17 }}>
120
- {item.title}
121
- <Text style={{ color: colors.foregroundMuted }}>
122
- {item.kind === "overdue" ? m.overdueAside : item.kind === "blocked" ? m.blockedAside(item.note) : ""}
177
+ ) : null}
178
+ {dashboard.stuck.length ? (
179
+ <View style={{ gap: 4 }}>
180
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
181
+ <Icon name="OctagonAlert" size={12} color={colors.statusDanger} />
182
+ <Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>{m.stuck}</Text>
183
+ </View>
184
+ {dashboard.stuck.map((item) => (
185
+ <Text key={item.key} style={{ color: colors.foreground, fontSize: 12, lineHeight: 17 }}>
186
+ {item.title}
187
+ <Text style={{ color: colors.foregroundMuted }}>
188
+ {item.kind === "overdue" ? m.overdueAside : item.kind === "blocked" ? m.blockedAside(item.note) : ""}
189
+ </Text>
123
190
  </Text>
124
- </Text>
125
- ))}
126
- </View>
127
- ) : null}
128
- </>
191
+ ))}
192
+ </View>
193
+ ) : null}
194
+ </List>
195
+ </View>
196
+ );
197
+ }
198
+
199
+ const stepSize = (touch: boolean) => (touch ? 36 : 24);
200
+ const stepSlop = (TOUCH_TARGET - stepSize(true)) / 2;
201
+
202
+ function StepButton({ colors, touch, onSurface1, icon, label, target, onPress }: {
203
+ colors: Colors;
204
+ touch: boolean;
205
+ onSurface1: boolean;
206
+ icon: "ChevronLeft" | "ChevronRight";
207
+ label: string;
208
+ target: Question | undefined;
209
+ onPress(target: Question): void;
210
+ }) {
211
+ const button = onSurface1 ? { rest: colors.surface2, pressed: colors.surface1 } : { rest: colors.surface1, pressed: colors.surface2 };
212
+ const size = stepSize(touch);
213
+ return (
214
+ <PressScale accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled: !target }} disabled={!target}
215
+ onPress={target ? () => onPress(target) : undefined} hitSlop={touch ? stepSlop : undefined}
216
+ style={({ pressed }) => ({ width: size, height: size, alignItems: "center", justifyContent: "center", borderRadius: touch ? 8 : 4, opacity: target ? 1 : 0.35, ...raised(colors),
217
+ backgroundColor: pressed ? button.pressed : button.rest })}>
218
+ <Icon name={icon} size={touch ? 16 : 14} color={colors.foreground} />
219
+ </PressScale>
129
220
  );
130
221
  }
@@ -356,7 +356,7 @@ const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
356
356
  const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
357
357
 
358
358
  // Roughly how tall the preview draws, including wrapped lines.
359
- 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 {
360
360
  if (!data) return 0;
361
361
  if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
362
362
  if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
@@ -51,7 +51,10 @@ export function raised(colors: Colors): ViewStyle {
51
51
  // drifting second color. Paseo's own status badges use 12% in light themes and
52
52
  // 16% in dark ones, where the surface swallows more of it.
53
53
  export function tint(color: string, colors: Colors): string {
54
+ return withAlpha(color, isLight(colors) ? 0.12 : 0.16) ?? colors.surface2;
55
+ }
56
+
57
+ export function withAlpha(color: string, alpha: number): string | null {
54
58
  const rgb = channels(color);
55
- if (!rgb) return colors.surface2;
56
- return `rgba(${rgb.join(", ")}, ${isLight(colors) ? 0.12 : 0.16})`;
59
+ return rgb ? `rgba(${rgb.join(", ")}, ${alpha})` : null;
57
60
  }
@@ -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.7.0",
3
+ "version": "1.9.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
  });