@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.
- package/OVERVIEW.md +1 -1
- package/README.md +28 -4
- 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 +77 -47
- 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 +42 -18
- package/client/settings.tsx +58 -0
- package/client/show-more.tsx +4 -2
- package/client/spinner.tsx +7 -3
- package/client/status-badge.tsx +3 -9
- package/client/surfaces.ts +20 -8
- package/client/ticket-dialog.tsx +37 -30
- package/client/video.tsx +3 -1
- package/client/whats-new.tsx +125 -0
- package/client/when.tsx +5 -3
- package/client/write-up.tsx +8 -5
- package/dist/server/cli.js +85 -20
- package/images/1-panel.png +0 -0
- package/images/3-question.png +0 -0
- package/images/5-settings.png +0 -0
- package/index.client.tsx +13 -1
- package/index.server.ts +12 -2
- package/package.json +1 -1
- package/paseo-plugin.json +2 -1
- package/server/cli.ts +28 -11
- package/server/question-mode.ts +61 -0
- package/server/store.ts +21 -4
- package/server/whats-new.ts +127 -0
- package/shared/dashboard.ts +9 -2
- package/shared/events.ts +9 -4
- package/shared/question-modes.ts +45 -0
- package/shared/rpc.ts +18 -0
- package/shared/settings.ts +20 -0
- package/shared/show.ts +3 -1
- package/shared/ticket-story.ts +12 -10
- package/shared/whats-new.ts +16 -0
- package/shared/write-up.ts +9 -8
- package/skills/paseo-progress/SKILL.md +26 -68
package/client/panel.tsx
CHANGED
|
@@ -2,10 +2,11 @@ import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/
|
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { DeliverablesList } from "./deliverables";
|
|
4
4
|
import { LauncherBanner, SkillBanner } from "./launcher";
|
|
5
|
+
import { WhatsNewBanner } from "./whats-new";
|
|
5
6
|
import { HistoryRow } from "./history";
|
|
6
7
|
import { AnsweredQuestionsList, QuestionsSection } from "./questions";
|
|
7
8
|
import { Spinner, StalledPulse, WaitingDot } from "./spinner";
|
|
8
|
-
import { IconSwap, Presence, StaggerRoot, nativeDriver } from "./motion";
|
|
9
|
+
import { IconSwap, PressScale, Presence, StaggerRoot, nativeDriver } from "./motion";
|
|
9
10
|
import { SectionTitle } from "./section-title";
|
|
10
11
|
import { raised } from "./surfaces";
|
|
11
12
|
import { unbroken, When } from "./when";
|
|
@@ -13,10 +14,10 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
13
14
|
import { ActivityIndicator, Animated, Platform, ScrollView, Text, View } from "react-native";
|
|
14
15
|
import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
|
|
15
16
|
import type { TicketStatus } from "../shared/events";
|
|
16
|
-
import {
|
|
17
|
+
import { minutesSince } from "../shared/format";
|
|
18
|
+
import { clientHeadline, formatClientMinutes, formatClientWorkDone } from "./i18n";
|
|
17
19
|
import { markPanelOpened } from "../shared/rpc";
|
|
18
20
|
import { useDashboard } from "./dashboard-query";
|
|
19
|
-
import { STATUS_LABEL } from "./status-badge";
|
|
20
21
|
import { TicketDialogs } from "./ticket-dialog";
|
|
21
22
|
import { PressableRow } from "./row";
|
|
22
23
|
import { TabStrip } from "./tabs";
|
|
@@ -24,17 +25,21 @@ import { ShowMoreRow, usePaged } from "./show-more";
|
|
|
24
25
|
import { notePanelOpened } from "./panel-opened";
|
|
25
26
|
import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
|
|
26
27
|
import { errorText } from "./errors";
|
|
28
|
+
import { useProgressLanguage } from "./language";
|
|
27
29
|
|
|
28
30
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
const mono = () => (Platform.OS === "ios" ? "Menlo" : "monospace");
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
type PanelProps = PluginWorkspacePanelProps & { openSettings(): void };
|
|
36
|
+
|
|
37
|
+
export function ProgressPanel(props: PanelProps) {
|
|
34
38
|
return <WorkspaceProgress key={`${props.host.id}:${props.workspaceId}`} {...props} />;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }:
|
|
41
|
+
function WorkspaceProgress({ theme, workspaceId, host, layout, navigation, openSettings }: PanelProps) {
|
|
42
|
+
const { messages: m } = useProgressLanguage();
|
|
38
43
|
const colors = theme.colors;
|
|
39
44
|
const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
|
|
40
45
|
// Paseo marks the workspace running while any of its agents is. Since 0.11 a Claude
|
|
@@ -50,18 +55,19 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
|
|
|
50
55
|
<NarrowProvider value={narrow}>
|
|
51
56
|
<ScrollView onLayout={(event) => setNarrow(event.nativeEvent.layout.width <= NARROW_MAX)}
|
|
52
57
|
style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
|
|
53
|
-
{query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel=
|
|
58
|
+
{query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingProgress} style={{ padding: 12 }} /> : null}
|
|
54
59
|
{query.error ? (
|
|
55
60
|
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
|
|
56
|
-
|
|
61
|
+
{m.readProgressError(errorText(query.error, m.unknownError))}
|
|
57
62
|
</Text>
|
|
58
63
|
) : null}
|
|
64
|
+
<WhatsNewBanner colors={colors} host={host} />
|
|
59
65
|
<LauncherBanner colors={colors} host={host} />
|
|
60
66
|
<SkillBanner colors={colors} host={host} />
|
|
61
67
|
{/* Fades in like the dashboard's sections instead of popping in after the spinner. */}
|
|
62
|
-
<Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} /> : null}</Presence>
|
|
68
|
+
<Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} openSettings={openSettings} /> : null}</Presence>
|
|
63
69
|
{/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
|
|
64
|
-
{result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
|
|
70
|
+
{result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} openSettings={openSettings} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
|
|
65
71
|
workspaceId={workspaceId} workspaceDirectory={result.root} file={result.file} navigation={navigation} /> : null}
|
|
66
72
|
{result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
|
|
67
73
|
</ScrollView>
|
|
@@ -84,17 +90,31 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
|
|
|
84
90
|
}, [needed, directory, workspaceId, mark]);
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
|
|
93
|
+
// Optical: the 24px button centers on a 20 to 21px title line.
|
|
94
|
+
function SettingsButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
|
|
95
|
+
const { messages: m } = useProgressLanguage();
|
|
96
|
+
return (
|
|
97
|
+
<PressScale accessibilityRole="button" accessibilityLabel={m.openSettings} hitSlop={6} outerStyle={{ marginVertical: -2 }} onPress={onPress}
|
|
98
|
+
style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface1 : "transparent" })}>
|
|
99
|
+
<Icon name="Settings" size={16} color={colors.foregroundMuted} />
|
|
100
|
+
</PressScale>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function EmptyState({ colors, file, openSettings }: { colors: Colors; file: string; openSettings(): void }) {
|
|
105
|
+
const { messages: m } = useProgressLanguage();
|
|
88
106
|
return (
|
|
89
107
|
<View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
|
|
90
108
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
91
109
|
<Icon name="ListChecks" size={18} color={colors.foregroundMuted} />
|
|
92
110
|
<Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 14, lineHeight: 20, fontWeight: "500" }}>
|
|
93
|
-
|
|
111
|
+
{m.noProgress}
|
|
94
112
|
</Text>
|
|
113
|
+
<View style={{ flex: 1 }} />
|
|
114
|
+
<SettingsButton colors={colors} onPress={openSettings} />
|
|
95
115
|
</View>
|
|
96
116
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
97
|
-
|
|
117
|
+
{m.emptyIntro[0]}<Text style={{ color: colors.foreground }}>paseo-progress</Text>{m.emptyIntro[1]}
|
|
98
118
|
</Text>
|
|
99
119
|
<View style={{ backgroundColor: colors.surface1, borderRadius: 6, padding: 10 }}>
|
|
100
120
|
<Text selectable style={{ color: colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>
|
|
@@ -103,7 +123,7 @@ function EmptyState({ colors, file }: { colors: Colors; file: string }) {
|
|
|
103
123
|
</View>
|
|
104
124
|
{/* So people know nothing lands in the repo. */}
|
|
105
125
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
106
|
-
|
|
126
|
+
{m.savedOutside[0]}<Text selectable style={{ color: colors.foreground }}>{file}</Text>{m.savedOutside[1]}
|
|
107
127
|
</Text>
|
|
108
128
|
</View>
|
|
109
129
|
);
|
|
@@ -132,7 +152,8 @@ function useNow(intervalMs: number): number {
|
|
|
132
152
|
|
|
133
153
|
const WIDE_MIN = 760;
|
|
134
154
|
|
|
135
|
-
function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation }: {
|
|
155
|
+
function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation, openSettings }: {
|
|
156
|
+
openSettings(): void;
|
|
136
157
|
colors: Colors;
|
|
137
158
|
dashboard: Dashboard;
|
|
138
159
|
agentRunning: boolean;
|
|
@@ -142,6 +163,8 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
142
163
|
file: string;
|
|
143
164
|
navigation: PluginWorkspacePanelProps["navigation"];
|
|
144
165
|
}) {
|
|
166
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
167
|
+
const headline = clientHeadline(locale, dashboard);
|
|
145
168
|
const now = useNow(15_000);
|
|
146
169
|
// Quiet isn't stale while an agent in the workspace is still running.
|
|
147
170
|
const stale = dashboard.stale && !agentRunning;
|
|
@@ -155,17 +178,20 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
155
178
|
<StaggerRoot>
|
|
156
179
|
<Presence show order={0}>
|
|
157
180
|
<View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 10, gap: 4, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
158
|
-
<
|
|
159
|
-
{
|
|
160
|
-
|
|
181
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
182
|
+
<Text accessibilityRole="header" style={{ flex: 1, color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" }}>
|
|
183
|
+
{dashboard.run?.title ?? "Progress"}
|
|
184
|
+
</Text>
|
|
185
|
+
<SettingsButton colors={colors} onPress={openSettings} />
|
|
186
|
+
</View>
|
|
161
187
|
{dashboard.run?.subtitle ? (
|
|
162
188
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{dashboard.run.subtitle}</Text>
|
|
163
189
|
) : null}
|
|
164
|
-
{
|
|
190
|
+
{headline.length ? (
|
|
165
191
|
<Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
166
|
-
{
|
|
192
|
+
{headline.map((part, index) => (
|
|
167
193
|
<Text key={part.text} style={part.tone === "danger" ? { color: colors.statusDanger } : undefined}>
|
|
168
|
-
{index ?
|
|
194
|
+
{index ? m.listSeparator : ""}{part.text}
|
|
169
195
|
</Text>
|
|
170
196
|
))}
|
|
171
197
|
</Text>
|
|
@@ -181,7 +207,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
181
207
|
{dashboard.run?.finished ? (
|
|
182
208
|
<>
|
|
183
209
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
184
|
-
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>
|
|
210
|
+
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{m.finished}</Text> <When colors={colors} iso={dashboard.run.finished.at} now={now} />
|
|
185
211
|
</Text>
|
|
186
212
|
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>{dashboard.run.finished.outcome}</Text>
|
|
187
213
|
</>
|
|
@@ -194,7 +220,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
194
220
|
{dashboard.updatedAt && !dashboard.run?.finished ? (
|
|
195
221
|
<StatusLine icon={<Icon name="Clock" size={12} color={colors.foregroundMuted} />}>
|
|
196
222
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
197
|
-
|
|
223
|
+
{m.lastUpdated}<When colors={colors} iso={dashboard.updatedAt} now={now} />
|
|
198
224
|
</Text>
|
|
199
225
|
</StatusLine>
|
|
200
226
|
) : null}
|
|
@@ -205,7 +231,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
205
231
|
{/* Optical: centers the 14px icon on the first 18px line. */}
|
|
206
232
|
<View style={{ paddingTop: 2 }}><Icon name="TriangleAlert" size={14} color={colors.statusWarning} /></View>
|
|
207
233
|
<Text style={{ flex: 1, color: colors.statusWarning, fontSize: 12, lineHeight: 18 }}>
|
|
208
|
-
|
|
234
|
+
{m.staleWarning(unbroken(formatClientMinutes(locale, minutesSince(dashboard.updatedAt, now))))}
|
|
209
235
|
</Text>
|
|
210
236
|
</View>
|
|
211
237
|
) : null}</Presence>
|
|
@@ -219,10 +245,10 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
219
245
|
) : null}</Presence>
|
|
220
246
|
<Presence show={Boolean(dashboard.stuck.length)} order={3}>{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}</Presence>
|
|
221
247
|
<Presence show order={4}>
|
|
222
|
-
<Card colors={colors} icon="ListChecks" title={
|
|
248
|
+
<Card colors={colors} icon="ListChecks" title={m.itemHeading(dashboard.run?.itemLabel ?? "Ticket")}>
|
|
223
249
|
{dashboard.tickets.length === 0 ? (
|
|
224
250
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
225
|
-
|
|
251
|
+
{m.noItems(dashboard.run?.itemLabel ?? "Ticket")}
|
|
226
252
|
</Text>
|
|
227
253
|
) : (
|
|
228
254
|
<TicketList colors={colors} dashboard={dashboard} live={live} now={now} workspaceId={workspaceId} onOpen={setOpenTicket} />
|
|
@@ -235,11 +261,11 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
235
261
|
<Presence show={hasHistory} order={5}>
|
|
236
262
|
{hasHistory ? (
|
|
237
263
|
<HistoryCard colors={colors} workspaceId={workspaceId} tabs={[
|
|
238
|
-
{ id: "activity", label:
|
|
264
|
+
{ id: "activity", label: m.activity, icon: "Activity", count: dashboard.activity.length, empty: m.noActivity,
|
|
239
265
|
content: <ActivityList colors={colors} activity={dashboard.activity} now={now} /> },
|
|
240
|
-
{ id: "answered", label:
|
|
266
|
+
{ id: "answered", label: m.answered, icon: "CircleCheck", count: dashboard.questions.answered.length, empty: m.noAnswers,
|
|
241
267
|
content: <AnsweredQuestionsList colors={colors} questions={dashboard.questions.answered} now={now} context={attachmentContext} /> },
|
|
242
|
-
{ id: "deliverables", label:
|
|
268
|
+
{ id: "deliverables", label: m.deliverables, icon: "Package", count: dashboard.deliverables.length, empty: m.noDeliverables,
|
|
243
269
|
content: <DeliverablesList colors={colors} deliverables={dashboard.deliverables} now={now} workspaceId={workspaceId}
|
|
244
270
|
workspaceDirectory={workspaceDirectory} navigation={navigation} /> },
|
|
245
271
|
]} />
|
|
@@ -303,14 +329,14 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
|
|
|
303
329
|
|
|
304
330
|
// Small and non-blocking: the rest of the dashboard still renders.
|
|
305
331
|
function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIssue[]; file: string }) {
|
|
332
|
+
const { messages: m } = useProgressLanguage();
|
|
306
333
|
const shown = issues.slice(0, 5);
|
|
307
334
|
return (
|
|
308
335
|
<View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
|
|
309
336
|
<View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
|
|
310
337
|
<Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
311
|
-
|
|
312
|
-
{shown.map((issue) =>
|
|
313
|
-
{issues.length > shown.length ? `, and ${issues.length - shown.length} more` : ""}.
|
|
338
|
+
{m.skippedLines(issues.length, file)}
|
|
339
|
+
{m.issueList(shown.map((issue) => m.issueLine(issue.line, issue.reason)), issues.length - shown.length)}
|
|
314
340
|
</Text>
|
|
315
341
|
</View>
|
|
316
342
|
);
|
|
@@ -326,15 +352,16 @@ function Card({ colors, icon, title, children }: { colors: Colors; icon: string;
|
|
|
326
352
|
}
|
|
327
353
|
|
|
328
354
|
function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Dashboard["progress"]; live: boolean }) {
|
|
355
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
329
356
|
const narrow = useNarrow();
|
|
330
357
|
return (
|
|
331
358
|
<View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
332
359
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
333
360
|
<Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
|
|
334
|
-
<Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> {narrow ?
|
|
361
|
+
<Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> {narrow ? m.workDoneShort : m.workDoneLong}
|
|
335
362
|
</Text>
|
|
336
363
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
337
|
-
{
|
|
364
|
+
{formatClientWorkDone(locale, progress.doneMin, progress.totalMin, progress.tookMin)}
|
|
338
365
|
</Text>
|
|
339
366
|
</View>
|
|
340
367
|
<View accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: progress.percent }}
|
|
@@ -379,10 +406,11 @@ function Shimmer() {
|
|
|
379
406
|
const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
|
|
380
407
|
|
|
381
408
|
function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
|
|
409
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
382
410
|
const narrow = useNarrow();
|
|
383
411
|
return (
|
|
384
412
|
<View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
|
|
385
|
-
<SectionTitle colors={colors} icon="OctagonAlert" title=
|
|
413
|
+
<SectionTitle colors={colors} icon="OctagonAlert" title={m.stuck} color={colors.statusDanger} style={{ borderBottomWidth: 1, borderBottomColor: colors.border }} />
|
|
386
414
|
{items.map((item, index) => (
|
|
387
415
|
<View key={item.key} style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
388
416
|
{/* Optical: centers the 14px icon on the 18px title line. */}
|
|
@@ -390,9 +418,9 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
|
|
|
390
418
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
391
419
|
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
|
|
392
420
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
393
|
-
{item.kind === "overdue" ?
|
|
394
|
-
{item.kind === "blocked" ?
|
|
395
|
-
{item.kind === "manual" ? `${item.ticketId ? `${item.reason}.
|
|
421
|
+
{item.kind === "overdue" ? m.overdueUpdate(unbroken(formatClientMinutes(locale, item.estimateMin))) : null}
|
|
422
|
+
{item.kind === "blocked" ? m.blockedSince(item.note, narrow) : null}
|
|
423
|
+
{item.kind === "manual" ? `${item.ticketId ? `${item.reason}${m.sentenceBreak}` : ""}${m.flagged}` : null}
|
|
396
424
|
<When colors={colors} iso={item.since} now={now} />
|
|
397
425
|
</Text>
|
|
398
426
|
</View>
|
|
@@ -432,15 +460,16 @@ function TicketList({ colors, dashboard, live, now, workspaceId, onOpen }: { col
|
|
|
432
460
|
// Sits on the ticket rows' columns: status icon, label, estimate, then the
|
|
433
461
|
// chevron, which points the way the list will move, like "Show 10 older".
|
|
434
462
|
function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tickets: Ticket[]; shown: boolean; onPress(): void }) {
|
|
463
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
435
464
|
const done = tickets.filter((ticket) => ticket.status === "done").length;
|
|
436
465
|
const skipped = tickets.length - done;
|
|
437
|
-
const label = [done ?
|
|
466
|
+
const label = [done ? m.doneCount(done) : "", skipped ? m.skippedCount(skipped) : ""].filter(Boolean).join(m.listSeparator);
|
|
438
467
|
const estimateMin = tickets.reduce((sum, ticket) => sum + (ticket.status === "done" ? ticket.estimateMin : 0), 0);
|
|
439
468
|
const chevron = shown ? "ChevronUp" : "ChevronDown";
|
|
440
469
|
const narrow = useNarrow();
|
|
441
470
|
return (
|
|
442
471
|
<PressableRow colors={colors} accessibilityRole="button" accessibilityState={{ expanded: shown }}
|
|
443
|
-
accessibilityLabel={
|
|
472
|
+
accessibilityLabel={m.toggleFinished(shown, label)} onPress={onPress}
|
|
444
473
|
style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
445
474
|
<View style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
|
|
446
475
|
<Icon name={done ? "Check" : "CircleSlash"} size={14} color={done ? colors.statusSuccess : colors.foregroundMuted} />
|
|
@@ -449,7 +478,7 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
|
|
|
449
478
|
{/* Narrow ticket rows drop their chevron, so this total would sit off their estimate column. */}
|
|
450
479
|
{narrow ? null : <>
|
|
451
480
|
<View style={{ width: 12 }} />
|
|
452
|
-
<Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ?
|
|
481
|
+
<Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatClientMinutes(locale, estimateMin) : ""}</Text>
|
|
453
482
|
</>}
|
|
454
483
|
<View style={{ paddingTop: 2 }}>
|
|
455
484
|
<IconSwap swapKey={chevron} size={14}><Icon name={chevron} size={14} color={colors.foregroundMuted} /></IconSwap>
|
|
@@ -460,16 +489,17 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
|
|
|
460
489
|
|
|
461
490
|
// Press for the ticket's story. Hover and press only tint the row: it's a list, pressed often.
|
|
462
491
|
function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors: Colors; ticket: Ticket; live: boolean; now: number; onOpen(): void; deliverables: number }) {
|
|
492
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
463
493
|
const muted = ticket.status === "skipped";
|
|
464
494
|
const working = ticket.status === "working";
|
|
465
495
|
// Narrow: tighter gaps and no chevron (the whole row still presses), so the title keeps its width.
|
|
466
496
|
const narrow = useNarrow();
|
|
467
497
|
return (
|
|
468
498
|
<PressableRow colors={colors} onSurface1={working} transparentAtRest={!working}
|
|
469
|
-
accessibilityRole="button" accessibilityLabel={
|
|
499
|
+
accessibilityRole="button" accessibilityLabel={m.details(ticket.id)} onPress={onOpen}
|
|
470
500
|
style={{ flexDirection: "row", alignItems: "flex-start", gap: narrow ? 6 : 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
471
501
|
{/* The icon is the only status mark on the row; the ticket dialog spells it out in a badge. */}
|
|
472
|
-
<View accessible accessibilityLabel={ticket.waitingFor ?
|
|
502
|
+
<View accessible accessibilityLabel={ticket.waitingFor ? m.waiting : m.status[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center", marginRight: narrow ? 2 : 0 }}>
|
|
473
503
|
<IconSwap swapKey={ticket.waitingFor || ticket.status === "not_started" ? "waiting" : ticket.status === "working" ? `working-${live}` : ticket.status} size={14}><StatusIcon colors={colors} status={ticket.status} live={live} waiting={Boolean(ticket.waitingFor)} /></IconSwap>
|
|
474
504
|
</View>
|
|
475
505
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
@@ -478,24 +508,24 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
|
|
|
478
508
|
</Text>
|
|
479
509
|
{ticket.status === "working" && ticket.stage && ticket.stageSince ? (
|
|
480
510
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
481
|
-
{ticket.stage}
|
|
511
|
+
{m.stageStarted(ticket.stage)}<When colors={colors} iso={ticket.stageSince} now={now} />
|
|
482
512
|
</Text>
|
|
483
513
|
) : null}
|
|
484
514
|
{ticket.status === "working" && ticket.workingSince ? (
|
|
485
515
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
486
|
-
{narrow ?
|
|
516
|
+
{narrow ? m.startedWorking : m.workingSince}<When colors={colors} iso={ticket.workingSince} now={now} />
|
|
487
517
|
</Text>
|
|
488
518
|
) : null}
|
|
489
519
|
{ticket.waitingFor ? (
|
|
490
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
520
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.waitsFor(shortTitle(ticket.waitingFor.title))}</Text>
|
|
491
521
|
) : null}
|
|
492
522
|
{ticket.note ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{ticket.note}</Text> : null}
|
|
493
523
|
</View>
|
|
494
524
|
{/* Fixed slots so the Package icon and estimates line up down the list, whether or not a row has deliverables; they're listed in its dialog. The estimate slot fits "45m" up to "9h 45m"; longer ones grow rather than clip. */}
|
|
495
|
-
<View accessibilityLabel={deliverables ? (deliverables
|
|
525
|
+
<View accessibilityLabel={deliverables ? m.hasDeliverables(deliverables) : undefined} style={{ width: 12, height: 18, justifyContent: "center" }}>
|
|
496
526
|
{deliverables ? <Icon name="Package" size={12} color={colors.foregroundMuted} /> : null}
|
|
497
527
|
</View>
|
|
498
|
-
<Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{
|
|
528
|
+
<Text style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatClientMinutes(locale, ticket.estimateMin)}</Text>
|
|
499
529
|
{narrow ? null : <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>}
|
|
500
530
|
</PressableRow>
|
|
501
531
|
);
|
package/client/pill-popover.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { galleryFiles } from "./preview";
|
|
|
12
12
|
import { QuestionDetail, QuestionState, useCopy } from "./questions";
|
|
13
13
|
import { errorText } from "./errors";
|
|
14
14
|
import { raised } from "./surfaces";
|
|
15
|
+
import { useProgressLanguage } from "./language";
|
|
15
16
|
|
|
16
17
|
type Colors = PluginButtonContentProps["theme"]["colors"];
|
|
17
18
|
|
|
@@ -22,6 +23,7 @@ const LIST_MAX_HEIGHT = 340;
|
|
|
22
23
|
// What the pill opens: the open questions with their Copy buttons, and anything
|
|
23
24
|
// stuck, so the user can answer without leaving the chat.
|
|
24
25
|
export function AttentionPopover({ theme, host, layout, workspaceId, close, openPanel }: PluginButtonContentProps & { openPanel(): void }) {
|
|
26
|
+
const { messages: m } = useProgressLanguage();
|
|
25
27
|
const colors = theme.colors;
|
|
26
28
|
// Paseo draws the desktop popover on surface1 and the phone sheet on surface0.
|
|
27
29
|
const onSurface1 = !layout.compact;
|
|
@@ -53,7 +55,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
53
55
|
</List>
|
|
54
56
|
) : (
|
|
55
57
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
56
|
-
{query.error ?
|
|
58
|
+
{query.error ? m.readProgressError(errorText(query.error, m.unknownError)) : m.loading}
|
|
57
59
|
</Text>
|
|
58
60
|
)}
|
|
59
61
|
<StackedDialog colors={colors} now={Date.now()} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory: root }}
|
|
@@ -65,7 +67,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
65
67
|
backgroundColor: onSurface1 ? (pressed ? colors.surface1 : colors.surface2) : pressed ? colors.surface2 : colors.surface1 })}>
|
|
66
68
|
{/* Beside the chat on desktop; on a phone it opens as its own tab, shown with the tab's icon. */}
|
|
67
69
|
<Icon name={touch ? "ListChecks" : "PanelRight"} size={touch ? 16 : 12} color={colors.foreground} />
|
|
68
|
-
<Text style={{ color: colors.foreground, fontSize: touch ? 15 : 12, lineHeight: touch ? 20 : 16, fontWeight: "600" }}>
|
|
70
|
+
<Text style={{ color: colors.foreground, fontSize: touch ? 15 : 12, lineHeight: touch ? 20 : 16, fontWeight: "600" }}>{m.openProgress}</Text>
|
|
69
71
|
</PressScale>
|
|
70
72
|
</View>
|
|
71
73
|
</View>
|
|
@@ -87,10 +89,11 @@ export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAtt
|
|
|
87
89
|
onCopy(question: Dashboard["questions"]["open"][number], letter: string): void;
|
|
88
90
|
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
89
91
|
}) {
|
|
92
|
+
const { messages: m } = useProgressLanguage();
|
|
90
93
|
const now = Date.now();
|
|
91
94
|
// The last question can be answered while the popover is open.
|
|
92
95
|
if (!dashboard.questions.open.length && !dashboard.stuck.length) {
|
|
93
|
-
return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
96
|
+
return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.nothingNeedsYou}</Text>;
|
|
94
97
|
}
|
|
95
98
|
return (
|
|
96
99
|
<>
|
|
@@ -110,13 +113,13 @@ export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAtt
|
|
|
110
113
|
<View style={{ gap: 4 }}>
|
|
111
114
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
112
115
|
<Icon name="OctagonAlert" size={12} color={colors.statusDanger} />
|
|
113
|
-
<Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>
|
|
116
|
+
<Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>{m.stuck}</Text>
|
|
114
117
|
</View>
|
|
115
118
|
{dashboard.stuck.map((item) => (
|
|
116
119
|
<Text key={item.key} style={{ color: colors.foreground, fontSize: 12, lineHeight: 17 }}>
|
|
117
120
|
{item.title}
|
|
118
121
|
<Text style={{ color: colors.foregroundMuted }}>
|
|
119
|
-
{item.kind === "overdue" ?
|
|
122
|
+
{item.kind === "overdue" ? m.overdueAside : item.kind === "blocked" ? m.blockedAside(item.note) : ""}
|
|
120
123
|
</Text>
|
|
121
124
|
</Text>
|
|
122
125
|
))}
|
package/client/pill-state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { messagesFor, type ProgressMessages } from "./i18n";
|
|
1
2
|
import type { AttentionResult } from "../shared/rpc";
|
|
2
3
|
|
|
3
4
|
// What an agent's Progress pill shows, decided in one place so its label, its
|
|
@@ -13,15 +14,15 @@ export type Pill =
|
|
|
13
14
|
|
|
14
15
|
// What needs the user first, else the nudge, else (on a phone) the way in.
|
|
15
16
|
// Null when there's no pill.
|
|
16
|
-
export function pillFor(report: AttentionResult | undefined, where: { phone: boolean }): Pill | null {
|
|
17
|
+
export function pillFor(report: AttentionResult | undefined, where: { phone: boolean }, m: ProgressMessages = messagesFor("en")): Pill | null {
|
|
17
18
|
if (!report?.configured) return null;
|
|
18
19
|
const { questions, stuck } = report;
|
|
19
20
|
if (questions || stuck) {
|
|
20
21
|
// Paseo cuts composer pills off at 160 px, so when something is stuck
|
|
21
22
|
// too, the questions shorten to "2Q" to leave room for "1 stuck".
|
|
22
23
|
const parts = [];
|
|
23
|
-
if (questions) parts.push(stuck ?
|
|
24
|
-
if (stuck) parts.push(
|
|
24
|
+
if (questions) parts.push(stuck ? m.shortQuestions(questions) : m.questionCount(questions));
|
|
25
|
+
if (stuck) parts.push(m.stuckCount(stuck));
|
|
25
26
|
return { mode: "attention", label: parts.join(" · "), icon: stuck ? "stuck" : "questions" };
|
|
26
27
|
}
|
|
27
28
|
if (report.runOpen && !report.panelOpened) return { mode: "started", label: "Progress", icon: "started" };
|
package/client/pills.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PluginButtonContentProps, PluginButtonIconProps, PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
-
import React, { useSyncExternalStore } from "react";
|
|
3
|
+
import React, { useEffect, useSyncExternalStore } from "react";
|
|
4
4
|
import { Platform } from "react-native";
|
|
5
5
|
import { type AttentionResult, getAttention } from "../shared/rpc";
|
|
6
6
|
import { isIdle, type Pill, pillFor } from "./pill-state";
|
|
@@ -8,6 +8,8 @@ import { observeDirectory, type PaseoAgentUpdate, type PaseoWorkspaceUpdate } fr
|
|
|
8
8
|
import { onPanelOpened } from "./panel-opened";
|
|
9
9
|
import { IconSwap } from "./motion";
|
|
10
10
|
import { Spinner } from "./spinner";
|
|
11
|
+
import { messagesFor, resolveLocale } from "./i18n";
|
|
12
|
+
import { ProgressLanguageProvider, useProgressLanguage } from "./language";
|
|
11
13
|
import { AttentionPopover } from "./pill-popover";
|
|
12
14
|
|
|
13
15
|
export const PILL_POLL_MS = 5_000;
|
|
@@ -41,12 +43,13 @@ export function contributePills(client: PluginClientContext) {
|
|
|
41
43
|
const workspaces = new Map<string, string>();
|
|
42
44
|
// The last report per workspace; pillFor turns it into what the pill shows.
|
|
43
45
|
const reports = new Map<string, AttentionResult>();
|
|
46
|
+
let locale = resolveLocale("system");
|
|
44
47
|
const where = { phone: progressPanel().location === "workspace" };
|
|
45
|
-
const pillOf = (workspaceId: string) => pillFor(reports.get(workspaceId), where);
|
|
48
|
+
const pillOf = (workspaceId: string) => pillFor(reports.get(workspaceId), where, messagesFor(locale));
|
|
46
49
|
// Bumped on each report so a poll that was already in flight can't bring the pill back.
|
|
47
50
|
let openedReports = 0;
|
|
48
51
|
const listeners = new Set<() => void>();
|
|
49
|
-
const pills = new Map<string, { workspaceId: string; label: string; mode: Pill["mode"]; pill: PluginButtonRegistration }>();
|
|
52
|
+
const pills = new Map<string, { workspaceId: string; label: string; title: string; mode: Pill["mode"]; pill: PluginButtonRegistration }>();
|
|
50
53
|
const pending = new Set<string>();
|
|
51
54
|
// Polls left before an idle workspace is checked again, and when each was last checked.
|
|
52
55
|
const idleWait = new Map<string, number>();
|
|
@@ -56,6 +59,16 @@ export function contributePills(client: PluginClientContext) {
|
|
|
56
59
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
57
60
|
|
|
58
61
|
function PillIcon(props: PluginButtonIconProps) {
|
|
62
|
+
return <ProgressLanguageProvider><LocalizedPillIcon {...props} /></ProgressLanguageProvider>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function LocalizedPillIcon(props: PluginButtonIconProps) {
|
|
66
|
+
const language = useProgressLanguage();
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (stopped || locale === language.locale) return;
|
|
69
|
+
locale = language.locale;
|
|
70
|
+
publish();
|
|
71
|
+
}, [language.locale]);
|
|
59
72
|
const icon = useSyncExternalStore(
|
|
60
73
|
(listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
61
74
|
() => pillOf(props.workspaceId)?.icon ?? "open",
|
|
@@ -76,11 +89,11 @@ export function contributePills(client: PluginClientContext) {
|
|
|
76
89
|
};
|
|
77
90
|
|
|
78
91
|
const buttonFor = (workspaceId: string, mode: Pill["mode"], label: string) => mode === "attention"
|
|
79
|
-
? { title:
|
|
80
|
-
: { title:
|
|
92
|
+
? { title: messagesFor(locale).needsYou, icon: PillIcon, label, behavior: { kind: "popover" as const, Content: PillContent } }
|
|
93
|
+
: { title: messagesFor(locale).openProgress, icon: PillIcon, label, behavior: { kind: "action" as const, onPress: () => openPanel(workspaceId) } };
|
|
81
94
|
|
|
82
95
|
function PillContent(props: PluginButtonContentProps) {
|
|
83
|
-
return <AttentionPopover {...props} openPanel={() => openPanel(props.workspaceId)}
|
|
96
|
+
return <ProgressLanguageProvider><AttentionPopover {...props} openPanel={() => openPanel(props.workspaceId)} /></ProgressLanguageProvider>;
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
const publish = () => {
|
|
@@ -92,6 +105,7 @@ export function contributePills(client: PluginClientContext) {
|
|
|
92
105
|
const wanted = pillOf(workspaceId);
|
|
93
106
|
if (!wanted) continue;
|
|
94
107
|
const { mode, label } = wanted;
|
|
108
|
+
const title = mode === "attention" ? messagesFor(locale).needsYou : messagesFor(locale).openProgress;
|
|
95
109
|
seen.add(agent.id);
|
|
96
110
|
const existing = pills.get(agent.id);
|
|
97
111
|
if (existing && existing.workspaceId !== workspaceId) {
|
|
@@ -101,13 +115,13 @@ export function contributePills(client: PluginClientContext) {
|
|
|
101
115
|
const entry = pills.get(agent.id);
|
|
102
116
|
if (!entry) {
|
|
103
117
|
const pill = client.addComposerPill({ id: "progress-attention", workspaceId, agentId: agent.id, button: buttonFor(workspaceId, mode, label) });
|
|
104
|
-
pills.set(agent.id, { workspaceId, label, mode, pill });
|
|
118
|
+
pills.set(agent.id, { workspaceId, title, label, mode, pill });
|
|
105
119
|
} else if (entry.mode !== mode) {
|
|
106
120
|
entry.pill.update(buttonFor(workspaceId, mode, label));
|
|
107
|
-
Object.assign(entry, { mode, label });
|
|
108
|
-
} else if (entry.label !== label) {
|
|
109
|
-
entry.pill.update({ label });
|
|
110
|
-
entry
|
|
121
|
+
Object.assign(entry, { mode, label, title });
|
|
122
|
+
} else if (entry.label !== label || entry.title !== title) {
|
|
123
|
+
entry.pill.update({ label, title });
|
|
124
|
+
Object.assign(entry, { label, title });
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
for (const [agentId, entry] of pills) {
|