@stevecastaneda/paseo-progress 1.4.0 → 1.5.1
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/README.md +18 -1
- package/client/attachments.tsx +9 -6
- package/client/deliverables.tsx +6 -4
- package/client/dialog-stack.tsx +3 -0
- package/client/history.tsx +23 -18
- package/client/i18n.ts +86 -0
- package/client/language.tsx +23 -0
- package/client/launcher.tsx +22 -17
- package/client/locales/en.ts +207 -0
- package/client/locales/zh-CN.ts +207 -0
- package/client/messages.ts +189 -0
- package/client/panel.tsx +48 -38
- package/client/pill-popover.tsx +8 -5
- package/client/pill-state.ts +4 -3
- package/client/pills.tsx +25 -11
- package/client/preview.tsx +18 -13
- package/client/questions.tsx +26 -18
- package/client/settings.tsx +46 -28
- package/client/show-more.tsx +4 -2
- package/client/spinner.tsx +7 -3
- package/client/status-badge.tsx +3 -9
- package/client/ticket-dialog.tsx +35 -29
- package/client/video.tsx +3 -1
- package/client/whats-new.tsx +11 -6
- package/client/when.tsx +5 -3
- package/client/write-up.tsx +5 -3
- package/images/5-settings.png +0 -0
- package/index.client.tsx +3 -2
- package/index.server.ts +2 -1
- package/package.json +1 -1
- package/shared/settings.ts +10 -0
- package/shared/ticket-story.ts +12 -10
package/client/questions.tsx
CHANGED
|
@@ -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 ?
|
|
32
|
+
toast.show(letter ? m.copiedChoice(question.id, letter) : m.copiedQuestion(question.id), { variant: "success" });
|
|
31
33
|
} catch {
|
|
32
|
-
toast.error(
|
|
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" }}>
|
|
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}
|
|
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={
|
|
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 ?
|
|
127
|
+
{answer.changedCourse ? m.changedCourse : ""}
|
|
123
128
|
</Text>
|
|
124
129
|
</Text>
|
|
125
130
|
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
|
|
126
|
-
|
|
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={
|
|
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={
|
|
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" ? <>
|
|
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 ? <>
|
|
200
|
-
{question.state === "stopped" ?
|
|
201
|
-
{question.big ?
|
|
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 }}>
|
|
234
|
-
{recommended ? <Text style={{ color: settled ? colors.foregroundMuted : colors.accent }}>
|
|
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={
|
|
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 }}>
|
|
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
|
-
|
|
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
|
);
|
package/client/settings.tsx
CHANGED
|
@@ -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,
|
|
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=
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
}
|
package/client/show-more.tsx
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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 (
|
package/client/spinner.tsx
CHANGED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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
|
);
|
package/client/status-badge.tsx
CHANGED
|
@@ -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 ?
|
|
21
|
+
<Text style={{ color: tone.fg, fontSize: 11, lineHeight: 16 }}>{waiting ? m.waiting : m.status[status]}</Text>
|
|
28
22
|
</View>
|
|
29
23
|
);
|
|
30
24
|
}
|
package/client/ticket-dialog.tsx
CHANGED
|
@@ -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 {
|
|
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 ?
|
|
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:
|
|
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:
|
|
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:
|
|
79
|
-
...(story.activity.length ? [{ id: "activity" as const, label:
|
|
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"] }}>{
|
|
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:
|
|
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={
|
|
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 }}>
|
|
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:
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
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={
|
|
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
|
-
{
|
|
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" }}>
|
|
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
|
-
|
|
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 !== "
|
|
199
|
-
const icon = step.label === "
|
|
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"] }}>{
|
|
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={{
|
|
225
|
-
{step.minutes !== null ?
|
|
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
|
|
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);
|