@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
@@ -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
  }
@@ -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,9 +157,12 @@ 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
- <Text style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
162
+ <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
163
+ <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
164
+ <QuestionState colors={colors} question={question} />
165
+ </View>
157
166
  {/* Optical: centers the 14px chevron on the 19px first line. */}
158
167
  <View style={{ paddingTop: 2.5 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
159
168
  </PressableRow>
@@ -183,10 +192,24 @@ export function QuestionView({ colors, question, now, onCopy, onOpenAttachment,
183
192
  );
184
193
  }
185
194
 
186
- // The agent's pick, under an open question that has no lettered options to tag.
195
+ // Under an open question: whether the agent stopped, waits, or builds its pick,
196
+ // and its pick when there are no lettered options to tag.
187
197
  export function QuestionState({ colors, question }: { colors: Colors; question: Question }) {
188
- if (question.options.length || !question.recommended) return null;
189
- return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Recommended: <Text style={{ color: colors.foreground }}>{question.recommended}</Text></Text>;
198
+ const { messages: m } = useProgressLanguage();
199
+ const value = { color: colors.foreground };
200
+ // The letter alone means nothing in the rows that don't list the options.
201
+ const pick = question.options.find((option) => option.letter === question.recommended);
202
+ return (
203
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
204
+ {question.state === "building" ? <>{m.buildingWith}<Text style={value}>{question.recommended}{pick ? `${m.colon}${pick.label}` : ""}</Text></> : (
205
+ <>
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}
209
+ </>
210
+ )}
211
+ </Text>
212
+ );
190
213
  }
191
214
 
192
215
  // `onSurface1` when drawn on a surface1 background (Paseo's dialogs), so the
@@ -199,6 +222,7 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
199
222
  onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
200
223
  onSurface1?: boolean;
201
224
  }) {
225
+ const { messages: m } = useProgressLanguage();
202
226
  const button = onSurface1 ? { rest: colors.surface2, pressed: colors.surface1 } : { rest: colors.surface1, pressed: colors.surface2 };
203
227
  return (
204
228
  <View style={{ gap: 8, paddingTop: 2 }}>
@@ -214,17 +238,17 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
214
238
  <View style={{ flex: 1, gap: 2 }}>
215
239
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
216
240
  {option.label}
217
- {chosen ? <Text style={{ color: colors.statusSuccess }}> Your answer</Text> : null}
218
- {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}
219
243
  </Text>
220
244
  {option.consequence ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{option.consequence}</Text> : null}
221
245
  </View>
222
246
  {!question.answer ? (
223
- <PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} ${option.letter} for your agent`}
247
+ <PressScale accessibilityRole="button" accessibilityLabel={m.copyChoiceForAgent(question.id, option.letter)}
224
248
  onPress={() => onCopy(option.letter)} hitSlop={4}
225
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 })}>
226
250
  <Icon name="Copy" size={12} color={colors.foreground} />
227
- <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>
228
252
  </PressScale>
229
253
  ) : null}
230
254
  </View>
@@ -235,7 +259,7 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
235
259
  ) : null}
236
260
  <AttachmentList colors={colors} attachments={questionAttachments(question)} onOpen={onOpenAttachment} />
237
261
  <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
238
- 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) : ""}
239
263
  </Text>
240
264
  </View>
241
265
  );
@@ -0,0 +1,58 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import { SettingsAction, SettingsCard, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
3
+ import React from "react";
4
+ import { Text } from "react-native";
5
+ import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "../shared/question-modes";
6
+ import { questionSettings, LANGUAGE_PREFERENCES } from "../shared/settings";
7
+ import { useProgressLanguage } from "./language";
8
+
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();
32
+ const settings = useSettings(questionSettings);
33
+ const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
34
+ return (
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>}
41
+ </SettingsSection>
42
+ );
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,17 +3,20 @@ import type { ViewStyle } from "react-native";
3
3
 
4
4
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
5
5
 
6
- // Relative luminance of a #rgb/#rrggbb or rgb() color, or null when unreadable.
7
- function luminance(color: string): number | null {
8
- let rgb: number[] | null = null;
6
+ // The red, green and blue of a #rgb/#rrggbb or rgb() color, or null when unreadable.
7
+ function channels(color: string): number[] | null {
9
8
  const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
10
9
  if (hex) {
11
10
  const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d) : hex[1].match(/../g)!;
12
- rgb = digits.map((d) => parseInt(d, 16));
13
- } else {
14
- const fn = color.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
15
- if (fn) rgb = fn.slice(1, 4).map(Number);
11
+ return digits.map((d) => parseInt(d, 16));
16
12
  }
13
+ const fn = color.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
14
+ return fn ? fn.slice(1, 4).map(Number) : null;
15
+ }
16
+
17
+ // Relative luminance, or null when the color is unreadable.
18
+ function luminance(color: string): number | null {
19
+ const rgb = channels(color);
17
20
  if (!rgb) return null;
18
21
  const [r, g, b] = rgb.map((v) => {
19
22
  const c = v / 255;
@@ -22,7 +25,7 @@ function luminance(color: string): number | null {
22
25
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23
26
  }
24
27
 
25
- function isLight(colors: Colors): boolean | null {
28
+ export function isLight(colors: Colors): boolean | null {
26
29
  const value = luminance(colors.surface0);
27
30
  return value === null ? null : value > 0.4;
28
31
  }
@@ -43,3 +46,12 @@ export function raised(colors: Colors): ViewStyle {
43
46
  : "0px 0px 0px 1px rgba(255, 255, 255, 0.08)",
44
47
  };
45
48
  }
49
+
50
+ // A color at low opacity, for a tag's fill: the same hue as its text, never a
51
+ // drifting second color. Paseo's own status badges use 12% in light themes and
52
+ // 16% in dark ones, where the surface swallows more of it.
53
+ export function tint(color: string, colors: Colors): string {
54
+ const rgb = channels(color);
55
+ if (!rgb) return colors.surface2;
56
+ return `rgba(${rgb.join(", ")}, ${isLight(colors) ? 0.12 : 0.16})`;
57
+ }