@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
package/client/panel.tsx CHANGED
@@ -2,10 +2,11 @@ import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { DeliverablesList } from "./deliverables";
4
4
  import { LauncherBanner, SkillBanner } from "./launcher";
5
+ import { WhatsNewBanner } from "./whats-new";
5
6
  import { HistoryRow } from "./history";
6
7
  import { AnsweredQuestionsList, QuestionsSection } from "./questions";
7
8
  import { Spinner, StalledPulse, WaitingDot } from "./spinner";
8
- import { IconSwap, Presence, StaggerRoot, nativeDriver } from "./motion";
9
+ import { IconSwap, PressScale, Presence, StaggerRoot, nativeDriver } from "./motion";
9
10
  import { SectionTitle } from "./section-title";
10
11
  import { raised } from "./surfaces";
11
12
  import { unbroken, When } from "./when";
@@ -13,10 +14,10 @@ import React, { useEffect, useRef, useState } from "react";
13
14
  import { ActivityIndicator, Animated, Platform, ScrollView, Text, View } from "react-native";
14
15
  import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
15
16
  import type { TicketStatus } from "../shared/events";
16
- import { formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
17
+ import { minutesSince } from "../shared/format";
18
+ import { clientHeadline, formatClientMinutes, formatClientWorkDone } from "./i18n";
17
19
  import { markPanelOpened } from "../shared/rpc";
18
20
  import { useDashboard } from "./dashboard-query";
19
- import { STATUS_LABEL } from "./status-badge";
20
21
  import { TicketDialogs } from "./ticket-dialog";
21
22
  import { PressableRow } from "./row";
22
23
  import { TabStrip } from "./tabs";
@@ -24,17 +25,21 @@ import { ShowMoreRow, usePaged } from "./show-more";
24
25
  import { notePanelOpened } from "./panel-opened";
25
26
  import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
26
27
  import { errorText } from "./errors";
28
+ import { useProgressLanguage } from "./language";
27
29
 
28
30
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
29
31
 
30
32
 
31
33
  const mono = () => (Platform.OS === "ios" ? "Menlo" : "monospace");
32
34
 
33
- export function ProgressPanel(props: PluginWorkspacePanelProps) {
35
+ type PanelProps = PluginWorkspacePanelProps & { openSettings(): void };
36
+
37
+ export function ProgressPanel(props: PanelProps) {
34
38
  return <WorkspaceProgress key={`${props.host.id}:${props.workspaceId}`} {...props} />;
35
39
  }
36
40
 
37
- function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: PluginWorkspacePanelProps) {
41
+ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation, openSettings }: PanelProps) {
42
+ const { messages: m } = useProgressLanguage();
38
43
  const colors = theme.colors;
39
44
  const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
40
45
  // Paseo marks the workspace running while any of its agents is. Since 0.11 a Claude
@@ -50,18 +55,19 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
50
55
  <NarrowProvider value={narrow}>
51
56
  <ScrollView onLayout={(event) => setNarrow(event.nativeEvent.layout.width <= NARROW_MAX)}
52
57
  style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
53
- {query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading progress" style={{ padding: 12 }} /> : null}
58
+ {query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingProgress} style={{ padding: 12 }} /> : null}
54
59
  {query.error ? (
55
60
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
56
- Could not read progress: {errorText(query.error, "something went wrong")}
61
+ {m.readProgressError(errorText(query.error, m.unknownError))}
57
62
  </Text>
58
63
  ) : null}
64
+ <WhatsNewBanner colors={colors} host={host} />
59
65
  <LauncherBanner colors={colors} host={host} />
60
66
  <SkillBanner colors={colors} host={host} />
61
67
  {/* Fades in like the dashboard's sections instead of popping in after the spinner. */}
62
- <Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} /> : null}</Presence>
68
+ <Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} openSettings={openSettings} /> : null}</Presence>
63
69
  {/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
64
- {result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
70
+ {result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} openSettings={openSettings} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
65
71
  workspaceId={workspaceId} workspaceDirectory={result.root} file={result.file} navigation={navigation} /> : null}
66
72
  {result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
67
73
  </ScrollView>
@@ -84,17 +90,31 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
84
90
  }, [needed, directory, workspaceId, mark]);
85
91
  }
86
92
 
