@stevecastaneda/paseo-progress 1.4.0 → 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.
package/client/panel.tsx CHANGED
@@ -14,10 +14,10 @@ import React, { useEffect, useRef, useState } from "react";
14
14
  import { ActivityIndicator, Animated, Platform, ScrollView, Text, View } from "react-native";
15
15
  import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
16
16
  import type { TicketStatus } from "../shared/events";
17
- import { formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
17
+ import { minutesSince } from "../shared/format";
18
+ import { clientHeadline, formatClientMinutes, formatClientWorkDone } from "./i18n";
18
19
  import { markPanelOpened } from "../shared/rpc";
19
20
  import { useDashboard } from "./dashboard-query";
20
- import { STATUS_LABEL } from "./status-badge";
21
21
  import { TicketDialogs } from "./ticket-dialog";
22
22
  import { PressableRow } from "./row";
23
23
  import { TabStrip } from "./tabs";
@@ -25,6 +25,7 @@ import { ShowMoreRow, usePaged } from "./show-more";
25
25
  import { notePanelOpened } from "./panel-opened";
26
26
  import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
27
27
  import { errorText } from "./errors";
28
+ import { useProgressLanguage } from "./language";
28
29
 
29
30
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
30
31
 
@@ -38,6 +39,7 @@ export function ProgressPanel(props: PanelProps) {
38
39
  }
39
40
 
40
41
  function WorkspaceProgress({ theme, workspaceId, host, layout, navigation, openSettings }: PanelProps) {
42
+ const { messages: m } = useProgressLanguage();
41
43
  const colors = theme.colors;
42
44
  const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
43
45
  // Paseo marks the workspace running while any of its agents is. Since 0.11 a Claude
@@ -53,10 +55,10 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation, openS
53
55
  <NarrowProvider value={narrow}>
54
56
  <ScrollView onLayout={(event) => setNarrow(event.nativeEvent.layout.width <= NARROW_MAX)}
55
57
  style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
56
- {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}
57
59
  {query.error ? (
58
60
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
59
- Could not read progress: {errorText(query.error, "something went wrong")}
61
+ {m.readProgressError(errorText(query.error, m.unknownError))}
60
62
  </Text>
61
63
  ) : null}
62
64
  <WhatsNewBanner colors={colors} host={host} />
@@ -90,8 +92,9 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
90
92
 
91
93
  // Optical: the 24px button centers on a 20 to 21px title line.
92
94
  function SettingsButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
95
+ const { messages: m } = useProgressLanguage();
93
96
  return (
94
- <PressScale accessibilityRole="button" accessibilityLabel="Open Progress settings" hitSlop={6} outerStyle={{ marginVertical: -2 }} onPress={onPress}
97
+ <PressScale accessibilityRole="button" accessibilityLabel={m.openSettings} hitSlop={6} outerStyle={{ marginVertical: -2 }} onPress={onPress}
95
98
  style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface1 : "transparent" })}>
96
99
  <Icon name="Settings" size={16} color={colors.foregroundMuted} />
97
100
  </PressScale>
@@ -99,18 +102,19 @@ function SettingsButton({ colors, onPress }: { colors: Colors; onPress(): void }
99
102
  }
100
103
 
101
104
  function EmptyState({ colors, file, openSettings }: { colors: Colors; file: string; openSettings(): void }) {
105
+ const { messages: m } = useProgressLanguage();
102
106
  return (
103
107
  <View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
104
108
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
105
109
  <Icon name="ListChecks" size={18} color={colors.foregroundMuted} />
106
110
  <Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 14, lineHeight: 20, fontWeight: "500" }}>
107
- No progress recorded yet
111
+ {m.noProgress}
108
112
  </Text>
109
113
  <View style={{ flex: 1 }} />
110
114
  <SettingsButton colors={colors} onPress={openSettings} />
111
115
  </View>
112
116
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
113
- 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]}
114
118
  </Text>
115
119
  <View style={{ backgroundColor: colors.surface1, borderRadius: 6, padding: 10 }}>
