@stevecastaneda/paseo-progress 1.14.1 → 1.14.3

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.
Files changed (67) hide show
  1. package/README.md +28 -17
  2. package/client/attachments.tsx +122 -28
  3. package/client/blocks.tsx +33 -8
  4. package/client/chat-row-view.tsx +19 -6
  5. package/client/chat-row.ts +13 -2
  6. package/client/dashboard-query.ts +9 -4
  7. package/client/deliverables.tsx +82 -17
  8. package/client/dialog-stack.tsx +104 -42
  9. package/client/directory.ts +1 -3
  10. package/client/errors.ts +6 -1
  11. package/client/history.tsx +233 -79
  12. package/client/i18n.ts +10 -5
  13. package/client/launcher.tsx +57 -15
  14. package/client/locales/en.ts +28 -22
  15. package/client/locales/zh-CN.ts +18 -17
  16. package/client/messages.ts +1 -0
  17. package/client/motion.tsx +38 -14
  18. package/client/panel-opened.ts +3 -1
  19. package/client/panel.tsx +442 -160
  20. package/client/pdf.tsx +18 -4
  21. package/client/pieces.ts +6 -1
  22. package/client/pill-popover.tsx +108 -23
  23. package/client/pills.tsx +35 -12
  24. package/client/preview.tsx +265 -79
  25. package/client/questions.tsx +176 -55
  26. package/client/row.tsx +14 -3
  27. package/client/section-title.tsx +11 -2
  28. package/client/settings.tsx +76 -24
  29. package/client/show-more.tsx +23 -6
  30. package/client/spinner.tsx +11 -7
  31. package/client/status-badge.tsx +9 -7
  32. package/client/tabs.tsx +98 -20
  33. package/client/ticket-dialog.tsx +241 -73
  34. package/client/time-bar.tsx +5 -2
  35. package/client/video.tsx +15 -6
  36. package/client/whats-new.tsx +94 -36
  37. package/client/when.tsx +2 -1
  38. package/client/write-up.tsx +79 -12
  39. package/dist/server/cli.js +199 -101
  40. package/index.client.tsx +28 -13
  41. package/index.server.ts +19 -1
  42. package/package.json +15 -14
  43. package/server/cli.ts +72 -32
  44. package/server/dashboard.ts +9 -13
  45. package/server/launcher.ts +33 -17
  46. package/server/lock.ts +14 -7
  47. package/server/open.ts +32 -98
  48. package/server/progress-file.ts +6 -6
  49. package/server/question-mode.ts +12 -6
  50. package/server/skill.ts +22 -16
  51. package/server/store.ts +26 -41
  52. package/server/system.ts +113 -0
  53. package/server/tool.ts +89 -44
  54. package/server/whats-new.ts +25 -13
  55. package/shared/attachments.ts +40 -8
  56. package/shared/blocks.ts +9 -6
  57. package/shared/dashboard.ts +46 -29
  58. package/shared/events.ts +12 -10
  59. package/shared/markdown.ts +16 -8
  60. package/shared/question-modes.ts +8 -5
  61. package/shared/rpc.ts +9 -3
  62. package/shared/show.ts +20 -8
  63. package/shared/ticket-story.ts +1 -3
  64. package/skills/paseo-progress/SKILL.md +1 -0
  65. package/tsconfig.json +3 -10
  66. package/client/page-opener.ts +0 -9
  67. package/client/page-screen.tsx +0 -97
package/README.md CHANGED
@@ -13,7 +13,7 @@ The dashboard shows:
13
13
  - percent of estimated work done, time done of time estimated (like "1h 30m of 2h"; once everything is done, how long it really took), and a bar with one segment per ticket
14
14
  - **Stuck:** blocked tickets, working tickets with no update for longer than their estimate, and blockers the agent flags
15
15
  - **Tickets:** estimate, status, and the working ticket's stage. A ticket that waits for another (`--waits-for T03`) shows "Waits for Ticket 03" under its title until that one is done; waiting is the order of work, so it never counts as stuck, even if the agent also marked it blocked. Press a ticket for its story in a dialog. A summary on top shows its status and stage, time worked against the estimate as a bar that turns red past it, the agent's latest status line while it's working, and the agent on it. Tabs below hold the rest, each only when it has something: the ticket's write-up when it has a `--source` file (the start of it, with the rest a press away), a timeline of its stages and statuses with how long each took, and the activity, questions and deliverables that belong to it. The agent on a ticket shows by its chat title; pressing it opens that chat. Items tagged with `--ticket` belong exactly; older, untagged ones are matched by when they happened and marked "by time"