87
- function EmptyState({ colors, file }: { colors: Colors; file: string }) {
93
+ // Optical: the 24px button centers on a 20 to 21px title line.
94
+ function SettingsButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
95
+ const { messages: m } = useProgressLanguage();
96
+ return (
97
+ <PressScale accessibilityRole="button" accessibilityLabel={m.openSettings} hitSlop={6} outerStyle={{ marginVertical: -2 }} onPress={onPress}
98
+ style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface1 : "transparent" })}>
99
+ <Icon name="Settings" size={16} color={colors.foregroundMuted} />
100
+ </PressScale>
101
+ );
102
+ }
103
+
104
+ function EmptyState({ colors, file, openSettings }: { colors: Colors; file: string; openSettings(): void }) {
105
+ const { messages: m } = useProgressLanguage();
88
106
  return (
89
107
  <View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
90
108
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
91
109
  <Icon name="ListChecks" size={18} color={colors.foregroundMuted} />
92
110
  <Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 14, lineHeight: 20, fontWeight: "500" }}>
93
- No progress recorded yet
111
+ {m.noProgress}
94
112
  </Text>
113
+ <View style={{ flex: 1 }} />
114
+ <SettingsButton colors={colors} onPress={openSettings} />
95
115
  </View>
96
116
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
97
- An agent working in this worktree records progress with the <Text style={{ color: colors.foreground }}>paseo-progress</Text> command. It shows up here as soon as the first run starts.
117
+ {m.emptyIntro[0]}<Text style={{ color: colors.foreground }}>paseo-progress</Text>{m.emptyIntro[1]}
98
118
  </Text>
99
119
  <View style={{ backgroundColor: colors.surface1, borderRadius: 6, padding: 10 }}>
100
120
  <Text selectable style={{ color: colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>
@@ -103,7 +123,7 @@ function EmptyState({ colors, file }: { colors: Colors; file: string }) {
103
123
  </View>
104
124
  {/* So people know nothing lands in the repo. */}
105
125
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
106
- Progress is saved outside the repo, in <Text selectable style={{ color: colors.foreground }}>{file}</Text>, so git never sees it.
126
+ {m.savedOutside[0]}<Text selectable style={{ color: colors.foreground }}>{file}</Text>{m.savedOutside[1]}
107
127
  </Text>
108
128
  </View>
109
129
  );
@@ -132,7 +152,8 @@ function useNow(intervalMs: number): number {
132
152
 
133
153
  const WIDE_MIN = 760;
134
154
 
135
- function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation }: {
155
+ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation, openSettings }: {
156
+ openSettings(): void;
136
157
  colors: Colors;
137
158
  dashboard: Dashboard;
138
159
  agentRunning: boolean;
@@ -142,6 +163,8 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
142
163
  file: string;
143
164
  navigation: PluginWorkspacePanelProps["navigation"];
144
165
  }) {
166
+ const { messages: m, locale } = useProgressLanguage();
167
+ const headline = clientHeadline(locale, dashboard);
145
168
  const now = useNow(15_000);
146
169
  // Quiet isn't stale while an agent in the workspace is still running.
147
170
  const stale = dashboard.stale && !agentRunning;
@@ -155,17 +178,20 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
155
178
  <StaggerRoot>
156
179
  <Presence show order={0}>
157
180
  <View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 10, gap: 4, borderBottomWidth: 1, borderBottomColor: colors.border }}>
