@stevecastaneda/paseo-progress 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/client/attachments.tsx +95 -0
  4. package/client/dashboard-query.ts +32 -0
  5. package/client/deliverables.tsx +67 -0
  6. package/client/dialog-stack.tsx +104 -0
  7. package/client/directory.ts +90 -0
  8. package/client/launcher.tsx +111 -0
  9. package/client/motion.tsx +173 -0
  10. package/client/panel-opened.ts +13 -0
  11. package/client/panel.tsx +480 -0
  12. package/client/pill-popover.tsx +122 -0
  13. package/client/pill-state.ts +34 -0
  14. package/client/pills.tsx +235 -0
  15. package/client/preview.tsx +228 -0
  16. package/client/questions.tsx +248 -0
  17. package/client/row.tsx +34 -0
  18. package/client/section-title.tsx +24 -0
  19. package/client/show-more.tsx +37 -0
  20. package/client/spinner.tsx +35 -0
  21. package/client/status-badge.tsx +30 -0
  22. package/client/surfaces.ts +45 -0
  23. package/client/ticket-dialog.tsx +203 -0
  24. package/client/when.tsx +15 -0
  25. package/dist/server/cli.js +528 -0
  26. package/dist/server/launcher.js +90 -0
  27. package/dist/server/lock.js +34 -0
  28. package/dist/server/paths.js +6 -0
  29. package/dist/server/progress-file.js +123 -0
  30. package/dist/shared/attachments.js +86 -0
  31. package/dist/shared/dashboard.js +369 -0
  32. package/dist/shared/events.js +117 -0
  33. package/dist/shared/format.js +37 -0
  34. package/dist/shared/show.js +72 -0
  35. package/images/1-panel.png +0 -0
  36. package/images/2-ticket.png +0 -0
  37. package/images/3-question.png +0 -0
  38. package/images/4-pill.png +0 -0
  39. package/index.client.tsx +33 -0
  40. package/index.server.ts +19 -0
  41. package/npm-shrinkwrap.json +4041 -0
  42. package/package.json +62 -0
  43. package/paseo-plugin.json +7 -0
  44. package/server/cli.ts +541 -0
  45. package/server/dashboard.ts +46 -0
  46. package/server/launcher.ts +97 -0
  47. package/server/lock.ts +32 -0
  48. package/server/open.ts +107 -0
  49. package/server/paths.ts +7 -0
  50. package/server/progress-file.ts +141 -0
  51. package/server/skill.ts +85 -0
  52. package/shared/attachments.ts +100 -0
  53. package/shared/dashboard.ts +491 -0
  54. package/shared/events.ts +131 -0
  55. package/shared/format.ts +39 -0
  56. package/shared/rpc.ts +109 -0
  57. package/shared/show.ts +68 -0
  58. package/shared/ticket-story.ts +93 -0
  59. package/skills/paseo-progress/SKILL.md +79 -0
  60. package/tsconfig.json +24 -0