16
- - **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the option the agent recommends. You pick what the agent does while you decide in Paseo's Settings (Settings, Plugins, Progress, Settings). **Stop**: it stops all work until you answer. **Work around**, the default: it holds what your answer affects and keeps working on the rest. **Keep building**: it builds its recommendation and changes course if you pick another, except for big questions, which wait. A question is big when building ahead would send something off your computer, when the answer sets something people or programs outside the repo rely on, or when most of the remaining work would be built on it. Each open question shows what the agent is doing: "Agent stopped until you answer", "Waiting for you" (with the reason when it's big), or "Building with B: <its label>". Press a question to open it in a dialog, then press **Copy Q2 B** to copy `Q2 (title): B` for your reply. Files and links the agent attached (`--file`) open like deliverables: images, videos, PDFs and text files preview in Paseo (with **Back** to the question), a folder lists its files there, links open in Paseo's browser, and other files open in their default app. From the pill's popover, the preview opens over the popover, which stays open so you can go back to it. Answers stay listed under the same reference.
16
+ - **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the option the agent recommends. You pick what the agent does while you decide in Paseo's Settings (Settings, Plugins, Progress, Settings). **Stop**: it stops all work until you answer. **Work around**, the default: it holds what your answer affects and keeps working on the rest. **Keep building**: it builds its recommendation and changes course if you pick another, except for big questions, which wait. A question is big when building ahead would send something off your computer, when the answer sets something people or programs outside the repo rely on, or when most of the remaining work would be built on it. Each open question shows what the agent is doing: "Agent stopped until you answer", "Waiting for you" (with the reason when it's big), or "Building with B: <its label>". Press a question to open it in a dialog, then press **Copy Q2 B** to copy `Q2 (title): B` for your reply. Files and links the agent attached (`--file`) open like deliverables: images, videos, PDFs and text files preview in Paseo (with **Back** to the question), a folder lists its files there, links open in Paseo's browser, audio files open in their default app, and any other file copies its path. From the pill's popover, the preview opens over the popover, which stays open so you can go back to it. Answers stay listed under the same reference.
17
17
  - **Activity**, **Answered** questions and **Deliverables** (press one to open it), sharing one card with a tab for each; the panel remembers the tab you picked. Each shows the newest 10, with **Show 10 older** below
18
18
 
19
19
  <img src="images/2-ticket.png" alt="A ticket's story: its status, time against the estimate, the agent on it, and its timeline" width="400"> <img src="images/3-question.png" alt="A question waiting for an answer, with lettered options, the agent's recommendation and Copy buttons" width="400">
@@ -24,7 +24,7 @@ The panel lives in Explorer, beside the agent chat, so you can watch both. Open
24
24
 
25
25
  <img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
26
26
 
27
- On phones, Progress opens in Explorer too. On any phone, the pill stays above the message box while a run is open, so there's always a way in. SVG files, videos and PDFs preview on desktop and in Paseo's web app; on phones they open on the daemon host. A PDF in the Deliverables tab also has a button beside Copy that opens it in a Paseo tab, which is where it shows on an iPhone or iPad.
27
+ On phones, Progress opens in Explorer too. On any phone, the pill stays above the message box while a run is open, so there's always a way in. SVG files, videos and PDFs preview on desktop and in Paseo's web app. On phones, videos and PDFs open on the daemon host, and an SVG file copies its path. A PDF in the Deliverables tab also has a button beside Copy that opens it in a Paseo tab, which is where it shows on an iPhone or iPad.
28
28
 
29
29
  The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `progress-panel-opened` file next to the progress file and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
30
30
 
@@ -86,19 +86,19 @@ Versions before 0.2.0 kept progress in `.scratch/` in each worktree. From 1.14.1
86
86
 
87
87
  On Paseo 0.14 and later, Progress hands agents a tool for each common command, so recording progress needs nothing installed and every update names the agent that sent it. Paseo only gives plugin tools to agents while **Settings → your host → Agents → Enable Paseo tools** is on. It's off by default, and turning it on also gives agents Paseo's own tools for starting, messaging and stopping other agents. Progress never changes the setting. Once it's on, start a new chat or reload one to give it the tools.
88
88
 
89
- | Tool | Does what this command does |
90
- | --- | --- |
91
- | `progress_show` | `show` |
92
- | `progress_start` | `start` |
93
- | `progress_ticket_add` | `ticket add` |
94
- | `progress_ticket_update` | `ticket update` |
95
- | `progress_question_ask` | `question ask` |
96
- | `progress_question_answer` | `question answer` |
97
- | `progress_deliverable_add` | `deliverable add` |
98
- | `progress_activity_add` | `activity add` |
99
- | `progress_ticker` | `ticker set`, or `ticker clear` when the text is empty |
100
- | `progress_finish` | `finish` |
101
- | `paseo_progress` | Any command, given as the list of words you'd type after `paseo-progress` |
89
+ | Tool | Does what this command does |
90
+ | -------------------------- | ------------------------------------------------------------------------- |
91
+ | `progress_show` | `show` |
92
+ | `progress_start` | `start` |
93
+ | `progress_ticket_add` | `ticket add` |
94
+ | `progress_ticket_update` | `ticket update` |
95
+ | `progress_question_ask` | `question ask` |
96
+ | `progress_question_answer` | `question answer` |
97
+ | `progress_deliverable_add` | `deliverable add` |
98
+ | `progress_activity_add` | `activity add` |
99
+ | `progress_ticker` | `ticker set`, or `ticker clear` when the text is empty |
100
+ | `progress_finish` | `finish` |
101
+ | `paseo_progress` | Any command, given as the list of words you'd type after `paseo-progress` |
102
102
 
103
103
  Each tool runs the command inside the plugin, so it checks the same things and answers with the same words. Statuses and deliverable kinds are listed in the tool, so an agent can't send one the command doesn't know. Paths are relative to the agent's folder, like the command run there. Claude Code shows them as `mcp__paseo__progress_show` and so on, and holds them back until the agent searches for them. The skill tells agents to search.
104
104
 
@@ -152,7 +152,7 @@ paseo-progress finish "Snapshots ship for all four tables"
152
152
  - A working ticket is stuck once it goes longer than its estimate without an update, timed from its last status, stage or note change, or activity tagged to it. The ticket's dialog still shows total time worked against the estimate.
153
153
  - `question ask` needs `--recommend`, the agent's pick. The question dialog tags that option "Recommended". Its output tells the agent the question mode and what to do until the answer. `--big "<why>"` marks a big question, which waits for the answer even in Keep building. `start` and `show` also print the mode.
