@stevecastaneda/paseo-progress 1.3.1 → 1.5.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 (50) hide show
  1. package/OVERVIEW.md +1 -1
  2. package/README.md +28 -4
  3. package/client/attachments.tsx +9 -6
  4. package/client/deliverables.tsx +6 -4
  5. package/client/dialog-stack.tsx +3 -0
  6. package/client/history.tsx +23 -18
  7. package/client/i18n.ts +86 -0
  8. package/client/language.tsx +23 -0
  9. package/client/launcher.tsx +22 -17
  10. package/client/locales/en.ts +207 -0
  11. package/client/locales/zh-CN.ts +207 -0
  12. package/client/messages.ts +189 -0
  13. package/client/panel.tsx +77 -47
  14. package/client/pill-popover.tsx +8 -5
  15. package/client/pill-state.ts +4 -3
  16. package/client/pills.tsx +25 -11
  17. package/client/preview.tsx +18 -13
  18. package/client/questions.tsx +42 -18
  19. package/client/settings.tsx +58 -0
  20. package/client/show-more.tsx +4 -2
  21. package/client/spinner.tsx +7 -3
  22. package/client/status-badge.tsx +3 -9
  23. package/client/surfaces.ts +20 -8
  24. package/client/ticket-dialog.tsx +37 -30
  25. package/client/video.tsx +3 -1
  26. package/client/whats-new.tsx +125 -0
  27. package/client/when.tsx +5 -3
  28. package/client/write-up.tsx +8 -5
  29. package/dist/server/cli.js +85 -20
  30. package/images/1-panel.png +0 -0
  31. package/images/3-question.png +0 -0
  32. package/images/5-settings.png +0 -0
  33. package/index.client.tsx +13 -1
  34. package/index.server.ts +12 -2
  35. package/package.json +1 -1
  36. package/paseo-plugin.json +2 -1
  37. package/server/cli.ts +28 -11
  38. package/server/question-mode.ts +61 -0
  39. package/server/store.ts +21 -4
  40. package/server/whats-new.ts +127 -0
  41. package/shared/dashboard.ts +9 -2
  42. package/shared/events.ts +9 -4
  43. package/shared/question-modes.ts +45 -0
  44. package/shared/rpc.ts +18 -0
  45. package/shared/settings.ts +20 -0
  46. package/shared/show.ts +3 -1
  47. package/shared/ticket-story.ts +12 -10
  48. package/shared/whats-new.ts +16 -0
  49. package/shared/write-up.ts +9 -8
  50. package/skills/paseo-progress/SKILL.md +26 -68
@@ -3,17 +3,19 @@ import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import React, { useState } from "react";
4
4
  import { Text, View } from "react-native";
5
5
  import type { Dashboard, Ticket } from "../shared/dashboard";
6
- import { formatMinutes, formatTimeOfDay, formatWorkDone } from "../shared/format";
6
+ import { formatClientMinutes, formatClientTimeOfDay, formatClientWorkDone, timelineLabel } from "./i18n";
7
7
  import { type Link, ticketStory, type TimelineStep } from "../shared/ticket-story";
8
8
  import { type Attachment, AttachmentList, deliverableAttachment, ticketSourceAttachment } from "./attachments";
9
9
  import { StackedDialog } from "./dialog-stack";
10
10
  import { IconSwap } from "./motion";
11
+ import { QuestionState } from "./questions";
11
12
  import { PressableRow } from "./row";
12
13
  import { Spinner, StalledPulse, WaitingDot } from "./spinner";
13
14
  import { StatusBadge } from "./status-badge";
14
15
  import { type TabEntry, TabStrip } from "./tabs";
15
16
  import { TimeBar } from "./time-bar";
16
17
  import { WriteUp } from "./write-up";
18
+ import { useProgressLanguage } from "./language";
17
19
 
18
20
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
19
21
  type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
@@ -47,6 +49,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
47
49
  // Undefined where Paseo can't navigate to an agent.
