@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.
@@ -10,6 +10,7 @@ import { PressScale } from "./motion";
10
10
  import { StackedDialog } from "./dialog-stack";
11
11
  import { raised } from "./surfaces";
12
12
  import { When } from "./when";
13
+ import { useProgressLanguage } from "./language";
13
14
 
14
15
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
15
16
 
@@ -23,13 +24,14 @@ export function replyWithChoice(question: Pick<Question, "id" | "title">, letter
23
24
  }
24
25
 
25
26
  export function useCopy() {
27
+ const { messages: m } = useProgressLanguage();
26
28
  const toast = useToast();
27
29
  return async function copy(question: Question, letter?: string) {
28
30
  try {
29
31
  await copyText(letter ? replyWithChoice(question, letter) : replyPrefix(question));
30
- toast.show(letter ? `Copied "${question.id} ${letter}". Paste it to your agent.` : `Copied ${question.id} for your reply`, { variant: "success" });
32
+ toast.show(letter ? m.copiedChoice(question.id, letter) : m.copiedQuestion(question.id), { variant: "success" });
31
33
  } catch {
32
- toast.error("Could not copy. Select the text and use Copy.");
34
+ toast.error(m.copyFailure);
33
35
  }
34
36
  };
35
37
  }
@@ -50,15 +52,16 @@ export function QuestionsSection({ colors, questions, answered, now, context }:
50
52
  now: number;
51
53
  context: AttachmentContext;
52
54
  }) {
55
+ const { messages: m } = useProgressLanguage();
53
56
  const [openId, setOpenId] = useState<string | null>(null);
54
57
  const copy = useCopy();
55
58
  return (
56
59
  <View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.accent, borderRadius: 6, overflow: "hidden" }}>
57
60
  <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
58
61
  <Icon name="MessageCircleQuestion" size={14} color={colors.accent} />