154
154
  - Paseo keeps the question mode in its own settings, which only the plugin's server can read, so the server copies it to `~/.local/state/paseo-progress/settings.json` for the command. Without that file, for example while the plugin is off, the command uses Work around.
155
- - Deliverable paths are stored relative to the worktree root. Web links open in Paseo's browser. Pressing an image (PNG, JPEG, GIF, WebP, up to 10 MB) or text deliverable (Markdown, text, logs, JSON, YAML, CSV; the first 256 KB) previews it in a dialog inside Paseo, which also works from a phone. Markdown shows formatted, with links and images relative to the file's own folder; **Open** there opens the file in its default app. **Open** shows only when the computer running Paseo can open files. A Linux server with no desktop can't, so there a file Paseo can't preview copies its path and says why. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app, and a PDF (up to 100 MB) shows there too. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing a folder lists its files in the same dialog. Press a file to preview it, or a folder inside to list that one, and **Back** returns to the list. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser). On macOS, **Open** sends text files to your default browser. Only recorded deliverables inside the worktree open this way; otherwise the path is copied. The copy icon on each row copies the path.
155
+ - Deliverable paths are stored relative to the worktree root. Web links open in Paseo's browser. Pressing an image (PNG, JPEG, GIF, WebP, up to 10 MB) or text deliverable (Markdown, text, logs, JSON, YAML, CSV; the first 256 KB) previews it in a dialog inside Paseo, which also works from a phone. Markdown shows formatted, with links and images relative to the file's own folder; **Open** there opens the file in its default app. **Open** shows only when the computer running Paseo can open files. A Linux server with no desktop can't, so there a file Paseo can't preview copies its path and says why. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app, and a PDF (up to 100 MB) shows there too. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing a folder lists its files in the same dialog. Press a file to preview it, or a folder inside to list that one, and **Back** returns to the list. Pressing an audio file opens it with its default app on the machine running the Paseo daemon. Pressing an HTML page opens it in a Paseo tab with Paseo's own preview, on desktop and on phones, never in your browser. The preview runs the page's own scripts but loads nothing from the web, so a report needs its CSS and images inside the file. Progress hands no other file or folder to a default app, because a script or an app would run, so pressing one copies its path. On macOS, **Open** sends text files to your default browser. Previews and **Open** work only for recorded deliverables inside the worktree; for any other file the path is copied. An HTML page or PDF opens in a Paseo tab from the path the agent recorded. The copy icon on each row copies the path.
156
156
  - Several agents can share one run. When another session adds a ticket, each agent sees it at the end of its next command's output (and in `show`), and the skill has it work that ticket in order after its current one. An agent only hears about tickets added since its own last update in the run, and only once.
157
157
  - Subagents record their own progress. A Claude Code subagent can call the Progress tools too, and its updates carry its parent chat's agent id. Since Paseo 0.11, an agent waiting on background subagents shows as idle, so only the subagents' updates show the work is still going. The skill has the agent give each subagent the worktree path and ticket id, and the subagent records its stages and a ticker line on that worktree's dashboard.
158
158
  - `start` begins a fresh dashboard. Earlier runs stay in the file. Question references stay unique across runs.
@@ -173,11 +173,22 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
173
173
  - Progress folders outside the repo stay behind when a worktree is deleted. They're small, and you can delete them from `~/.local/state/paseo-progress/`.
174
174
  - Paseo can't open a panel without switching you to its workspace. So a new run shows a "Progress" pill, and you open the panel yourself.
175
175
  - Pills check busy worktrees every 5 seconds and quiet ones every 30. In a quiet worktree, a new question can take up to half a minute to show.
176
- - Previews cover images up to 10 MB, videos up to 200 MB and PDFs up to 100 MB (desktop and web apps only), the first 256 KB of a text file, and the first 500 items in a folder. The folder list leaves out hidden files. Other files open in their default app. A video the app can't decode shows the player's error, and **Open** still works.
176
+ - Previews cover images up to 10 MB, videos up to 200 MB and PDFs up to 100 MB (desktop and web apps only), the first 256 KB of a text file, and the first 500 items in a folder. The folder list leaves out hidden files. Audio files open in their default app, and other files copy their path. A video the app can't decode shows the player's error, and **Open** still works.
177
177
  - Stale warnings and overdue times use the daemon host's clock.
178
178
 
179
179
  ## Changelog
180
180
 
181
+ ### 1.14.3
182
+
183
+ - [Fixed] **Open hands only pictures, video, audio, PDFs and text to their default app.** This fixes a security issue in opening deliverables. Progress used to open any file an agent recorded with its default app, and for a script or an app that means running it. Now Open works only for images, video, audio, PDFs and text files. Pressing any other file, an SVG file or a folder copies its path, and an HTML page always opens in Paseo.
184
+ - [Fixed] **Agents can't install the paseo-progress command.** This fixes a security issue in the agent tools. You install it yourself with **Install command** in the Progress panel.
185
+ - [Fixed] **HTML reports open in Paseo's own preview.** This fixes a security issue in the HTML preview. Progress used to show a report on a screen of its own and copy the files it loaded into the page, which could hand the page a file git ignores. Now pressing an HTML report opens it in a Paseo tab, on desktop and on phones. Paseo's preview runs the page's own scripts but loads nothing from the web, and Progress no longer copies files into the page, so a report needs its CSS in the page and its images embedded in the file.
186
+ - [Fixed] **The Windows command runs the Node that installed it.** This fixes a security issue in `paseo-progress.cmd`. It now names the full path to Node, and it looks for Paseo from Node's folder. Because the command changed on every system, the panel asks you once to update it.
187
+
188
+ ### 1.14.2
189
+
190
+ - [Fixed] **Links in a question open, and its text can be selected.** A web address in a question or in its background used to show as plain text, so you couldn't click it, and the question itself couldn't be selected or copied. The question dialog now draws the question and its background with Paseo's own Markdown view, so an address is a link and the question's text can be selected and copied. The text on an option, and what choosing it leads to, can be selected too. Because the text is read as Markdown now, characters like `*` and `_` format it, a line starting with `#` or `-` becomes a heading or a list, and the background takes Paseo's body size and color instead of the small, muted style it had.
191
+
181
192
  ### 1.14.1