48
50
  onOpenAgent?(agentId: string): void;
49
51
  }) {
52
+ const { messages: m, locale } = useProgressLanguage();
50
53
  const story = ticketStory(ticket, {
51
54
  deliverables: dashboard.deliverables,
52
55
  questions: [...dashboard.questions.open, ...dashboard.questions.answered].sort((a, b) => Date.parse(a.askedAt) - Date.parse(b.askedAt)),
@@ -61,44 +64,44 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
61
64
  ? dashboard.ticker.by === ticket.agentId
62
65
  : dashboard.tickets.filter((candidate) => candidate.status === "working").length === 1) ? dashboard.ticker.text : null;
63
66
  // What the badge doesn't say: the stage it's in, or what it waits for.
64
- const detail = ticket.waitingFor ? `for ${ticket.waitingFor.id}: ${ticket.waitingFor.title}` : ticket.status === "working" ? ticket.stage : undefined;
67
+ const detail = ticket.waitingFor ? m.waitingDetail(ticket.waitingFor.id, ticket.waitingFor.title) : ticket.status === "working" ? ticket.stage : undefined;
65
68
  const timeline = story.timeline.map((step, index) => {
66
69
  const last = index === story.timeline.length - 1;
67
70
  // A current "Blocked" that's only waiting on another ticket reads as waiting, in the calm color.
68
71
  const shown = last && ticket.waitingFor && step.status === "blocked"
69
- ? { ...step, label: `Waiting for ${ticket.waitingFor.id}`, waiting: true }
72
+ ? { ...step, label: { kind: "waiting" as const, id: ticket.waitingFor.id }, waiting: true }
70
73
  : step;
71
74
  return <TimelineRow key={`${step.at}-${index}`} colors={colors} step={shown} last={last} now={now} live={live} />;
72
75
  });
73
76
  // Only tabs with something in them; the write-up leads when there is one.
74
77
  const tabs: Array<TabEntry<StoryTab> & { content: React.ReactNode }> = [
75
- ...(source ? [{ id: "write-up" as const, label: "Write-up", icon: "FileText",
78
+ ...(source ? [{ id: "write-up" as const, label: m.writeUp, icon: "FileText",
76
79
  content: <WriteUp colors={colors} source={source} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory} onOpen={onOpenAttachment} /> }] : []),
77
- { id: "timeline", label: "Timeline", icon: "History", count: story.timeline.length, content: <View>{timeline}</View> },
78
- ...(story.activity.length ? [{ id: "activity" as const, label: "Activity", icon: "Activity", count: story.activity.length, content: (
80
+ { id: "timeline", label: m.timeline, icon: "History", count: story.timeline.length, content: <View>{timeline}</View> },
81
+ ...(story.activity.length ? [{ id: "activity" as const, label: m.activity, icon: "Activity", count: story.activity.length, content: (
79
82
  <View>
80
83
  {story.activity.map((entry) => (
81
84
  <View key={entry.id} style={{ flexDirection: "row", gap: 12, paddingVertical: 4 }}>
82
85
  <Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{entry.text}<LinkNote colors={colors} link={entry.link} /></Text>
83
- <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{formatTimeOfDay(entry.at, now)}</Text>
86
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{formatClientTimeOfDay(locale, entry.at, now)}</Text>
84
87
  </View>
85
88
  ))}
86
89
  </View>
87
90
  ) }] : []),
88
- ...(story.questions.length ? [{ id: "questions" as const, label: "Questions", icon: "MessageCircleQuestion", count: story.questions.length, content: (
91
+ ...(story.questions.length ? [{ id: "questions" as const, label: m.questions, icon: "MessageCircleQuestion", count: story.questions.length, content: (
89
92
  <View>
90
93
  {story.questions.map((question) => {
91
94
  const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
92
95
  return (
93
- <PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={`Open ${question.id}`}
96
+ <PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={m.openItem(question.id)}
94
97
  onPress={() => onOpenQuestion(question.id)}
95
98
  style={{ flexDirection: "row", gap: 8, paddingVertical: 4, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
96
99
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}>{question.id}</Text>
97
100
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
98
101
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{question.title}<LinkNote colors={colors} link={question.link} /></Text>
99
102
  {question.answer
100
- ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Answered <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}</Text>{chosen ? <Text style={{ color: colors.foreground }}>: {chosen.label}</Text> : null}</Text>
101
- : null}
103
+ ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.answered} <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}</Text>{chosen ? <Text style={{ color: colors.foreground }}>: {chosen.label}</Text> : null}</Text>
104
+ : <QuestionState colors={colors} question={question} />}
102
105
  </View>
103
106
  <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
104
107
  </PressableRow>
@@ -106,7 +109,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
106
109
  })}
107
110
  </View>
108
111
  ) }] : []),
109
- ...(story.deliverables.length ? [{ id: "deliverables" as const, label: "Deliverables", icon: "Package", count: story.deliverables.length,
112
+ ...(story.deliverables.length ? [{ id: "deliverables" as const, label: m.deliverables, icon: "Package", count: story.deliverables.length,
110
113
  content: <AttachmentList colors={colors} attachments={story.deliverables.map(deliverableAttachment)} onOpen={onOpenAttachment} /> }] : []),
111
114
  ];
112
115
  const tabKey = `${context.workspaceId}:${ticket.id}`;
@@ -123,9 +126,9 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
123
126
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
124
127
  <StatusBadge colors={colors} status={ticket.status} waiting={Boolean(ticket.waitingFor)} />
125
128
  <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{detail}</Text>
126
- <Text accessibilityLabel={`Worked ${formatMinutes(story.workedMin)} of a ${formatMinutes(ticket.estimateMin)} estimate`}
129
+ <Text accessibilityLabel={m.workedEstimate(formatClientMinutes(locale, story.workedMin), formatClientMinutes(locale, ticket.estimateMin))}
127
130
  style={{ color: over ? colors.statusDanger : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
128
- {formatWorkDone(story.workedMin, ticket.estimateMin)}
131
+ {formatClientWorkDone(locale, story.workedMin, ticket.estimateMin)}
129
132
  </Text>
130
133
  </View>
131
134
  <TimeBar colors={colors} workedMin={story.workedMin} estimateMin={ticket.estimateMin} />
@@ -141,7 +144,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
141
144
 
142
145
  {byTime ? (
143
146
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
144
- Items marked “by time” weren't tagged to {ticket.id}; they happened while it was the {(dashboard.run?.itemLabel ?? "ticket").toLowerCase()} being worked on.
147
+ {m.byTimeHint(ticket.id, dashboard.run?.itemLabel ?? "Ticket")}
145
148
  </Text>
146
149
  ) : null}
147
150
  </View>
@@ -156,9 +159,9 @@ const storyTabs = new Map<string, StoryTab>();
156
159
  // Paseo titles a chat from the first line of its first message, cut at 60
157
160
  // characters without an ellipsis (create-agent-title.ts); add the one it left off.
158
161
  const PASEO_TITLE_CUT = 60;
159
- function agentTitle(raw: string | null): string {
162
+ function agentTitle(raw: string | null, fallback: string): string {
160
163
  const title = raw?.trim();
161
- if (!title) return "Untitled agent";
164
+ if (!title) return fallback;
162
165
  return title.length === PASEO_TITLE_CUT ? `${title.trimEnd()}…` : title;
163
166
  }
164
167
 
@@ -166,36 +169,39 @@ function agentTitle(raw: string | null): string {
166
169
  // opens that chat. Shown only while Paseo knows the agent; an archived one
167
170
  // leaves nothing to open. Its state shows only when it needs the user.
168
171
  function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string; onOpen?(agentId: string): void }) {
172
+ const { messages: m } = useProgressLanguage();
169
173
  const agent = useAgent(agentId, (snapshot) => ({
170
174
  title: snapshot.title,
171
- alert: snapshot.requiresAttention && snapshot.attentionReason === "permission" ? { text: "Needs your permission", danger: false }
172
- : snapshot.status === "error" ? { text: "Stopped with an error", danger: true } : null,
175
+ needsPermission: snapshot.requiresAttention && snapshot.attentionReason === "permission",
176
+ hasError: snapshot.status === "error",
173
177
  }));
174
178
  if (!agent) return null;
175
- const title = agentTitle(agent.title);
179
+ const title = agentTitle(agent.title, m.untitledAgent);
180
+ const alert = agent.needsPermission ? { text: m.permissionNeeded, danger: false } : agent.hasError ? { text: m.agentError, danger: true } : null;
176
181
  const open = onOpen ? () => onOpen(agentId) : undefined;
177
182
  // A quiet line in the summary: who's on it, and the way to their chat.
178
183
  return (
179
- <PressableRow colors={colors} onSurface1 disabled={!open} accessibilityRole="button" accessibilityLabel={`Open the chat with ${title}`} onPress={open}
184
+ <PressableRow colors={colors} onSurface1 disabled={!open} accessibilityRole="button" accessibilityLabel={m.openAgentChat(title)} onPress={open}
180
185
  style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4 }}>
181
186
  <Icon name="Bot" size={12} color={colors.foregroundMuted} />
182
187
  <Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{title}</Text>
183
- {agent.alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: agent.alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{agent.alert.text}</Text> : null}
188
+ {alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{alert.text}</Text> : null}
184
189
  <View style={{ flex: 1 }} />
185
- {open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>Open chat</Text> : null}
190
+ {open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
186
191
  </PressableRow>
187
192
  );
188
193
  }
189
194
 
190
195
  function LinkNote({ colors, link }: { colors: Colors; link: Link }) {
191
- return link === "by-time" ? <Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}> by time</Text> : null;
196
+ const { messages: m } = useProgressLanguage();
197
+ return link === "by-time" ? <Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>{m.byTime}</Text> : null;
192
198
  }
193
199
 
194
200
  // The same icons as the ticket rows, so a step reads like the status it was.
195
201
  // Past stage steps get an accent ringed dot: work that happened, now over.
196
202
  function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiting?: boolean } }) {
197
- if (step.waiting || (step.status === "not_started" && step.label !== "Added")) return <WaitingDot color={colors.foregroundMuted} />;
198
- const icon = step.label === "Added" ? { name: "CirclePlus", color: colors.foregroundMuted }
203
+ if (step.waiting || (step.status === "not_started" && step.label.kind !== "added")) return <WaitingDot color={colors.foregroundMuted} />;
204
+ const icon = step.label.kind === "added" ? { name: "CirclePlus", color: colors.foregroundMuted }
199
205
  : step.status === "done" ? { name: "CircleCheck", color: colors.statusSuccess }
200
206
  : step.status === "blocked" ? { name: "Ban", color: colors.statusDanger }
201
207
  : step.status === "skipped" ? { name: "CircleSlash", color: colors.foregroundMuted }
@@ -206,6 +212,7 @@ function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiti
206
212
  // One line per step: its status icon, what it was, when it began and how long
207
213
  // it took. The step still going shows the live spinner.
208
214
  function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step: TimelineStep & { waiting?: boolean }; last: boolean; now: number; live: boolean }) {
215
+ const { messages: m, locale } = useProgressLanguage();
209
216
  const current = last && step.minutes !== null;
210
217
  const working = current && step.status === "working";
211
218
  return (
@@ -217,11 +224,11 @@ function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step:
217
224
  {working ? (live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />) : <Marker colors={colors} step={step} />}
218
225
  </IconSwap>
219
226
  </View>
220
- <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{step.label}</Text>
221
- <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatTimeOfDay(step.at, now)}</Text>
227
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{timelineLabel(m, step.label)}</Text>
228
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatClientTimeOfDay(locale, step.at, now)}</Text>
222
229
  {/* A fixed column, so durations line up down the list. */}
223
- <Text style={{ width: 84, textAlign: "right", color: current ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
224
- {step.minutes !== null ? `${formatMinutes(step.minutes)}${current ? " so far" : ""}` : ""}
230
+ <Text numberOfLines={1} style={{ minWidth: 84, textAlign: "right", color: current ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
231
+ {step.minutes !== null ? current ? m.soFar(formatClientMinutes(locale, step.minutes)) : formatClientMinutes(locale, step.minutes) : ""}
225
232
  </Text>
226
233
  </View>
227
234
  {step.notes.map((note, index) => (
package/client/video.tsx CHANGED
@@ -28,6 +28,8 @@ export interface LoadedVideo {
28
28
  ratio: number | null;
29
29
  }
30
30
 
31
+ export class VideoChangedError extends Error {}
32
+
31
33
  // Fetches a video piece by piece into one blob, then reads its shape.
32
34
  export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
33
35
  bytes: number;
@@ -40,7 +42,7 @@ export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
40
42
  while (loaded < bytes) {
41
43
  const piece = await readPiece(loaded);
42
44
  const decoded = decode(piece.data);
43
- if (piece.bytes !== bytes || !decoded.length) throw new Error("the video changed while it was loading. Try again.");
45
+ if (piece.bytes !== bytes || !decoded.length) throw new VideoChangedError("the video changed while it was loading. Try again.");
44
46
  pieces.push(decoded);
45
47
  loaded += decoded.length;
46
48
  onProgress(loaded);
@@ -0,0 +1,125 @@
1
+ import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import React, { useState } from "react";
5
+ import { Text, View } from "react-native";
6
+ import { dismissWhatsNew, getWhatsNew } from "../shared/rpc";
7
+ import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote } from "../shared/whats-new";
8
+ import { spans } from "../shared/write-up";
9
+ import { PressScale, Presence } from "./motion";
10
+ import { isLight, raised, tint } from "./surfaces";
11
+ import { BlockView, Spans } from "./write-up";
12
+ import { ProgressLanguageProvider, useProgressLanguage } from "./language";
13
+
14
+ type Colors = PluginWorkspacePanelProps["theme"]["colors"];
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.
18
+ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
19
+ const { messages: m } = useProgressLanguage();
20
+ const fetchWhatsNew = useRpc(getWhatsNew);
21
+ const dismiss = useRpc(dismissWhatsNew);
22
+ const queryClient = useQueryClient();
23
+ const queryKey = ["progress", "whats-new", host.id];
24
+ const query = useQuery({ queryKey, queryFn: () => fetchWhatsNew({}) });
25
+ const whatsNew = query.data ?? null;
26
+ // Paseo's Modal keeps drawing its content while it fades out, so the notes outlive the dismissal.
27
+ const [shown, setShown] = useState<WhatsNew | null>(null);
28
+ const [open, setOpen] = useState(false);
29
+ const close = (version: string) => {
30
+ queryClient.setQueryData(queryKey, null);
31
+ dismiss({ version }).catch(() => queryClient.invalidateQueries({ queryKey }));
32
+ };
33
+ const closeDialog = () => {
34
+ if (!shown) return;
35
+ setOpen(false);
36
+ close(shown.version);
37
+ };
38
+
39
+ return (
40
+ <>
41
+ <Presence show={Boolean(whatsNew)}>{whatsNew ? (
42
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
43
+ <View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
44
+ <Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
45
+ <Text style={{ color: colors.foreground, fontWeight: "500" }}>{m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
46
+ </Text>
47
+ {/* 8px keeps the close button's 6px hit slop off the button beside it. */}
48
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
49
+ <PressScale accessibilityRole="button" onPress={() => { setShown(whatsNew); setOpen(true); }}
50
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
51
+ <Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.seeWhatsNew}</Text>
52
+ </PressScale>
53
+ <PressScale accessibilityRole="button" accessibilityLabel={m.dismissWhatsNew} hitSlop={6} onPress={() => close(whatsNew.version)}
54
+ style={({ pressed, hovered }) => ({ padding: 5, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
55
+ <Icon name="X" size={16} color={colors.foregroundMuted} />
56
+ </PressScale>
57
+ </View>
58
+ </View>
59
+ ) : null}</Presence>
60
+ <Modal title={shown ? m.whatsNewTitle(shown.version) : ""} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
61
+ open={open} onOpenChange={(next) => { if (!next) closeDialog(); }}>
62
+ <Modal.Content>
63
+ <ProgressLanguageProvider>
64
+ {shown ? <Notes colors={colors} notes={shown.notes} /> : null}
65
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", marginTop: 8 }}>
66
+ <PressScale accessibilityRole="button" onPress={closeDialog}
67
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
68
+ <Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.gotIt}</Text>
69
+ </PressScale>
70
+ </View>
71
+ </ProgressLanguageProvider>
72
+ </Modal.Content>
73
+ </Modal>
74
+ </>
75
+ );
76
+ }
77
+
78
+ // Titled notes read as a short list of headings with their details; a changelog
79
+ // written without titles stays a bulleted list.
80
+ function Notes({ colors, notes }: { colors: Colors; notes: WhatsNewNote[] }) {
81
+ if (!notes.some((note) => note.title || note.kind)) {
82
+ return <View style={{ gap: 12 }}>{notes.map((note, index) => (
83
+ <BlockView key={index} colors={colors} block={{ kind: "item", marker: "•", depth: 0, spans: spans(note.body) }} first={index === 0} />
84
+ ))}</View>;
85
+ }
86
+ return (
87
+ <View style={{ gap: 20 }}>
88
+ {notes.map((note, index) => (
89
+ <View key={index} style={{ gap: 4 }}>
90
+ {note.title || note.kind ? (
91
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
92
+ {note.kind ? <KindTag colors={colors} kind={note.kind} /> : null}
93
+ {note.title ? <Text accessibilityRole="header" selectable style={{ flex: 1, color: colors.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{note.title}</Text> : null}
94
+ </View>
95
+ ) : null}
96
+ {note.body ? (
97
+ <Text selectable style={{ color: note.title ? colors.foregroundMuted : colors.foreground, fontSize: 13, lineHeight: 19 }}>
98
+ <Spans colors={colors} spans={spans(note.body)} />
99
+ </Text>
100
+ ) : null}
101
+ </View>
102
+ ))}
103
+ </View>
104
+ );
105
+ }
106
+
107
+ // Paseo has no violet for plugins, so it's Paseo's own "merged" purple, which
108
+ // shares the status colors' lightness and chroma.
109
+ const TONE_COLOR: Record<NoteTone, (colors: Colors) => string> = {
110
+ violet: (colors) => isLight(colors) ? "#7347af" : "#a890d5",
111
+ green: (colors) => colors.statusSuccess,
112
+ amber: (colors) => colors.statusWarning,
113
+ };
114
+
115
+ // Sits on the title's first line: 18px tall, 1px down in its 20px line. As wide
116
+ // as "Improved" at least, so the titles beside the tags start at one edge.
117
+ function KindTag({ colors, kind }: { colors: Colors; kind: NoteKind }) {
118
+ const { messages: m } = useProgressLanguage();
119
+ const color = TONE_COLOR[NOTE_KINDS[kind].tone](colors);
120
+ return (
121
+ <View style={{ marginTop: 1, minWidth: 64, borderRadius: 4, paddingHorizontal: 7, backgroundColor: tint(color, colors) }}>
122
+ <Text numberOfLines={1} style={{ textAlign: "center", color, fontSize: 11, lineHeight: 18, fontWeight: "500" }}>{m.noteKinds[kind]}</Text>
123
+ </View>
124
+ );
125
+ }
package/client/when.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
2
  import React from "react";
3
3
  import { Text } from "react-native";
4
- import { formatAgo, formatClock } from "../shared/format";
4
+ import { formatClientAgo, formatClientClock } from "./i18n";
5
5
  import { useNarrow } from "./narrow";
6
+ import { useProgressLanguage } from "./language";
6
7
 
7
8
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
8
9
 
@@ -15,11 +16,12 @@ export function unbroken(text: string): string {
15
16
  // "Sep 27, 8:14 PM (10h 2m ago)", inline inside other text. A narrow
16
17
  // panel shows only "10h 2m ago"; the ticket dialog keeps the clock time.
17
18
  export function When({ colors, iso, now }: { colors: Colors; iso: string; now: number }) {
18
- const ago = unbroken(formatAgo(iso, now));
19
+ const { locale, messages: m } = useProgressLanguage();
20
+ const ago = unbroken(formatClientAgo(locale, iso, now));
19
21
  if (useNarrow()) return <Text>{ago}</Text>;
20
22
  return (
21
23
  <Text>
22
- {formatClock(iso)} <Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>({ago})</Text>
24
+ {formatClientClock(locale, iso)}<Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>{m.parenthetical(ago)}</Text>
23
25
  </Text>
24
26
  );
25
27
  }
@@ -9,6 +9,7 @@ import { type Block, parseWriteUp, type Span, withoutTitle, writeUpExcerpt } fro
9
9
  import { type Attachment, attachmentIcon } from "./attachments";
10
10
  import { PressableRow } from "./row";
11
11
  import { errorText } from "./errors";
12
+ import { useProgressLanguage } from "./language";
12
13
 
13
14
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
14
15
  const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
@@ -22,6 +23,7 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
22
23
  workspaceDirectory: string;
23
24
  onOpen(attachment: Attachment, group: Attachment[]): void;
24
25
  }) {
26
+ const { messages: m } = useProgressLanguage();
25
27
  const fetchPreview = useRpc(previewDeliverable);
26
28
  const readable = Boolean(source.path && previewKind(source.path) === "text");
27
29
  // The same query as the preview dialog, so opening the file after reading here is instant.
@@ -38,12 +40,12 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
38
40
  return (
39
41
  <View style={{ gap: 12, padding: 14, borderRadius: 8, backgroundColor: colors.surface0 }}>
40
42
  {shown.length ? <View style={{ gap: 8 }}>{shown.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View> : null}
41
- {preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Couldn't read it here: {errorText(preview.error, "something went wrong")}</Text> : null}
43
+ {preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.readError(errorText(preview.error, m.unknownError))}</Text> : null}
42
44
  {/* The file on the left, opening it whole; expanding in place on the right. */}
43
45
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
44
- <TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={`Open ${source.title}`} onPress={() => onOpen(source, [source])} grow />
46
+ <TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={m.openItem(source.title)} onPress={() => onOpen(source, [source])} grow />
45
47
  <View style={{ flex: 1 }} />
46
- {excerpt.more ? <TextButton colors={colors} label={all ? "Show less" : "Read more"} onPress={() => setAll(!all)} expanded={all} /> : null}
48
+ {excerpt.more ? <TextButton colors={colors} label={all ? m.showLess : m.readMore} onPress={() => setAll(!all)} expanded={all} /> : null}
47
49
  </View>
48
50
  </View>
49
51
  );
@@ -62,7 +64,7 @@ function TextButton({ colors, icon, label, accessibilityLabel, onPress, grow = f
62
64
  );
63
65
  }
64
66
 
65
- function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
67
+ export function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
66
68
  switch (block.kind) {
67
69
  case "heading":
68
70
  return (
@@ -89,8 +91,9 @@ function BlockView({ colors, block, first }: { colors: Colors; block: Block; fir
89
91
  }
90
92
  }
91
93
 
92
- function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
94
+ export function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
93
95
  return <>{spans.map((span, index) => span.code
94
96
  ? <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>{span.text}</Text>
97
+ : span.strong ? <Text key={index} style={{ fontWeight: "600" }}>{span.text}</Text>
95
98
  : <React.Fragment key={index}>{span.text}</React.Fragment>)}</>;
96
99
  }