59
- <Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>Questions</Text>
62
+ <Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{m.questions}</Text>
60
63
  <View style={{ borderWidth: 1, borderColor: colors.accent, backgroundColor: colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
61
- <Text style={{ color: colors.accentForeground, fontSize: 11, lineHeight: 16 }}>{questions.length} waiting</Text>
64
+ <Text style={{ color: colors.accentForeground, fontSize: 11, lineHeight: 16 }}>{m.waitingCount(questions.length)}</Text>
62
65
  </View>
63
66
  </View>
64
67
  {questions.map((question, index) => (
@@ -79,6 +82,7 @@ export function AnsweredQuestionsList({ colors, questions, now, context }: {
79
82
  now: number;
80
83
  context: AttachmentContext;
81
84
  }) {
85
+ const { messages: m } = useProgressLanguage();
82
86
  const [openId, setOpenId] = useState<string | null>(null);
83
87
  const paged = usePaged(questions);
84
88
  return (
@@ -88,7 +92,7 @@ export function AnsweredQuestionsList({ colors, questions, now, context }: {
88
92
  const chosen = question.options.find((option) => option.letter === answer.choice);
89
93
  return (
90
94
  <View key={question.id} style={{ borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
91
- <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} details`} onPress={() => setOpenId(question.id)}
95
+ <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={m.details(question.id)} onPress={() => setOpenId(question.id)}
92
96
  style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 10, paddingVertical: 8 }}>
93
97
  {/* Room for two-digit ids so titles line up; longer ones widen instead of wrapping. */}
94
98
  <Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30, flexShrink: 0 }}>{question.id}</Text>
@@ -110,6 +114,7 @@ export function AnsweredQuestionsList({ colors, questions, now, context }: {
110
114
  }
111
115
 
112
116
  function AnswerSummary({ colors, question, now }: { colors: Colors; question: Question; now: number }) {
117
+ const { messages: m } = useProgressLanguage();
113
118
  const answer = question.answer!;
114
119
  const chosen = question.options.find((option) => option.letter === answer.choice);
115
120
  return (
@@ -119,11 +124,11 @@ function AnswerSummary({ colors, question, now }: { colors: Colors; question: Qu
119
124
  {chosen ? ` ${chosen.label}.` : ""}
120
125
  {answer.words ? <Text style={{ color: colors.foregroundMuted }}> “{answer.words}”</Text> : null}
121
126
  <Text style={{ color: colors.foregroundMuted }}>
122
- {answer.changedCourse ? " The agent had already built another option, so work changed course." : ""}
127
+ {answer.changedCourse ? m.changedCourse : ""}
123
128
  </Text>
124
129
  </Text>
125
130
  <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
126
- Answer recorded <When colors={colors} iso={answer.at} now={now} />{question.raisedBy ? `, raised by ${question.raisedBy}` : ""}
131
+ {m.answerRecorded}<When colors={colors} iso={answer.at} now={now} />{question.raisedBy ? m.raisedBy(question.raisedBy) : ""}
127
132
  </Text>
128
133
  </View>
129
134
  );
@@ -136,9 +141,10 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
136
141
  onOpen(): void;
137
142
  onCopy(): void;
138
143
  }) {
144
+ const { messages: m } = useProgressLanguage();
139
145
  return (
140
146
  <View style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 10, borderTopWidth: first ? 0 : 1, borderTopColor: colors.border }}>
141
- <PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} for your reply`} onPress={onCopy} hitSlop={6}
147
+ <PressScale accessibilityRole="button" accessibilityLabel={m.copyQuestion(question.id)} onPress={onCopy} hitSlop={6}
142
148
  outerStyle={{ alignSelf: "flex-start", marginTop: 1 }}
143
149
  style={({ pressed }) => ({
144
150
  // One width for every id, so the questions beside them start on one edge.
@@ -151,7 +157,7 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
151
157
  })}>
152
158
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 17, fontWeight: "600" }}>{question.id}</Text>
153
159
  </PressScale>
154
- <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} choices`} onPress={onOpen}
160
+ <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={m.questionChoices(question.id)} onPress={onOpen}
155
161
  style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "flex-start", gap: 8, borderRadius: 4, margin: -4, padding: 4 }}>
156
162
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
157
163
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
@@ -189,16 +195,17 @@ export function QuestionView({ colors, question, now, onCopy, onOpenAttachment,
189
195
  // Under an open question: whether the agent stopped, waits, or builds its pick,
190
196
  // and its pick when there are no lettered options to tag.
191
197
  export function QuestionState({ colors, question }: { colors: Colors; question: Question }) {
198
+ const { messages: m } = useProgressLanguage();
192
199
  const value = { color: colors.foreground };
193
200
  // The letter alone means nothing in the rows that don't list the options.
194
201
  const pick = question.options.find((option) => option.letter === question.recommended);
195
202
  return (
196
203
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
197
- {question.state === "building" ? <>Building with <Text style={value}>{question.recommended}{pick ? `: ${pick.label}` : ""}</Text></> : (
204
+ {question.state === "building" ? <>{m.buildingWith}<Text style={value}>{question.recommended}{pick ? `${m.colon}${pick.label}` : ""}</Text></> : (
198
205
  <>
199
- {!question.options.length && question.recommended ? <>Recommended: <Text style={value}>{question.recommended}</Text>. </> : null}
200
- {question.state === "stopped" ? "Agent stopped until you answer" : "Waiting for you"}
201
- {question.big ? <>. <Text style={value}>Big</Text>: {question.big}</> : null}
206
+ {!question.options.length && question.recommended ? <>{m.recommended}{m.colon}<Text style={value}>{question.recommended}</Text>{m.sentenceBreak}</> : null}
207
+ {question.state === "stopped" ? m.agentStopped : m.waitingForYou}
208
+ {question.big ? <>{m.sentenceBreak}<Text style={value}>{m.big}</Text>{m.colon}{question.big}</> : null}
202
209
  </>
203
210
  )}
204
211
  </Text>
@@ -215,6 +222,7 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
215
222
  onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
216
223
  onSurface1?: boolean;
217
224
  }) {
225
+ const { messages: m } = useProgressLanguage();
218
226
  const button = onSurface1 ? { rest: colors.surface2, pressed: colors.surface1 } : { rest: colors.surface1, pressed: colors.surface2 };
219
227
  return (
220
228
  <View style={{ gap: 8, paddingTop: 2 }}>
@@ -230,17 +238,17 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
230
238
  <View style={{ flex: 1, gap: 2 }}>
231
239
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
232
240
  {option.label}
233
- {chosen ? <Text style={{ color: colors.statusSuccess }}> Your answer</Text> : null}
234
- {recommended ? <Text style={{ color: settled ? colors.foregroundMuted : colors.accent }}> Recommended</Text> : null}
241
+ {chosen ? <Text style={{ color: colors.statusSuccess }}>{" " + m.yourAnswer}</Text> : null}
242
+ {recommended ? <Text style={{ color: settled ? colors.foregroundMuted : colors.accent }}>{" " + m.recommended}</Text> : null}
235
243
  </Text>
236
244
  {option.consequence ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{option.consequence}</Text> : null}
237
245
  </View>
238
246
  {!question.answer ? (
239
- <PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} ${option.letter} for your agent`}
247
+ <PressScale accessibilityRole="button" accessibilityLabel={m.copyChoiceForAgent(question.id, option.letter)}
240
248
  onPress={() => onCopy(option.letter)} hitSlop={4}
241
249
  style={({ pressed }) => ({ flexDirection: "row", alignItems: "center", gap: 4, paddingLeft: 6, paddingRight: 8, paddingVertical: 3, borderRadius: 4, ...raised(colors), backgroundColor: pressed ? button.pressed : button.rest })}>
242
250
  <Icon name="Copy" size={12} color={colors.foreground} />
243
- <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16 }}>Copy {question.id} {option.letter}</Text>
251
+ <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16 }}>{m.copyChoice(question.id, option.letter)}</Text>
244
252
  </PressScale>
245
253
  ) : null}
