@stevecastaneda/paseo-progress 1.5.1 → 1.7.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
@@ -18,9 +18,9 @@ The dashboard shows:
18
18
 
19
19
  <img src="images/2-ticket.png" alt="A ticket's story: its status, time against the estimate, the agent on it, and its timeline" width="400"> <img src="images/3-question.png" alt="A question waiting for an answer, with lettered options, the agent's recommendation and Copy buttons" width="400">
20
20
 
21
- <img src="images/5-settings.png" alt="Progress settings in Paseo: the Questions setting set to Work around, and the Language setting set to System" width="600">
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 in that version once. 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 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.
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
 
@@ -82,7 +82,7 @@ Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.sc
82
82
 
83
83
  ## Agent skill
84
84
 
85
- The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions, and finish. It stays short and leaves the details to `paseo-progress --help`. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode. If Paseo later runs the plugin from somewhere else, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped.
85
+ The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions, and finish. It stays short and leaves the details to `paseo-progress --help`. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode. Paseo puts each plugin update in a new folder, so when Progress starts it repoints the links it made to that folder. If it can't, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped. To read what agents get, open **View skill** under Agent Skills in Progress settings.
86
86
 
87
87
  From a terminal instead:
88
88
 
@@ -145,9 +145,20 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.7.0
149
+
150
+ - [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.
151
+ - [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.
152
+
153
+ ### 1.6.0
154
+
155
+ - [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.
156
+ - [Fixed] **The skill stays linked after updates.** Paseo puts each plugin update in a new folder, so after every update Progress asked you to press **Update skill**, even when the skill hadn't changed. Progress now fixes the link itself.
157
+ - [Improved] **What's new catches you up.** If you skip an update, What's new now shows the changes from every version you missed, newest first.
158
+ - [Fixed] **Code inside lists.** A code block inside a list item in a write-up no longer shows shifted to the right.
159
+
148
160
  ### 1.5.1
149
161
 
150
- - [New] **Simplified Chinese.** Progress now comes in English and Simplified Chinese. Pick one under Display in Progress settings, or let it follow each device's language. Thanks to @xzthemeofsss for the translation.
151
162
  - [Improved] **Settings screenshot.** It shows the new Language setting.
152
163
 
153
164
  ### 1.5.0
@@ -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
  }
@@ -5,6 +5,7 @@ import type { Dashboard, Question } from "../shared/dashboard";
5
5
  import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
6
6
  import { NarrowProvider } from "./narrow";
7
7
  import { BackButton, galleryFiles, PreviewContent } from "./preview";
8
+ import { previewKind } from "../shared/attachments";
8
9
  import { QuestionView, useCopy } from "./questions";
9
10
  import { TicketStoryView } from "./ticket-dialog";
10
11
  import { ProgressLanguageProvider } from "./language";
@@ -12,11 +13,13 @@ import { ProgressLanguageProvider } from "./language";
12
13
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
13
14
  type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
14
15
 
15
- // What the dialog is showing. A preview keeps the files beside it for Previous and Next.
16
+ // What the dialog is showing. A preview keeps the files beside it for Previous and Next,
17
+ // and whether its image fills the window. `leftFullScreen` puts focus back on the
18
+ // Full screen button; it lives here because leaving can remount the preview.
16
19
  export type Step =
17
20
  | { kind: "ticket"; id: string }
18
21
  | { kind: "question"; id: string }
19
- | { kind: "preview"; attachment: Attachment; group: Attachment[] };
22
+ | { kind: "preview"; attachment: Attachment; group: Attachment[]; fullScreen: boolean; leftFullScreen?: boolean };
20
23
 
21
24
  // Paseo gives each dialog its own backdrop, so opening a second one darkens
22
25
  // the screen while the first fades out; steps swap content inside this one instead.
@@ -43,7 +46,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
43
46
  const group = useRef<Attachment[]>([]);
44
47
  const push = (step: Step) => setSteps((current) => [...current, step]);
45
48
  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) }) });
49
+ const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current), fullScreen: false }) });
47
50
  const openAttachment = (attachment: Attachment, from: Attachment[]) => {
48
51
  group.current = from;
49
52
  // A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
@@ -62,6 +65,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
62
65
  const ticket = top?.kind === "ticket" ? dashboard?.tickets.find((candidate) => candidate.id === top.id) : undefined;
63
66
  const question = top?.kind === "question" ? questions.find((candidate) => candidate.id === top.id) : undefined;
64
67
  const preview = top?.kind === "preview" ? top : undefined;
68
+ const setFullScreen = (on: boolean) => preview && replaceTop({ ...preview, fullScreen: on, leftFullScreen: !on });
65
69
  // The ticket or question it opened on went away (answered elsewhere, or a new run): close rather than show a blank dialog.
66
70
  const rootMissing = root !== null && (root.kind === "ticket" ? !dashboard?.tickets.some((candidate) => candidate.id === root.id)
67
71
  : root.kind === "question" ? !questions.some((candidate) => candidate.id === root.id) : false);
@@ -74,9 +78,16 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
74
78
 
75
79
  return (
76
80
  <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)}>