182
193
 
183
194
  - [Fixed] **Run history stays in its folder.** This fixes a security issue in saving run history. Progress now saves a run only when the dashboard can read the line that started it, and only into a folder inside the repo and outside `.git`.
@@ -1,12 +1,12 @@
1
1
  import { openExternalUrl, type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { copyText, Icon, useToast } from "@getpaseo/plugin/client/ui";
3
- import React from "react";
2
+ import { copyText, Icon, Markdown, useToast } from "@getpaseo/plugin/client/ui";
3
+ import React, { createRef, type RefObject } from "react";
4
4
  import { Platform, Text, View } from "react-native";
5
- import { type Attachment, attachmentIcon, canPreview, opensAsPage, shortName } from "../shared/attachments";
6
- import { openPage } from "./page-opener";
5
+ import { type Attachment, attachmentIcon, canPreview, isFolder, isPage, opensOnHost, shortName } from "../shared/attachments";
6
+ import { localLinkTarget } from "../shared/markdown";
7
7
  import { errorText } from "./errors";
8
8
  import { PressableRow } from "./row";
9
- import { canOpenOnHost, openDeliverable } from "../shared/rpc";
9
+ import { openDeliverable } from "../shared/rpc";
10
10
  import { useProgressLanguage } from "./language";
11
11
 
12
12
  export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName, ticketSourceAttachment } from "../shared/attachments";
@@ -19,6 +19,12 @@ export function isPreviewable(attachment: Attachment): boolean {
19
19
  return canPreview(attachment, { browser: Platform.OS === "web" });
20
20
  }
21
21
 
22
+ // Whether Open hands it to the host's default app. The server makes the same
23
+ // check on the file itself.
24
+ export function isHostOpenable(attachment: Attachment): boolean {
25
+ return Boolean(attachment.path) && !isFolder(attachment) && opensOnHost(attachment.path!);
26
+ }
27
+
22
28
  // Full path on the daemon host for a stored path. `root` is the worktree
23
29
  // root the dashboard was read from, not the workspace directory.
24
30
  export function absolutePath(path: string, root: string): string {
@@ -27,7 +33,12 @@ export function absolutePath(path: string, root: string): string {
27
33
  return path === "./" ? `${base}/` : `${base}/${path}`;
28
34
  }
29
35
 
30
- export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview }: {
36
+ export function useAttachmentOpener({
37
+ workspaceId,
38
+ workspaceDirectory,
39
+ navigation,
40
+ onPreview,
41
+ }: {
31
42
  workspaceId: string;
32
43
  workspaceDirectory: string;
33
44
  navigation?: Navigation;
@@ -36,7 +47,6 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
36
47
  const { messages: m } = useProgressLanguage();
37
48
  const toast = useToast();
38
49
  const openOnHostRpc = useRpc(openDeliverable);
39
- const checkHost = useRpc(canOpenOnHost);
40
50
  async function copyPath(attachment: Attachment, prefix = "") {
41
51
  try {
42
52
  await copyText(absolutePath(attachment.path!, workspaceDirectory));
@@ -63,44 +73,128 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
63
73
  }
64
74
  return;
65
75
  }
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
- }
76
+ // A page's row is a link to it, which already opened it in a tab.
77
+ if (isPage(attachment)) return;
72
78
  if (onPreview && isPreviewable(attachment)) {
73
79
  onPreview(attachment);
74
80
  return;
75
81
  }
76
- await openOnHost(attachment);
82
+ if (isHostOpenable(attachment)) await openOnHost(attachment);
83
+ else await copyPath(attachment, `${m.openRefused}. `);
77
84
  }
78
85
  return { open, openOnHost, copyPath };
79
86
  }
80
87
 
81
88
  export const ATTACHMENT_ROW = { gap: 2, paddingVertical: 3, lineHeight: 17 };
82
89
 