116
120
  <Text selectable style={{ color: colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>
@@ -119,7 +123,7 @@ function EmptyState({ colors, file, openSettings }: { colors: Colors; file: stri
119
123
  </View>
120
124
  {/* So people know nothing lands in the repo. */}
121
125
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
122
- 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]}
123
127
  </Text>
124
128
  </View>
125
129
  );
@@ -159,6 +163,8 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
159
163
  file: string;
160
164
  navigation: PluginWorkspacePanelProps["navigation"];
161
165
  }) {
166
+ const { messages: m, locale } = useProgressLanguage();
167
+ const headline = clientHeadline(locale, dashboard);
162
168
  const now = useNow(15_000);
163
169
  // Quiet isn't stale while an agent in the workspace is still running.
164
170
  const stale = dashboard.stale && !agentRunning;
@@ -181,11 +187,11 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
181
187
  {dashboard.run?.subtitle ? (
182
188
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{dashboard.run.subtitle}</Text>
183
189
  ) : null}
184
- {dashboard.headline.length ? (
190
+ {headline.length ? (
185
191
  <Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
186
- {dashboard.headline.map((part, index) => (
192
+ {headline.map((part, index) => (
187
193
  <Text key={part.text} style={part.tone === "danger" ? { color: colors.statusDanger } : undefined}>
188
- {index ? ", " : ""}{part.text}
194
+ {index ? m.listSeparator : ""}{part.text}
189
195
  </Text>
190
196
  ))}
191
197
  </Text>
@@ -201,7 +207,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
201
207
  {dashboard.run?.finished ? (
202
208
  <>
203
209
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
204
- <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} />
205
211
  </Text>
206
212
  <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>{dashboard.run.finished.outcome}</Text>
207
213
  </>
@@ -214,7 +220,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
214
220
  {dashboard.updatedAt && !dashboard.run?.finished ? (
215
221
  <StatusLine icon={<Icon name="Clock" size={12} color={colors.foregroundMuted} />}>
216
222
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
217
- Last updated <When colors={colors} iso={dashboard.updatedAt} now={now} />
223
+ {m.lastUpdated}<When colors={colors} iso={dashboard.updatedAt} now={now} />
218
224
  </Text>
219
225
  </StatusLine>
220
226
  ) : null}
@@ -225,7 +231,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
225
231
  {/* Optical: centers the 14px icon on the first 18px line. */}
226
232
  <View style={{ paddingTop: 2 }}><Icon name="TriangleAlert" size={14} color={colors.statusWarning} /></View>
227
233
  <Text style={{ flex: 1, color: colors.statusWarning, fontSize: 12, lineHeight: 18 }}>
228
- 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))))}
229
235
  </Text>
230
236
  </View>
231
237
  ) : null}</Presence>
@@ -239,10 +245,10 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
239
245
  ) : null}</Presence>
240
246
  <Presence show={Boolean(dashboard.stuck.length)} order={3}>{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}</Presence>
241
247
  <Presence show order={4}>
