@stevecastaneda/paseo-progress 1.14.1 → 1.14.3
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 +28 -17
- package/client/attachments.tsx +122 -28
- package/client/blocks.tsx +33 -8
- package/client/chat-row-view.tsx +19 -6
- package/client/chat-row.ts +13 -2
- package/client/dashboard-query.ts +9 -4
- package/client/deliverables.tsx +82 -17
- package/client/dialog-stack.tsx +104 -42
- package/client/directory.ts +1 -3
- package/client/errors.ts +6 -1
- package/client/history.tsx +233 -79
- package/client/i18n.ts +10 -5
- package/client/launcher.tsx +57 -15
- package/client/locales/en.ts +28 -22
- package/client/locales/zh-CN.ts +18 -17
- package/client/messages.ts +1 -0
- package/client/motion.tsx +38 -14
- package/client/panel-opened.ts +3 -1
- package/client/panel.tsx +442 -160
- package/client/pdf.tsx +18 -4
- package/client/pieces.ts +6 -1
- package/client/pill-popover.tsx +108 -23
- package/client/pills.tsx +35 -12
- package/client/preview.tsx +265 -79
- package/client/questions.tsx +176 -55
- package/client/row.tsx +14 -3
- package/client/section-title.tsx +11 -2
- package/client/settings.tsx +76 -24
- package/client/show-more.tsx +23 -6
- package/client/spinner.tsx +11 -7
- package/client/status-badge.tsx +9 -7
- package/client/tabs.tsx +98 -20
- package/client/ticket-dialog.tsx +241 -73
- package/client/time-bar.tsx +5 -2
- package/client/video.tsx +15 -6
- package/client/whats-new.tsx +94 -36
- package/client/when.tsx +2 -1
- package/client/write-up.tsx +79 -12
- package/dist/server/cli.js +199 -101
- package/index.client.tsx +28 -13
- package/index.server.ts +19 -1
- package/package.json +15 -14
- package/server/cli.ts +72 -32
- package/server/dashboard.ts +9 -13
- package/server/launcher.ts +33 -17
- package/server/lock.ts +14 -7
- package/server/open.ts +32 -98
- package/server/progress-file.ts +6 -6
- package/server/question-mode.ts +12 -6
- package/server/skill.ts +22 -16
- package/server/store.ts +26 -41
- package/server/system.ts +113 -0
- package/server/tool.ts +89 -44
- package/server/whats-new.ts +25 -13
- package/shared/attachments.ts +40 -8
- package/shared/blocks.ts +9 -6
- package/shared/dashboard.ts +46 -29
- package/shared/events.ts +12 -10
- package/shared/markdown.ts +16 -8
- package/shared/question-modes.ts +8 -5
- package/shared/rpc.ts +9 -3
- package/shared/show.ts +20 -8
- package/shared/ticket-story.ts +1 -3
- package/skills/paseo-progress/SKILL.md +1 -0
- package/tsconfig.json +3 -10
- package/client/page-opener.ts +0 -9
- package/client/page-screen.tsx +0 -97
package/client/panel.tsx
CHANGED
|
@@ -12,7 +12,17 @@ import { raised } from "./surfaces";
|
|
|
12
12
|
import { unbroken, When } from "./when";
|
|
13
13
|
import React, { useEffect, useRef, useState } from "react";
|
|
14
14
|
import { ActivityIndicator, Animated, Platform, ScrollView, Text, View } from "react-native";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
finishedToFold,
|
|
17
|
+
isFinished,
|
|
18
|
+
shortTitle,
|
|
19
|
+
type Activity,
|
|
20
|
+
type Dashboard,
|
|
21
|
+
type FileIssue,
|
|
22
|
+
type ProgressSegment,
|
|
23
|
+
type StuckItem,
|
|
24
|
+
type Ticket,
|
|
25
|
+
} from "../shared/dashboard";
|
|
16
26
|
import type { TicketStatus } from "../shared/events";
|
|
17
27
|
import { minutesSince } from "../shared/format";
|
|
18
28
|
import { clientHeadline, formatClientMinutes, formatClientWorkDone } from "./i18n";
|
|
@@ -29,7 +39,6 @@ import { useProgressLanguage } from "./language";
|
|
|
29
39
|
|
|
30
40
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
31
41
|
|
|
32
|
-
|
|
33
42
|
const mono = () => (Platform.OS === "ios" ? "Menlo" : "monospace");
|
|
34
43
|
|
|
35
44
|
type PanelProps = PluginWorkspacePanelProps & { openSettings(): void };
|
|
@@ -53,24 +62,41 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation, openS
|
|
|
53
62
|
|
|
54
63
|
return (
|
|
55
64
|
<NarrowProvider value={narrow}>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
<ScrollView
|
|
66
|
+
onLayout={(event) => setNarrow(event.nativeEvent.layout.width <= NARROW_MAX)}
|
|
67
|
+
style={{ flex: 1, backgroundColor: colors.surface0 }}
|
|
68
|
+
contentContainerStyle={{ paddingBottom: 16 }}
|
|
69
|
+
>
|
|
70
|
+
{query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingProgress} style={{ padding: 12 }} /> : null}
|
|
71
|
+
{query.error ? (
|
|
72
|
+
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
|
|
73
|
+
{m.readProgressError(errorText(query.error, m.unknownError))}
|
|
74
|
+
</Text>
|
|
75
|
+
) : null}
|
|
76
|
+
<WhatsNewBanner colors={colors} host={host} />
|
|
77
|
+
<LauncherBanner colors={colors} host={host} />
|
|
78
|
+
<SkillBanner colors={colors} host={host} />
|
|
79
|
+
{/* Fades in like the dashboard's sections instead of popping in after the spinner. */}
|
|
80
|
+
<Presence show={Boolean(result && !result.configured)}>
|
|
81
|
+
{result && !result.configured ? <EmptyState colors={colors} hostId={host.id} file={result.file} openSettings={openSettings} /> : null}
|
|
82
|
+
</Presence>
|
|
83
|
+
{/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
|
|
84
|
+
{result?.configured ? (
|
|
85
|
+
<DashboardView
|
|
86
|
+
key={result.dashboard.run?.startedAt ?? "none"}
|
|
87
|
+
colors={colors}
|
|
88
|
+
openSettings={openSettings}
|
|
89
|
+
dashboard={result.dashboard}
|
|
90
|
+
agentRunning={agentRunning}
|
|
91
|
+
compact={layout.compact}
|
|
92
|
+
workspaceId={workspaceId}
|
|
93
|
+
workspaceDirectory={result.root}
|
|
94
|
+
file={result.file}
|
|
95
|
+
navigation={navigation}
|
|
96
|
+
/>
|
|
97
|
+
) : null}
|
|
98
|
+
{result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
|
|
99
|
+
</ScrollView>
|
|
74
100
|
</NarrowProvider>
|
|
75
101
|
);
|
|
76
102
|
}
|
|
@@ -86,7 +112,9 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
|
|
|
86
112
|
// Tell the pill only once the file exists, so its next poll agrees.
|
|
87
113
|
mark({ workspaceId, workspaceDirectory: directory })
|
|
88
114
|
.then(() => notePanelOpened(workspaceId))
|
|
89
|
-
.catch(() => {
|
|
115
|
+
.catch(() => {
|
|
116
|
+
sent.current = false;
|
|
117
|
+
});
|
|
90
118
|
}, [needed, directory, workspaceId, mark]);
|
|
91
119
|
}
|
|
92
120
|
|
|
@@ -94,8 +122,14 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
|
|
|
94
122
|
function SettingsButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
|
|
95
123
|
const { messages: m } = useProgressLanguage();
|
|
96
124
|
return (
|
|
97
|
-
<PressScale
|
|
98
|
-
|
|
125
|
+
<PressScale
|
|
126
|
+
accessibilityRole="button"
|
|
127
|
+
accessibilityLabel={m.openSettings}
|
|
128
|
+
hitSlop={6}
|
|
129
|
+
outerStyle={{ marginVertical: -2 }}
|
|
130
|
+
onPress={onPress}
|
|
131
|
+
style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface1 : "transparent" })}
|
|
132
|
+
>
|
|
99
133
|
<Icon name="Settings" size={16} color={colors.foregroundMuted} />
|
|
100
134
|
</PressScale>
|
|
101
135
|
);
|
|
@@ -119,7 +153,9 @@ function EmptyState({ colors, hostId, file, openSettings }: { colors: Colors; ho
|
|
|
119
153
|
) : (
|
|
120
154
|
<>
|
|
121
155
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
122
|
-
{m.emptyIntro[0]}
|
|
156
|
+
{m.emptyIntro[0]}
|
|
157
|
+
<Text style={{ color: colors.foreground }}>paseo-progress</Text>
|
|
158
|
+
{m.emptyIntro[1]}
|
|
123
159
|
</Text>
|
|
124
160
|
<View style={{ backgroundColor: colors.surface1, borderRadius: 6, padding: 10 }}>
|
|
125
161
|
<Text selectable style={{ color: colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>
|
|
@@ -136,7 +172,11 @@ function EmptyState({ colors, hostId, file, openSettings }: { colors: Colors; ho
|
|
|
136
172
|
) : null}
|
|
137
173
|
{/* So people know nothing lands in the repo. */}
|
|
138
174
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
139
|
-
{m.savedOutside[0]}
|
|
175
|
+
{m.savedOutside[0]}
|
|
176
|
+
<Text selectable style={{ color: colors.foreground }}>
|
|
177
|
+
{file}
|
|
178
|
+
</Text>
|
|
179
|
+
{m.savedOutside[1]}
|
|
140
180
|
</Text>
|
|
141
181
|
</View>
|
|
142
182
|
);
|
|
@@ -165,7 +205,17 @@ function useNow(intervalMs: number): number {
|
|
|
165
205
|
|
|
166
206
|
const WIDE_MIN = 760;
|
|
167
207
|
|
|
168
|
-
function DashboardView({
|
|
208
|
+
function DashboardView({
|
|
209
|
+
colors,
|
|
210
|
+
dashboard,
|
|
211
|
+
agentRunning,
|
|
212
|
+
compact,
|
|
213
|
+
workspaceId,
|
|
214
|
+
workspaceDirectory,
|
|
215
|
+
file,
|
|
216
|
+
navigation,
|
|
217
|
+
openSettings,
|
|
218
|
+
}: {
|
|
169
219
|
openSettings(): void;
|
|
170
220
|
colors: Colors;
|
|
171
221
|
dashboard: Dashboard;
|
|
@@ -190,98 +240,189 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
|
|
|
190
240
|
return (
|
|
191
241
|
<StaggerRoot>
|
|
192
242
|
<Presence show order={0}>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{index ? m.listSeparator : ""}{part.text}
|
|
208
|
-
</Text>
|
|
209
|
-
))}
|
|
210
|
-
</Text>
|
|
211
|
-
) : null}
|
|
212
|
-
{/* One status slot: the icon morphs from spinner (or stalled pulse) to the check on finish. */}
|
|
213
|
-
{dashboard.run?.finished || dashboard.ticker ? (
|
|
214
|
-
<StatusLine icon={
|
|
215
|
-
<IconSwap swapKey={dashboard.run?.finished ? "finished" : live ? "live" : "stale"} size={12}>
|
|
216
|
-
{dashboard.run?.finished ? <Icon name="CircleCheck" size={12} color={colors.statusSuccess} />
|
|
217
|
-
: live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />}
|
|
218
|
-
</IconSwap>
|
|
219
|
-
}>
|
|
220
|
-
{dashboard.run?.finished ? (
|
|
221
|
-
<>
|
|
222
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
223
|
-
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{m.finished}</Text> <When colors={colors} iso={dashboard.run.finished.at} now={now} />
|
|
243
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 10, gap: 4, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
244
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
245
|
+
<Text accessibilityRole="header" style={{ flex: 1, color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" }}>
|
|
246
|
+
{dashboard.run?.title ?? "Progress"}
|
|
247
|
+
</Text>
|
|
248
|
+
<SettingsButton colors={colors} onPress={openSettings} />
|
|
249
|
+
</View>
|
|
250
|
+
{dashboard.run?.subtitle ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{dashboard.run.subtitle}</Text> : null}
|
|
251
|
+
{headline.length ? (
|
|
252
|
+
<Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
253
|
+
{headline.map((part, index) => (
|
|
254
|
+
<Text key={part.text} style={part.tone === "danger" ? { color: colors.statusDanger } : undefined}>
|
|
255
|
+
{index ? m.listSeparator : ""}
|
|
256
|
+
{part.text}
|
|
224
257
|
</Text>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
258
|
+
))}
|
|
259
|
+
</Text>
|
|
260
|
+
) : null}
|
|
261
|
+
{/* One status slot: the icon morphs from spinner (or stalled pulse) to the check on finish. */}
|
|
262
|
+
{dashboard.run?.finished || dashboard.ticker ? (
|
|
263
|
+
<StatusLine
|
|
264
|
+
icon={
|
|
265
|
+
<IconSwap swapKey={dashboard.run?.finished ? "finished" : live ? "live" : "stale"} size={12}>
|
|
266
|
+
{dashboard.run?.finished ? (
|
|
267
|
+
<Icon name="CircleCheck" size={12} color={colors.statusSuccess} />
|
|
268
|
+
) : live ? (
|
|
269
|
+
<Spinner color={colors.accent} />
|
|
270
|
+
) : (
|
|
271
|
+
<StalledPulse color={colors.statusWarning} />
|
|
272
|
+
)}
|
|
273
|
+
</IconSwap>
|
|
274
|
+
}
|
|
275
|
+
>
|
|
276
|
+
{dashboard.run?.finished ? (
|
|
277
|
+
<>
|
|
278
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
279
|
+
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{m.finished}</Text>{" "}
|
|
280
|
+
<When colors={colors} iso={dashboard.run.finished.at} now={now} />
|
|
281
|
+
</Text>
|
|
282
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>{dashboard.run.finished.outcome}</Text>
|
|
283
|
+
</>
|
|
284
|
+
) : (
|
|
285
|
+
<Text accessibilityLiveRegion="polite" style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>
|
|
286
|
+
{dashboard.ticker!.text}
|
|
287
|
+
</Text>
|
|
288
|
+
)}
|
|
289
|
+
</StatusLine>
|
|
290
|
+
) : null}
|
|
291
|
+
{/* A finished run takes no more updates, so its finish time already says this. */}
|
|
292
|
+
{dashboard.updatedAt && !dashboard.run?.finished ? (
|
|
293
|
+
<StatusLine icon={<Icon name="Clock" size={12} color={colors.foregroundMuted} />}>
|
|
294
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
295
|
+
{m.lastUpdated}
|
|
296
|
+
<When colors={colors} iso={dashboard.updatedAt} now={now} />
|
|
297
|
+
</Text>
|
|
298
|
+
</StatusLine>
|
|
299
|
+
) : null}
|
|
300
|
+
</View>
|
|
301
|
+
</Presence>
|
|
302
|
+
<Presence show={Boolean(stale && dashboard.updatedAt)} order={1}>
|
|
303
|
+
{stale && dashboard.updatedAt ? (
|
|
304
|
+
<View
|
|
305
|
+
accessibilityRole="alert"
|
|
306
|
+
style={{
|
|
307
|
+
flexDirection: "row",
|
|
308
|
+
gap: 8,
|
|
309
|
+
margin: 12,
|
|
310
|
+
marginBottom: 0,
|
|
311
|
+
padding: 10,
|
|
312
|
+
borderWidth: 1,
|
|
313
|
+
borderColor: colors.statusWarning,
|
|
314
|
+
borderRadius: 6,
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{/* Optical: centers the 14px icon on the first 18px line. */}
|
|
318
|
+
<View style={{ paddingTop: 2 }}>
|
|
319
|
+
<Icon name="TriangleAlert" size={14} color={colors.statusWarning} />
|
|
320
|
+
</View>
|
|
321
|
+
<Text style={{ flex: 1, color: colors.statusWarning, fontSize: 12, lineHeight: 18 }}>
|
|
322
|
+
{m.staleWarning(unbroken(formatClientMinutes(locale, minutesSince(dashboard.updatedAt, now))))}
|
|
237
323
|
</Text>
|
|
238
|
-
</
|
|
324
|
+
</View>
|
|
239
325
|
) : null}
|
|
240
|
-
</View>
|
|
241
326
|
</Presence>
|
|
242
|
-
<Presence show={Boolean(
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<Presence show={Boolean(dashboard.progress.totalMin > 0)} order={2}>{dashboard.progress.totalMin > 0 ? <ProgressBar colors={colors} progress={dashboard.progress} live={live} /> : null}</Presence>
|
|
253
|
-
<View onLayout={(event) => setWide(event.nativeEvent.layout.width >= WIDE_MIN)}
|
|
254
|
-
style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
|
|
327
|
+
<Presence show={Boolean(dashboard.issues.length)} order={1}>
|
|
328
|
+
{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} file={file} /> : null}
|
|
329
|
+
</Presence>
|
|
330
|
+
<Presence show={Boolean(dashboard.progress.totalMin > 0)} order={2}>
|
|
331
|
+
{dashboard.progress.totalMin > 0 ? <ProgressBar colors={colors} progress={dashboard.progress} live={live} /> : null}
|
|
332
|
+
</Presence>
|
|
333
|
+
<View
|
|
334
|
+
onLayout={(event) => setWide(event.nativeEvent.layout.width >= WIDE_MIN)}
|
|
335
|
+
style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}
|
|
336
|
+
>
|
|
255
337
|
<View style={{ flex: wide ? 3 : undefined, alignSelf: "stretch", minWidth: 0 }}>
|
|
256
|
-
<Presence show={dashboard.questions.open.length > 0} order={3}>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
338
|
+
<Presence show={dashboard.questions.open.length > 0} order={3}>
|
|
339
|
+
{dashboard.questions.open.length ? (
|
|
340
|
+
<QuestionsSection
|
|
341
|
+
colors={colors}
|
|
342
|
+
questions={dashboard.questions.open}
|
|
343
|
+
answered={dashboard.questions.answered}
|
|
344
|
+
now={now}
|
|
345
|
+
context={attachmentContext}
|
|
346
|
+
/>
|
|
347
|
+
) : null}
|
|
348
|
+
</Presence>
|
|
349
|
+
<Presence show={Boolean(dashboard.stuck.length)} order={3}>
|
|
350
|
+
{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}
|
|
351
|
+
</Presence>
|
|
260
352
|
<Presence show order={4}>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
353
|
+
<Card colors={colors} icon="ListChecks" title={m.itemHeading(dashboard.run?.itemLabel ?? "Ticket")}>
|
|
354
|
+
{dashboard.tickets.length === 0 ? (
|
|
355
|
+
<Text
|
|
356
|
+
style={{
|
|
357
|
+
color: colors.foregroundMuted,
|
|
358
|
+
fontSize: 12,
|
|
359
|
+
lineHeight: 18,
|
|
360
|
+
paddingHorizontal: 10,
|
|
361
|
+
paddingVertical: 8,
|
|
362
|
+
borderTopWidth: 1,
|
|
363
|
+
borderTopColor: colors.border,
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
{m.noItems(dashboard.run?.itemLabel ?? "Ticket")}
|
|
367
|
+
</Text>
|
|
368
|
+
) : (
|
|
369
|
+
<TicketList colors={colors} dashboard={dashboard} live={live} now={now} workspaceId={workspaceId} onOpen={setOpenTicket} />
|
|
370
|
+
)}
|
|
371
|
+
<TicketDialogs
|
|
372
|
+
colors={colors}
|
|
373
|
+
dashboard={dashboard}
|
|
374
|
+
openId={openTicket}
|
|
375
|
+
setOpenId={setOpenTicket}
|
|
376
|
+
now={now}
|
|
377
|
+
live={live}
|
|
378
|
+
compact={compact}
|
|
379
|
+
context={attachmentContext}
|
|
380
|
+
/>
|
|
381
|
+
</Card>
|
|
271
382
|
</Presence>
|
|
272
383
|
</View>
|
|
273
384
|
<View style={{ flex: wide ? 2 : undefined, alignSelf: "stretch", minWidth: 0 }}>
|
|
274
385
|
<Presence show={hasHistory} order={5}>
|
|
275
386
|
{hasHistory ? (
|
|
276
|
-
<HistoryCard
|
|
277
|
-
{
|
|
278
|
-
|
|
279
|
-
{
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
387
|
+
<HistoryCard
|
|
388
|
+
colors={colors}
|
|
389
|
+
workspaceId={workspaceId}
|
|
390
|
+
tabs={[
|
|
391
|
+
{
|
|
392
|
+
id: "activity",
|
|
393
|
+
label: m.activity,
|
|
394
|
+
icon: "Activity",
|
|
395
|
+
count: dashboard.activity.length,
|
|
396
|
+
empty: m.noActivity,
|
|
397
|
+
content: <ActivityList colors={colors} activity={dashboard.activity} now={now} />,
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
id: "answered",
|
|
401
|
+
label: m.answered,
|
|
402
|
+
icon: "CircleCheck",
|
|
403
|
+
count: dashboard.questions.answered.length,
|
|
404
|
+
empty: m.noAnswers,
|
|
405
|
+
content: <AnsweredQuestionsList colors={colors} questions={dashboard.questions.answered} now={now} context={attachmentContext} />,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "deliverables",
|
|
409
|
+
label: m.deliverables,
|
|
410
|
+
icon: "Package",
|
|
411
|
+
count: dashboard.deliverables.length,
|
|
412
|
+
empty: m.noDeliverables,
|
|
413
|
+
content: (
|
|
414
|
+
<DeliverablesList
|
|
415
|
+
colors={colors}
|
|
416
|
+
deliverables={dashboard.deliverables}
|
|
417
|
+
now={now}
|
|
418
|
+
workspaceId={workspaceId}
|
|
419
|
+
workspaceDirectory={workspaceDirectory}
|
|
420
|
+
navigation={navigation}
|
|
421
|
+
/>
|
|
422
|
+
),
|
|
423
|
+
},
|
|
424
|
+
]}
|
|
425
|
+
/>
|
|
285
426
|
) : null}
|
|
286
427
|
</Presence>
|
|
287
428
|
</View>
|
|
@@ -310,13 +451,23 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
|
|
|
310
451
|
};
|
|
311
452
|
const current = tabs.find((entry) => entry.id === tab) ?? tabs[0];
|
|
312
453
|
return (
|
|
313
|
-
<View
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
454
|
+
<View
|
|
455
|
+
onLayout={(event) => setShowIcons(event.nativeEvent.layout.width >= TAB_ICONS_MIN)}
|
|
456
|
+
style={{ margin: 12, marginBottom: 0, ...raised(colors), borderRadius: 6, overflow: "hidden" }}
|
|
457
|
+
>
|
|
458
|
+
<TabStrip
|
|
459
|
+
colors={colors}
|
|
460
|
+
narrow={narrow}
|
|
461
|
+
tabs={tabs.map((entry) => ({ ...entry, icon: showIcons ? entry.icon : null }))}
|
|
462
|
+
selected={current.id}
|
|
463
|
+
onSelect={choose}
|
|
464
|
+
style={{ paddingHorizontal: narrow ? 8 : 10 }}
|
|
465
|
+
/>
|
|
317
466
|
{current.count === 0 ? (
|
|
318
467
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8 }}>{current.empty}</Text>
|
|
319
|
-
) :
|
|
468
|
+
) : (
|
|
469
|
+
current.content
|
|
470
|
+
)}
|
|
320
471
|
</View>
|
|
321
472
|
);
|
|
322
473
|
}
|
|
@@ -329,7 +480,9 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
|
|
|
329
480
|
{paged.shown.map((entry, index) => (
|
|
330
481
|
<View key={entry.id} style={{ paddingHorizontal: 10, paddingVertical: 8, gap: 1, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
331
482
|
{/* Muted: a log to glance at, not something to act on. */}
|
|
332
|
-
<Text selectable style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
|
|
483
|
+
<Text selectable style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
|
|
484
|
+
{entry.text}
|
|
485
|
+
</Text>
|
|
333
486
|
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, opacity: 0.8 }}>
|
|
334
487
|
<When colors={colors} iso={entry.at} now={now} />
|
|
335
488
|
</Text>
|
|
@@ -346,10 +499,15 @@ function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIs
|
|
|
346
499
|
const shown = issues.slice(0, 5);
|
|
347
500
|
return (
|
|
348
501
|
<View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
|
|
349
|
-
<View style={{ paddingTop: 1.5 }}
|
|
502
|
+
<View style={{ paddingTop: 1.5 }}>
|
|
503
|
+
<Icon name="FileWarning" size={14} color={colors.statusWarning} />
|
|
504
|
+
</View>
|
|
350
505
|
<Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
351
506
|
{m.skippedLines(issues.length, file)}
|
|
352
|
-
{m.issueList(
|
|
507
|
+
{m.issueList(
|
|
508
|
+
shown.map((issue) => m.issueLine(issue.line, issue.reason)),
|
|
509
|
+
issues.length - shown.length,
|
|
510
|
+
)}
|
|
353
511
|
</Text>
|
|
354
512
|
</View>
|
|
355
513
|
);
|
|
@@ -377,8 +535,11 @@ function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Das
|
|
|
377
535
|
{formatClientWorkDone(locale, progress.doneMin, progress.totalMin, progress.tookMin)}
|
|
378
536
|
</Text>
|
|
379
537
|
</View>
|
|
380
|
-
<View
|
|
381
|
-
|
|
538
|
+
<View
|
|
539
|
+
accessibilityRole="progressbar"
|
|
540
|
+
accessibilityValue={{ min: 0, max: 100, now: progress.percent }}
|
|
541
|
+
style={{ flexDirection: "row", gap: 2, height: 8, borderRadius: 4, overflow: "hidden" }}
|
|
542
|
+
>
|
|
382
543
|
{progress.segments.map((segment) => (
|
|
383
544
|
<Segment key={segment.id} colors={colors} segment={segment} live={live} />
|
|
384
545
|
))}
|
|
@@ -389,10 +550,14 @@ function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Das
|
|
|
389
550
|
|
|
390
551
|
function Segment({ colors, segment, live }: { colors: Colors; segment: ProgressSegment; live: boolean }) {
|
|
391
552
|
const working = segment.status === "working";
|
|
392
|
-
const color =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
553
|
+
const color =
|
|
554
|
+
segment.status === "done"
|
|
555
|
+
? colors.accent
|
|
556
|
+
: working
|
|
557
|
+
? colors.accent
|
|
558
|
+
: segment.status === "blocked" && !segment.waiting
|
|
559
|
+
? colors.statusDanger
|
|
560
|
+
: colors.surface2;
|
|
396
561
|
return (
|
|
397
562
|
<View style={{ flex: segment.estimateMin, backgroundColor: color, opacity: working ? 0.45 : 1, overflow: "hidden" }}>
|
|
398
563
|
{working && live ? <Shimmer /> : null}
|
|
@@ -405,17 +570,18 @@ function Segment({ colors, segment, live }: { colors: Colors; segment: ProgressS
|
|
|
405
570
|
function Shimmer() {
|
|
406
571
|
const value = useRef(new Animated.Value(0)).current;
|
|
407
572
|
useEffect(() => {
|
|
408
|
-
const loop = Animated.loop(
|
|
409
|
-
Animated.
|
|
410
|
-
|
|
411
|
-
|
|
573
|
+
const loop = Animated.loop(
|
|
574
|
+
Animated.sequence([
|
|
575
|
+
Animated.timing(value, { toValue: 1, duration: 900, useNativeDriver: nativeDriver() }),
|
|
576
|
+
Animated.timing(value, { toValue: 0, duration: 900, useNativeDriver: nativeDriver() }),
|
|
577
|
+
]),
|
|
578
|
+
);
|
|
412
579
|
loop.start();
|
|
413
580
|
return () => loop.stop();
|
|
414
581
|
}, [value]);
|
|
415
582
|
return <Animated.View style={{ flex: 1, backgroundColor: "#ffffff", opacity: value.interpolate({ inputRange: [0, 1], outputRange: [0, 0.35] }) }} />;
|
|
416
583
|
}
|
|
417
584
|
|
|
418
|
-
|
|
419
585
|
const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
|
|
420
586
|
|
|
421
587
|
function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
|
|
@@ -423,19 +589,30 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
|
|
|
423
589
|
const narrow = useNarrow();
|
|
424
590
|
return (
|
|
425
591
|
<View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
|
|
426
|
-
<SectionTitle
|
|
592
|
+
<SectionTitle
|
|
593
|
+
colors={colors}
|
|
594
|
+
icon="OctagonAlert"
|
|
595
|
+
title={m.stuck}
|
|
596
|
+
color={colors.statusDanger}
|
|
597
|
+
style={{ borderBottomWidth: 1, borderBottomColor: colors.border }}
|
|
598
|
+
/>
|
|
427
599
|
{items.map((item, index) => (
|
|
428
|
-
<View
|
|
600
|
+
<View
|
|
601
|
+
key={item.key}
|
|
602
|
+
style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}
|
|
603
|
+
>
|
|
429
604
|
{/* Optical: centers the 14px icon on the 18px title line. */}
|
|
430
|
-
<View style={{ paddingTop: 2 }}
|
|
605
|
+
<View style={{ paddingTop: 2 }}>
|
|
606
|
+
<Icon name={STUCK_ICON[item.kind]} size={14} color={colors.statusDanger} />
|
|
607
|
+
</View>
|
|
431
608
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
609
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
|
|
610
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
611
|
+
{item.kind === "overdue" ? m.overdueUpdate(unbroken(formatClientMinutes(locale, item.estimateMin))) : null}
|
|
612
|
+
{item.kind === "blocked" ? m.blockedSince(item.note, narrow) : null}
|
|
613
|
+
{item.kind === "manual" ? `${item.ticketId ? `${item.reason}${m.sentenceBreak}` : ""}${m.flagged}` : null}
|
|
614
|
+
<When colors={colors} iso={item.since} now={now} />
|
|
615
|
+
</Text>
|
|
439
616
|
</View>
|
|
440
617
|
</View>
|
|
441
618
|
))}
|
|
@@ -449,7 +626,21 @@ const finishedShown = new Map<string, boolean>();
|
|
|
449
626
|
|
|
450
627
|
// Finished tickets fold into one row at the top, so the work still ahead is
|
|
451
628
|
// near the top without scrolling (see finishedToFold).
|
|
452
|
-
function TicketList({
|
|
629
|
+
function TicketList({
|
|
630
|
+
colors,
|
|
631
|
+
dashboard,
|
|
632
|
+
live,
|
|
633
|
+
now,
|
|
634
|
+
workspaceId,
|
|
635
|
+
onOpen,
|
|
636
|
+
}: {
|
|
637
|
+
colors: Colors;
|
|
638
|
+
dashboard: Dashboard;
|
|
639
|
+
live: boolean;
|
|
640
|
+
now: number;
|
|
641
|
+
workspaceId: string;
|
|
642
|
+
onOpen(id: string): void;
|
|
643
|
+
}) {
|
|
453
644
|
const [shown, setShown] = useState(() => finishedShown.get(workspaceId) ?? false);
|
|
454
645
|
const toggle = () => {
|
|
455
646
|
finishedShown.set(workspaceId, !shown);
|
|
@@ -457,8 +648,15 @@ function TicketList({ colors, dashboard, live, now, workspaceId, onOpen }: { col
|
|
|
457
648
|
};
|
|
458
649
|
const finished = finishedToFold(dashboard.tickets);
|
|
459
650
|
const row = (ticket: Ticket) => (
|
|
460
|
-
<TicketRow
|
|
461
|
-
|
|
651
|
+
<TicketRow
|
|
652
|
+
key={ticket.id}
|
|
653
|
+
colors={colors}
|
|
654
|
+
ticket={ticket}
|
|
655
|
+
live={live}
|
|
656
|
+
now={now}
|
|
657
|
+
onOpen={() => onOpen(ticket.id)}
|
|
658
|
+
deliverables={dashboard.deliverables.filter((deliverable) => deliverable.ticketId === ticket.id).length}
|
|
659
|
+
/>
|
|
462
660
|
);
|
|
463
661
|
if (!finished.length) return <>{dashboard.tickets.map(row)}</>;
|
|
464
662
|
return (
|
|
@@ -481,52 +679,129 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
|
|
|
481
679
|
const chevron = shown ? "ChevronUp" : "ChevronDown";
|
|
482
680
|
const narrow = useNarrow();
|
|
483
681
|
return (
|
|
484
|
-
<PressableRow
|
|
485
|
-
|
|
486
|
-
|
|
682
|
+
<PressableRow
|
|
683
|
+
colors={colors}
|
|
684
|
+
accessibilityRole="button"
|
|
685
|
+
aria-expanded={shown}
|
|
686
|
+
accessibilityLabel={m.toggleFinished(shown, label)}
|
|
687
|
+
onPress={onPress}
|
|
688
|
+
style={{
|
|
689
|
+
flexDirection: "row",
|
|
690
|
+
alignItems: "flex-start",
|
|
691
|
+
gap: 10,
|
|
692
|
+
paddingHorizontal: 10,
|
|
693
|
+
paddingVertical: 8,
|
|
694
|
+
borderTopWidth: 1,
|
|
695
|
+
borderTopColor: colors.border,
|
|
696
|
+
}}
|
|
697
|
+
>
|
|
487
698
|
<View style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
|
|
488
699
|
<Icon name={done ? "Check" : "CircleSlash"} size={14} color={done ? colors.statusSuccess : colors.foregroundMuted} />
|
|
489
700
|
</View>
|
|
490
|
-
<Text style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"] }}>
|
|
701
|
+
<Text style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"] }}>
|
|
702
|
+
{label}
|
|
703
|
+
</Text>
|
|
491
704
|
{/* Narrow ticket rows drop their chevron, so this total would sit off their estimate column. */}
|
|
492
|
-
{narrow ? null :
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
705
|
+
{narrow ? null : (
|
|
706
|
+
<>
|
|
707
|
+
<View style={{ width: 12 }} />
|
|
708
|
+
<Text
|
|
709
|
+
style={{
|
|
710
|
+
minWidth: 44,
|
|
711
|
+
flexShrink: 0,
|
|
712
|
+
textAlign: "right",
|
|
713
|
+
color: colors.foregroundMuted,
|
|
714
|
+
fontSize: 12,
|
|
715
|
+
lineHeight: 18,
|
|
716
|
+
fontVariant: ["tabular-nums"],
|
|
717
|
+
}}
|
|
718
|
+
>
|
|
719
|
+
{estimateMin ? formatClientMinutes(locale, estimateMin) : ""}
|
|
720
|
+
</Text>
|
|
721
|
+
</>
|
|
722
|
+
)}
|
|
496
723
|
<View style={{ paddingTop: 2 }}>
|
|
497
|
-
<IconSwap swapKey={chevron} size={14}
|
|
724
|
+
<IconSwap swapKey={chevron} size={14}>
|
|
725
|
+
<Icon name={chevron} size={14} color={colors.foregroundMuted} />
|
|
726
|
+
</IconSwap>
|
|
498
727
|
</View>
|
|
499
728
|
</PressableRow>
|
|
500
729
|
);
|
|
501
730
|
}
|
|
502
731
|
|
|
503
732
|
// Press for the ticket's story. Hover and press only tint the row: it's a list, pressed often.
|
|
504
|
-
function TicketRow({
|
|
733
|
+
function TicketRow({
|
|
734
|
+
colors,
|
|
735
|
+
ticket,
|
|
736
|
+
live,
|
|
737
|
+
now,
|
|
738
|
+
onOpen,
|
|
739
|
+
deliverables,
|
|
740
|
+
}: {
|
|
741
|
+
colors: Colors;
|
|
742
|
+
ticket: Ticket;
|
|
743
|
+
live: boolean;
|
|
744
|
+
now: number;
|
|
745
|
+
onOpen(): void;
|
|
746
|
+
deliverables: number;
|
|
747
|
+
}) {
|
|
505
748
|
const { messages: m, locale } = useProgressLanguage();
|
|
506
749
|
const muted = ticket.status === "skipped";
|
|
507
750
|
const working = ticket.status === "working";
|
|
508
751
|
// Narrow: tighter gaps and no chevron (the whole row still presses), so the title keeps its width.
|
|
509
752
|
const narrow = useNarrow();
|
|
510
753
|
return (
|
|
511
|
-
<PressableRow
|
|
512
|
-
|
|
513
|
-
|
|
754
|
+
<PressableRow
|
|
755
|
+
colors={colors}
|
|
756
|
+
onSurface1={working}
|
|
757
|
+
transparentAtRest={!working}
|
|
758
|
+
accessibilityRole="button"
|
|
759
|
+
accessibilityLabel={m.details(ticket.id)}
|
|
760
|
+
onPress={onOpen}
|
|
761
|
+
style={{
|
|
762
|
+
flexDirection: "row",
|
|
763
|
+
alignItems: "flex-start",
|
|
764
|
+
gap: narrow ? 6 : 10,
|
|
765
|
+
paddingHorizontal: 10,
|
|
766
|
+
paddingVertical: 8,
|
|
767
|
+
borderTopWidth: 1,
|
|
768
|
+
borderTopColor: colors.border,
|
|
769
|
+
}}
|
|
770
|
+
>
|
|
514
771
|
{/* The icon is the only status mark on the row; the ticket dialog spells it out in a badge. */}
|
|
515
|
-
<View
|
|
516
|
-
|
|
772
|
+
<View
|
|
773
|
+
accessible
|
|
774
|
+
accessibilityLabel={ticket.waitingFor ? m.waiting : m.status[ticket.status]}
|
|
775
|
+
style={{ width: 16, paddingTop: 2, alignItems: "center", marginRight: narrow ? 2 : 0 }}
|
|
776
|
+
>
|
|
777
|
+
<IconSwap
|
|
778
|
+
swapKey={ticket.waitingFor || ticket.status === "not_started" ? "waiting" : ticket.status === "working" ? `working-${live}` : ticket.status}
|
|
779
|
+
size={14}
|
|
780
|
+
>
|
|
781
|
+
<StatusIcon colors={colors} status={ticket.status} live={live} waiting={Boolean(ticket.waitingFor)} />
|
|
782
|
+
</IconSwap>
|
|
517
783
|
</View>
|
|
518
784
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
519
|
-
<Text
|
|
785
|
+
<Text
|
|
786
|
+
style={{
|
|
787
|
+
color: muted ? colors.foregroundMuted : colors.foreground,
|
|
788
|
+
fontSize: 13,
|
|
789
|
+
lineHeight: 18,
|
|
790
|
+
textDecorationLine: muted ? "line-through" : "none",
|
|
791
|
+
}}
|
|
792
|
+
>
|
|
520
793
|
{ticket.title}
|
|
521
794
|
</Text>
|
|
522
795
|
{ticket.status === "working" && ticket.stage && ticket.stageSince ? (
|
|
523
796
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
524
|
-
{m.stageStarted(ticket.stage)}
|
|
797
|
+
{m.stageStarted(ticket.stage)}
|
|
798
|
+
<When colors={colors} iso={ticket.stageSince} now={now} />
|
|
525
799
|
</Text>
|
|
526
800
|
) : null}
|
|
527
801
|
{ticket.status === "working" && ticket.workingSince ? (
|
|
528
802
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
529
|
-
{narrow ? m.startedWorking : m.workingSince}
|
|
803
|
+
{narrow ? m.startedWorking : m.workingSince}
|
|
804
|
+
<When colors={colors} iso={ticket.workingSince} now={now} />
|
|
530
805
|
</Text>
|
|
531
806
|
) : null}
|
|
532
807
|
{ticket.waitingFor ? (
|
|
@@ -538,8 +813,16 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
|
|
|
538
813
|
<View accessibilityLabel={deliverables ? m.hasDeliverables(deliverables) : undefined} style={{ width: 12, height: 18, justifyContent: "center" }}>
|
|
539
814
|
{deliverables ? <Icon name="Package" size={12} color={colors.foregroundMuted} /> : null}
|
|
540
815
|
</View>
|
|
541
|
-
<Text
|
|
542
|
-
|
|
816
|
+
<Text
|
|
817
|
+
style={{ minWidth: 44, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}
|
|
818
|
+
>
|
|
819
|
+
{formatClientMinutes(locale, ticket.estimateMin)}
|
|
820
|
+
</Text>
|
|
821
|
+
{narrow ? null : (
|
|
822
|
+
<View style={{ paddingTop: 2 }}>
|
|
823
|
+
<Icon name="ChevronRight" size={14} color={colors.foregroundMuted} />
|
|
824
|
+
</View>
|
|
825
|
+
)}
|
|
543
826
|
</PressableRow>
|
|
544
827
|
);
|
|
545
828
|
}
|
|
@@ -560,4 +843,3 @@ function StatusIcon({ colors, status, live, waiting }: { colors: Colors; status:
|
|
|
560
843
|
return <WaitingDot color={colors.foregroundMuted} size={14} />;
|
|
561
844
|
}
|
|
562
845
|
}
|
|
563
|
-
|