81
+ open={Boolean(root)} onOpenChange={(next) => {
82
+ if (next) return;
83
+ // Esc leaves full screen first; the dialog stays open under it.
84
+ if (preview?.fullScreen) setFullScreen(false);
85
+ else onClose();
86
+ }}>
87
+ {/* One Modal.Content for every step: replacing it would re-present the sheet on phones.
88
+ Full screen turns its scrolling off. The image's fixed position reaches the window only
89
+ when no ancestor has a transform, and a scroll view has one. */}
90
+ <Modal.Content scrollable={!(preview && (previewLong || preview.fullScreen))}>
80
91
  <ProgressLanguageProvider>
81
92
  {/* A dialog has room for clock times, however narrow the panel that opened it. */}
82
93
  <NarrowProvider value={false}>
@@ -84,7 +95,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
84
95
  <PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
85
96
  onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
86
97
  onCopyPath={() => void opener.copyPath(preview.attachment)} onBack={back}
87
- gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }} onLong={setPreviewLong}
98
+ gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment, fullScreen: preview.fullScreen && previewKind(attachment.path ?? "") === "image", leftFullScreen: false }) }}
99
+ onLong={setPreviewLong} fullScreen={preview.fullScreen} leftFullScreen={preview.leftFullScreen === true} onFullScreen={setFullScreen}
88
100
  onOpenEntry={openAttachment} />
89
101
  ) : (
90
102
  <>
@@ -70,23 +70,28 @@ export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginW
70
70
  return <SetupBanner colors={colors} icon="Terminal" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
71
71
  }
72
72
 
73
- export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
73
+ // The panel banner and Settings share the status and the install action.
74
+ export function useSkill(hostId: string) {
74
75
  const { messages: m } = useProgressLanguage();
75
76
  const fetchStatus = useRpc(getSkillStatus);
76
77
  const install = useRpc(installSkill);
77
78
  const queryClient = useQueryClient();
78
79
  const toast = useToast();
79
- const status = useQuery({ queryKey: skillQueryKey(host.id), queryFn: () => fetchStatus({}), staleTime: 60_000 });
80
+ const status = useQuery({ queryKey: skillQueryKey(hostId), queryFn: () => fetchStatus({}), staleTime: 60_000 });
80
81
  const mutation = useMutation({
81
82
  mutationFn: () => install({}),
82
83
  onSuccess: (next) => {
83
- queryClient.setQueryData(skillQueryKey(host.id), next);
84
+ queryClient.setQueryData(skillQueryKey(hostId), next);
84
85
  toast.show(m.skillInstalled, { variant: "success" });
85
86
  },
86
87
  onError: (error) => toast.error(errorText(error, m.installSkillFailure)),
87
88
  });
88
- const notice = status.data ? skillNotice(status.data, m) : null;
89
- return <SetupBanner colors={colors} icon="Sparkles" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
89
+ return { notice: status.data ? skillNotice(status.data, m) : null, state: status.data?.state, pending: mutation.isPending, install: () => mutation.mutate() };
90
+ }
91
+
92
+ export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
93
+ const skill = useSkill(host.id);
94
+ return <SetupBanner colors={colors} icon="Sparkles" notice={skill.notice} pending={skill.pending} onPress={skill.install} />;
90
95
  }
91
96
 