242
- <Card colors={colors} icon="ListChecks" title={`${dashboard.run?.itemLabel ?? "Ticket"}s`}>
248
+ <Card colors={colors} icon="ListChecks" title={m.itemHeading(dashboard.run?.itemLabel ?? "Ticket")}>
243
249
  {dashboard.tickets.length === 0 ? (
244
250
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
245
- No {(dashboard.run?.itemLabel ?? "ticket").toLowerCase()}s yet.
251
+ {m.noItems(dashboard.run?.itemLabel ?? "Ticket")}
246
252
  </Text>
247
253
  ) : (
248
254
  <TicketList colors={colors} dashboard={dashboard} live={live} now={now} workspaceId={workspaceId} onOpen={setOpenTicket} />
@@ -255,11 +261,11 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
255
261
  <Presence show={hasHistory} order={5}>
256
262
  {hasHistory ? (
257
263
  <HistoryCard colors={colors} workspaceId={workspaceId} tabs={[
258
- { 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,
259
265
  content: <ActivityList colors={colors} activity={dashboard.activity} now={now} /> },
260
- { 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,
261
267
  content: <AnsweredQuestionsList colors={colors} questions={dashboard.questions.answered} now={now} context={attachmentContext} /> },
262
- { 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,
263
269
  content: <DeliverablesList colors={colors} deliverables={dashboard.deliverables} now={now} workspaceId={workspaceId}
264
270
  workspaceDirectory={workspaceDirectory} navigation={navigation} /> },
265
271
  ]} />
@@ -323,14 +329,14 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
323
329
 
324
330
  // Small and non-blocking: the rest of the dashboard still renders.
325
331
  function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIssue[]; file: string }) {
332
+ const { messages: m } = useProgressLanguage();
326
333
  const shown = issues.slice(0, 5);
327
334
  return (
328
335
  <View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
329
336
  <View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
330
337
  <Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
331
- Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {file}:{" "}
332
- {shown.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}
333
- {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)}
334
340
  </Text>
335
341
  </View>
336
342
  );
@@ -346,15 +352,16 @@ function Card({ colors, icon, title, children }: { colors: Colors; icon: string;
346
352
  }
347
353
 
348
354
  function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Dashboard["progress"]; live: boolean }) {
355
+ const { messages: m, locale } = useProgressLanguage();
349
356
  const narrow = useNarrow();
350
357
  return (
351
358
  <View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
352
359
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
353
360
  <Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
354
- <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}
355
362
  </Text>
356
363
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
357
- {formatWorkDone(progress.doneMin, progress.totalMin, progress.tookMin)}
364
+ {formatClientWorkDone(locale, progress.doneMin, progress.totalMin, progress.tookMin)}
358
365
  </Text>
359
366
  </View>
360
367
  <View accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: progress.percent }}