@@ -0,0 +1,235 @@
1
+ import type { PaseoAgentUpdate, PaseoWorkspaceUpdate } from "@getpaseo/client";
2
+ import type { PluginButtonContentProps, PluginButtonIconProps, PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import React, { useSyncExternalStore } from "react";
5
+ import { Platform } from "react-native";
6
+ import { type AttentionResult, getAttention } from "../shared/rpc";
7
+ import { isIdle, type Pill, pillFor } from "./pill-state";
8
+ import { observeDirectory } from "./directory";
9
+ import { onPanelOpened } from "./panel-opened";
10
+ import { IconSwap } from "./motion";
11
+ import { Spinner } from "./spinner";
12
+ import { AttentionPopover } from "./pill-popover";
13
+
14
+ export const PILL_POLL_MS = 5_000;
15
+ // A worktree with nothing going on (no file, or a closed run with nothing
16
+ // waiting) is checked on every 6th poll, 30 seconds. Its agents' updates
17
+ // still trigger a check, at most once a poll interval.
18
+ export const IDLE_POLL_EVERY = 6;
19
+
20
+ // Paseo's desktop app shows the panel in Explorer, beside the chat. The phone
21
+ // apps never draw Explorer's pane, so there the panel is a tab of its own.
22
+ // It has its own id on phones: Paseo 0.10.1 reopens an existing tab where it
23
+ // already is, and earlier versions of this plugin left a "progress" tab in the
24
+ // phone's hidden Explorer pane, which a plugin can't move or close.
25
+ export function progressPanel(): { id: string; location: "explorer" | "workspace" } {
26
+ return Platform.OS === "web" ? { id: "progress", location: "explorer" } : { id: "progress-tab", location: "workspace" };
27
+ }
28
+
29
+ type Workspace = { id: string; workspaceDirectory?: string; projectRootPath: string };
30
+
31
+ function directoryOf(workspace: Workspace): string {
32
+ return workspace.workspaceDirectory ?? workspace.projectRootPath;
33
+ }
34
+
35
+ // A pill above the message box for every agent whose workspace has something
36
+ // to show; pill-state.ts decides what. Questions or something stuck open a
37
+ // popover with the questions, their Copy buttons and a way into the panel.
38
+ // The other pills open the panel. Paseo can't open a panel without switching
39
+ // to its workspace, so the first-run pill is a nudge in that workspace rather
40
+ // than opening the panel on its own.
41
+ export function contributePills(client: PluginClientContext) {
42
+ const workspaces = new Map<string, string>();
43
+ // The last report per workspace; pillFor turns it into what the pill shows.
44
+ const reports = new Map<string, AttentionResult>();
45
+ const where = { phone: progressPanel().location === "workspace" };
46
+ const pillOf = (workspaceId: string) => pillFor(reports.get(workspaceId), where);
47
+ // Bumped on each report so a poll that was already in flight can't bring the pill back.
48
+ let openedReports = 0;
49
+ const listeners = new Set<() => void>();
50
+ const pills = new Map<string, { workspaceId: string; label: string; mode: Pill["mode"]; pill: PluginButtonRegistration }>();
51
+ const pending = new Set<string>();
52
+ // Polls left before an idle workspace is checked again, and when each was last checked.
53
+ const idleWait = new Map<string, number>();
54
+ const checkedAt = new Map<string, number>();
55
+ let agents: Array<{ id: string; workspaceId?: string | null }> = [];
56
+ let stopped = false;
57
+ let timer: ReturnType<typeof setTimeout> | undefined;
58
+
59
+ function PillIcon(props: PluginButtonIconProps) {
60
+ const icon = useSyncExternalStore(
61
+ (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
62
+ () => pillOf(props.workspaceId)?.icon ?? "open",
63
+ );
64
+ return (
65
+ <IconSwap swapKey={icon} size={props.size}>
66
+ {icon === "stuck" ? <Icon name="OctagonAlert" size={props.size} color={props.theme.colors.statusDanger} />
67
+ : icon === "questions" ? <Icon name="MessageCircleQuestion" size={props.size} color={props.color} />
68
+ : icon === "started" ? <Spinner color={props.color} size={props.size} />
69
+ : <Icon name="ListChecks" size={props.size} color={props.color} />}
70
+ </IconSwap>
71
+ );
72
+ }
73
+
74
+ const openPanel = (workspaceId: string) => {
75
+ const { id, location } = progressPanel();
76
+ client.openPanel(id, { workspaceId, location });
77
+ };
78
+
79
+ const buttonFor = (workspaceId: string, mode: Pill["mode"], label: string) => mode === "attention"
80
+ ? { title: "Progress needs you", icon: PillIcon, label, behavior: { kind: "popover" as const, Content: PillContent } }
81
+ : { title: "Open Progress", icon: PillIcon, label, behavior: { kind: "action" as const, onPress: () => openPanel(workspaceId) } };
82
+
83
+ function PillContent(props: PluginButtonContentProps) {
84
+ return <AttentionPopover {...props} openPanel={() => openPanel(props.workspaceId)} />;
85
+ }
86
+
87
+ const publish = () => {
88
+ if (stopped) return;
89
+ const seen = new Set<string>();
90
+ for (const agent of agents) {
91
+ const workspaceId = agent.workspaceId;
92
+ if (!workspaceId) continue;
93
+ const wanted = pillOf(workspaceId);
94
+ if (!wanted) continue;
95
+ const { mode, label } = wanted;
96
+ seen.add(agent.id);
97
+ const existing = pills.get(agent.id);
98
+ if (existing && existing.workspaceId !== workspaceId) {
99
+ existing.pill.remove();
100
+ pills.delete(agent.id);
101
+ }
102
+ const entry = pills.get(agent.id);
103
+ if (!entry) {
104
+ const pill = client.addComposerPill({ id: "progress-attention", workspaceId, agentId: agent.id, button: buttonFor(workspaceId, mode, label) });
105
+ pills.set(agent.id, { workspaceId, label, mode, pill });
106
+ } else if (entry.mode !== mode) {
107
+ entry.pill.update(buttonFor(workspaceId, mode, label));
108
+ Object.assign(entry, { mode, label });
109
+ } else if (entry.label !== label) {
110
+ entry.pill.update({ label });
111
+ entry.label = label;
112
+ }
113
+ }
114
+ for (const [agentId, entry] of pills) {
115
+ if (!seen.has(agentId)) {
116
+ entry.pill.remove();
117
+ pills.delete(agentId);
118
+ }
119
+ }
120
+ };
121
+
122
+ // Every change goes through here, so the icons redraw whenever a pill could change.
123
+ const setReport = (workspaceId: string, next: AttentionResult | undefined) => {
124
+ if (next) reports.set(workspaceId, next);
125
+ else reports.delete(workspaceId);
126
+ for (const listener of listeners) listener();
127
+ };
128
+
129
+ // Only workspaces with an agent can show a pill, so only those are read.
130
+ const refresh = async (workspaceId: string) => {
131
+ const directory = workspaces.get(workspaceId);
132
+ if (stopped || !directory || pending.has(workspaceId)) return;
133
+ pending.add(workspaceId);
134
+ checkedAt.set(workspaceId, Date.now());
135
+ const reportsBefore = openedReports;
136
+ try {
137
+ const result = await client.rpc(getAttention, { workspaceId, workspaceDirectory: directory });
138
+ if (stopped || workspaces.get(workspaceId) !== directory) return;
139
+ // The panel was opened while this poll was in flight: its answer is already out of date.
140
+ setReport(workspaceId, openedReports === reportsBefore ? result : { ...result, panelOpened: true });
141
+ if (isIdle(result)) idleWait.set(workspaceId, IDLE_POLL_EVERY - 1);
142
+ else idleWait.delete(workspaceId);
143
+ publish();
144
+ } catch { /* Try again on the next poll. */ } finally {
145
+ pending.delete(workspaceId);
146
+ }
147
+ };
148
+ const watched = () => new Set(agents.flatMap((agent) => (agent.workspaceId && workspaces.has(agent.workspaceId) ? [agent.workspaceId] : [])));
149
+ const due = (workspaceId: string) => {
150
+ const wait = idleWait.get(workspaceId);
151
+ if (!wait) return true;
152
+ idleWait.set(workspaceId, wait - 1);
153
+ return false;
154
+ };
155
+ // An agent update can mean a new run; check soon, but not on every update.
156
+ const nudge = (workspaceId: string) => {
157
+ if (Date.now() - (checkedAt.get(workspaceId) ?? 0) >= PILL_POLL_MS) void refresh(workspaceId);
158
+ };
159
+ // The next poll doesn't wait on this one: a slow worktree can't hold up every pill.
160
+ // `pending` already stops a second check of the same worktree overlapping.
161
+ const sync = () => {
162
+ for (const workspaceId of [...watched()].filter(due)) void refresh(workspaceId);
163
+ if (!stopped) timer = setTimeout(sync, PILL_POLL_MS);
164
+ };
165
+ const forget = (workspaceId: string) => {
166
+ workspaces.delete(workspaceId);
167
+ idleWait.delete(workspaceId);
168
+ checkedAt.delete(workspaceId);
169
+ setReport(workspaceId, undefined);
170
+ };
171
+
172
+ const stopWorkspaces = observeDirectory({
173
+ list: (options) => client.paseo.workspaces.list(options),
174
+ select: (message) => (message.type === "workspace_update" ? message.payload : undefined),
175
+ snapshot: (entries) => {
176
+ const next = new Map((entries as Workspace[]).map((workspace) => [workspace.id, directoryOf(workspace)]));
177
+ for (const id of [...workspaces.keys()]) if (!next.has(id)) forget(id);
178
+ const hasAgent = new Set(agents.map((agent) => agent.workspaceId));
179
+ for (const [id, directory] of next) {
180
+ if (workspaces.get(id) !== directory) {
181
+ workspaces.set(id, directory);
182
+ if (hasAgent.has(id)) void refresh(id);
183
+ }
184
+ }
185
+ publish();
186
+ },
187
+ update: (update: PaseoWorkspaceUpdate) => {
188
+ if (update.kind === "remove") forget(update.id);
189
+ else {
190
+ const directory = directoryOf(update.workspace as Workspace);
191
+ if (workspaces.get(update.workspace.id) !== directory) {
192
+ workspaces.set(update.workspace.id, directory);
193
+ if (agents.some((agent) => agent.workspaceId === update.workspace.id)) void refresh(update.workspace.id);
194
+ }
195
+ }
196
+ publish();
197
+ },
198
+ });
199
+ const stopAgents = observeDirectory({
200
+ list: (options) => client.paseo.agents.list({ ...options, filter: { includeArchived: false } }),
201
+ select: (message) => (message.type === "agent_update" ? message.payload : undefined),
202
+ snapshot: (entries) => {
203
+ agents = entries.map(({ agent }) => agent);
204
+ for (const id of watched()) if (!reports.has(id)) void refresh(id);
205
+ publish();
206
+ },
207
+ update: (update: PaseoAgentUpdate) => {
208
+ const id = update.kind === "remove" ? update.agentId : update.agent.id;
209
+ agents = agents.filter((agent) => agent.id !== id);
210
+ if (update.kind !== "remove") {
211
+ agents.push(update.agent);
212
+ if (update.agent.workspaceId && workspaces.has(update.agent.workspaceId)) nudge(update.agent.workspaceId);
213
+ }
214
+ publish();
215
+ },
216
+ });
217
+ const stopOpened = onPanelOpened((workspaceId) => {
218
+ openedReports++;
219
+ const report = reports.get(workspaceId);
220
+ if (report) setReport(workspaceId, { ...report, panelOpened: true });
221
+ publish();
222
+ });
223
+ timer = setTimeout(sync, PILL_POLL_MS);
224
+
225
+ return () => {
226
+ stopped = true;
227
+ stopWorkspaces();
228
+ stopAgents();
229
+ stopOpened();
230
+ if (timer) clearTimeout(timer);
231
+ for (const { pill } of pills.values()) pill.remove();
232
+ pills.clear();
233
+ listeners.clear();
234
+ };
235
+ }
@@ -0,0 +1,228 @@
1
+ import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal, ScrollView } from "@getpaseo/plugin/client/react-native";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import React, { useEffect, useRef, useState } from "react";
5
+ import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
6
+ import { type Attachment, attachmentIcon, isPreviewable } from "./attachments";
7
+ import { previewDeliverable } from "../shared/rpc";
8
+ import { PressScale, useLastPresent } from "./motion";
9
+ import { imageOutline, raised } from "./surfaces";
10
+
11
+ type Colors = PluginWorkspacePanelProps["theme"]["colors"];
12
+
13
+ // An image or text attachment shown in a dialog, with a way out to the host's own app.
14
+ // `onBack` returns to where the preview was opened from, e.g. a question's dialog.
15
+ export function PreviewDialog({ colors, attachment, workspaceId, workspaceDirectory, onClose, onOpenOnHost, backLabel, onBack, gallery }: {
16
+ colors: Colors;
17
+ attachment: Attachment | null;
18
+ workspaceId: string;
19
+ workspaceDirectory: string;
20
+ onClose(): void;
21
+ onOpenOnHost(attachment: Attachment): void;
22
+ backLabel?: string;
23
+ onBack?(): void;
24
+ gallery?: Gallery;
25
+ }) {
26
+ const open = Boolean(attachment);
27
+ attachment = useLastPresent(attachment);
28
+ return (
29
+ <Modal title={attachment?.title ?? "Preview"} icon={<Icon name={attachment ? attachmentIcon(attachment) : "FileText"} size={16} color={colors.foregroundMuted} />}
30
+ open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
31
+ {attachment ? (
32
+ <PreviewBody colors={colors} attachment={attachment} workspaceId={workspaceId} workspaceDirectory={workspaceDirectory}
33
+ onOpenOnHost={() => { onClose(); onOpenOnHost(attachment); }} backLabel={backLabel} onBack={onBack} gallery={gallery} />
34
+ ) : <Modal.Content>{null}</Modal.Content>}
35
+ </Modal>
36
+ );
37
+ }
38
+
39
+ // The other previewable files beside this one (a question's images, a ticket's
40
+ // screenshots), for Previous and Next. Only shown when there's more than one.
41
+ export interface Gallery {
42
+ items: Attachment[];
43
+ onSelect(attachment: Attachment): void;
44
+ }
45
+
46
+ // Keeps only what the preview can show, for a Gallery's items.
47
+ export function previewable(group: Attachment[]): Attachment[] {
48
+ return group.filter(isPreviewable);
49
+ }
50
+
51
+ type PreviewProps = {
52
+ colors: Colors;
53
+ attachment: Attachment;
54
+ workspaceId: string;
55
+ workspaceDirectory: string;
56
+ onOpenOnHost(): void;
57
+ backLabel?: string;
58
+ onBack?(): void;
59
+ gallery?: Gallery;
60
+ };
61
+
62
+ // The preview dialog's body in its own Modal.Content.
63
+ export function PreviewBody(props: PreviewProps) {
64
+ const [long, setLong] = useState(false);
65
+ return <Modal.Content scrollable={!long}><PreviewContent {...props} onLong={setLong} /></Modal.Content>;
66
+ }
67
+
68
+ // The preview without its Modal.Content, for a dialog that already has one (see
69
+ // StackedDialog): swapping Modal.Content itself would re-present the sheet on phones.
70
+ // `onLong` says when the file is too tall for the dialog, so the host turns off
71
+ // its own scrolling and the file scrolls with the actions pinned below.
72
+ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, backLabel, onBack, gallery, onLong }: PreviewProps & { onLong(long: boolean): void }) {
73
+ const fetchPreview = useRpc(previewDeliverable);
74
+ const previewQuery = (target: Attachment) => ({
75
+ queryKey: ["progress", "preview", workspaceId, target.ref, target.path],
76
+ queryFn: () => fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref }),
77
+ staleTime: 30_000,
78
+ // Images arrive as data URIs up to ~13 MB; let them go soon after the dialog moves on.
79
+ gcTime: 30_000,
80
+ });
81
+ // Stepping to another file keeps this one on screen (dimmed) until the next loads,
82
+ // so the dialog doesn't collapse to a spinner and back.
83
+ const preview = useQuery({ ...previewQuery(attachment), placeholderData: (previous) => previous });
84
+ const stepping = preview.isPlaceholderData;
85
+ // Measure the image from its data rather than waiting on onLoad, which doesn't
86
+ // report a size for data URIs on every platform; without it the box stays 16:10.
87
+ const imageUri = !stepping && preview.data?.kind === "image" ? preview.data.dataUri : null;
88
+ useEffect(() => {
89
+ if (!imageUri) return;
90
+ let current = true;
91
+ Image.getSize(imageUri, (width, height) => {
92
+ if (current && width && height) setMeasured({ ref: attachment.ref, ratio: width / height });
93
+ }, () => {});
94
+ return () => { current = false; };
95
+ }, [imageUri, attachment.ref]);
96
+ // Load the next file in the direction of travel ahead of time, so stepping is
97
+ // instant without holding both neighbors in memory.
98
+ const queryClient = useQueryClient();
99
+ const items = gallery?.items ?? [];
100
+ const index = items.findIndex((item) => item.ref === attachment.ref);
101
+ const lastIndex = useRef(index);
102
+ useEffect(() => {
103
+ if (index < 0 || items.length < 2) return;
104
+ const step = (lastIndex.current - index + items.length) % items.length === 1 ? -1 : 1;
105
+ lastIndex.current = index;
106
+ void queryClient.prefetchQuery(previewQuery(items[(index + step + items.length) % items.length]));
107
+ }, [attachment.ref, items.length]);
108
+ // Screenshots vary from phone-tall to full-width; size to the real image once it loads.
109
+ // Kept per file, so stepping to the next image doesn't borrow this one's shape.
110
+ const [measured, setMeasured] = useState<{ ref: string; ratio: number } | null>(null);
111
+ const aspectRatio = measured && (measured.ref === attachment.ref || stepping) ? measured.ratio : 16 / 10;
112
+ // A long file gets its own scroll area so the actions below it stay in view.
113
+ // Short ones keep the dialog sized to them.
114
+ const { height: windowHeight } = useWindowDimensions();
115
+ const long = estimatedHeight(preview.data, aspectRatio) > roomForContent(windowHeight);
116
+ useEffect(() => onLong(long), [long]);
117
+ const content = (
118
+ <>
119
+ {preview.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading preview" style={{ padding: 24 }} /> : null}
120
+ {preview.error ? (
121
+ <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
122
+ Could not preview it: {preview.error.message}
123
+ </Text>
124
+ ) : null}
125
+ {preview.data?.kind === "text" ? (
126
+ <View style={{ padding: 10, borderRadius: 6, backgroundColor: colors.surface0, opacity: stepping ? 0.6 : 1 }}>
127
+ <Text selectable style={{ color: colors.foreground, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", fontSize: 12, lineHeight: 18 }}>
128
+ {preview.data.text}
129
+ </Text>
130
+ {preview.data.truncated ? (
131
+ <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop: 8 }}>
132
+ Showing the first 256 KB. Press Open to see the rest in its default app.
133
+ </Text>
134
+ ) : null}
135
+ </View>
136
+ ) : null}
137
+ {preview.data?.kind === "image" ? (
138
+ <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain"
139
+ onLoad={(event) => {
140
+ const { width, height } = event.nativeEvent.source ?? {};
141
+ if (width && height && !stepping) setMeasured({ ref: attachment.ref, ratio: width / height });
142
+ }}
143
+ style={{ width: "100%", aspectRatio, maxHeight: 640, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 }} />
144
+ ) : null}
145
+ {/* The path as a caption under the file, where it has the full width. */}
146
+ <Text selectable numberOfLines={1} ellipsizeMode="middle" style={{ marginTop: -4, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
147
+ {attachment.path}
148
+ </Text>
149
+ </>
150
+ );
151
+ // Back on the left, Previous and Next in the middle, Open on the right.
152
+ const actions = (
153
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
154
+ <View style={{ flex: 1, alignItems: "flex-start" }}>
155
+ {onBack ? <BackButton colors={colors} label={backLabel ?? "Back"} onPress={onBack} /> : null}
156
+ </View>
157
+ {gallery && gallery.items.length > 1 ? <Stepper colors={colors} gallery={gallery} current={attachment} /> : null}
158
+ <View style={{ flex: 1, alignItems: "flex-end" }}>
159
+ <DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} />
160
+ </View>
161
+ </View>
162
+ );
163
+ return long ? (
164
+ <>
165
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
166
+ {actions}
167
+ </>
168
+ ) : (
169
+ <View style={{ gap: 20 }}>
170
+ <View style={{ gap: 12 }}>{content}</View>
171
+ {actions}
172
+ </View>
173
+ );
174
+ }
175
+
176
+ // Width left for content: Paseo's 520px dialog less its 24px padding each side.
177
+ const CONTENT_WIDTH = 472;
178
+ // Characters of 12px monospace per line of the text box (10px padding each side).
179
+ const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
180
+
181
+ // Roughly how tall the preview draws, including wrapped lines.
182
+ function estimatedHeight(data: { kind: "image" } | { kind: "text"; text: string } | undefined, aspectRatio: number): number {
183
+ if (!data) return 0;
184
+ if (data.kind === "image") return Math.min(CONTENT_WIDTH / aspectRatio, 640);
185
+ const lines = data.text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0);
186
+ return lines * 18 + 20;
187
+ }
188
+
189
+ // Height the dialog has for the preview before it must scroll: Paseo caps the card
190
+ // at 85% of the window (inside a 24px margin), less its header and padding, the
191
+ // path caption (17px after an 8px gap) and the footer (30px after a 20px gap).
192
+ function roomForContent(windowHeight: number): number {
193
+ return (windowHeight - 48) * 0.85 - 57 - 48 - (8 + 17) - (20 + 30);
194
+ }
195
+
196
+ // A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
197
+ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, outerStyle }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; outerStyle?: ViewStyle }) {
198
+ return (
199
+ <PressScale accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
200
+ style={({ pressed }) => ({
201
+ flexDirection: "row", alignItems: "center", gap: 6,
202
+ paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
203
+ borderRadius: 6, ...raised(colors), backgroundColor: pressed ? colors.surface1 : colors.surface2,
204
+ })}>
205
+ <Icon name={icon} size={14} color={colors.foreground} />
206
+ {label ? <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{label}</Text> : null}
207
+ </PressScale>
208
+ );
209
+ }
210
+
211
+ // Returns to the dialog this one was opened from. For surface1 dialogs.
212
+ export function BackButton({ colors, label, onPress }: { colors: Colors; label: string; onPress(): void }) {
213
+ return <DialogButton colors={colors} icon="ChevronLeft" label={label} onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
214
+ }
215
+
216
+ // Previous and next through a gallery, wrapping at the ends, with "2 of 3" between.
217
+ function Stepper({ colors, gallery, current }: { colors: Colors; gallery: Gallery; current: Attachment }) {
218
+ const { items } = gallery;
219
+ const index = Math.max(0, items.findIndex((item) => item.ref === current.ref));
220
+ const go = (step: number) => gallery.onSelect(items[(index + step + items.length) % items.length]);
221
+ return (
222
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
223
+ <DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel="Previous file" onPress={() => go(-1)} />
224
+ <Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {items.length}</Text>
225
+ <DialogButton colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() => go(1)} />
226
+ </View>
227
+ );
228
+ }