158
- <Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" }}>
159
- {dashboard.run?.title ?? "Progress"}
160
- </Text>
181
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
182
+ <Text accessibilityRole="header" style={{ flex: 1, color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" }}>
183
+ {dashboard.run?.title ?? "Progress"}
184
+ </Text>
185
+ <SettingsButton colors={colors} onPress={openSettings} />
186
+ </View>
161
187
  {dashboard.run?.subtitle ? (
162
188
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{dashboard.run.subtitle}</Text>
163
189
  ) : null}
164
- {dashboard.headline.length ? (
190
+ {headline.length ? (
165
191
  <Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
166
- {dashboard.headline.map((part, index) => (
192
+ {headline.map((part, index) => (
167
193
  <Text key={part.text} style={part.tone === "danger" ? { color: colors.statusDanger } : undefined}>
168
- {index ? ", " : ""}{part.text}
194
+ {index ? m.listSeparator : ""}{part.text}
169
195
  </Text>
170
196
  ))}
171
197
  </Text>
@@ -181,7 +207,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
181
207
  {dashboard.run?.finished ? (
182
208
  <>
183
209
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
184
- <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>Finished</Text> <When colors={colors} iso={dashboard.run.finished.at} now={now} />
210
+ <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{m.finished}</Text> <When colors={colors} iso={dashboard.run.finished.at} now={now} />
185
211
  </Text>
186
212
  <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>{dashboard.run.finished.outcome}</Text>
187
213
  </>
@@ -194,7 +220,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
194
220
  {dashboard.updatedAt && !dashboard.run?.finished ? (
195
221
  <StatusLine icon={<Icon name="Clock" size={12} color={colors.foregroundMuted} />}>
196
222
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
197
- Last updated <When colors={colors} iso={dashboard.updatedAt} now={now} />
223
+ {m.lastUpdated}<When colors={colors} iso={dashboard.updatedAt} now={now} />
198
224
  </Text>
199
225
  </StatusLine>
200
226
  ) : null}
@@ -205,7 +231,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
205
231
  {/* Optical: centers the 14px icon on the first 18px line. */}
206
232
  <View style={{ paddingTop: 2 }}><Icon name="TriangleAlert" size={14} color={colors.statusWarning} /></View>
207
233
  <Text style={{ flex: 1, color: colors.statusWarning, fontSize: 12, lineHeight: 18 }}>
208
- Possibly stale. No update for {unbroken(formatMinutes(minutesSince(dashboard.updatedAt, now)))}. The work may have stopped, so working items show a warning until the next update.
234
+ {m.staleWarning(unbroken(formatClientMinutes(locale, minutesSince(dashboard.updatedAt, now))))}
209
235
  </Text>
210
236
  </View>
211
237
  ) : null}</Presence>
@@ -219,10 +245,10 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
219
245
  ) : null}</Presence>
220
246
  <Presence show={Boolean(dashboard.stuck.length)} order={3}>{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}</Presence>
221
247
  <Presence show order={4}>
222
- <Card colors={colors} icon="ListChecks" title={`${dashboard.run?.itemLabel ?? "Ticket"}s`}>
248
+ <Card colors={colors} icon="ListChecks" title={m.itemHeading(dashboard.run?.itemLabel ?? "Ticket")}>
223
249
  {dashboard.tickets.length === 0 ? (
224
250
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
225
- No {(dashboard.run?.itemLabel ?? "ticket").toLowerCase()}s yet.
251
+ {m.noItems(dashboard.run?.itemLabel ?? "Ticket")}
226
252
  </Text>
227
253
  ) : (
228
254
  <TicketList colors={colors} dashboard={dashboard} live={live} now={now} workspaceId={workspaceId} onOpen={setOpenTicket} />
@@ -235,11 +261,11 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
235
261
  <Presence show={hasHistory} order={5}>
236
262
  {hasHistory ? (
237
263
  <HistoryCard colors={colors} workspaceId={workspaceId} tabs={[
238
- { id: "activity", label: "Activity", icon: "Activity", count: dashboard.activity.length, empty: "No activity yet.",
264
+ { id: "activity", label: m.activity, icon: "Activity", count: dashboard.activity.length, empty: m.noActivity,
239
265
  content: <ActivityList colors={colors} activity={dashboard.activity} now={now} /> },
240
- { id: "answered", label: "Answered", icon: "CircleCheck", count: dashboard.questions.answered.length, empty: "No answered questions yet.",
266
+ { id: "answered", label: m.answered, icon: "CircleCheck", count: dashboard.questions.answered.length, empty: m.noAnswers,
241
267
  content: <AnsweredQuestionsList colors={colors} questions={dashboard.questions.answered} now={now} context={attachmentContext} /> },
242
- { id: "deliverables", label: "Deliverables", icon: "Package", count: dashboard.deliverables.length, empty: "No deliverables yet.",
268
+ { id: "deliverables", label: m.deliverables, icon: "Package", count: dashboard.deliverables.length, empty: m.noDeliverables,
243
269
  content: <DeliverablesList colors={colors} deliverables={dashboard.deliverables} now={now} workspaceId={workspaceId}
244
270
  workspaceDirectory={workspaceDirectory} navigation={navigation} /> },
245
271
  ]} />
@@ -303,14 +329,14 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
303
329
 
304
330
  // Small and non-blocking: the rest of the dashboard still renders.
305
331
  function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIssue[]; file: string }) {
332
+ const { messages: m } = useProgressLanguage();
306
333
  const shown = issues.slice(0, 5);
307
334
  return (
308
335
  <View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
309
336
  <View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
310
337
  <Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
311
- Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {file}:{" "}
312
- {shown.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}
313
- {issues.length > shown.length ? `, and ${issues.length - shown.length} more` : ""}.
338
+ {m.skippedLines(issues.length, file)}
339
+ {m.issueList(shown.map((issue) => m.issueLine(issue.line, issue.reason)), issues.length - shown.length)}
314
340
  </Text>
315
341
  </View>
316
342
  );
@@ -326,15 +352,16 @@ function Card({ colors, icon, title, children }: { colors: Colors; icon: string;
326
352
  }
327
353
 
328
354
  function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Dashboard["progress"]; live: boolean }) {
355
+ const { messages: m, locale } = useProgressLanguage();
329
356
  const narrow = useNarrow();
330
357
  return (
331
358
  <View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
332
359
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
333
360
  <Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
334
- <Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> {narrow ? "done" : "of estimated work done"}
361
+ <Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> {narrow ? m.workDoneShort : m.workDoneLong}
335
362
  </Text>
336
363
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
337
- {formatWorkDone(progress.doneMin, progress.totalMin, progress.tookMin)}
364
+ {formatClientWorkDone(locale, progress.doneMin, progress.totalMin, progress.tookMin)}
338
365
  </Text>
339
366
  </View>
340
367
  <View accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: progress.percent }}
@@ -379,10 +406,11 @@ function Shimmer() {
379
406
  const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
380
407
 
381
408
  function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
409
+ const { messages: m, locale } = useProgressLanguage();
382
410
  const narrow = useNarrow();
383
411
  return (
384
412
  <View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
385
- <SectionTitle colors={colors} icon="OctagonAlert" title="Stuck" color={colors.statusDanger} style={{ borderBottomWidth: 1, borderBottomColor: colors.border }} />
413
+ <SectionTitle colors={colors} icon="OctagonAlert" title={m.stuck} color={colors.statusDanger} style={{ borderBottomWidth: 1, borderBottomColor: colors.border }} />
386
414
  {items.map((item, index) => (
387
415
  <View key={item.key} style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
388
416
  {/* Optical: centers the 14px icon on the 18px title line. */}
@@ -390,9 +418,9 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
390
418
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
391
419
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
392
420
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
393
- {item.kind === "overdue" ? `No update for longer than its ${unbroken(formatMinutes(item.estimateMin))} estimate. Last update ` : null}
394
- {item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}${narrow ? ", " : ". Since "}` : null}
395
- {item.kind === "manual" ? `${item.ticketId ? `${item.reason}. ` : ""}Flagged ` : null}
421
+ {item.kind === "overdue" ? m.overdueUpdate(unbroken(formatClientMinutes(locale, item.estimateMin))) : null}
422
+ {item.kind === "blocked" ? m.blockedSince(item.note, narrow) : null}
423
+ {item.kind === "manual" ? `${item.ticketId ? `${item.reason}${m.sentenceBreak}` : ""}${m.flagged}` : null}
396
424
  <When colors={colors} iso={item.since} now={now} />
397
425
  </Text>
398
426
  </View>
@@ -432,15 +460,16 @@ function TicketList({ colors, dashboard, live, now, workspaceId, onOpen }: { col
432
460
  // Sits on the ticket rows' columns: status icon, label, estimate, then the
433
461
  // chevron, which points the way the list will move, like "Show 10 older".
434
462
  function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tickets: Ticket[]; shown: boolean; onPress(): void }) {
463
+ const { messages: m, locale } = useProgressLanguage();
435
464
  const done = tickets.filter((ticket) => ticket.status === "done").length;
436
465
  const skipped = tickets.length - done;
437
- const label = [done ? `${done} done` : "", skipped ? `${skipped} skipped` : ""].filter(Boolean).join(", ");
466
+ const label = [done ? m.doneCount(done) : "", skipped ? m.skippedCount(skipped) : ""].filter(Boolean).join(m.listSeparator);
438
467
  const estimateMin = tickets.reduce((sum, ticket) => sum + (ticket.status === "done" ? ticket.estimateMin : 0), 0);
439
468
  const chevron = shown ? "ChevronUp" : "ChevronDown";
440
469
  const narrow = useNarrow();
441
470
  return (
442
471
  <PressableRow colors={colors} accessibilityRole="button" accessibilityState={{ expanded: shown }}
443
- accessibilityLabel={`${shown ? "Hide" : "Show"} ${label}`} onPress={onPress}
472
+ accessibilityLabel={m.toggleFinished(shown, label)} onPress={onPress}
444
473
  style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
445
474
  <View style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
446
475
  <Icon name={done ? "Check" : "CircleSlash"} size={14} color={done ? colors.statusSuccess : colors.foregroundMuted} />
@@ -449,7 +478,7 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
449
478
  {/* Narrow ticket rows drop their chevron, so this total would sit off their estimate column. */}
450
479
  {narrow ? null : <>
451
480
  <View style={{ width: 12 }} />
452
- <Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatMinutes(estimateMin) : ""}</Text>
481
+ <Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatClientMinutes(locale, estimateMin) : ""}</Text>
453
482
  </>}
454
483
  <View style={{ paddingTop: 2 }}>
455
484
  <IconSwap swapKey={chevron} size={14}><Icon name={chevron} size={14} color={colors.foregroundMuted} /></IconSwap>
@@ -460,16 +489,17 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
460
489
 
461
490
  // Press for the ticket's story. Hover and press only tint the row: it's a list, pressed often.
462
491
  function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors: Colors; ticket: Ticket; live: boolean; now: number; onOpen(): void; deliverables: number }) {
492
+ const { messages: m, locale } = useProgressLanguage();
463
493
  const muted = ticket.status === "skipped";
464
494
  const working = ticket.status === "working";
465
495
  // Narrow: tighter gaps and no chevron (the whole row still presses), so the title keeps its width.
466
496
  const narrow = useNarrow();
467
497
  return (
468
498
  <PressableRow colors={colors} onSurface1={working} transparentAtRest={!working}
469
- accessibilityRole="button" accessibilityLabel={`${ticket.id} details`} onPress={onOpen}
499
+ accessibilityRole="button" accessibilityLabel={m.details(ticket.id)} onPress={onOpen}
470
500
  style={{ flexDirection: "row", alignItems: "flex-start", gap: narrow ? 6 : 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
471
501
  {/* The icon is the only status mark on the row; the ticket dialog spells it out in a badge. */}
472
- <View accessible accessibilityLabel={ticket.waitingFor ? "Waiting" : STATUS_LABEL[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center", marginRight: narrow ? 2 : 0 }}>
502
+ <View accessible accessibilityLabel={ticket.waitingFor ? m.waiting : m.status[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center", marginRight: narrow ? 2 : 0 }}>
473
503
  <IconSwap swapKey={ticket.waitingFor || ticket.status === "not_started" ? "waiting" : ticket.status === "working" ? `working-${live}` : ticket.status} size={14}><StatusIcon colors={colors} status={ticket.status} live={live} waiting={Boolean(ticket.waitingFor)} /></IconSwap>
474
504
  </View>
475
505
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
@@ -478,24 +508,24 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
478
508
  </Text>
479
509
  {ticket.status === "working" && ticket.stage && ticket.stageSince ? (
480
510
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
481
- {ticket.stage} stage started <When colors={colors} iso={ticket.stageSince} now={now} />
511
+ {m.stageStarted(ticket.stage)}<When colors={colors} iso={ticket.stageSince} now={now} />
482
512
  </Text>
483
513
  ) : null}
484
514
  {ticket.status === "working" && ticket.workingSince ? (
485
515
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
486
- {narrow ? "Started working " : "Working since "}<When colors={colors} iso={ticket.workingSince} now={now} />
516
+ {narrow ? m.startedWorking : m.workingSince}<When colors={colors} iso={ticket.workingSince} now={now} />
487
517
  </Text>
488
518
  ) : null}
489
519
  {ticket.waitingFor ? (
490
- <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Waits for {shortTitle(ticket.waitingFor.title)}</Text>
520
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.waitsFor(shortTitle(ticket.waitingFor.title))}</Text>
491
521
  ) : null}
492
522
  {ticket.note ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{ticket.note}</Text> : null}
493
523
  </View>
494
524
  {/* Fixed slots so the Package icon and estimates line up down the list, whether or not a row has deliverables; they're listed in its dialog. The estimate slot fits "45m" up to "9h 45m"; longer ones grow rather than clip. */}
495
- <View accessibilityLabel={deliverables ? (deliverables === 1 ? "Has a deliverable" : `Has ${deliverables} deliverables`) : undefined} style={{ width: 12, height: 18, justifyContent: "center" }}>
525
+ <View accessibilityLabel={deliverables ? m.hasDeliverables(deliverables) : undefined} style={{ width: 12, height: 18, justifyContent: "center" }}>
496
526
  {deliverables ? <Icon name="Package" size={12} color={colors.foregroundMuted} /> : null}
497
527
  </View>
498
- <Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatMinutes(ticket.estimateMin)}</Text>
528
+ <Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatClientMinutes(locale, ticket.estimateMin)}</Text>
499
529
  {narrow ? null : <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>}
500
530
  </PressableRow>
501
531
  );
@@ -12,6 +12,7 @@ import { galleryFiles } from "./preview";
12
12
  import { QuestionDetail, QuestionState, useCopy } from "./questions";
13
13
  import { errorText } from "./errors";
14
14
  import { raised } from "./surfaces";
15
+ import { useProgressLanguage } from "./language";
15
16
 
16
17
  type Colors = PluginButtonContentProps["theme"]["colors"];
17
18
 
@@ -22,6 +23,7 @@ const LIST_MAX_HEIGHT = 340;
22
23
  // What the pill opens: the open questions with their Copy buttons, and anything
23
24
  // stuck, so the user can answer without leaving the chat.
24
25
  export function AttentionPopover({ theme, host, layout, workspaceId, close, openPanel }: PluginButtonContentProps & { openPanel(): void }) {
26
+ const { messages: m } = useProgressLanguage();
25
27
  const colors = theme.colors;
26
28
  // Paseo draws the desktop popover on surface1 and the phone sheet on surface0.
27
29
  const onSurface1 = !layout.compact;
@@ -53,7 +55,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
53
55
  </List>
54
56
  ) : (
55
57
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
56
- {query.error ? `Could not read progress: ${errorText(query.error, "something went wrong")}` : "Loading…"}
58
+ {query.error ? m.readProgressError(errorText(query.error, m.unknownError)) : m.loading}
57
59
  </Text>
58
60
  )}
59
61
  <StackedDialog colors={colors} now={Date.now()} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory: root }}
@@ -65,7 +67,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
65
67
  backgroundColor: onSurface1 ? (pressed ? colors.surface1 : colors.surface2) : pressed ? colors.surface2 : colors.surface1 })}>
66
68
  {/* Beside the chat on desktop; on a phone it opens as its own tab, shown with the tab's icon. */}
67
69
  <Icon name={touch ? "ListChecks" : "PanelRight"} size={touch ? 16 : 12} color={colors.foreground} />
68
- <Text style={{ color: colors.foreground, fontSize: touch ? 15 : 12, lineHeight: touch ? 20 : 16, fontWeight: "600" }}>Open Progress</Text>
70
+ <Text style={{ color: colors.foreground, fontSize: touch ? 15 : 12, lineHeight: touch ? 20 : 16, fontWeight: "600" }}>{m.openProgress}</Text>
69
71
  </PressScale>
70
72
  </View>
71
73
  </View>
@@ -87,10 +89,11 @@ export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAtt
87
89
  onCopy(question: Dashboard["questions"]["open"][number], letter: string): void;
88
90
  onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
89
91
  }) {
92
+ const { messages: m } = useProgressLanguage();
90
93
  const now = Date.now();
91
94
  // The last question can be answered while the popover is open.
92
95
  if (!dashboard.questions.open.length && !dashboard.stuck.length) {
93
- return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Nothing needs you right now.</Text>;
96
+ return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.nothingNeedsYou}</Text>;
94
97
  }
95
98
  return (
96
99
  <>
@@ -110,13 +113,13 @@ export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAtt
110
113
  <View style={{ gap: 4 }}>
111
114
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
112
115
  <Icon name="OctagonAlert" size={12} color={colors.statusDanger} />
113
- <Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>Stuck</Text>
116
+ <Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>{m.stuck}</Text>
114
117
  </View>
115
118
  {dashboard.stuck.map((item) => (
116
119
  <Text key={item.key} style={{ color: colors.foreground, fontSize: 12, lineHeight: 17 }}>
117
120
  {item.title}
118
121
  <Text style={{ color: colors.foregroundMuted }}>
119
- {item.kind === "overdue" ? " (no update past its estimate)" : item.kind === "blocked" ? ` (blocked${item.note ? `: ${item.note}` : ""})` : ""}
122
+ {item.kind === "overdue" ? m.overdueAside : item.kind === "blocked" ? m.blockedAside(item.note) : ""}
120
123
  </Text>
121
124
  </Text>
122
125
  ))}
@@ -1,3 +1,4 @@
1
+ import { messagesFor, type ProgressMessages } from "./i18n";
1
2
  import type { AttentionResult } from "../shared/rpc";
2
3
 
3
4
  // What an agent's Progress pill shows, decided in one place so its label, its
@@ -13,15 +14,15 @@ export type Pill =
13
14
 
14
15
  // What needs the user first, else the nudge, else (on a phone) the way in.
15
16
  // Null when there's no pill.
16
- export function pillFor(report: AttentionResult | undefined, where: { phone: boolean }): Pill | null {
17
+ export function pillFor(report: AttentionResult | undefined, where: { phone: boolean }, m: ProgressMessages = messagesFor("en")): Pill | null {
17
18
  if (!report?.configured) return null;
18
19
  const { questions, stuck } = report;
19
20
  if (questions || stuck) {
20
21
  // Paseo cuts composer pills off at 160 px, so when something is stuck
21
22
  // too, the questions shorten to "2Q" to leave room for "1 stuck".
22
23
  const parts = [];
23
- if (questions) parts.push(stuck ? `${questions}Q` : `${questions} ${questions === 1 ? "question" : "questions"}`);
24
- if (stuck) parts.push(`${stuck} stuck`);
24
+ if (questions) parts.push(stuck ? m.shortQuestions(questions) : m.questionCount(questions));
25
+ if (stuck) parts.push(m.stuckCount(stuck));
25
26
  return { mode: "attention", label: parts.join(" · "), icon: stuck ? "stuck" : "questions" };
26
27
  }
27
28
  if (report.runOpen && !report.panelOpened) return { mode: "started", label: "Progress", icon: "started" };
package/client/pills.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PluginButtonContentProps, PluginButtonIconProps, PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import React, { useSyncExternalStore } from "react";
3
+ import React, { useEffect, useSyncExternalStore } from "react";
4
4
  import { Platform } from "react-native";
5
5
  import { type AttentionResult, getAttention } from "../shared/rpc";
6
6
  import { isIdle, type Pill, pillFor } from "./pill-state";
@@ -8,6 +8,8 @@ import { observeDirectory, type PaseoAgentUpdate, type PaseoWorkspaceUpdate } fr
8
8
  import { onPanelOpened } from "./panel-opened";
9
9
  import { IconSwap } from "./motion";
10
10
  import { Spinner } from "./spinner";
11
+ import { messagesFor, resolveLocale } from "./i18n";
12
+ import { ProgressLanguageProvider, useProgressLanguage } from "./language";
11
13
  import { AttentionPopover } from "./pill-popover";
12
14
 
13
15
  export const PILL_POLL_MS = 5_000;
@@ -41,12 +43,13 @@ export function contributePills(client: PluginClientContext) {
41
43
  const workspaces = new Map<string, string>();
42
44
  // The last report per workspace; pillFor turns it into what the pill shows.
43
45
  const reports = new Map<string, AttentionResult>();
46
+ let locale = resolveLocale("system");
44
47
  const where = { phone: progressPanel().location === "workspace" };
45
- const pillOf = (workspaceId: string) => pillFor(reports.get(workspaceId), where);
48
+ const pillOf = (workspaceId: string) => pillFor(reports.get(workspaceId), where, messagesFor(locale));
46
49
  // Bumped on each report so a poll that was already in flight can't bring the pill back.
47
50
  let openedReports = 0;
48
51
  const listeners = new Set<() => void>();
49
- const pills = new Map<string, { workspaceId: string; label: string; mode: Pill["mode"]; pill: PluginButtonRegistration }>();
52
+ const pills = new Map<string, { workspaceId: string; label: string; title: string; mode: Pill["mode"]; pill: PluginButtonRegistration }>();
50
53
  const pending = new Set<string>();
51
54
  // Polls left before an idle workspace is checked again, and when each was last checked.
52
55
  const idleWait = new Map<string, number>();
@@ -56,6 +59,16 @@ export function contributePills(client: PluginClientContext) {
56
59
  let timer: ReturnType<typeof setTimeout> | undefined;
57
60
 
58
61
  function PillIcon(props: PluginButtonIconProps) {
62
+ return <ProgressLanguageProvider><LocalizedPillIcon {...props} /></ProgressLanguageProvider>;
63
+ }
64
+
65
+ function LocalizedPillIcon(props: PluginButtonIconProps) {
66
+ const language = useProgressLanguage();
67
+ useEffect(() => {
68
+ if (stopped || locale === language.locale) return;
69
+ locale = language.locale;
70
+ publish();
71
+ }, [language.locale]);
59
72
  const icon = useSyncExternalStore(
60
73
  (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
61
74
  () => pillOf(props.workspaceId)?.icon ?? "open",
@@ -76,11 +89,11 @@ export function contributePills(client: PluginClientContext) {
76
89
  };
77
90
 
78
91
  const buttonFor = (workspaceId: string, mode: Pill["mode"], label: string) => mode === "attention"
79
- ? { title: "Progress needs you", icon: PillIcon, label, behavior: { kind: "popover" as const, Content: PillContent } }
80
- : { title: "Open Progress", icon: PillIcon, label, behavior: { kind: "action" as const, onPress: () => openPanel(workspaceId) } };
92
+ ? { title: messagesFor(locale).needsYou, icon: PillIcon, label, behavior: { kind: "popover" as const, Content: PillContent } }
93
+ : { title: messagesFor(locale).openProgress, icon: PillIcon, label, behavior: { kind: "action" as const, onPress: () => openPanel(workspaceId) } };
81
94
 
82
95
  function PillContent(props: PluginButtonContentProps) {
83
- return <AttentionPopover {...props} openPanel={() => openPanel(props.workspaceId)} />;
96
+ return <ProgressLanguageProvider><AttentionPopover {...props} openPanel={() => openPanel(props.workspaceId)} /></ProgressLanguageProvider>;
84
97
  }
85
98
 
86
99
  const publish = () => {
@@ -92,6 +105,7 @@ export function contributePills(client: PluginClientContext) {
92
105
  const wanted = pillOf(workspaceId);
93
106
  if (!wanted) continue;
94
107
  const { mode, label } = wanted;
108
+ const title = mode === "attention" ? messagesFor(locale).needsYou : messagesFor(locale).openProgress;
95
109
  seen.add(agent.id);
96
110
  const existing = pills.get(agent.id);
97
111
  if (existing && existing.workspaceId !== workspaceId) {
@@ -101,13 +115,13 @@ export function contributePills(client: PluginClientContext) {
101
115
  const entry = pills.get(agent.id);
102
116
  if (!entry) {
103
117
  const pill = client.addComposerPill({ id: "progress-attention", workspaceId, agentId: agent.id, button: buttonFor(workspaceId, mode, label) });
104
- pills.set(agent.id, { workspaceId, label, mode, pill });
118
+ pills.set(agent.id, { workspaceId, title, label, mode, pill });
105
119
  } else if (entry.mode !== mode) {
106
120
  entry.pill.update(buttonFor(workspaceId, mode, label));
107
- Object.assign(entry, { mode, label });
108
- } else if (entry.label !== label) {
109
- entry.pill.update({ label });
110
- entry.label = label;
121
+ Object.assign(entry, { mode, label, title });
122
+ } else if (entry.label !== label || entry.title !== title) {
123
+ entry.pill.update({ label, title });
124
+ Object.assign(entry, { label, title });
111
125
  }
112
126
  }
113
127
  for (const [agentId, entry] of pills) {