90
+ const PAGE_LINK_FILL = Array(200).fill("████").join(" ");
91
+
92
+ // A Paseo Markdown link to a local file is the only way a plugin can open a tab,
93
+ // so a page's row holds a link to the page. On a phone the link covers the row,
94
+ // almost clear, because iOS ignores a view under 1% opaque. In a browser it stays
95
+ // hidden and the row clicks it, so the row stays one keyboard stop.
96
+ export function PageLink({ link, path }: { link: RefObject<View | null>; path: string }) {
97
+ const target = localLinkTarget(path);
98
+ if (Platform.OS === "web") {
99
+ return (
100
+ <View ref={link} style={{ display: "none" }}>
101
+ <Markdown>{`[page](${target})`}</Markdown>
102
+ </View>
103
+ );
104
+ }
105
+ return (
106
+ <View style={{ position: "absolute", top: 0, bottom: 0, start: 0, end: 0, overflow: "hidden", opacity: 0.011 }}>
107
+ <Markdown>{`[${PAGE_LINK_FILL}](${target})`}</Markdown>
108
+ </View>
109
+ );
110
+ }
111
+
112
+ // The plugin compiles without the DOM's types.
113
+ type LinkElement = { querySelector(selector: string): { click(): void } | null };
114
+ let clickingPageLink = false;
115
+
116
+ // Opens a page row's link from the row's press, and is false for the press that
117
+ // click sends back to the row, which the first one already handled. On a phone
118
+ // the press landed on the link itself.
119
+ export function followPageLink(link: RefObject<View | null>): boolean {
120
+ if (Platform.OS !== "web") return true;
121
+ if (clickingPageLink) return false;
122
+ clickingPageLink = true;
123
+ try {
124
+ (link.current as unknown as LinkElement | null)?.querySelector("a")?.click();
125
+ } finally {
126
+ clickingPageLink = false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ export function pageLinkRef(attachment: Attachment): RefObject<View | null> | null {
132
+ return isPage(attachment) ? createRef<View>() : null;
133
+ }
134
+
83
135
  // A question's attachments as pressable rows.
84
136
  // Drawn in dialogs and the popover (surface1) unless `onSurface1` is false.
85
137
  // `onOpen` also gets the whole list, so a preview can step through its siblings.
86
- export function AttachmentList({ colors, attachments, onOpen, onSurface1 = true }: { colors: Colors; attachments: Attachment[]; onOpen(attachment: Attachment, group: Attachment[]): void; onSurface1?: boolean }) {
138
+ // `root` is the worktree root their paths are relative to.
139
+ export function AttachmentList({
140
+ colors,
141
+ attachments,
142
+ root,
143
+ onOpen,
144
+ onSurface1 = true,
145
+ }: {
146
+ colors: Colors;
147
+ attachments: Attachment[];
148
+ root: string;
149
+ onOpen(attachment: Attachment, group: Attachment[]): void;
150
+ onSurface1?: boolean;
151
+ }) {
87
152
  const { messages: m } = useProgressLanguage();
88
153
  if (!attachments.length) return null;
89
154
  return (
90
155
  <View style={{ gap: ATTACHMENT_ROW.gap }}>
91
- {attachments.map((attachment) => (
92
- <PressableRow key={attachment.ref} colors={colors} onSurface1={onSurface1} accessibilityRole={attachment.url ? "link" : "button"} accessibilityLabel={m.openItem(attachment.title)}
93
- onPress={() => onOpen(attachment, attachments)}
94
- style={{ flexDirection: "row", gap: 6, alignItems: "center", paddingVertical: ATTACHMENT_ROW.paddingVertical, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
95
- {/* A 14px slot, like a section icon, so the title lines up with the heading above. */}
96
- <View style={{ width: 14, alignItems: "center" }}><Icon name={attachmentIcon(attachment)} size={12} color={colors.foregroundMuted} /></View>
97
- {/* The row is the target, so nothing in it is styled as a link. */}
98
- <Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foreground, fontSize: 12, lineHeight: ATTACHMENT_ROW.lineHeight }}>{attachment.title}</Text>
99
- {shortName(attachment) !== attachment.title ? (
100
- <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>{shortName(attachment)}</Text>
101
- ) : <View style={{ flex: 1 }} />}
102
- </PressableRow>
103
- ))}
156
+ {attachments.map((attachment) => {
157
+ const link = pageLinkRef(attachment);
158
+ return (
159
+ <PressableRow
160
+ key={attachment.ref}
161
+ colors={colors}
162
+ onSurface1={onSurface1}
163
+ accessibilityRole={attachment.url ? "link" : "button"}
164
+ accessibilityLabel={m.openItem(attachment.title)}
165
+ onPress={() => {
166
+ if (link && !followPageLink(link)) return;
167
+ onOpen(attachment, attachments);
168
+ }}
169
+ style={{
170
+ flexDirection: "row",
171
+ gap: 6,
172
+ alignItems: "center",
173
+ paddingVertical: ATTACHMENT_ROW.paddingVertical,
174
+ paddingHorizontal: 4,
175
+ marginHorizontal: -4,
176
+ borderRadius: 4,
177
+ }}
178
+ >
179
+ {/* A 14px slot, like a section icon, so the title lines up with the heading above. */}
180
+ <View style={{ width: 14, alignItems: "center" }}>
181
+ <Icon name={attachmentIcon(attachment)} size={12} color={colors.foregroundMuted} />
182
+ </View>
183
+ {/* The row is the target, so nothing in it is styled as a link. */}
184
+ <Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foreground, fontSize: 12, lineHeight: ATTACHMENT_ROW.lineHeight }}>
185
+ {attachment.title}
186
+ </Text>
187
+ {shortName(attachment) !== attachment.title ? (
188
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
189
+ {shortName(attachment)}
190
+ </Text>
191
+ ) : (
192
+ <View style={{ flex: 1 }} />
193
+ )}
194
+ {link ? <PageLink link={link} path={absolutePath(attachment.path!, root)} /> : null}
195
+ </PressableRow>
196
+ );
197
+ })}
104
198
  </View>
105
199
  );
106
200
  }
package/client/blocks.tsx CHANGED
@@ -11,7 +11,11 @@ export function BlockView({ colors, block, first }: { colors: Colors; block: Blo
11
11
  case "heading":
12
12
  return (
13
13
  // A step under the section's own 13px title: smaller and muted, set apart from the body by weight.
14
- <Text accessibilityRole="header" selectable style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", marginTop: first ? 0 : 6 }}>
14
+ <Text
15
+ accessibilityRole="header"
16
+ selectable
17
+ style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", marginTop: first ? 0 : 6 }}
18
+ >
15
19
  <Spans colors={colors} spans={block.spans} />
16
20
  </Text>
17
21
  );
@@ -19,23 +23,44 @@ export function BlockView({ colors, block, first }: { colors: Colors; block: Blo
19
23
  return (
20
24
  <View style={{ flexDirection: "row", gap: 6, paddingStart: block.depth * 14 }}>
21
25
  <Text style={{ minWidth: 10, color: colors.foregroundMuted, fontSize: 13, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{block.marker}</Text>
22
- <Text selectable style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>
26
+ <Text selectable style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>
27
+ <Spans colors={colors} spans={block.spans} />
28
+ </Text>
23
29
  </View>
24
30
  );
25
31
  case "code":
26
32
  return (
27
33
  <View style={{ padding: 8, borderRadius: 6, backgroundColor: colors.surface0 }}>
28
- <Text selectable style={{ color: colors.foreground, fontFamily: MONO, fontSize: 12, lineHeight: 18 }}>{block.text}</Text>
34
+ <Text selectable style={{ color: colors.foreground, fontFamily: MONO, fontSize: 12, lineHeight: 18 }}>
35
+ {block.text}
36
+ </Text>
29
37
  </View>
30
38
  );
31
39
  default:
32
- return <Text selectable style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>;
40
+ return (
41
+ <Text selectable style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>
42
+ <Spans colors={colors} spans={block.spans} />
43
+ </Text>
44
+ );
33
45
  }
34
46
  }
35
47
 
36
48
  export function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
37
- return <>{spans.map((span, index) => span.code
38
- ? <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>{span.text}</Text>
39
- : span.strong ? <Text key={index} style={{ fontWeight: "600" }}>{span.text}</Text>
40
- : <React.Fragment key={index}>{span.text}</React.Fragment>)}</>;
49
+ return (
50
+ <>
51
+ {spans.map((span, index) =>
52
+ span.code ? (
53
+ <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>
54
+ {span.text}
55
+ </Text>
56
+ ) : span.strong ? (
57
+ <Text key={index} style={{ fontWeight: "600" }}>
58
+ {span.text}
59
+ </Text>
60
+ ) : (
61
+ <React.Fragment key={index}>{span.text}</React.Fragment>
62
+ ),
63
+ )}
64
+ </>
65
+ );
41
66
  }
@@ -24,9 +24,15 @@ export function ChatRowView({ item, theme }: PluginTimelineItemProps<ChatRow>) {
24
24
  const active = more !== null && (hovered || expanded);
25
25
  const iconColor = failed ? colors.statusDanger : active ? colors.foreground : colors.foregroundMuted;
26
26
 
27
- const icon = active
28
- ? <View style={{ marginLeft: -4, transform: [{ scale: 1.3 }, { rotate: expanded ? "90deg" : "0deg" }] }}><Icon name="ChevronRight" size={12} color={colors.foreground} /></View>
29
- : <View style={{ marginLeft: -1, opacity: failed ? 0.8 : 1 }}><Icon name={failed ? "TriangleAlert" : "ListChecks"} size={12} color={iconColor} /></View>;
27
+ const icon = active ? (
28
+ <View style={{ marginLeft: -4, transform: [{ scale: 1.3 }, { rotate: expanded ? "90deg" : "0deg" }] }}>
29
+ <Icon name="ChevronRight" size={12} color={colors.foreground} />
30
+ </View>
31
+ ) : (
32
+ <View style={{ marginLeft: -1, opacity: failed ? 0.8 : 1 }}>
33
+ <Icon name={failed ? "TriangleAlert" : "ListChecks"} size={12} color={iconColor} />
34
+ </View>
35
+ );
30
36
 
31
37
  return (
32
38
  <View style={{ marginHorizontal: -13 }}>
@@ -66,10 +72,17 @@ export function ChatRowView({ item, theme }: PluginTimelineItemProps<ChatRow>) {
66
72
  </Text>
67
73
  </Pressable>
68
74
  {open ? (
69
- <View style={{ borderWidth: 1, borderTopWidth: 0, borderColor: colors.border, borderBottomLeftRadius: 8, borderBottomRightRadius: 8, overflow: "hidden" }}>
75
+ <View
76
+ style={{ borderWidth: 1, borderTopWidth: 0, borderColor: colors.border, borderBottomLeftRadius: 8, borderBottomRightRadius: 8, overflow: "hidden" }}
77
+ >
70
78
  {/* Scrolling past the end stays in the output instead of moving the chat. */}
71
- <ScrollView style={[{ maxHeight: 320 }, Platform.OS === "web" ? ({ overscrollBehavior: "contain" } as object) : null]} contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8 }}>
72
- <Text selectable style={{ color: failed ? colors.statusDanger : colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>{more}</Text>
79
+ <ScrollView
80
+ style={[{ maxHeight: 320 }, Platform.OS === "web" ? ({ overscrollBehavior: "contain" } as object) : null]}
81
+ contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8 }}
82
+ >
83
+ <Text selectable style={{ color: failed ? colors.statusDanger : colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>
84
+ {more}
85
+ </Text>
73
86
  </ScrollView>
74
87
  </View>
75
88
  ) : null}
@@ -63,12 +63,23 @@ export function transformChatRow({ item }: { item: ToolCall }) {
63
63
  return data && { items: [{ type: "plugin" as const, kind: CHAT_ROW_KIND, version: CHAT_ROW_VERSION, data }] };
64
64
  }
65
65
 
66
+ function firstLine(text: string): string {
67
+ return (
68
+ text
69
+ .split("\n")
70
+ .map((line) => line.trim())
71
+ .find(Boolean) ?? ""
72
+ );
73
+ }
74
+
75
+ function withMore(line: string, text: string): { line: string; more: string | null } {
76
+ return { line, more: text.trim() || null };
77
+ }
78
+
66
79
  // What the row says: one line, and the full text the row opens to. The line
67
80
  // truncates on a narrow chat, so any text at all can be opened.
68
81
  export function chatRowText(row: ChatRow, m: ProgressMessages): { line: string; more: string | null } {
69
82
  const labels = m.toolRow[row.tool];
70
- const firstLine = (text: string) => text.split("\n").map((line) => line.trim()).find(Boolean) ?? "";
71
- const withMore = (line: string, text: string) => ({ line, more: text.trim() || null });
72
83
  switch (row.state) {
73
84
  case "running":
74
85
  return { line: labels.running, more: null };
@@ -9,13 +9,19 @@ export const POLL_MS = 3_000;
9
9
 
10
10
  // `previous` is what the cache already holds. Polls send its version, and an
11
11
  // "unchanged" reply keeps that same object, so nothing re-renders.
12
- export function dashboardQueryOptions(fetchDashboard: FetchDashboard, hostId: string, workspaceId: string, directory: string | null, previous?: () => DashboardResult | undefined) {
12
+ export function dashboardQueryOptions(
13
+ fetchDashboard: FetchDashboard,
14
+ hostId: string,
15
+ workspaceId: string,
16
+ directory: string | null,
17
+ previous?: () => DashboardResult | undefined,
18
+ ) {
13
19
  return {
14
20
  queryKey: ["progress", "dashboard", hostId, workspaceId, directory],
15
21
  queryFn: async (): Promise<DashboardResult> => {
16
22
  const last = previous?.();
17
23
  const result = await fetchDashboard({ workspaceId, workspaceDirectory: directory!, ...(last ? { since: last.version } : {}) });
18
- return "unchanged" in result && last ? last : result as DashboardResult;
24
+ return "unchanged" in result && last ? last : (result as DashboardResult);
19
25
  },
20
26
  enabled: Boolean(directory),
21
27
  staleTime: POLL_MS,
@@ -26,7 +32,6 @@ export function dashboardQueryOptions(fetchDashboard: FetchDashboard, hostId: st
26
32
  export function useDashboard(hostId: string, workspaceId: string, directory: string | null) {
27
33
  const fetchDashboard = useRpc(getDashboard);
28
34
  const queryClient = useQueryClient();
29
- const options = dashboardQueryOptions(fetchDashboard, hostId, workspaceId, directory,
30
- () => queryClient.getQueryData<DashboardResult>(options.queryKey));
35
+ const options = dashboardQueryOptions(fetchDashboard, hostId, workspaceId, directory, () => queryClient.getQueryData<DashboardResult>(options.queryKey));
31
36
  return useQuery(options);
32
37
  }
@@ -5,7 +5,17 @@ import { Platform, Text, View } from "react-native";
5
5
  import type { Deliverable } from "../shared/dashboard";
6
6
  import { opensInTab } from "../shared/attachments";
7
7
  import { localLinkTarget } from "../shared/markdown";
8
- import { type Attachment, absolutePath, attachmentIcon, deliverableAttachment, shortName, useAttachmentOpener } from "./attachments";
8
+ import {
9
+ type Attachment,
10
+ absolutePath,
11
+ attachmentIcon,
12
+ deliverableAttachment,
13
+ followPageLink,
14
+ PageLink,
15
+ pageLinkRef,
16
+ shortName,
17
+ useAttachmentOpener,
18
+ } from "./attachments";
9
19
  import { formatClientAgo } from "./i18n";
10
20
  import { PressScale } from "./motion";
11
21
  import { StackedDialog } from "./dialog-stack";
@@ -24,7 +34,14 @@ const BUTTON_PAIR_GAP = 4;
24
34
  const TAP_AREA_FILL = Array(8).fill("████").join(" ");
25
35
 
26
36
  // The Deliverables tab: newest first, 10 at a time (see usePaged).
27
- export function DeliverablesList({ colors, deliverables, now, workspaceId, workspaceDirectory, navigation }: {
37
+ export function DeliverablesList({
38
+ colors,
39
+ deliverables,
40
+ now,
41
+ workspaceId,
42
+ workspaceDirectory,
43
+ navigation,
44
+ }: {
28
45
  colors: Colors;
29
46
  now: number;
30
47
  deliverables: Deliverable[];
@@ -41,56 +58,104 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
41
58
  {paged.shown.map((deliverable, index) => {
42
59
  const attachment = deliverableAttachment(deliverable);
43
60
  const tab = opensInTab(attachment, { platform: Platform.OS });
61
+ const link = pageLinkRef(attachment);
44
62
  return (
45
- <PressableRow key={deliverable.id} colors={colors} accessibilityRole={deliverable.url ? "link" : "button"}
63
+ <PressableRow
64
+ key={deliverable.id}
65
+ colors={colors}
66
+ accessibilityRole={deliverable.url ? "link" : "button"}
46
67
  accessibilityLabel={m.openItem(deliverable.title)}
47
- onPress={() => void open(attachment)}
48
- style={{ flexDirection: "row", gap: ROW_GAP, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
68
+ onPress={() => {
69
+ if (link && !followPageLink(link)) return;
70
+ void open(attachment);
71
+ }}
72
+ style={{
73
+ flexDirection: "row",
74
+ gap: ROW_GAP,
75
+ paddingHorizontal: 10,
76
+ paddingVertical: 8,
77
+ borderTopWidth: index ? 1 : 0,
78
+ borderTopColor: colors.border,
79
+ }}
80
+ >
49
81
  {/* Optical: centers the 14px icon on the 18px title line. */}
50
- <View style={{ paddingTop: 2 }}><Icon name={attachmentIcon(attachment)} size={14} color={colors.foregroundMuted} /></View>
82
+ <View style={{ paddingTop: 2 }}>
83
+ <Icon name={attachmentIcon(attachment)} size={14} color={colors.foregroundMuted} />
84
+ </View>
51
85
  <View style={{ flex: 1, minWidth: 0, gap: 1 }}>
52
86
  {/* What the agent found is the headline; the row itself is what opens. */}
53
- <Text numberOfLines={3} style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{deliverable.title}</Text>
87
+ <Text numberOfLines={3} style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
88
+ {deliverable.title}
89
+ </Text>
54
90
  {/* Just the name, ticket and age. The full path is one press of Copy away. */}
55
91
  <Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
56
- {[shortName(attachment), deliverable.ticketLabel ?? deliverable.ticketId, formatClientAgo(locale, deliverable.addedAt, now)].filter(Boolean).join(" · ")}
92
+ {[shortName(attachment), deliverable.ticketLabel ?? deliverable.ticketId, formatClientAgo(locale, deliverable.addedAt, now)]
93
+ .filter(Boolean)
94
+ .join(" · ")}
57
95
  </Text>
58
96
  </View>
97
+ {/* Under Copy, so Copy still takes its own presses. */}
98
+ {link ? <PageLink link={link} path={absolutePath(deliverable.path!, workspaceDirectory)} /> : null}
59
99
  {tab ? (
60
100
  <TabButton colors={colors} label={m.openInTab(deliverable.title)} target={localLinkTarget(absolutePath(deliverable.path!, workspaceDirectory))} />
61
101
  ) : null}
62
102
  {deliverable.path ? (
63
103
  // Optical: the 22px button centers on the 18px title line.
64
- <PressScale accessibilityRole="button" accessibilityLabel={m.copyItemPath(deliverable.title)}
65
- hitSlop={tab ? { top: 6, bottom: 6, left: BUTTON_PAIR_GAP / 2, right: 6 } : 6} outerStyle={{ marginTop: -2 }}
104
+ <PressScale
105
+ accessibilityRole="button"
106
+ accessibilityLabel={m.copyItemPath(deliverable.title)}
107
+ hitSlop={tab ? { top: 6, bottom: 6, left: BUTTON_PAIR_GAP / 2, right: 6 } : 6}
108
+ outerStyle={{ marginTop: -2 }}
66
109
  onPress={() => void copyPath(attachment)}
67
110
  // A step above the row's own hover tint (surface1), so it reads as its own target.
68
- style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
111
+ style={({ pressed, hovered }) => ({
112
+ padding: 4,
113
+ borderRadius: 4,
114
+ backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent",
115
+ })}
116
+ >
69
117
  <Icon name="Copy" size={14} color={colors.foregroundMuted} />
70
118
  </PressScale>
71
119
  ) : (
72
- <View style={{ paddingTop: 2 }}><Icon name="ExternalLink" size={14} color={colors.foregroundMuted} /></View>
120
+ <View style={{ paddingTop: 2 }}>
121
+ <Icon name="ExternalLink" size={14} color={colors.foregroundMuted} />
122
+ </View>
73
123
  )}
74
124
  </PressableRow>
75
125
  );
76
126
  })}
77
127
  <ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
78
- <StackedDialog colors={colors} now={now} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory, navigation }}
79
- root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)), fullScreen: false } : null} />
128
+ <StackedDialog
129
+ colors={colors}
130
+ now={now}
131
+ onClose={() => setPreviewing(null)}
132
+ context={{ workspaceId, workspaceDirectory, navigation }}
133
+ root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)), fullScreen: false } : null}
134
+ />
80
135
  </View>
81
136
  );
82
137
  }
83
138
 
139
+ function keepPressFromRow() {}
140
+
84
141
  // A Paseo Markdown link to a local file is the only way a plugin can open a tab.
85
142
  // A browser sends touches to a clear view; iOS ignores a view under 1% opaque.
86
143
  function TabButton({ colors, label, target }: { colors: Colors; label: string; target: string }) {
87
144
  const linkTakesFocus = Platform.OS === "web";
88
- const keepPressFromRow = () => {};
89
145
  return (
90
- <PressScale accessibilityRole="button" accessibilityLabel={label} focusable={!linkTakesFocus}
146
+ <PressScale
147
+ accessibilityRole="button"
148
+ accessibilityLabel={label}
149
+ focusable={!linkTakesFocus}
91
150
  outerStyle={{ marginTop: -2, marginEnd: BUTTON_PAIR_GAP - ROW_GAP, alignSelf: "flex-start" }}
92
151
  onPress={keepPressFromRow}
93
- style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, overflow: "hidden", backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
152
+ style={({ pressed, hovered }) => ({
153
+ padding: 4,
154
+ borderRadius: 4,
155
+ overflow: "hidden",
156
+ backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent",
157
+ })}
158
+ >
94
159
  <Icon name="Maximize2" size={14} color={colors.foregroundMuted} />
95
160
  <View style={{ position: "absolute", top: -24, start: -24, width: 72, height: 72, opacity: Platform.OS === "web" ? 0 : 0.011 }}>
96
161
  <Markdown>{`[${TAP_AREA_FILL}](${target})`}</Markdown>