246
254
  </View>
@@ -251,7 +259,7 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
251
259
  ) : null}
252
260
  <AttachmentList colors={colors} attachments={questionAttachments(question)} onOpen={onOpenAttachment} />
253
261
  <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
254
- Asked <When colors={colors} iso={question.askedAt} now={now} />{question.raisedBy ? `, raised by ${question.raisedBy}` : ""}
262
+ {m.asked}<When colors={colors} iso={question.askedAt} now={now} />{question.raisedBy ? m.raisedBy(question.raisedBy) : ""}
255
263
  </Text>
256
264
  </View>
257
265
  );
@@ -2,39 +2,57 @@ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
2
  import { SettingsAction, SettingsCard, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
3
3
  import React from "react";
4
4
  import { Text } from "react-native";
5
- import { DEFAULT_QUESTION_MODE, QUESTION_MODE_RULES, QUESTION_MODES } from "../shared/question-modes";
6
- import { questionSettings } from "../shared/settings";
7
-
8
- const OPTIONS = QUESTION_MODES.map((value) => ({ label: QUESTION_MODE_RULES[value].label, value }));
5
+ import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "../shared/question-modes";
6
+ import { questionSettings, LANGUAGE_PREFERENCES } from "../shared/settings";
7
+ import { useProgressLanguage } from "./language";
9
8
 
10
9
  export function QuestionSettings({ theme }: PluginSurfaceProps) {
10
+ return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /></>;
11
+ }
12
+
13
+ type Colors = PluginSurfaceProps["theme"]["colors"];
14
+
15
+ function DisplaySettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
16
+ const { messages: m, settings } = useProgressLanguage();
17
+ const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
18
+ const options = LANGUAGE_PREFERENCES.map((value) => ({ value, label: value === "system" ? m.systemLanguage : value === "en" ? "English" : "简体中文" }));
19
+ return (
20
+ <SettingsSection title={m.display}>
21
+ {settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
22
+ : settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} />
23
+ : <SettingsCard><SettingsSelect label={m.language} hint={m.languageHint} value={settings.values.language} options={options}
24
+ disabled={settings.saving} error={settings.saveError}
25
+ onValueChange={(language) => void settings.save({ ...settings.values, language }, settings.revision)} /></SettingsCard>}
26
+ </SettingsSection>
27
+ );
28
+ }
29
+
30
+ function QuestionModeSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
31
+ const { messages: m } = useProgressLanguage();
11
32
  const settings = useSettings(questionSettings);
12
33
  const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