92
97
  function SetupBanner({ colors, icon, notice, pending, onPress }: {
@@ -86,6 +86,7 @@ export const messages: ProgressMessages = {
86
86
  showFewer: "Show fewer",
87
87
  seeWhatsNew: "See what’s new",
88
88
  dismissWhatsNew: "Dismiss what's new",
89
+ whatsNewAll: "What’s new in Progress",
89
90
  gotIt: "Got it",
90
91
  loadingPreview: "Loading preview",
91
92
  truncatedText: "Showing the first 256 KB.",
@@ -95,6 +96,10 @@ export const messages: ProgressMessages = {
95
96
  back: "Back",
96
97
  previousFile: "Previous file",
97
98
  nextFile: "Next file",
99
+ fullScreen: "Full screen",
100
+ exitFullScreen: "Exit full screen",
101
+ fitToWidth: "Fit to width",
102
+ fitToScreen: "Fit to screen",
98
103
  videoChanged: "the video changed while it was loading. Try again.",
99
104
  localHistory: "Run history stays on this computer.",
100
105
  historyStopped: "Runs will stay on this computer. Files already saved stay in the repo.",
@@ -124,6 +129,11 @@ export const messages: ProgressMessages = {
124
129
  skillForeign: "Something else already exists at every paseo-progress skill path, so the plugin won't install the skill.",
125
130
  skillInstalled: "Installed the paseo-progress skill. New agent sessions will use it.",
126
131
  installSkillFailure: "Could not install the skill.",
132
+ agentSkill: "Agent Skills",
133
+ skillName: "paseo-progress skill",
134
+ viewSkill: "View skill",
135
+ skillNotInstalled: "Not installed",
136
+ skillLinkOutdated: "Link out of date",
127
137
  installing: "Installing…",
128
138
  loadingSettings: "Loading settings…",
129
139
  tryAgain: "Try again",
@@ -178,6 +188,7 @@ export const messages: ProgressMessages = {
178
188
  readError: (error: string) => `Couldn't read it here: ${error}`,
179
189
  showOlder: (count: number) => `Show ${count} older`,
180
190
  newVersion: (version: string) => `New in ${version}:`,
191
+ updatedTo: (version: string) => `Updated to ${version}:`,
181
192
  whatsNewTitle: (version: string) => `What’s new in Progress ${version}`,
182
193
  loadingVideo: (percent: number) => `Loading video, ${percent}%`,
183
194
  previewError: (error: string) => `Could not preview it: ${error}`,
@@ -86,6 +86,7 @@ export const messages: ProgressMessages = {
86
86
  showFewer: "收起",
87
87
  seeWhatsNew: "查看更新",
88
88
  dismissWhatsNew: "关闭更新提示",
89
+ whatsNewAll: "Progress 更新内容",
89
90
  gotIt: "知道了",
90
91
  loadingPreview: "正在加载预览",
91
92
  truncatedText: "仅显示前 256 KB。",
@@ -95,6 +96,10 @@ export const messages: ProgressMessages = {
95
96
  back: "返回",
96
97
  previousFile: "上一个文件",
97
98
  nextFile: "下一个文件",
99
+ fullScreen: "全屏",
100
+ exitFullScreen: "退出全屏",
101
+ fitToWidth: "适应宽度",
102
+ fitToScreen: "适应屏幕",
98
103
  videoChanged: "视频在加载过程中发生了变化。请重试。",
99
104
  localHistory: "运行记录保存在这台电脑上。",
100
105
  historyStopped: "运行记录将保存在这台电脑上。已保存的文件会保留在仓库中。",
@@ -124,6 +129,11 @@ export const messages: ProgressMessages = {
124
129
  skillForeign: "所有 paseo-progress 技能路径都已被其他内容占用,插件无法安装技能。",
125
130
  skillInstalled: "已安装 paseo-progress 技能。新的智能体会话将使用此技能。",
126
131
  installSkillFailure: "无法安装技能。",
132
+ agentSkill: "智能体技能",
133
+ skillName: "paseo-progress 技能",
134
+ viewSkill: "查看技能",
135
+ skillNotInstalled: "未安装",
136
+ skillLinkOutdated: "链接已过期",
127
137
  installing: "安装中…",
128
138
  loadingSettings: "正在加载设置…",
129
139
  tryAgain: "重试",
@@ -178,6 +188,7 @@ export const messages: ProgressMessages = {
178
188
  readError: (error: string) => `无法在此读取:${error}`,
179
189
  showOlder: (count: number) => `显示更早的 ${count} 项`,
180
190
  newVersion: (version: string) => `${version} 的更新:`,
191
+ updatedTo: (version: string) => `已更新到 ${version}:`,
181
192
  whatsNewTitle: (version: string) => `Progress ${version} 更新内容`,
182
193
  loadingVideo: (percent: number) => `正在加载视频,${percent}%`,
183
194
  previewError: (error: string) => `无法预览:${error}`,
@@ -68,6 +68,7 @@ export interface ProgressMessages {
68
68
  showFewer: string;
69
69
  seeWhatsNew: string;
70
70
  dismissWhatsNew: string;
71
+ whatsNewAll: string;
71
72
  gotIt: string;
72
73
  loadingPreview: string;
73
74
  truncatedText: string;
@@ -77,6 +78,10 @@ export interface ProgressMessages {
77
78
  back: string;
78
79
  previousFile: string;
79
80
  nextFile: string;
81
+ fullScreen: string;
82
+ exitFullScreen: string;
83
+ fitToWidth: string;
84
+ fitToScreen: string;
80
85
  videoChanged: string;
81
86
  localHistory: string;
82
87
  historyStopped: string;
@@ -106,6 +111,11 @@ export interface ProgressMessages {
106
111
  skillForeign: string;
107
112
  skillInstalled: string;
108
113
  installSkillFailure: string;
114
+ agentSkill: string;
115
+ skillName: string;
116
+ viewSkill: string;
117
+ skillNotInstalled: string;
118
+ skillLinkOutdated: string;
109
119
  installing: string;
110
120
  loadingSettings: string;
111
121
  tryAgain: string;
@@ -160,6 +170,7 @@ export interface ProgressMessages {
160
170
  readError: (error: string) => string;
161
171
  showOlder: (count: number) => string;
162
172
  newVersion: (version: string) => string;
173
+ updatedTo: (version: string) => string;
163
174
  whatsNewTitle: (version: string) => string;
164
175
  loadingVideo: (percent: number) => string;
165
176
  previewError: (error: string) => string;
@@ -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).
@@ -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,
@@ -1,29 +1,78 @@
1
- import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
- import { SettingsAction, SettingsCard, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
3
- import React from "react";
4
- import { Text } from "react-native";
1
+ import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
3
+ import { SettingsAction, SettingsCard, SettingsSelect } from "@getpaseo/plugin/client/ui";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import React, { type ReactNode, useState } from "react";
6
+ import { Text, View } from "react-native";
5
7
  import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "../shared/question-modes";
8
+ import { readSkill } from "../shared/rpc";
6
9
  import { questionSettings, LANGUAGE_PREFERENCES } from "../shared/settings";
10
+ import { parseWriteUp, withoutTitle } from "../shared/write-up";
11
+ import { errorText } from "./errors";
7
12
  import { useProgressLanguage } from "./language";
13
+ import { useSkill } from "./launcher";
14
+ import { BlockView } from "./write-up";
8
15
 
9
- export function QuestionSettings({ theme }: PluginSurfaceProps) {
10
- return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /></>;
16
+ export function QuestionSettings({ theme, host }: PluginSurfaceProps) {
17
+ return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /><SkillSettings colors={theme.colors} hostId={host.id} /></>;
11
18
  }
12
19
 
13
20
  type Colors = PluginSurfaceProps["theme"]["colors"];
14
21
 
22
+ // Paseo's SettingsSection has no icon slot, so this copies its layout and adds one.
23
+ function Section({ colors, icon, title, children }: { colors: Colors; icon: string; title: string; children: ReactNode }) {
24
+ return (
25
+ <View style={{ marginBottom: 24 }}>
26
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 12, marginLeft: 4 }}>
27
+ <Icon name={icon} size={14} color={colors.foregroundMuted} />
28
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, fontWeight: "400" }}>{title}</Text>
29
+ </View>
30
+ <View style={{ gap: 12 }}>{children}</View>
31
+ </View>
32
+ );
33
+ }
34
+
15
35
  function DisplaySettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
16
36
  const { messages: m, settings } = useProgressLanguage();
17
37
  const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
18
38
  const options = LANGUAGE_PREFERENCES.map((value) => ({ value, label: value === "system" ? m.systemLanguage : value === "en" ? "English" : "简体中文" }));
19
39
  return (
20
- <SettingsSection title={m.display}>
40
+ <Section colors={theme.colors} icon="Languages" title={m.display}>
21
41
  {settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
22
42
  : settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} />
23
43
  : <SettingsCard><SettingsSelect label={m.language} hint={m.languageHint} value={settings.values.language} options={options}
24
44
  disabled={settings.saving} error={settings.saveError}
25
45
  onValueChange={(language) => void settings.save({ ...settings.values, language }, settings.revision)} /></SettingsCard>}
26
- </SettingsSection>
46
+ </Section>
47
+ );
48
+ }
49
+
50
+ function SkillSettings({ colors, hostId }: { colors: Colors; hostId: string }) {
51
+ const { messages: m } = useProgressLanguage();
52
+ const skill = useSkill(hostId);
53
+ const fetchSkill = useRpc(readSkill);
54
+ const document = useQuery({ queryKey: ["progress", "skill-document", hostId], queryFn: () => fetchSkill({}), staleTime: 60_000 });
55
+ const [open, setOpen] = useState(false);
56
+ const blocks = document.data ? withoutTitle(parseWriteUp(document.data.markdown)) : [];
57
+ const muted = { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
58
+ return (
59
+ <Section colors={colors} icon="Sparkles" title={m.agentSkill}>
60
+ <SettingsCard>
61
+ <SettingsAction label={m.skillName} hint={document.data?.description || undefined} actionLabel={m.viewSkill} onPress={() => setOpen(true)} />
62
+ {skill.notice?.action ? (
63
+ <SettingsAction label={skill.state === "outdated" ? m.skillLinkOutdated : m.skillNotInstalled} hint={skill.notice.text}
64
+ actionLabel={skill.pending ? m.installing : skill.notice.action} disabled={skill.pending} onPress={skill.install} />
65
+ ) : null}
66
+ </SettingsCard>
67
+ {/* Paseo draws the modal outside this tree, past the language context, so it only gets finished text. */}
68
+ <Modal title={m.skillName} icon={<Icon name="Sparkles" size={16} color={colors.foregroundMuted} />} open={open} onOpenChange={setOpen}>
69
+ <Modal.Content>
70
+ {document.error ? <Text style={muted}>{m.readError(errorText(document.error, m.unknownError))}</Text>
71
+ : !document.data ? <Text style={muted}>{m.loading}</Text>
72
+ : <View style={{ gap: 8 }}>{blocks.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View>}
73
+ </Modal.Content>
74
+ </Modal>
75
+ </Section>
27
76
  );
28
77
  }
29
78
 
@@ -32,13 +81,13 @@ function QuestionModeSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
32
81
  const settings = useSettings(questionSettings);
33
82
  const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
34
83
  return (
35
- <SettingsSection title={m.questions}>
84
+ <Section colors={theme.colors} icon="MessageCircleQuestion" title={m.questions}>
36
85
  {settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
37
86
  : settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} resetLabel={m.resetTo(m.questionModes[DEFAULT_QUESTION_MODE].label)} />
38
87
  : <SettingsCard><SettingsSelect label={m.questionMode} hint={m.questionModes[settings.values.mode].hint}
39
88
  error={settings.saveError} value={settings.values.mode} options={QUESTION_MODES.map((value) => ({ value, label: m.questionModes[value].label }))}
40
89
  disabled={settings.saving} onValueChange={(mode) => void settings.save({ ...settings.values, mode }, settings.revision)} /></SettingsCard>}
41
- </SettingsSection>
90
+ </Section>
42
91
  );
43
92
  }
44
93
 
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import React, { useState } from "react";
5
5
  import { Text, View } from "react-native";
6
6
  import { dismissWhatsNew, getWhatsNew } from "../shared/rpc";
7
- import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote } from "../shared/whats-new";
7
+ import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new";
8
8
  import { spans } from "../shared/write-up";
9
9
  import { PressScale, Presence } from "./motion";
10
10
  import { isLight, raised, tint } from "./surfaces";
@@ -13,8 +13,8 @@ import { ProgressLanguageProvider, useProgressLanguage } from "./language";
13
13
 
14
14
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
15
15
 
16
- // Once after an update: this version's headline, with its full notes a press
17
- // away. Closing either hides both for this version.
16
+ // Once after an update: the headline of what changed, with the full notes a
17
+ // press away. Closing either hides both until the next version.
18
18
  export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
19
19
  const { messages: m } = useProgressLanguage();
20
20
  const fetchWhatsNew = useRpc(getWhatsNew);
@@ -42,7 +42,7 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
42
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
43
  <View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
44
44
  <Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
45
- <Text style={{ color: colors.foreground, fontWeight: "500" }}>{m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
45
+ <Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
46
46
  </Text>
47
47
  {/* 8px keeps the close button's 6px hit slop off the button beside it. */}
48
48
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -57,11 +57,11 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
57
57
  </View>
58
58
  </View>
59
59
  ) : null}</Presence>
60
- <Modal title={shown ? m.whatsNewTitle(shown.version) : ""} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
60
+ <Modal title={!shown ? "" : shown.releases.length > 1 ? m.whatsNewAll : m.whatsNewTitle(shown.version)} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
61
61
  open={open} onOpenChange={(next) => { if (!next) closeDialog(); }}>
62
62
  <Modal.Content>
63
63
  <ProgressLanguageProvider>
64
- {shown ? <Notes colors={colors} notes={shown.notes} /> : null}
64
+ {shown ? <Releases colors={colors} releases={shown.releases} /> : null}
65
65
  <View style={{ flexDirection: "row", justifyContent: "flex-end", marginTop: 8 }}>
66
66
  <PressScale accessibilityRole="button" onPress={closeDialog}
67
67
  style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
@@ -75,6 +75,22 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
75
75
  );
76
76
  }
77
77
 
78
+ // One version reads as before. After a skipped update, each version's notes sit
79
+ // under its number, so it's clear which release brought what.
80
+ function Releases({ colors, releases }: { colors: Colors; releases: WhatsNewRelease[] }) {
81
+ if (releases.length === 1) return <Notes colors={colors} notes={releases[0].notes} />;
82
+ return (
83
+ <View style={{ gap: 40 }}>
84
+ {releases.map((release) => (
85
+ <View key={release.version} style={{ gap: 12 }}>
86
+ <Text accessibilityRole="header" selectable style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{release.version}</Text>
87
+ <Notes colors={colors} notes={release.notes} />
88
+ </View>
89
+ ))}
90
+ </View>
91
+ );
92
+ }
93
+
78
94
  // Titled notes read as a short list of headings with their details; a changelog
79
95
  // written without titles stays a bulleted list.
80
96
  function Notes({ colors, notes }: { colors: Colors; notes: WhatsNewNote[] }) {
Binary file
package/index.server.ts CHANGED
@@ -3,9 +3,9 @@ import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPa
3
3
  import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
4
4
  import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
5
5
  import { mirrorQuestionMode } from "./server/question-mode";
6
- import { handleInstallSkill, handleSkillStatus } from "./server/skill";
6
+ import { handleInstallSkill, handleReadSkill, handleSkillStatus, repairSkill } from "./server/skill";
7
7
  import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
8
- import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
8
+ import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
9
9
  import { displaySettings, questionSettings } from "./shared/settings";
10
10
 
11
11
  export default function contribute(server: PluginServerContext) {
@@ -20,6 +20,7 @@ export default function contribute(server: PluginServerContext) {
20
20
  server.handle(readDeliverablePiece, handleReadDeliverablePiece);
21
21
  server.handle(getSkillStatus, handleSkillStatus);
22
22
  server.handle(installSkill, handleInstallSkill);
23
+ server.handle(readSkill, handleReadSkill);
23
24
  server.handle(listFolders, handleListFolders);
24
25
  server.handle(setHistoryFolder, handleSetHistoryFolder);
25
26
  server.handle(getWhatsNew, handleGetWhatsNew);
@@ -27,6 +28,7 @@ export default function contribute(server: PluginServerContext) {
27
28
  // Settles a new install before any panel opens. Opening one makes a
28
29
  // worktree folder, which would look like an earlier version's.
29
30
  void handleGetWhatsNew().catch(() => {});
31
+ void repairSkill();
30
32
  server.registerSettings(displaySettings);
31
33
  const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
32
34
  return mirror.stop;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.5.1",
3
+ "version": "1.7.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/skill.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { exec, execFile } from "node:child_process";
2
- import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, readlink, symlink, unlink } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
@@ -31,24 +31,41 @@ export async function skillState(path: string, pluginDirectory: string): Promise
31
31
  return basename(target) === SKILL_NAME && basename(dirname(target)) === "skills" ? "outdated" : "foreign";
32
32
  }
33
33
 
34
+ // Repoints each link this plugin made to an old copy at the copy Paseo runs
35
+ // now. Paseo puts every update in a new folder, so this follows each update.
36
+ // Returns whether it wrote.
37
+ export async function relinkSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
38
+ let wrote = false;
39
+ for (const path of paths) {
40
+ if (await skillState(path, pluginDirectory) !== "outdated") continue;
41
+ await unlink(path);
42
+ await link(path, pluginDirectory);
43
+ wrote = true;
44
+ }
45
+ return wrote;
46
+ }
47
+
34
48
  // Links the skill wherever it is missing or points at an old copy of the
35
49
  // plugin. Leaves alone anything else at those paths. Returns whether it wrote.
36
50
  export async function installSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
37
51
  const states = await Promise.all(paths.map((path) => skillState(path, pluginDirectory)));
38
52
  if (states.every((state) => state === "foreign")) throw new Error(`${paths.join(", ")} already exist and were not linked by this plugin.`);
39
- let wrote = false;
53
+ let wrote = await relinkSkill(paths, pluginDirectory);
40
54
  for (const [index, path] of paths.entries()) {
41
- if (states[index] !== "missing" && states[index] !== "outdated") continue;
42
- if (states[index] === "outdated") await unlink(path);
43
- await mkdir(dirname(path), { recursive: true });
44
- // A junction on Windows, where a directory symlink needs admin rights or
45
- // Developer Mode. Other systems ignore the type and make a symlink.
46
- await symlink(skillSource(pluginDirectory), path, "junction");
55
+ if (states[index] !== "missing") continue;
56
+ await link(path, pluginDirectory);
47
57
  wrote = true;
48
58
  }
49
59
  return wrote;
50
60
  }
51
61
 
62
+ async function link(path: string, pluginDirectory: string) {
63
+ await mkdir(dirname(path), { recursive: true });
64
+ // A junction on Windows, where a directory symlink needs admin rights or
65
+ // Developer Mode. Other systems ignore the type and make a symlink.
66
+ await symlink(skillSource(pluginDirectory), path, "junction");
67
+ }
68
+
52
69
  // "current" once every folder that can take the link has it.
53
70
  export async function skillStatus(paths: string[], pluginDirectory: string) {
54
71
  const targets = await Promise.all(paths.map(async (path) => ({ path, state: await skillState(path, pluginDirectory) })));
@@ -75,19 +92,45 @@ export async function findPluginDirectory(): Promise<string> {
75
92
  }
76
93
 
77
94
  // On Windows Paseo's command is paseo.cmd, which Node can start, or find on
78
- // PATH, only through cmd.exe.
95
+ // PATH, only through cmd.exe. A hung CLI must fail so the next one is tried.
79
96
  function askPaseo(cli: string) {
97
+ const options = { timeout: 10_000 };
80
98
  return process.platform === "win32"
81
- ? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`)
82
- : promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
99
+ ? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`, options)
100
+ : promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"], options);
101
+ }
102
+
103
+ // Runs once per load, when the server starts: an update reloads the bundle.
104
+ // Only repoints links the user already asked for; new ones wait for Install
105
+ // skill. A failure leaves the links as they were, and status says so.
106
+ let repair: Promise<unknown> | null = null;
107
+
108
+ export function repairSkill(): Promise<unknown> {
109
+ repair ??= findPluginDirectory().then((directory) => relinkSkill(defaultSkillPaths(), directory)).catch(() => {});
110
+ return repair;
83
111
  }
84
112
 
85
113
  export async function handleSkillStatus() {
114
+ await repairSkill();
86
115
  return skillStatus(defaultSkillPaths(), await findPluginDirectory());
87
116
  }
88
117
 
89
- // Runs only when the user presses Install skill in the panel.
118
+ // SKILL.md's frontmatter holds one-line fields; only the description is shown.
119
+ export function skillDocument(text: string): { markdown: string; description: string } {
120
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
121
+ const description = /^description:[ \t]*(.*)$/m.exec(frontmatter?.[1] ?? "")?.[1].trim().replace(/^(["'])(.*)\1$/, "$2") ?? "";
122
+ return { markdown: text.slice(frontmatter?.[0].length ?? 0).trimStart(), description };
123
+ }
124
+
125
+ export async function handleReadSkill() {
126
+ return skillDocument(await readFile(join(skillSource(await findPluginDirectory()), "SKILL.md"), "utf8"));
127
+ }
128
+
129
+ // Runs only when the user presses Install skill. Links that only need to
130
+ // follow an update are repointed on their own (repairSkill), which must finish
131
+ // first: both would unlink the same outdated link.
90
132
  export async function handleInstallSkill() {
133
+ await repairSkill();
91
134
  const pluginDirectory = await findPluginDirectory();
92
135
  await installSkill(defaultSkillPaths(), pluginDirectory);
93
136
  return skillStatus(defaultSkillPaths(), pluginDirectory);
@@ -1,40 +1,56 @@
1
- // After an update, the panel shows what changed in this version, once. The
2
- // notes come from the changelog in the plugin's own README, so they're
3
- // written in one place. Whether they've been seen is kept per user on this
4
- // computer.
1
+ // After an update, the panel shows what changed since the version last seen,
2
+ // once. The notes come from the changelog in the plugin's own README, so
3
+ // they're written in one place. Which version was seen is kept per user on
4
+ // this computer.
5
5
  import { readdir, readFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { z } from "zod";
9
- import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote } from "../shared/whats-new.ts";
9
+ import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new.ts";
10
10
  import { spans } from "../shared/write-up.ts";
11
11
  import { findPluginDirectory } from "./skill.ts";
12
12
  import { stateRoot, writeAtomic } from "./store.ts";
13
13
 
14
14
  export type Release = { version: string; readme: string | null };
15
15
 
16
- const seenSchema = z.object({ seen: z.string() });
16
+ const seenSchema = z.object({ seen: z.string().regex(/^\d+\.\d+\.\d+$/) });
17
17
  const packageSchema = z.object({ version: z.string() });
18
18
 
19
19
  export function whatsNewPath(home = homedir()): string {
20
20
  return join(stateRoot(home), "whats-new.json");
21
21
  }
22
22
 
23
- // The top-level bullets under `### <version>` in the README's `## Changelog`,
24
- // or null when that version has none.
25
- export function changelogNotes(readme: string, version: string): WhatsNewNote[] | null {
26
- let section: "other" | "changelog" | "version" = "other";
27
- const notes: string[] = [];
23
+ // Each `### x.y.z` under the README's `## Changelog` with its top-level
24
+ // bullets, newest first. Versions without bullets are left out.
25
+ export function changelog(readme: string): WhatsNewRelease[] {
26
+ const releases: { version: string; bullets: string[] }[] = [];
27
+ let inChangelog = false;
28
+ let current: string[] | null = null;
28
29
  for (const line of readme.replace(/\r\n?/g, "\n").split("\n")) {
29
- if (section === "version" && /^#{2,3}\s/.test(line)) break;
30
- if (/^##\s/.test(line)) section = line.trim() === "## Changelog" ? "changelog" : "other";
31
- else if (section === "changelog" && line.trim() === `### ${version}`) section = "version";
32
- else if (section === "version" && /^[-*]\s/.test(line)) notes.push(line.slice(2).trim());
33
- else if (section === "version" && notes.length && /^\s+\S/.test(line) && !/^\s+([-*+]|\d+[.)])\s/.test(line)) {
34
- notes[notes.length - 1] += ` ${line.trim()}`;
30
+ if (/^##\s/.test(line)) {
31
+ inChangelog = line.trim() === "## Changelog";
32
+ current = null;
33
+ continue;
34
+ }
35
+ if (!inChangelog) continue;
36
+ if (/^###\s/.test(line)) {
37
+ const version = /^###\s+(\d+\.\d+\.\d+)\s*$/.exec(line)?.[1];
38
+ current = null;
39
+ if (version) releases.push({ version, bullets: (current = []) });
40
+ } else if (current && /^[-*]\s/.test(line)) current.push(line.slice(2).trim());
41
+ else if (current?.length && /^\s+\S/.test(line) && !/^\s+([-*+]|\d+[.)])\s/.test(line)) {
42
+ current[current.length - 1] += ` ${line.trim()}`;
35
43
  }
36
44
  }
37
- return notes.length ? notes.map(note) : null;
45
+ return releases
46
+ .filter((release) => release.bullets.length)
47
+ .map(({ version, bullets }) => ({ version, notes: bullets.map(note) }))
48
+ .sort((a, b) => compareVersions(b.version, a.version));
49
+ }
50
+
51
+ function compareVersions(a: string, b: string): number {
52
+ const [x, y] = [a, b].map((version) => version.split(".").map(Number));
53
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
38
54
  }
39
55
 
40
56
  // A bullet can open with a kind tag, like `[Fixed]`, and then a bold
@@ -88,18 +104,23 @@ async function usedBefore(home: string): Promise<boolean> {
88
104
  return entries.some((entry) => entry.isDirectory() && /-[0-9a-f]{8}$/.test(entry.name));
89
105
  }
90
106
 
91
- // What to show, or null. A new install has nothing to catch up on, so it
92
- // records this version as seen.
107
+ // What to show, or null: every version since the one last seen, newest
108
+ // first. A new install has nothing to catch up on, so it records this version
109
+ // as seen. An update from before versions were recorded can't say where it
110
+ // started, so it shows this version alone rather than the whole history.
93
111
  export async function whatsNew({ version, readme }: Release, home = homedir()): Promise<WhatsNew | null> {
94
- const notes = readme === null ? null : changelogNotes(readme, version);
95
- if (!notes) return null;
96
112
  const seen = await readSeen(home);
97
113
  if (seen === version) return null;
98
114
  if (seen === null && !(await usedBefore(home))) {
99
115
  await markWhatsNewSeen(version, home);
100
116
  return null;
101
117
  }
102
- return { version, headline: headline(notes[0]), notes };
118
+ const releases = (readme === null ? [] : changelog(readme)).filter((release) => seen === null
119
+ ? release.version === version
120
+ : compareVersions(release.version, seen) > 0 && compareVersions(release.version, version) <= 0);
121
+ if (!releases.length) return null;
122
+ const notes = releases.flatMap((release) => release.notes);
123
+ return { version, headline: headline(notes.find((note) => note.kind === "new") ?? notes[0]), releases };
103
124
  }
104
125
 
105
126
  // The server bundle can't read files beside it, so ask Paseo where the plugin
package/shared/rpc.ts CHANGED
@@ -89,6 +89,14 @@ export const installSkill = defineRpc({
89
89
  output: skillStatus,
90
90
  });
91
91
 
92
+ // The skill agents get: its SKILL.md without the frontmatter, and the
93
+ // frontmatter's description.
94
+ export const readSkill = defineRpc({
95
+ name: "progress.skill.read",
96
+ input: z.object({}),
97
+ output: z.object({ markdown: z.string(), description: z.string() }),
98
+ });
99
+
92
100
  // The folders inside one folder of the worktree ("" is the top), for the
93
101
  // folder picker.
94
102
  export const listFolders = defineRpc({
@@ -13,4 +13,7 @@ export type NoteKind = keyof typeof NOTE_KINDS;
13
13
 
14
14
  export type WhatsNewNote = { kind?: NoteKind; title?: string; body: string };
15
15
 
16
- export type WhatsNew = { version: string; headline: string; notes: WhatsNewNote[] };
16
+ export type WhatsNewRelease = { version: string; notes: WhatsNewNote[] };
17
+
18
+ // `version` is the one running now; `releases` run newest first and are never empty.
19
+ export type WhatsNew = { version: string; headline: string; releases: WhatsNewRelease[] };
@@ -33,7 +33,9 @@ export function spans(text: string): Span[] {
33
33
  export function parseWriteUp(markdown: string): Block[] {
34
34
  const blocks: Block[] = [];
35
35
  let paragraph: string[] = [];
36
- let fence: string[] | null = null;
36
+ // A fence opened inside a list item is indented like the item; its lines
37
+ // lose up to that much indentation.
38
+ let fence: { indent: number; lines: string[] } | null = null;
37
39
  const flush = () => {
38
40
  if (paragraph.length) blocks.push({ kind: "paragraph", spans: spans(paragraph.join(" ")) });
39
41
  paragraph = [];
@@ -41,14 +43,14 @@ export function parseWriteUp(markdown: string): Block[] {
41
43
  for (const raw of markdown.replace(/\r\n?/g, "\n").split("\n")) {
42
44
  if (fence) {
43
45
  if (/^\s*(```|~~~)/.test(raw)) {
44
- blocks.push({ kind: "code", text: fence.join("\n") });
46
+ blocks.push({ kind: "code", text: fence.lines.join("\n") });
45
47
  fence = null;
46
- } else fence.push(raw);
48
+ } else fence.lines.push(raw.replace(new RegExp(`^[ \\t]{0,${fence.indent}}`), ""));
47
49
  continue;
48
50
  }
49
51
  if (/^\s*(```|~~~)/.test(raw)) {
50
52
  flush();
51
- fence = [];
53
+ fence = { indent: /^[ \t]*/.exec(raw)![0].length, lines: [] };
52
54
  continue;
53
55
  }
54
56
  const line = raw.trimEnd();
@@ -69,7 +71,7 @@ export function parseWriteUp(markdown: string): Block[] {
69
71
  } else paragraph.push(line.trim().replace(/^>\s?/, ""));
70
72
  }
71
73
  flush();
72
- if (fence) blocks.push({ kind: "code", text: fence.join("\n") });
74
+ if (fence) blocks.push({ kind: "code", text: fence.lines.join("\n") });
73
75
  return blocks;
74
76
  }
75
77