@@ -399,10 +406,11 @@ function Shimmer() {
399
406
  const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
400
407
 
401
408
  function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
409
+ const { messages: m, locale } = useProgressLanguage();
402
410
  const narrow = useNarrow();
403
411
  return (
404
412
  <View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
405
- <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 }} />
406
414
  {items.map((item, index) => (
407
415
  <View key={item.key} style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
408
416
  {/* Optical: centers the 14px icon on the 18px title line. */}
@@ -410,9 +418,9 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
410
418
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
411
419
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
412
420
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
413
- {item.kind === "overdue" ? `No update for longer than its ${unbroken(formatMinutes(item.estimateMin))} estimate. Last update ` : null}
414
- {item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}${narrow ? ", " : ". Since "}` : null}
415
- {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}
416
424
  <When colors={colors} iso={item.since} now={now} />
417
425
  </Text>
418
426
  </View>
@@ -452,15 +460,16 @@ function TicketList({ colors, dashboard, live, now, workspaceId, onOpen }: { col
452
460
  // Sits on the ticket rows' columns: status icon, label, estimate, then the
453
461
  // chevron, which points the way the list will move, like "Show 10 older".
454
462
  function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tickets: Ticket[]; shown: boolean; onPress(): void }) {
463
+ const { messages: m, locale } = useProgressLanguage();
455
464
  const done = tickets.filter((ticket) => ticket.status === "done").length;
456
465
  const skipped = tickets.length - done;
457
- 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);
458
467
  const estimateMin = tickets.reduce((sum, ticket) => sum + (ticket.status === "done" ? ticket.estimateMin : 0), 0);
459
468
  const chevron = shown ? "ChevronUp" : "ChevronDown";
460
469
  const narrow = useNarrow();
461
470
  return (
462
471
  <PressableRow colors={colors} accessibilityRole="button" accessibilityState={{ expanded: shown }}
463
- accessibilityLabel={`${shown ? "Hide" : "Show"} ${label}`} onPress={onPress}
472
+ accessibilityLabel={m.toggleFinished(shown, label)} onPress={onPress}
464
473
  style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
465
474
  <View style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
466
475
  <Icon name={done ? "Check" : "CircleSlash"} size={14} color={done ? colors.statusSuccess : colors.foregroundMuted} />
@@ -469,7 +478,7 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
469
478
  {/* Narrow ticket rows drop their chevron, so this total would sit off their estimate column. */}
470
479
  {narrow ? null : <>
471
480
  <View style={{ width: 12 }} />
472
- <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>
473
482
  </>}
474
483
  <View style={{ paddingTop: 2 }}>
475
484
  <IconSwap swapKey={chevron} size={14}><Icon name={chevron} size={14} color={colors.foregroundMuted} /></IconSwap>
@@ -480,16 +489,17 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
480
489
 
481
490
  // Press for the ticket's story. Hover and press only tint the row: it's a list, pressed often.
482
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();
483
493
  const muted = ticket.status === "skipped";
484
494
  const working = ticket.status === "working";
485
495
  // Narrow: tighter gaps and no chevron (the whole row still presses), so the title keeps its width.
486
496
  const narrow = useNarrow();
487
497
  return (
488
498
  <PressableRow colors={colors} onSurface1={working} transparentAtRest={!working}
489
- accessibilityRole="button" accessibilityLabel={`${ticket.id} details`} onPress={onOpen}
499
+ accessibilityRole="button" accessibilityLabel={m.details(ticket.id)} onPress={onOpen}
490
500
  style={{ flexDirection: "row", alignItems: "flex-start", gap: narrow ? 6 : 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
491
501
  {/* The icon is the only status mark on the row; the ticket dialog spells it out in a badge. */}
492
- <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 }}>
493
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>
494
504
  </View>
495
505
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
@@ -498,24 +508,24 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
498
508
  </Text>
499
509
  {ticket.status === "working" && ticket.stage && ticket.stageSince ? (
500
510
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
501
- {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} />
502
512
  </Text>
503
513
  ) : null}
504
514
  {ticket.status === "working" && ticket.workingSince ? (
505
515
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
506
- {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} />
507
517
  </Text>
508
518
  ) : null}
509
519
  {ticket.waitingFor ? (
510
- <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>
511
521
  ) : null}
512
522
  {ticket.note ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{ticket.note}</Text> : null}
513
523
  </View>
514
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. */}
515
- <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" }}>
516
526
  {deliverables ? <Icon name="Package" size={12} color={colors.foregroundMuted} /> : null}
517
527
  </View>
518
- <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>
519
529
  {narrow ? null : <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>}
520
530
  </PressableRow>
521
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) {
@@ -9,7 +9,8 @@ import { folderAttachments, isFolder, previewKind } from "../shared/attachments"
9
9
  import { canOpenOnHost, previewDeliverable, readDeliverablePiece } from "../shared/rpc";
10
10
  import { PressScale } from "./motion";
11
11
  import { imageOutline, raised } from "./surfaces";
12
- import { loadVideo, VideoPlayer } from "./video";
12
+ import { loadVideo, VideoChangedError, VideoPlayer } from "./video";
13
+ import { useProgressLanguage } from "./language";
13
14
 
14
15
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
15
16
 
@@ -39,6 +40,7 @@ type PreviewProps = {
39
40
  // `onLong` says when the file is too tall for the dialog, so the host turns off
40
41
  // its own scrolling and the file scrolls with the actions pinned below.
41
42
  export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong }: PreviewProps & { onLong(long: boolean): void }) {
43
+ const { messages: m } = useProgressLanguage();
42
44
  const fetchPreview = useRpc(previewDeliverable);
43
45
  const readPiece = useRpc(readDeliverablePiece);
44
46
  const checkHost = useRpc(canOpenOnHost);
@@ -118,19 +120,19 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
118
120
  useEffect(() => onLong(long), [long]);
119
121
  // How far a video has loaded: under the spinner, or in place of the path while
120
122
  // the last file stays on screen, so nothing moves.
121
- const videoLoading = preview.isFetching && loading !== undefined && loading < 1 ? `Loading video, ${Math.floor(loading * 100)}%` : null;
123
+ const videoLoading = preview.isFetching && loading !== undefined && loading < 1 ? m.loadingVideo(Math.floor(loading * 100)) : null;
122
124
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
123
125
  const content = (
124
126
  <>
125
127
  {preview.isPending ? (
126
128
  <View style={{ alignItems: "center", gap: 8, padding: 24 }}>
127
- <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading preview" />
129
+ <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingPreview} />
128
130
  {videoLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text> : null}
129
131
  </View>
130
132
  ) : null}
131
133
  {preview.error ? (
132
134
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
133
- Could not preview it: {errorText(preview.error, "something went wrong")}
135
+ {m.previewError(preview.error instanceof VideoChangedError ? m.videoChanged : errorText(preview.error, m.unknownError))}
134
136
  </Text>
135
137
  ) : null}
136
138
  {preview.data?.kind === "text" ? (
@@ -140,7 +142,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
140
142
  </Text>
141
143
  {preview.data.truncated ? (
142
144
  <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop: 8 }}>
143
- Showing the first 256 KB.{canOpen ? " Press Open to see the rest in its default app." : ""}
145
+ {m.truncatedText}{canOpen ? m.openRest : ""}
144
146
  </Text>
145
147
  ) : null}
146
148
  </View>
@@ -166,7 +168,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
166
168
  {attachment.path}
167
169
  </Text>
168
170
  {/* Optical: the 22px button centers on the 17px caption line without growing it. */}
169
- <PressScale accessibilityRole="button" accessibilityLabel={`Copy the path of ${attachment.title}`} hitSlop={6} outerStyle={{ marginVertical: -3 }}
171
+ <PressScale accessibilityRole="button" accessibilityLabel={m.copyItemPath(attachment.title)} hitSlop={6} outerStyle={{ marginVertical: -3 }}
170
172
  onPress={onCopyPath}
171
173
  style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
172
174
  <Icon name="Copy" size={14} color={colors.foregroundMuted} />
@@ -181,7 +183,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
181
183
  </View>
182
184
  {canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
183
185
  <View style={{ flex: 1, alignItems: "flex-end" }}>
184
- {canOpen ? <DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} /> : null}
186
+ {canOpen ? <DialogButton colors={colors} icon="ExternalLink" label={m.open} accessibilityLabel={m.openDefault} onPress={onOpenOnHost} /> : null}
185
187
  </View>
186
188
  </View>
187
189
  );
@@ -219,12 +221,13 @@ function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; tex
219
221
  }
220
222
 
221
223
  function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { colors: Colors; folder: Attachment; entries: Attachment[]; truncated: boolean; dimmed: boolean; onOpen(attachment: Attachment, group: Attachment[]): void }) {
224
+ const { messages: m } = useProgressLanguage();
222
225
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
223
- if (!entries.length) return <Text style={caption}>Nothing in {folder.title} yet.</Text>;
226
+ if (!entries.length) return <Text style={caption}>{m.emptyFolder(folder.title)}</Text>;
224
227
  return (
225
228
  <View style={{ gap: 8, opacity: dimmed ? 0.6 : 1 }}>
226
229
  <AttachmentList colors={colors} attachments={entries} onOpen={onOpen} />
227
- {truncated ? <Text style={caption}>Showing the first {entries.length}.</Text> : null}
230
+ {truncated ? <Text style={caption}>{m.firstEntries(entries.length)}</Text> : null}
228
231
  </View>
229
232
  );
230
233
  }
@@ -261,16 +264,18 @@ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress,
261
264
 
262
265
  // Returns to the dialog this one was opened from. For surface1 dialogs.
263
266
  export function BackButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
264
- return <DialogButton colors={colors} icon="ChevronLeft" label="Back" onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
267
+ const { messages: m } = useProgressLanguage();
268
+ return <DialogButton colors={colors} icon="ChevronLeft" label={m.back} onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
265
269
  }
266
270
 
267
271
  // Previous and next through a gallery, with "2 of 3" between.
268
272
  function Stepper({ colors, index, count, onStep, nextRef }: { colors: Colors; index: number; count: number; onStep(offset: number): void; nextRef: React.Ref<View> }) {
273
+ const { messages: m } = useProgressLanguage();
269
274
  return (
270
275
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
271
- <DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel="Previous file" onPress={() => onStep(-1)} />
272
- <Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {count}</Text>
273
- <DialogButton ref={nextRef} colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() => onStep(1)} />
276
+ <DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel={m.previousFile} onPress={() => onStep(-1)} />
277
+ <Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{m.galleryPosition(index + 1, count)}</Text>
278
+ <DialogButton ref={nextRef} colors={colors} icon="ChevronRight" accessibilityLabel={m.nextFile} onPress={() => onStep(1)} />
274
279
  </View>
275
280
  );
276
281
  }