@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/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The dashboard shows:
|
|
|
18
18
|
|
|
19
19
|
<img src="images/2-ticket.png" alt="A ticket's story: its status, time against the estimate, the agent on it, and its timeline" width="400"> <img src="images/3-question.png" alt="A question waiting for an answer, with lettered options, the agent's recommendation and Copy buttons" width="400">
|
|
20
20
|
|
|
21
|
-
<img src="images/5-settings.png" alt="
|
|
21
|
+
<img src="images/5-settings.png" alt="Progress settings in Paseo: the Questions setting set to Work around, and the Language setting set to System" width="600">
|
|
22
22
|
|
|
23
23
|
The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new in that version once. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
|
|
24
24
|
|
|
@@ -58,6 +58,14 @@ The command, and **Install skill** in the panel, look for Paseo's own command in
|
|
|
58
58
|
|
|
59
59
|
Setting `PASEO_PROGRESS_PLUGIN_DIR` to a plugin folder makes the command run that copy without asking Paseo. Use it only for testing, because an installed plugin's folder changes with every update.
|
|
60
60
|
|
|
61
|
+
## Language
|
|
62
|
+
|
|
63
|
+
Progress comes in English and Simplified Chinese. To pick one, open the cog in the Progress panel and choose **Display → Language**. **System** follows each device's language and falls back to English for anything other than Simplified Chinese, so Traditional Chinese stays in English for now.
|
|
64
|
+
|
|
65
|
+
The choice is saved on the Paseo host, so every device connected to it shows the same language. Progress can't follow Paseo's own language setting, because Paseo doesn't share it with plugins. Open dialogs and pills switch as soon as you change it. The **Progress** name and its **Open Progress** and **Settings** entries stay in English.
|
|
66
|
+
|
|
67
|
+
Progress translates its own labels, not what agents write. Titles, questions, choices, stages, notes, item labels like `Ticket`, file paths and release notes stay as written, and so do error messages from the host. The `paseo-progress` command and the reply you copy for an agent stay in English too.
|
|
68
|
+
|
|
61
69
|
## Where progress is kept
|
|
62
70
|
|
|
63
71
|
Each worktree's progress lives outside the repo, in its own folder under `~/.local/state/paseo-progress/` on the daemon host. There's nothing to set up and nothing to hide: git never sees these files, and the plugin never edits a `.gitignore` or git's exclude file. The panel's empty state shows the exact path.
|
|
@@ -137,6 +145,15 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
|
|
|
137
145
|
|
|
138
146
|
## Changelog
|
|
139
147
|
|
|
148
|
+
### 1.5.1
|
|
149
|
+
|
|
150
|
+
- [New] **Simplified Chinese.** Progress now comes in English and Simplified Chinese. Pick one under Display in Progress settings, or let it follow each device's language. Thanks to @xzthemeofsss for the translation.
|
|
151
|
+
- [Improved] **Settings screenshot.** It shows the new Language setting.
|
|
152
|
+
|
|
153
|
+
### 1.5.0
|
|
154
|
+
|
|
155
|
+
- [New] **Simplified Chinese.** Progress now comes in English and Simplified Chinese. Pick one under Display in Progress settings, or let it follow each device's language. Thanks to @xzthemeofsss for the translation.
|
|
156
|
+
|
|
140
157
|
### 1.4.0
|
|
141
158
|
|
|
142
159
|
- [New] **Choose what agents do while a question waits.** Open Settings with the cog at the top right of the Progress panel. **Stop** halts all work until you answer. **Work around**, the default, holds only what your answer affects, which is how questions already worked. **Keep building** builds the agent's pick and changes course if you choose another.
|
package/client/attachments.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { type Attachment, attachmentIcon, canPreview, shortName } from "../share
|
|
|
6
6
|
import { errorText } from "./errors";
|
|
7
7
|
import { PressableRow } from "./row";
|
|
8
8
|
import { openDeliverable } from "../shared/rpc";
|
|
9
|
+
import { useProgressLanguage } from "./language";
|
|
9
10
|
|
|
10
11
|
export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName, ticketSourceAttachment } from "../shared/attachments";
|
|
11
12
|
|
|
@@ -31,22 +32,23 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
|
|
|
31
32
|
navigation?: Navigation;
|
|
32
33
|
onPreview?(attachment: Attachment): void;
|
|
33
34
|
}) {
|
|
35
|
+
const { messages: m } = useProgressLanguage();
|
|
34
36
|
const toast = useToast();
|
|
35
37
|
const openOnHostRpc = useRpc(openDeliverable);
|
|
36
38
|
async function copyPath(attachment: Attachment, prefix = "") {
|
|
37
39
|
try {
|
|
38
40
|
await copyText(absolutePath(attachment.path!, workspaceDirectory));
|
|
39
|
-
toast.show(`${prefix}
|
|
41
|
+
toast.show(`${prefix}${m.copyPathDone}`, { variant: prefix ? "warning" : "success" });
|
|
40
42
|
} catch {
|
|
41
|
-
toast.error(
|
|
43
|
+
toast.error(m.copyPathFailure);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
async function openOnHost(attachment: Attachment) {
|
|
45
47
|
try {
|
|
46
48
|
await openOnHostRpc({ workspaceId, workspaceDirectory, ref: attachment.ref });
|
|
47
|
-
toast.show(
|
|
49
|
+
toast.show(m.opened(attachment.title), { variant: "success" });
|
|
48
50
|
} catch (error) {
|
|
49
|
-
await copyPath(attachment, `${errorText(error,
|
|
51
|
+
await copyPath(attachment, `${errorText(error, m.openFailure)}. `);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
async function open(attachment: Attachment) {
|
|
@@ -55,7 +57,7 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
|
|
|
55
57
|
if (navigation?.openBrowser) navigation.openBrowser({ url: attachment.url, workspaceId });
|
|
56
58
|
else await openExternalUrl(attachment.url);
|
|
57
59
|
} catch {
|
|
58
|
-
toast.error(
|
|
60
|
+
toast.error(m.linkFailure);
|
|
59
61
|
}
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
@@ -74,11 +76,12 @@ export const ATTACHMENT_ROW = { gap: 2, paddingVertical: 3, lineHeight: 17 };
|
|
|
74
76
|
// Drawn in dialogs and the popover (surface1) unless `onSurface1` is false.
|
|
75
77
|
// `onOpen` also gets the whole list, so a preview can step through its siblings.
|
|
76
78
|
export function AttachmentList({ colors, attachments, onOpen, onSurface1 = true }: { colors: Colors; attachments: Attachment[]; onOpen(attachment: Attachment, group: Attachment[]): void; onSurface1?: boolean }) {
|
|
79
|
+
const { messages: m } = useProgressLanguage();
|
|
77
80
|
if (!attachments.length) return null;
|
|
78
81
|
return (
|
|
79
82
|
<View style={{ gap: ATTACHMENT_ROW.gap }}>
|
|
80
83
|
{attachments.map((attachment) => (
|
|
81
|
-
<PressableRow key={attachment.ref} colors={colors} onSurface1={onSurface1} accessibilityRole={attachment.url ? "link" : "button"} accessibilityLabel={
|
|
84
|
+
<PressableRow key={attachment.ref} colors={colors} onSurface1={onSurface1} accessibilityRole={attachment.url ? "link" : "button"} accessibilityLabel={m.openItem(attachment.title)}
|
|
82
85
|
onPress={() => onOpen(attachment, attachments)}
|
|
83
86
|
style={{ flexDirection: "row", gap: 6, alignItems: "center", paddingVertical: ATTACHMENT_ROW.paddingVertical, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
|
|
84
87
|
{/* A 14px slot, like a section icon, so the title lines up with the heading above. */}
|
package/client/deliverables.tsx
CHANGED
|
@@ -4,12 +4,13 @@ import React, { useState } from "react";
|
|
|
4
4
|
import { Text, View } from "react-native";
|
|
5
5
|
import type { Deliverable } from "../shared/dashboard";
|
|
6
6
|
import { type Attachment, attachmentIcon, deliverableAttachment, shortName, useAttachmentOpener } from "./attachments";
|
|
7
|
-
import {
|
|
7
|
+
import { formatClientAgo } from "./i18n";
|
|
8
8
|
import { PressScale } from "./motion";
|
|
9
9
|
import { StackedDialog } from "./dialog-stack";
|
|
10
10
|
import { galleryFiles } from "./preview";
|
|
11
11
|
import { PressableRow } from "./row";
|
|
12
12
|
import { ShowMoreRow, usePaged } from "./show-more";
|
|
13
|
+
import { useProgressLanguage } from "./language";
|
|
13
14
|
|
|
14
15
|
export { absolutePath } from "./attachments";
|
|
15
16
|
|
|
@@ -25,6 +26,7 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
|
|
|
25
26
|
workspaceDirectory: string;
|
|
26
27
|
navigation: Navigation;
|
|
27
28
|
}) {
|
|
29
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
28
30
|
const [previewing, setPreviewing] = useState<Attachment | null>(null);
|
|
29
31
|
const { open, copyPath } = useAttachmentOpener({ workspaceId, workspaceDirectory, navigation, onPreview: setPreviewing });
|
|
30
32
|
const paged = usePaged(deliverables);
|
|
@@ -32,7 +34,7 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
|
|
|
32
34
|
<View>
|
|
33
35
|
{paged.shown.map((deliverable, index) => (
|
|
34
36
|
<PressableRow key={deliverable.id} colors={colors} accessibilityRole={deliverable.url ? "link" : "button"}
|
|
35
|
-
accessibilityLabel={
|
|
37
|
+
accessibilityLabel={m.openItem(deliverable.title)}
|
|
36
38
|
onPress={() => void open(deliverableAttachment(deliverable))}
|
|
37
39
|
style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
38
40
|
{/* Optical: centers the 14px icon on the 18px title line. */}
|
|
@@ -42,12 +44,12 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
|
|
|
42
44
|
<Text numberOfLines={3} style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{deliverable.title}</Text>
|
|
43
45
|
{/* Just the name, ticket and age. The full path is one press of Copy away. */}
|
|
44
46
|
<Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
45
|
-
{[shortName(deliverableAttachment(deliverable)), deliverable.ticketLabel ?? deliverable.ticketId,
|
|
47
|
+
{[shortName(deliverableAttachment(deliverable)), deliverable.ticketLabel ?? deliverable.ticketId, formatClientAgo(locale, deliverable.addedAt, now)].filter(Boolean).join(" · ")}
|
|
46
48
|
</Text>
|
|
47
49
|
</View>
|
|
48
50
|
{deliverable.path ? (
|
|
49
51
|
// Optical: the 22px button centers on the 18px title line.
|
|
50
|
-
<PressScale accessibilityRole="button" accessibilityLabel={
|
|
52
|
+
<PressScale accessibilityRole="button" accessibilityLabel={m.copyItemPath(deliverable.title)} hitSlop={6} outerStyle={{ marginTop: -2 }}
|
|
51
53
|
onPress={() => void copyPath(deliverableAttachment(deliverable))}
|
|
52
54
|
// A step above the row's own hover tint (surface1), so it reads as its own target.
|
|
53
55
|
style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
package/client/dialog-stack.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { NarrowProvider } from "./narrow";
|
|
|
7
7
|
import { BackButton, galleryFiles, PreviewContent } from "./preview";
|
|
8
8
|
import { QuestionView, useCopy } from "./questions";
|
|
9
9
|
import { TicketStoryView } from "./ticket-dialog";
|
|
10
|
+
import { ProgressLanguageProvider } from "./language";
|
|
10
11
|
|
|
11
12
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
13
|
type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
|
|
@@ -76,6 +77,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
76
77
|
open={Boolean(root)} onOpenChange={(next) => { if (!next) onClose(); }}>
|
|
77
78
|
{/* One Modal.Content for every step: replacing it would re-present the sheet on phones. */}
|
|
78
79
|
<Modal.Content scrollable={!(preview && previewLong)}>
|
|
80
|
+
<ProgressLanguageProvider>
|
|
79
81
|
{/* A dialog has room for clock times, however narrow the panel that opened it. */}
|
|
80
82
|
<NarrowProvider value={false}>
|
|
81
83
|
{preview ? (
|
|
@@ -100,6 +102,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
100
102
|
</>
|
|
101
103
|
)}
|
|
102
104
|
</NarrowProvider>
|
|
105
|
+
</ProgressLanguageProvider>
|
|
103
106
|
</Modal.Content>
|
|
104
107
|
</Modal>
|
|
105
108
|
);
|
package/client/history.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { PressScale } from "./motion";
|
|
|
7
7
|
import { PressableRow } from "./row";
|
|
8
8
|
import { listFolders, setHistoryFolder } from "../shared/rpc";
|
|
9
9
|
import { errorText } from "./errors";
|
|
10
|
+
import { ProgressLanguageProvider, useProgressLanguage } from "./language";
|
|
10
11
|
|
|
11
12
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
13
|
|
|
@@ -18,6 +19,7 @@ export function HistoryRow({ colors, workspaceId, directory, savedTo }: {
|
|
|
18
19
|
directory: string;
|
|
19
20
|
savedTo: string | null;
|
|
20
21
|
}) {
|
|
22
|
+
const { messages: m } = useProgressLanguage();
|
|
21
23
|
const [picking, setPicking] = useState(false);
|
|
22
24
|
const set = useRpc(setHistoryFolder);
|
|
23
25
|
const queryClient = useQueryClient();
|
|
@@ -26,22 +28,22 @@ export function HistoryRow({ colors, workspaceId, directory, savedTo }: {
|
|
|
26
28
|
mutationFn: (folder: string | null) => set({ workspaceId, workspaceDirectory: directory, folder }),
|
|
27
29
|
onSuccess: ({ savedTo: next }) => {
|
|
28
30
|
queryClient.invalidateQueries({ queryKey: ["progress", "dashboard"] });
|
|
29
|
-
toast.show(next ?
|
|
31
|
+
toast.show(next ? m.historySaved(next) : m.historyStopped, { variant: "success" });
|
|
30
32
|
},
|
|
31
|
-
onError: (error) => toast.error(errorText(error,
|
|
33
|
+
onError: (error) => toast.error(errorText(error, m.historyFailure)),
|
|
32
34
|
});
|
|
33
35
|
// While saving, the button that started it says so.
|
|
34
36
|
const busy = mutation.isPending ? (mutation.variables === null ? "stop" : "pick") : null;
|
|
35
37
|
const actions: { label: string; busyLabel: string; kind: "pick" | "stop"; onPress(): void }[] = savedTo
|
|
36
|
-
? [{ label:
|
|
37
|
-
: [{ label:
|
|
38
|
+
? [{ label: m.changeFolder, busyLabel: m.saving, kind: "pick", onPress: () => setPicking(true) }, { label: m.stopSaving, busyLabel: m.stopping, kind: "stop", onPress: () => mutation.mutate(null) }]
|
|
39
|
+
: [{ label: m.saveToRepo, busyLabel: m.saving, kind: "pick", onPress: () => setPicking(true) }];
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
42
|
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 8, rowGap: 4, marginHorizontal: 12, marginTop: 16 }}>
|
|
41
43
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6, flex: 1, minWidth: 180 }}>
|
|
42
44
|
<View style={{ paddingTop: 2 }}><Icon name="History" size={13} color={colors.foregroundMuted} /></View>
|
|
43
45
|
<Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
44
|
-
{savedTo ? <>
|
|
46
|
+
{savedTo ? <>{m.historyInRepo[0]}<Text style={{ color: colors.foreground }}>{savedTo}</Text>{m.historyInRepo[1]}</> : m.localHistory}
|
|
45
47
|
</Text>
|
|
46
48
|
</View>
|
|
47
49
|
<View style={{ flexDirection: "row", gap: 4 }}>
|
|
@@ -71,6 +73,7 @@ function FolderPicker({ colors, workspaceId, directory, open, start, onClose, on
|
|
|
71
73
|
onClose(): void;
|
|
72
74
|
onPick(folder: string): void;
|
|
73
75
|
}) {
|
|
76
|
+
const { messages: m } = useProgressLanguage();
|
|
74
77
|
const parentOf = (path: string) => path.split("/").slice(0, -1).join("/");
|
|
75
78
|
const [at, setAt] = useState(() => parentOf(start));
|
|
76
79
|
const [naming, setNaming] = useState<string | null>(null);
|
|
@@ -86,15 +89,16 @@ function FolderPicker({ colors, workspaceId, directory, open, start, onClose, on
|
|
|
86
89
|
const newName = naming?.trim().replace(/\/+$/, "") ?? "";
|
|
87
90
|
|
|
88
91
|
return (
|
|
89
|
-
<Modal title=
|
|
92
|
+
<Modal title={m.historyFolderTitle} icon={<Icon name="FolderOpen" size={16} color={colors.foregroundMuted} />} open={open} onOpenChange={(next) => { if (!next) close(); }}>
|
|
90
93
|
{/* Paseo sizes a non-scrolling dialog to 85% of the window, so let it size to
|
|
91
94
|
its content; the list's own height cap keeps the buttons in view. */}
|
|
92
95
|
<Modal.Content>
|
|
96
|
+
<ProgressLanguageProvider>
|
|
93
97
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
94
|
-
|
|
98
|
+
{m.historyFolderHint}
|
|
95
99
|
</Text>
|
|
96
100
|
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 2, paddingHorizontal: 4 }}>
|
|
97
|
-
{[
|
|
101
|
+
{[m.repoTop, ...crumbs].map((crumb, index) => (
|
|
98
102
|
<React.Fragment key={index}>
|
|
99
103
|
{index ? <Icon name="ChevronRight" size={12} color={colors.foregroundMuted} /> : null}
|
|
100
104
|
<PressScale accessibilityRole="button" disabled={index === crumbs.length} onPress={() => { setNaming(null); setAt(crumbs.slice(0, index).join("/")); }}
|
|
@@ -108,9 +112,9 @@ function FolderPicker({ colors, workspaceId, directory, open, start, onClose, on
|
|
|
108
112
|
|
|
109
113
|
{/* Only the list scrolls, so Use and Cancel stay in view in a big repo. */}
|
|
110
114
|
<ScrollView style={{ maxHeight: 320, flexGrow: 0 }} contentContainerStyle={{ gap: 2 }}>
|
|
111
|
-
{listed.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel=
|
|
115
|
+
{listed.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingFolders} style={{ alignSelf: "flex-start", padding: 8 }} /> : null}
|
|
112
116
|
{listed.data?.folders.map((name) => (
|
|
113
|
-
<PressableRow key={name} colors={colors} accessibilityRole="button" accessibilityLabel={
|
|
117
|
+
<PressableRow key={name} colors={colors} accessibilityRole="button" accessibilityLabel={m.openItem(name)} onPress={() => { setNaming(null); setAt(join(name)); }}
|
|
114
118
|
style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, paddingVertical: 6, borderRadius: 6 }}>
|
|
115
119
|
<Icon name="Folder" size={14} color={colors.foregroundMuted} />
|
|
116
120
|
<Text numberOfLines={1} ellipsizeMode="middle" style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 16 }}>{name}</Text>
|
|
@@ -118,40 +122,41 @@ function FolderPicker({ colors, workspaceId, directory, open, start, onClose, on
|
|
|
118
122
|
</PressableRow>
|
|
119
123
|
))}
|
|
120
124
|
{listed.data && !listed.data.folders.length ? (
|
|
121
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6 }}>
|
|
125
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6 }}>{m.noFolders}</Text>
|
|
122
126
|
) : null}
|
|
123
|
-
{listed.error ? <Text accessibilityRole="alert" style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, paddingHorizontal: 8 }}>{errorText(listed.error,
|
|
127
|
+
{listed.error ? <Text accessibilityRole="alert" style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, paddingHorizontal: 8 }}>{errorText(listed.error, m.listFoldersFailure)}</Text> : null}
|
|
124
128
|
</ScrollView>
|
|
125
129
|
|
|
126
130
|
{naming === null ? (
|
|
127
131
|
<PressScale accessibilityRole="button" onPress={() => setNaming("")}
|
|
128
132
|
style={({ pressed, hovered }) => ({ alignSelf: "flex-start", flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
129
133
|
<Icon name="FolderPlus" size={14} color={colors.foreground} />
|
|
130
|
-
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>
|
|
134
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.newFolder}</Text>
|
|
131
135
|
</PressScale>
|
|
132
136
|
) : (
|
|
133
137
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
134
138
|
<TextInput autoFocus value={naming} onChangeText={setNaming} onSubmitEditing={() => { if (newName) onPick(join(newName)); }}
|
|
135
|
-
autoCapitalize="none" autoCorrect={false} spellCheck={false} accessibilityLabel=
|
|
139
|
+
autoCapitalize="none" autoCorrect={false} spellCheck={false} accessibilityLabel={m.newFolderName} placeholder="progress-history" placeholderTextColor={colors.foregroundMuted}
|
|
136
140
|
style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6, borderRadius: 6, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface1 }} />
|
|
137
141
|
<PressScale accessibilityRole="button" disabled={!newName} onPress={() => onPick(join(newName))}
|
|
138
142
|
style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: !newName ? 0.5 : pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
139
|
-
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>
|
|
143
|
+
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.useNewFolder}</Text>
|
|
140
144
|
</PressScale>
|
|
141
145
|
</View>
|
|
142
146
|
)}
|
|
143
147
|
|
|
144
148
|
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
|
145
|
-
{at ? null : <Text style={{ flex: 1, minWidth: 140, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
149
|
+
{at ? null : <Text style={{ flex: 1, minWidth: 140, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.pickFolderHint}</Text>}
|
|
146
150
|
<PressScale accessibilityRole="button" onPress={close}
|
|
147
151
|
style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
148
|
-
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>
|
|
152
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.cancel}</Text>
|
|
149
153
|
</PressScale>
|
|
150
154
|
<PressScale accessibilityRole="button" disabled={!at} onPress={() => onPick(at)} outerStyle={{ flexShrink: 1 }}
|
|
151
155
|
style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: !at ? 0.5 : pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
152
|
-
<Text numberOfLines={1} ellipsizeMode="middle" style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{at ?
|
|
156
|
+
<Text numberOfLines={1} ellipsizeMode="middle" style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{at ? m.useNamedFolder(crumbs[crumbs.length - 1]) : m.useFolder}</Text>
|
|
153
157
|
</PressScale>
|
|
154
158
|
</View>
|
|
159
|
+
</ProgressLanguageProvider>
|
|
155
160
|
</Modal.Content>
|
|
156
161
|
</Modal>
|
|
157
162
|
);
|
package/client/i18n.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Dashboard, HeadlinePart } from "../shared/dashboard";
|
|
2
|
+
import type { LanguagePreference } from "../shared/settings";
|
|
3
|
+
import type { TimelineLabel } from "../shared/ticket-story";
|
|
4
|
+
import type { ProgressMessages } from "./messages";
|
|
5
|
+
import { messages as en } from "./locales/en";
|
|
6
|
+
import { messages as zhCN } from "./locales/zh-CN";
|
|
7
|
+
|
|
8
|
+
export type { ProgressMessages } from "./messages";
|
|
9
|
+
export type ProgressLocale = "en" | "zh-CN";
|
|
10
|
+
|
|
11
|
+
export function systemLocale(): string {
|
|
12
|
+
try {
|
|
13
|
+
return Intl.DateTimeFormat().resolvedOptions().locale;
|
|
14
|
+
} catch {
|
|
15
|
+
return "en";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveLocale(preference: LanguagePreference, system = systemLocale()): ProgressLocale {
|
|
20
|
+
if (preference !== "system") return preference;
|
|
21
|
+
const [language, ...rest] = system.replaceAll("_", "-").toLowerCase().split("-");
|
|
22
|
+
// Extensions like "-u-nu-hanidec" say nothing about the script.
|
|
23
|
+
const singleton = rest.findIndex((part) => part.length === 1);
|
|
24
|
+
const subtags = singleton === -1 ? rest : rest.slice(0, singleton);
|
|
25
|
+
if (language !== "zh") return "en";
|
|
26
|
+
// A script tag beats the region: zh-Hans-HK is Simplified.
|
|
27
|
+
if (subtags.includes("hans")) return "zh-CN";
|
|
28
|
+
if (subtags.includes("hant")) return "en";
|
|
29
|
+
return subtags.length === 0 || subtags.some((part) => ["cn", "sg"].includes(part)) ? "zh-CN" : "en";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function messagesFor(locale: ProgressLocale): ProgressMessages {
|
|
33
|
+
return locale === "zh-CN" ? zhCN : en;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatClientMinutes(locale: ProgressLocale, totalMin: number): string {
|
|
37
|
+
const m = messagesFor(locale);
|
|
38
|
+
const whole = Math.max(0, Math.floor(totalMin));
|
|
39
|
+
if (whole < 1) return m.lessThanMinute;
|
|
40
|
+
const hours = Math.floor(whole / 60);
|
|
41
|
+
const minutes = whole % 60;
|
|
42
|
+
return !hours ? m.minutes(minutes) : minutes ? m.hoursMinutes(hours, minutes) : m.hours(hours);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatClientWorkDone(locale: ProgressLocale, doneMin: number, totalMin: number, tookMin?: number): string {
|
|
46
|
+
if (tookMin !== undefined) return formatClientMinutes(locale, tookMin);
|
|
47
|
+
const m = messagesFor(locale);
|
|
48
|
+
return m.workDone(doneMin < 1 ? m.zeroMinutes : formatClientMinutes(locale, doneMin), formatClientMinutes(locale, totalMin));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatClientClock(locale: ProgressLocale, iso: string): string {
|
|
52
|
+
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(iso));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatClientTimeOfDay(locale: ProgressLocale, iso: string, nowMs: number): string {
|
|
56
|
+
const date = new Date(iso);
|
|
57
|
+
const now = new Date(nowMs);
|
|
58
|
+
const sameDay = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
|
|
59
|
+
return sameDay ? new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(date) : formatClientClock(locale, iso);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatClientAgo(locale: ProgressLocale, iso: string, nowMs: number): string {
|
|
63
|
+
const m = messagesFor(locale);
|
|
64
|
+
const minutes = (nowMs - Date.parse(iso)) / 60_000;
|
|
65
|
+
return minutes < 1 ? m.justNow : m.ago(formatClientMinutes(locale, minutes));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clientHeadline(locale: ProgressLocale, dashboard: Dashboard): HeadlinePart[] {
|
|
69
|
+
const m = messagesFor(locale);
|
|
70
|
+
const counted = dashboard.tickets.filter((ticket) => ticket.status !== "skipped");
|
|
71
|
+
const done = counted.filter((ticket) => ticket.status === "done").length;
|
|
72
|
+
const parts: HeadlinePart[] = [{ text: m.headlineDone(done, counted.length, dashboard.run?.itemLabel ?? "Ticket"), tone: "normal" }];
|
|
73
|
+
if (dashboard.questions.open.length) parts.push({ text: m.questionsWaiting(dashboard.questions.open.length), tone: "normal" });
|
|
74
|
+
if (dashboard.stuck.length) parts.push({ text: m.stuckCount(dashboard.stuck.length), tone: "danger" });
|
|
75
|
+
return parts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function timelineLabel(m: ProgressMessages, label: TimelineLabel): string {
|
|
79
|
+
switch (label.kind) {
|
|
80
|
+
case "added": return m.added;
|
|
81
|
+
case "reset": return m.resetStatus;
|
|
82
|
+
case "stage": return m.stageLabel(label.stage);
|
|
83
|
+
case "status": return m.status[label.status];
|
|
84
|
+
case "waiting": return m.waitingForTicket(label.id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import React, { createContext, useContext } from "react";
|
|
3
|
+
import { displaySettings } from "../shared/settings";
|
|
4
|
+
import { messagesFor, resolveLocale } from "./i18n";
|
|
5
|
+
|
|
6
|
+
function useLanguageSettings() {
|
|
7
|
+
const settings = useSettings(displaySettings);
|
|
8
|
+
const locale = resolveLocale(settings.status === "ready" ? settings.values.language : "system");
|
|
9
|
+
return { locale, messages: messagesFor(locale), settings };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LanguageContext = createContext<ReturnType<typeof useLanguageSettings> | null>(null);
|
|
13
|
+
|
|
14
|
+
export function ProgressLanguageProvider({ children }: { children: React.ReactNode }) {
|
|
15
|
+
const language = useLanguageSettings();
|
|
16
|
+
return <LanguageContext.Provider value={language}>{children}</LanguageContext.Provider>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useProgressLanguage() {
|
|
20
|
+
const language = useContext(LanguageContext);
|
|
21
|
+
if (!language) throw new Error("ProgressLanguageProvider is missing");
|
|
22
|
+
return language;
|
|
23
|
+
}
|
package/client/launcher.tsx
CHANGED
|
@@ -6,7 +6,9 @@ import { Text, View } from "react-native";
|
|
|
6
6
|
import { PressScale, Presence } from "./motion";
|
|
7
7
|
import { raised } from "./surfaces";
|
|
8
8
|
import { getLauncherStatus, getSkillStatus, installLauncher, installSkill, type LauncherStatus, type SkillStatus } from "../shared/rpc";
|
|
9
|
+
import { messagesFor, type ProgressMessages } from "./i18n";
|
|
9
10
|
import { errorText } from "./errors";
|
|
11
|
+
import { useProgressLanguage } from "./language";
|
|
10
12
|
|
|
11
13
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
14
|
|
|
@@ -19,30 +21,30 @@ export function skillQueryKey(hostId: string) {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
// What the banner says, or null when agents can already run the command.
|
|
22
|
-
export function launcherNotice(status: LauncherStatus): { text: string; action?: string } | null {
|
|
24
|
+
export function launcherNotice(status: LauncherStatus, m: ProgressMessages = messagesFor("en")): { text: string; action?: string } | null {
|
|
23
25
|
switch (status.state) {
|
|
24
26
|
case "missing":
|
|
25
|
-
return { text:
|
|
27
|
+
return { text: m.launcherMissing(status.path), action: m.installCommand };
|
|
26
28
|
case "outdated":
|
|
27
|
-
return { text:
|
|
29
|
+
return { text: m.launcherOutdated(status.path), action: m.updateCommand };
|
|
28
30
|
case "foreign":
|
|
29
|
-
return { text:
|
|
31
|
+
return { text: m.launcherForeign(status.path) };
|
|
30
32
|
case "current":
|
|
31
|
-
return status.onPath ? null : { text:
|
|
33
|
+
return status.onPath ? null : { text: m.launcherOffPath(status.path) };
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// What the skill banner says, or null when every agent folder has the skill.
|
|
36
|
-
export function skillNotice(status: SkillStatus): { text: string; action?: string } | null {
|
|
38
|
+
export function skillNotice(status: SkillStatus, m: ProgressMessages = messagesFor("en")): { text: string; action?: string } | null {
|
|
37
39
|
const folders = (state: string) => status.targets.filter((target) => target.state === state).map((target) => target.path.replace(/[\\/]paseo-progress$/, ""));
|
|
38
|
-
const skipped = folders("foreign").length ?
|
|
40
|
+
const skipped = folders("foreign").length ? m.skillSkipped(folders("foreign").join(m.listSeparator)) : "";
|
|
39
41
|
switch (status.state) {
|
|
40
42
|
case "missing":
|
|
41
|
-
return { text:
|
|
43
|
+
return { text: m.skillMissing(folders("missing").join(m.listSeparator), skipped), action: m.installSkill };
|
|
42
44
|
case "outdated":
|
|
43
|
-
return { text:
|
|
45
|
+
return { text: m.skillOutdated(folders("outdated").join(m.listSeparator), skipped), action: m.updateSkill };
|
|
44
46
|
case "foreign":
|
|
45
|
-
return { text:
|
|
47
|
+
return { text: m.skillForeign };
|
|
46
48
|
case "current":
|
|
47
49
|
return null;
|
|
48
50
|
}
|
|
@@ -50,6 +52,7 @@ export function skillNotice(status: SkillStatus): { text: string; action?: strin
|
|
|
50
52
|
|
|
51
53
|
// Nothing is written until the user presses the button.
|
|
52
54
|
export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
55
|
+
const { messages: m } = useProgressLanguage();
|
|
53
56
|
const fetchStatus = useRpc(getLauncherStatus);
|
|
54
57
|
const install = useRpc(installLauncher);
|
|
55
58
|
const queryClient = useQueryClient();
|
|
@@ -59,15 +62,16 @@ export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
59
62
|
mutationFn: () => install({}),
|
|
60
63
|
onSuccess: (next) => {
|
|
61
64
|
queryClient.setQueryData(launcherQueryKey(host.id), next);
|
|
62
|
-
toast.show(
|
|
65
|
+
toast.show(m.launcherInstalled(next.path), { variant: "success" });
|
|
63
66
|
},
|
|
64
|
-
onError: (error) => toast.error(errorText(error,
|
|
67
|
+
onError: (error) => toast.error(errorText(error, m.installCommandFailure)),
|
|
65
68
|
});
|
|
66
|
-
const notice = status.data ? launcherNotice(status.data) : null;
|
|
69
|
+
const notice = status.data ? launcherNotice(status.data, m) : null;
|
|
67
70
|
return <SetupBanner colors={colors} icon="Terminal" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
74
|
+
const { messages: m } = useProgressLanguage();
|
|
71
75
|
const fetchStatus = useRpc(getSkillStatus);
|
|
72
76
|
const install = useRpc(installSkill);
|
|
73
77
|
const queryClient = useQueryClient();
|
|
@@ -77,11 +81,11 @@ export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWork
|
|
|
77
81
|
mutationFn: () => install({}),
|
|
78
82
|
onSuccess: (next) => {
|
|
79
83
|
queryClient.setQueryData(skillQueryKey(host.id), next);
|
|
80
|
-
toast.show(
|
|
84
|
+
toast.show(m.skillInstalled, { variant: "success" });
|
|
81
85
|
},
|
|
82
|
-
onError: (error) => toast.error(errorText(error,
|
|
86
|
+
onError: (error) => toast.error(errorText(error, m.installSkillFailure)),
|
|
83
87
|
});
|
|
84
|
-
const notice = status.data ? skillNotice(status.data) : null;
|
|
88
|
+
const notice = status.data ? skillNotice(status.data, m) : null;
|
|
85
89
|
return <SetupBanner colors={colors} icon="Sparkles" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -92,6 +96,7 @@ function SetupBanner({ colors, icon, notice, pending, onPress }: {
|
|
|
92
96
|
pending: boolean;
|
|
93
97
|
onPress(): void;
|
|
94
98
|
}) {
|
|
99
|
+
const { messages: m } = useProgressLanguage();
|
|
95
100
|
return (
|
|
96
101
|
<Presence show={Boolean(notice)}>{notice ? (
|
|
97
102
|
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
|
|
@@ -102,7 +107,7 @@ function SetupBanner({ colors, icon, notice, pending, onPress }: {
|
|
|
102
107
|
<PressScale accessibilityRole="button" disabled={pending} onPress={onPress}
|
|
103
108
|
style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed || pending ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
104
109
|
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>
|
|
105
|
-
{pending ?
|
|
110
|
+
{pending ? m.installing : notice.action}
|
|
106
111
|
</Text>
|
|
107
112
|
</PressScale>
|
|
108
113
|
) : null}
|