13
- if (settings.status === "loading") return <Text style={muted}>Loading settings…</Text>;
14
- if (settings.status !== "ready") {
15
- return (
16
- <SettingsSection title="Questions">
17
- <Text style={muted}>{settings.error}</Text>
18
- <SettingsCard>
19
- <SettingsAction label="Try again" actionLabel="Reload" onPress={settings.reload} />
20
- {settings.status === "invalid" ? <SettingsAction label={`Go back to ${QUESTION_MODE_RULES[DEFAULT_QUESTION_MODE].label}`} actionLabel="Reset" onPress={settings.reset} /> : null}
21
- </SettingsCard>
22
- </SettingsSection>
23
- );
24
- }
25
34
  return (
26
- <SettingsSection title="Questions">
27
- <SettingsCard>
28
- <SettingsSelect
29
- label="When an agent asks you a question"
30
- hint={QUESTION_MODE_RULES[settings.values.mode].hint}
31
- error={settings.saveError}
32
- value={settings.values.mode}
33
- options={OPTIONS}
34
- disabled={settings.saving}
35
- onValueChange={(mode) => void settings.save({ ...settings.values, mode }, settings.revision)}
36
- />
37
- </SettingsCard>
35
+ <SettingsSection title={m.questions}>
36
+ {settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
37
+ : settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} resetLabel={m.resetTo(m.questionModes[DEFAULT_QUESTION_MODE].label)} />
38
+ : <SettingsCard><SettingsSelect label={m.questionMode} hint={m.questionModes[settings.values.mode].hint}
39
+ error={settings.saveError} value={settings.values.mode} options={QUESTION_MODES.map((value) => ({ value, label: m.questionModes[value].label }))}
40
+ disabled={settings.saving} onValueChange={(mode) => void settings.save({ ...settings.values, mode }, settings.revision)} /></SettingsCard>}
38
41
  </SettingsSection>
39
42
  );
40
43
  }
44
+
45
+ function SettingsRecovery({ colors, settings, resetLabel }: {
46
+ colors: Colors;
47
+ settings: { status: "error" | "invalid"; error: string; reload(): Promise<void>; reset(): Promise<boolean> };
48
+ resetLabel?: string;
49
+ }) {
50
+ const { messages: m } = useProgressLanguage();
51
+ return <>
52
+ <Text style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{settings.error}</Text>
53
+ <SettingsCard>
54
+ <SettingsAction label={m.tryAgain} actionLabel={m.reload} onPress={settings.reload} />
55
+ {settings.status === "invalid" ? <SettingsAction label={resetLabel ?? m.resetTo(m.systemLanguage)} actionLabel={m.reset} onPress={settings.reset} /> : null}
56
+ </SettingsCard>
57
+ </>;
58
+ }
@@ -4,6 +4,7 @@ import React, { useState } from "react";
4
4
  import { Text } from "react-native";
5
5
  import { IconSwap } from "./motion";
6
6
  import { PressableRow } from "./row";
7
+ import { useProgressLanguage } from "./language";
7
8
 
8
9
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
9
10
 
@@ -23,8 +24,9 @@ export function usePaged<T>(items: T[]): { shown: T[]; older: number; showOlder(
23
24
 
24
25
  // The row under a paged list: "Show 10 older", or "Show fewer" once all are shown.
25
26
  export function ShowMoreRow({ colors, total, paged }: { colors: Colors; total: number; paged: ReturnType<typeof usePaged> }) {
26
- const more = paged.older > 0 ? { label: `Show ${Math.min(paged.older, PAGE)} older`, icon: "ChevronDown", onPress: paged.showOlder }
27
- : total > PAGE ? { label: "Show fewer", icon: "ChevronUp", onPress: paged.showFewer }
27
+ const { messages: m } = useProgressLanguage();
28
+ const more = paged.older > 0 ? { label: m.showOlder(Math.min(paged.older, PAGE)), icon: "ChevronDown", onPress: paged.showOlder }
29
+ : total > PAGE ? { label: m.showFewer, icon: "ChevronUp", onPress: paged.showFewer }
28
30
  : null;
29
31
  if (!more) return null;
30
32
  return (
@@ -1,13 +1,15 @@
1
1
  import { Icon } from "@getpaseo/plugin/client/react-native";
2
2
  import React, { useEffect, useRef } from "react";
3
3
  import { ActivityIndicator, Animated, Easing, Platform, View } from "react-native";
4
+ import { useProgressLanguage } from "./language";
4
5
  import { nativeDriver } from "./motion";
5
6
 
6
7
  // The system spinner, sized to sit beside a line of text. iOS draws it only
7
8
  // at 20pt, so it's scaled into the slot there.
8
9
  export function Spinner({ color, size = 12 }: { color: string; size?: number }) {
10
+ const { messages: m } = useProgressLanguage();
9
11
  return (
10
- <View accessibilityLabel="Working" style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
12
+ <View accessibilityLabel={m.status.working} style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
11
13
  {Platform.OS === "ios"
12
14
  ? <ActivityIndicator size="small" color={color} style={{ transform: [{ scale: size / 20 }] }} />
13
15
  : <ActivityIndicator size={size} color={color} />}
@@ -18,6 +20,7 @@ export function Spinner({ color, size = 12 }: { color: string; size?: number })
18
20
  // Shown in place of the spinner once the dashboard is stale: the work may have
19
21
  // stopped, so the icon asks for attention instead of looking busy.
20
22
  export function StalledPulse({ color, size = 12 }: { color: string; size?: number }) {
23
+ const { messages: m } = useProgressLanguage();
21
24
  const opacity = useRef(new Animated.Value(1)).current;
22
25
  useEffect(() => {
23
26
  const loop = Animated.loop(Animated.sequence([
@@ -28,7 +31,7 @@ export function StalledPulse({ color, size = 12 }: { color: string; size?: numbe
28
31
  return () => loop.stop();
29
32
  }, [opacity]);
30
33
  return (
31
- <Animated.View accessibilityLabel="Possibly stalled" style={{ width: size, height: size, opacity }}>
34
+ <Animated.View accessibilityLabel={m.possiblyStalled} style={{ width: size, height: size, opacity }}>
32
35
  <Icon name="TriangleAlert" size={size} color={color} />
33
36
  </Animated.View>
34
37
  );
@@ -37,9 +40,10 @@ export function StalledPulse({ color, size = 12 }: { color: string; size?: numbe
37
40
  // A ticket not started yet, or waiting on another one: a still, faded dot, so it
38
41
  // reads as parked rather than busy. Centered in the same slot an icon would take.
39
42
  export function WaitingDot({ color, size = 12 }: { color: string; size?: number }) {
43
+ const { messages: m } = useProgressLanguage();
40
44
  const dot = Math.round(size / 2);
41
45
  return (
42
- <View accessibilityLabel="Waiting" style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
46
+ <View accessibilityLabel={m.waiting} style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
43
47
  <View style={{ width: dot, height: dot, borderRadius: dot / 2, backgroundColor: color, opacity: 0.5 }} />
44
48
  </View>
45
49
  );
@@ -2,19 +2,13 @@ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
2
  import React from "react";
3
3
  import { Text, View } from "react-native";
4
4
  import type { TicketStatus } from "../shared/events";
5
+ import { useProgressLanguage } from "./language";
5
6
 
6
7
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
7
8
 
8
- export const STATUS_LABEL: Record<TicketStatus, string> = {
9
- not_started: "Not started",
10
- working: "Working",
11
- blocked: "Blocked",
12
- done: "Done",
13
- skipped: "Skipped",
14
- };
15
-
16
9
  // `waiting`: held for another ticket. Shown as calm as "Not started", whatever the status.
17
10
  export function StatusBadge({ colors, status, waiting = false }: { colors: Colors; status: TicketStatus; waiting?: boolean }) {
11
+ const { messages: m } = useProgressLanguage();
18
12
  const tone = waiting ? { fg: colors.foregroundMuted, bg: "transparent", border: colors.border } : {
19
13
  done: { fg: colors.statusSuccess, bg: "transparent", border: colors.statusSuccess },
20
14
  working: { fg: colors.accentForeground, bg: colors.accent, border: colors.accent },
@@ -24,7 +18,7 @@ export function StatusBadge({ colors, status, waiting = false }: { colors: Color
24
18
  }[status];
25
19
  return (
26
20
  <View style={{ borderWidth: 1, borderColor: tone.border, backgroundColor: tone.bg, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
27
- <Text style={{ color: tone.fg, fontSize: 11, lineHeight: 16 }}>{waiting ? "Waiting" : STATUS_LABEL[status]}</Text>
21
+ <Text style={{ color: tone.fg, fontSize: 11, lineHeight: 16 }}>{waiting ? m.waiting : m.status[status]}</Text>
28
22
  </View>
29
23
  );
30
24
  }
@@ -3,7 +3,7 @@ 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";
@@ -15,6 +15,7 @@ import { StatusBadge } from "./status-badge";
15
15
  import { type TabEntry, TabStrip } from "./tabs";
16
16
  import { TimeBar } from "./time-bar";
17
17
  import { WriteUp } from "./write-up";
18
+ import { useProgressLanguage } from "./language";
18
19
 
19
20
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
20
21
  type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
@@ -48,6 +49,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
48
49
  // Undefined where Paseo can't navigate to an agent.
49
50
  onOpenAgent?(agentId: string): void;
50
51
  }) {
52
+ const { messages: m, locale } = useProgressLanguage();
51
53
  const story = ticketStory(ticket, {
52
54
  deliverables: dashboard.deliverables,
53
55
  questions: [...dashboard.questions.open, ...dashboard.questions.answered].sort((a, b) => Date.parse(a.askedAt) - Date.parse(b.askedAt)),
@@ -62,43 +64,43 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
62
64
  ? dashboard.ticker.by === ticket.agentId
63
65
  : dashboard.tickets.filter((candidate) => candidate.status === "working").length === 1) ? dashboard.ticker.text : null;
64
66
  // What the badge doesn't say: the stage it's in, or what it waits for.
65
- 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;
66
68
  const timeline = story.timeline.map((step, index) => {
67
69
  const last = index === story.timeline.length - 1;
68
70
  // A current "Blocked" that's only waiting on another ticket reads as waiting, in the calm color.
69
71
  const shown = last && ticket.waitingFor && step.status === "blocked"
70
- ? { ...step, label: `Waiting for ${ticket.waitingFor.id}`, waiting: true }
72
+ ? { ...step, label: { kind: "waiting" as const, id: ticket.waitingFor.id }, waiting: true }
71
73
  : step;
72
74
  return <TimelineRow key={`${step.at}-${index}`} colors={colors} step={shown} last={last} now={now} live={live} />;
73
75
  });
74
76
  // Only tabs with something in them; the write-up leads when there is one.
75
77
  const tabs: Array<TabEntry<StoryTab> & { content: React.ReactNode }> = [
76
- ...(source ? [{ id: "write-up" as const, label: "Write-up", icon: "FileText",
78
+ ...(source ? [{ id: "write-up" as const, label: m.writeUp, icon: "FileText",
77
79
  content: <WriteUp colors={colors} source={source} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory} onOpen={onOpenAttachment} /> }] : []),
78
- { id: "timeline", label: "Timeline", icon: "History", count: story.timeline.length, content: <View>{timeline}</View> },
79
- ...(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: (
80
82
  <View>
81
83
  {story.activity.map((entry) => (
82
84
  <View key={entry.id} style={{ flexDirection: "row", gap: 12, paddingVertical: 4 }}>
83
85
  <Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{entry.text}<LinkNote colors={colors} link={entry.link} /></Text>
84
- <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>
85
87
  </View>
86
88
  ))}
87
89
  </View>
88
90
  ) }] : []),
89
- ...(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: (
90
92
  <View>
91
93
  {story.questions.map((question) => {
92
94
  const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
93
95
  return (
94
- <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)}
95
97
  onPress={() => onOpenQuestion(question.id)}
96
98
  style={{ flexDirection: "row", gap: 8, paddingVertical: 4, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
97
99
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}>{question.id}</Text>
98
100
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
99
101
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{question.title}<LinkNote colors={colors} link={question.link} /></Text>
100
102
  {question.answer
101
- ? <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>
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>
102
104
  : <QuestionState colors={colors} question={question} />}
103
105
  </View>
104
106
  <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
@@ -107,7 +109,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
107
109
  })}
108
110
  </View>
109
111
  ) }] : []),
110
- ...(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,
111
113
  content: <AttachmentList colors={colors} attachments={story.deliverables.map(deliverableAttachment)} onOpen={onOpenAttachment} /> }] : []),
112
114
  ];
113
115
  const tabKey = `${context.workspaceId}:${ticket.id}`;
@@ -124,9 +126,9 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
124
126
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
125
127
  <StatusBadge colors={colors} status={ticket.status} waiting={Boolean(ticket.waitingFor)} />
126
128
  <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{detail}</Text>
127
- <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))}
128
130
  style={{ color: over ? colors.statusDanger : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
129
- {formatWorkDone(story.workedMin, ticket.estimateMin)}
131
+ {formatClientWorkDone(locale, story.workedMin, ticket.estimateMin)}
130
132
  </Text>
131
133
  </View>
132
134
  <TimeBar colors={colors} workedMin={story.workedMin} estimateMin={ticket.estimateMin} />
@@ -142,7 +144,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
142
144
 
143
145
  {byTime ? (
144
146
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
145
- 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")}
146
148
  </Text>
147
149
  ) : null}
148
150
  </View>
@@ -157,9 +159,9 @@ const storyTabs = new Map<string, StoryTab>();
157
159
  // Paseo titles a chat from the first line of its first message, cut at 60
158
160
  // characters without an ellipsis (create-agent-title.ts); add the one it left off.
159
161
  const PASEO_TITLE_CUT = 60;
160
- function agentTitle(raw: string | null): string {
162
+ function agentTitle(raw: string | null, fallback: string): string {
161
163
  const title = raw?.trim();
162
- if (!title) return "Untitled agent";
164
+ if (!title) return fallback;
163
165
  return title.length === PASEO_TITLE_CUT ? `${title.trimEnd()}…` : title;
164
166
  }
165
167
 
@@ -167,36 +169,39 @@ function agentTitle(raw: string | null): string {
167
169
  // opens that chat. Shown only while Paseo knows the agent; an archived one
168
170
  // leaves nothing to open. Its state shows only when it needs the user.
169
171
  function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string; onOpen?(agentId: string): void }) {
172
+ const { messages: m } = useProgressLanguage();
170
173
  const agent = useAgent(agentId, (snapshot) => ({
171
174
  title: snapshot.title,
172
- alert: snapshot.requiresAttention && snapshot.attentionReason === "permission" ? { text: "Needs your permission", danger: false }
173
- : snapshot.status === "error" ? { text: "Stopped with an error", danger: true } : null,
175
+ needsPermission: snapshot.requiresAttention && snapshot.attentionReason === "permission",
176
+ hasError: snapshot.status === "error",
174
177
  }));
175
178
  if (!agent) return null;
176
- 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;
177
181
  const open = onOpen ? () => onOpen(agentId) : undefined;
178
182
  // A quiet line in the summary: who's on it, and the way to their chat.
179
183
  return (
180
- <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}
181
185
  style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4 }}>
182
186
  <Icon name="Bot" size={12} color={colors.foregroundMuted} />
183
187
  <Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{title}</Text>
184
- {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}
185
189
  <View style={{ flex: 1 }} />
186
- {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}
187
191
  </PressableRow>
188
192
  );
189
193
  }
190
194
 
191
195
  function LinkNote({ colors, link }: { colors: Colors; link: Link }) {
192
- 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;
193
198
  }
194
199
 
195
200
  // The same icons as the ticket rows, so a step reads like the status it was.
196
201
  // Past stage steps get an accent ringed dot: work that happened, now over.
197
202
  function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiting?: boolean } }) {
198
- if (step.waiting || (step.status === "not_started" && step.label !== "Added")) return <WaitingDot color={colors.foregroundMuted} />;
199
- 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 }
200
205
  : step.status === "done" ? { name: "CircleCheck", color: colors.statusSuccess }
201
206
  : step.status === "blocked" ? { name: "Ban", color: colors.statusDanger }
202
207
  : step.status === "skipped" ? { name: "CircleSlash", color: colors.foregroundMuted }
@@ -207,6 +212,7 @@ function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiti
207
212
  // One line per step: its status icon, what it was, when it began and how long
208
213
  // it took. The step still going shows the live spinner.
209
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();
210
216
  const current = last && step.minutes !== null;
211
217
  const working = current && step.status === "working";
212
218
  return (
@@ -218,11 +224,11 @@ function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step:
218
224
  {working ? (live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />) : <Marker colors={colors} step={step} />}
219
225
  </IconSwap>
220
226
  </View>
221
- <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{step.label}</Text>
222
- <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>
223
229
  {/* A fixed column, so durations line up down the list. */}
224
- <Text style={{ width: 84, textAlign: "right", color: current ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
225
- {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) : ""}
226
232
  </Text>
227
233
  </View>
228
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);