@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/ticket-dialog.tsx
CHANGED
|
@@ -3,17 +3,19 @@ import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
|
3
3
|
import React, { useState } from "react";
|
|
4
4
|
import { Text, View } from "react-native";
|
|
5
5
|
import type { Dashboard, Ticket } from "../shared/dashboard";
|
|
6
|
-
import {
|
|
6
|
+
import { formatClientMinutes, formatClientTimeOfDay, formatClientWorkDone, timelineLabel } from "./i18n";
|
|
7
7
|
import { type Link, ticketStory, type TimelineStep } from "../shared/ticket-story";
|
|
8
8
|
import { type Attachment, AttachmentList, deliverableAttachment, ticketSourceAttachment } from "./attachments";
|
|
9
9
|
import { StackedDialog } from "./dialog-stack";
|
|
10
10
|
import { IconSwap } from "./motion";
|
|
11
|
+
import { QuestionState } from "./questions";
|
|
11
12
|
import { PressableRow } from "./row";
|
|
12
13
|
import { Spinner, StalledPulse, WaitingDot } from "./spinner";
|
|
13
14
|
import { StatusBadge } from "./status-badge";
|
|
14
15
|
import { type TabEntry, TabStrip } from "./tabs";
|
|
15
16
|
import { TimeBar } from "./time-bar";
|
|
16
17
|
import { WriteUp } from "./write-up";
|
|
18
|
+
import { useProgressLanguage } from "./language";
|
|
17
19
|
|
|
18
20
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
19
21
|
type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
|
|
@@ -47,6 +49,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
|
|
|
47
49
|
// Undefined where Paseo can't navigate to an agent.
|
|
48
50
|
onOpenAgent?(agentId: string): void;
|
|
49
51
|
}) {
|
|
52
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
50
53
|
const story = ticketStory(ticket, {
|
|
51
54
|
deliverables: dashboard.deliverables,
|
|
52
55
|
questions: [...dashboard.questions.open, ...dashboard.questions.answered].sort((a, b) => Date.parse(a.askedAt) - Date.parse(b.askedAt)),
|
|
@@ -61,44 +64,44 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
|
|
|
61
64
|
? dashboard.ticker.by === ticket.agentId
|
|
62
65
|
: dashboard.tickets.filter((candidate) => candidate.status === "working").length === 1) ? dashboard.ticker.text : null;
|
|
63
66
|
// What the badge doesn't say: the stage it's in, or what it waits for.
|
|
64
|
-
const detail = ticket.waitingFor ?
|
|
67
|
+
const detail = ticket.waitingFor ? m.waitingDetail(ticket.waitingFor.id, ticket.waitingFor.title) : ticket.status === "working" ? ticket.stage : undefined;
|
|
65
68
|
const timeline = story.timeline.map((step, index) => {
|
|
66
69
|
const last = index === story.timeline.length - 1;
|
|
67
70
|
// A current "Blocked" that's only waiting on another ticket reads as waiting, in the calm color.
|
|
68
71
|
const shown = last && ticket.waitingFor && step.status === "blocked"
|
|
69
|
-
? { ...step, label:
|
|
72
|
+
? { ...step, label: { kind: "waiting" as const, id: ticket.waitingFor.id }, waiting: true }
|
|
70
73
|
: step;
|
|
71
74
|
return <TimelineRow key={`${step.at}-${index}`} colors={colors} step={shown} last={last} now={now} live={live} />;
|
|
72
75
|
});
|
|
73
76
|
// Only tabs with something in them; the write-up leads when there is one.
|
|
74
77
|
const tabs: Array<TabEntry<StoryTab> & { content: React.ReactNode }> = [
|
|
75
|
-
...(source ? [{ id: "write-up" as const, label:
|
|
78
|
+
...(source ? [{ id: "write-up" as const, label: m.writeUp, icon: "FileText",
|
|
76
79
|
content: <WriteUp colors={colors} source={source} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory} onOpen={onOpenAttachment} /> }] : []),
|
|
77
|
-
{ id: "timeline", label:
|
|
78
|
-
...(story.activity.length ? [{ id: "activity" as const, label:
|
|
80
|
+
{ id: "timeline", label: m.timeline, icon: "History", count: story.timeline.length, content: <View>{timeline}</View> },
|
|
81
|
+
...(story.activity.length ? [{ id: "activity" as const, label: m.activity, icon: "Activity", count: story.activity.length, content: (
|
|
79
82
|
<View>
|
|
80
83
|
{story.activity.map((entry) => (
|
|
81
84
|
<View key={entry.id} style={{ flexDirection: "row", gap: 12, paddingVertical: 4 }}>
|
|
82
85
|
<Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{entry.text}<LinkNote colors={colors} link={entry.link} /></Text>
|
|
83
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{
|
|
86
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{formatClientTimeOfDay(locale, entry.at, now)}</Text>
|
|
84
87
|
</View>
|
|
85
88
|
))}
|
|
86
89
|
</View>
|
|
87
90
|
) }] : []),
|
|
88
|
-
...(story.questions.length ? [{ id: "questions" as const, label:
|
|
91
|
+
...(story.questions.length ? [{ id: "questions" as const, label: m.questions, icon: "MessageCircleQuestion", count: story.questions.length, content: (
|
|
89
92
|
<View>
|
|
90
93
|
{story.questions.map((question) => {
|
|
91
94
|
const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
|
|
92
95
|
return (
|
|
93
|
-
<PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={
|
|
96
|
+
<PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={m.openItem(question.id)}
|
|
94
97
|
onPress={() => onOpenQuestion(question.id)}
|
|
95
98
|
style={{ flexDirection: "row", gap: 8, paddingVertical: 4, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
|
|
96
99
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}>{question.id}</Text>
|
|
97
100
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
98
101
|
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{question.title}<LinkNote colors={colors} link={question.link} /></Text>
|
|
99
102
|
{question.answer
|
|
100
|
-
? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
101
|
-
:
|
|
103
|
+
? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.answered} <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}</Text>{chosen ? <Text style={{ color: colors.foreground }}>: {chosen.label}</Text> : null}</Text>
|
|
104
|
+
: <QuestionState colors={colors} question={question} />}
|
|
102
105
|
</View>
|
|
103
106
|
<View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
|
|
104
107
|
</PressableRow>
|
|
@@ -106,7 +109,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
|
|
|
106
109
|
})}
|
|
107
110
|
</View>
|
|
108
111
|
) }] : []),
|
|
109
|
-
...(story.deliverables.length ? [{ id: "deliverables" as const, label:
|
|
112
|
+
...(story.deliverables.length ? [{ id: "deliverables" as const, label: m.deliverables, icon: "Package", count: story.deliverables.length,
|
|
110
113
|
content: <AttachmentList colors={colors} attachments={story.deliverables.map(deliverableAttachment)} onOpen={onOpenAttachment} /> }] : []),
|
|
111
114
|
];
|
|
112
115
|
const tabKey = `${context.workspaceId}:${ticket.id}`;
|
|
@@ -123,9 +126,9 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
|
|
|
123
126
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
124
127
|
<StatusBadge colors={colors} status={ticket.status} waiting={Boolean(ticket.waitingFor)} />
|
|
125
128
|
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{detail}</Text>
|
|
126
|
-
<Text accessibilityLabel={
|
|
129
|
+
<Text accessibilityLabel={m.workedEstimate(formatClientMinutes(locale, story.workedMin), formatClientMinutes(locale, ticket.estimateMin))}
|
|
127
130
|
style={{ color: over ? colors.statusDanger : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
|
|
128
|
-
{
|
|
131
|
+
{formatClientWorkDone(locale, story.workedMin, ticket.estimateMin)}
|
|
129
132
|
</Text>
|
|
130
133
|
</View>
|
|
131
134
|
<TimeBar colors={colors} workedMin={story.workedMin} estimateMin={ticket.estimateMin} />
|
|
@@ -141,7 +144,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
|
|
|
141
144
|
|
|
142
145
|
{byTime ? (
|
|
143
146
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
144
|
-
|
|
147
|
+
{m.byTimeHint(ticket.id, dashboard.run?.itemLabel ?? "Ticket")}
|
|
145
148
|
</Text>
|
|
146
149
|
) : null}
|
|
147
150
|
</View>
|
|
@@ -156,9 +159,9 @@ const storyTabs = new Map<string, StoryTab>();
|
|
|
156
159
|
// Paseo titles a chat from the first line of its first message, cut at 60
|
|
157
160
|
// characters without an ellipsis (create-agent-title.ts); add the one it left off.
|
|
158
161
|
const PASEO_TITLE_CUT = 60;
|
|
159
|
-
function agentTitle(raw: string | null): string {
|
|
162
|
+
function agentTitle(raw: string | null, fallback: string): string {
|
|
160
163
|
const title = raw?.trim();
|
|
161
|
-
if (!title) return
|
|
164
|
+
if (!title) return fallback;
|
|
162
165
|
return title.length === PASEO_TITLE_CUT ? `${title.trimEnd()}…` : title;
|
|
163
166
|
}
|
|
164
167
|
|
|
@@ -166,36 +169,39 @@ function agentTitle(raw: string | null): string {
|
|
|
166
169
|
// opens that chat. Shown only while Paseo knows the agent; an archived one
|
|
167
170
|
// leaves nothing to open. Its state shows only when it needs the user.
|
|
168
171
|
function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string; onOpen?(agentId: string): void }) {
|
|
172
|
+
const { messages: m } = useProgressLanguage();
|
|
169
173
|
const agent = useAgent(agentId, (snapshot) => ({
|
|
170
174
|
title: snapshot.title,
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
needsPermission: snapshot.requiresAttention && snapshot.attentionReason === "permission",
|
|
176
|
+
hasError: snapshot.status === "error",
|
|
173
177
|
}));
|
|
174
178
|
if (!agent) return null;
|
|
175
|
-
const title = agentTitle(agent.title);
|
|
179
|
+
const title = agentTitle(agent.title, m.untitledAgent);
|
|
180
|
+
const alert = agent.needsPermission ? { text: m.permissionNeeded, danger: false } : agent.hasError ? { text: m.agentError, danger: true } : null;
|
|
176
181
|
const open = onOpen ? () => onOpen(agentId) : undefined;
|
|
177
182
|
// A quiet line in the summary: who's on it, and the way to their chat.
|
|
178
183
|
return (
|
|
179
|
-
<PressableRow colors={colors} onSurface1 disabled={!open} accessibilityRole="button" accessibilityLabel={
|
|
184
|
+
<PressableRow colors={colors} onSurface1 disabled={!open} accessibilityRole="button" accessibilityLabel={m.openAgentChat(title)} onPress={open}
|
|
180
185
|
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4 }}>
|
|
181
186
|
<Icon name="Bot" size={12} color={colors.foregroundMuted} />
|
|
182
187
|
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{title}</Text>
|
|
183
|
-
{
|
|
188
|
+
{alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{alert.text}</Text> : null}
|
|
184
189
|
<View style={{ flex: 1 }} />
|
|
185
|
-
{open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>
|
|
190
|
+
{open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
|
|
186
191
|
</PressableRow>
|
|
187
192
|
);
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
function LinkNote({ colors, link }: { colors: Colors; link: Link }) {
|
|
191
|
-
|
|
196
|
+
const { messages: m } = useProgressLanguage();
|
|
197
|
+
return link === "by-time" ? <Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>{m.byTime}</Text> : null;
|
|
192
198
|
}
|
|
193
199
|
|
|
194
200
|
// The same icons as the ticket rows, so a step reads like the status it was.
|
|
195
201
|
// Past stage steps get an accent ringed dot: work that happened, now over.
|
|
196
202
|
function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiting?: boolean } }) {
|
|
197
|
-
if (step.waiting || (step.status === "not_started" && step.label !== "
|
|
198
|
-
const icon = step.label === "
|
|
203
|
+
if (step.waiting || (step.status === "not_started" && step.label.kind !== "added")) return <WaitingDot color={colors.foregroundMuted} />;
|
|
204
|
+
const icon = step.label.kind === "added" ? { name: "CirclePlus", color: colors.foregroundMuted }
|
|
199
205
|
: step.status === "done" ? { name: "CircleCheck", color: colors.statusSuccess }
|
|
200
206
|
: step.status === "blocked" ? { name: "Ban", color: colors.statusDanger }
|
|
201
207
|
: step.status === "skipped" ? { name: "CircleSlash", color: colors.foregroundMuted }
|
|
@@ -206,6 +212,7 @@ function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiti
|
|
|
206
212
|
// One line per step: its status icon, what it was, when it began and how long
|
|
207
213
|
// it took. The step still going shows the live spinner.
|
|
208
214
|
function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step: TimelineStep & { waiting?: boolean }; last: boolean; now: number; live: boolean }) {
|
|
215
|
+
const { messages: m, locale } = useProgressLanguage();
|
|
209
216
|
const current = last && step.minutes !== null;
|
|
210
217
|
const working = current && step.status === "working";
|
|
211
218
|
return (
|
|
@@ -217,11 +224,11 @@ function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step:
|
|
|
217
224
|
{working ? (live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />) : <Marker colors={colors} step={step} />}
|
|
218
225
|
</IconSwap>
|
|
219
226
|
</View>
|
|
220
|
-
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{step.label}</Text>
|
|
221
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{
|
|
227
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{timelineLabel(m, step.label)}</Text>
|
|
228
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatClientTimeOfDay(locale, step.at, now)}</Text>
|
|
222
229
|
{/* A fixed column, so durations line up down the list. */}
|
|
223
|
-
<Text style={{
|
|
224
|
-
{step.minutes !== null ?
|
|
230
|
+
<Text numberOfLines={1} style={{ minWidth: 84, textAlign: "right", color: current ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
|
|
231
|
+
{step.minutes !== null ? current ? m.soFar(formatClientMinutes(locale, step.minutes)) : formatClientMinutes(locale, step.minutes) : ""}
|
|
225
232
|
</Text>
|
|
226
233
|
</View>
|
|
227
234
|
{step.notes.map((note, index) => (
|
package/client/video.tsx
CHANGED
|
@@ -28,6 +28,8 @@ export interface LoadedVideo {
|
|
|
28
28
|
ratio: number | null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export class VideoChangedError extends Error {}
|
|
32
|
+
|
|
31
33
|
// Fetches a video piece by piece into one blob, then reads its shape.
|
|
32
34
|
export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
|
|
33
35
|
bytes: number;
|
|
@@ -40,7 +42,7 @@ export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
|
|
|
40
42
|
while (loaded < bytes) {
|
|
41
43
|
const piece = await readPiece(loaded);
|
|
42
44
|
const decoded = decode(piece.data);
|
|
43
|
-
if (piece.bytes !== bytes || !decoded.length) throw new
|
|
45
|
+
if (piece.bytes !== bytes || !decoded.length) throw new VideoChangedError("the video changed while it was loading. Try again.");
|
|
44
46
|
pieces.push(decoded);
|
|
45
47
|
loaded += decoded.length;
|
|
46
48
|
onProgress(loaded);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import { dismissWhatsNew, getWhatsNew } from "../shared/rpc";
|
|
7
|
+
import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote } from "../shared/whats-new";
|
|
8
|
+
import { spans } from "../shared/write-up";
|
|
9
|
+
import { PressScale, Presence } from "./motion";
|
|
10
|
+
import { isLight, raised, tint } from "./surfaces";
|
|
11
|
+
import { BlockView, Spans } from "./write-up";
|
|
12
|
+
import { ProgressLanguageProvider, useProgressLanguage } from "./language";
|
|
13
|
+
|
|
14
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
15
|
+
|
|
16
|
+
// Once after an update: this version's headline, with its full notes a press
|
|
17
|
+
// away. Closing either hides both for this version.
|
|
18
|
+
export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
19
|
+
const { messages: m } = useProgressLanguage();
|
|
20
|
+
const fetchWhatsNew = useRpc(getWhatsNew);
|
|
21
|
+
const dismiss = useRpc(dismissWhatsNew);
|
|
22
|
+
const queryClient = useQueryClient();
|
|
23
|
+
const queryKey = ["progress", "whats-new", host.id];
|
|
24
|
+
const query = useQuery({ queryKey, queryFn: () => fetchWhatsNew({}) });
|
|
25
|
+
const whatsNew = query.data ?? null;
|
|
26
|
+
// Paseo's Modal keeps drawing its content while it fades out, so the notes outlive the dismissal.
|
|
27
|
+
const [shown, setShown] = useState<WhatsNew | null>(null);
|
|
28
|
+
const [open, setOpen] = useState(false);
|
|
29
|
+
const close = (version: string) => {
|
|
30
|
+
queryClient.setQueryData(queryKey, null);
|
|
31
|
+
dismiss({ version }).catch(() => queryClient.invalidateQueries({ queryKey }));
|
|
32
|
+
};
|
|
33
|
+
const closeDialog = () => {
|
|
34
|
+
if (!shown) return;
|
|
35
|
+
setOpen(false);
|
|
36
|
+
close(shown.version);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<Presence show={Boolean(whatsNew)}>{whatsNew ? (
|
|
42
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
|
|
43
|
+
<View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
|
|
44
|
+
<Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
45
|
+
<Text style={{ color: colors.foreground, fontWeight: "500" }}>{m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
|
|
46
|
+
</Text>
|
|
47
|
+
{/* 8px keeps the close button's 6px hit slop off the button beside it. */}
|
|
48
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
49
|
+
<PressScale accessibilityRole="button" onPress={() => { setShown(whatsNew); setOpen(true); }}
|
|
50
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
51
|
+
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.seeWhatsNew}</Text>
|
|
52
|
+
</PressScale>
|
|
53
|
+
<PressScale accessibilityRole="button" accessibilityLabel={m.dismissWhatsNew} hitSlop={6} onPress={() => close(whatsNew.version)}
|
|
54
|
+
style={({ pressed, hovered }) => ({ padding: 5, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
55
|
+
<Icon name="X" size={16} color={colors.foregroundMuted} />
|
|
56
|
+
</PressScale>
|
|
57
|
+
</View>
|
|
58
|
+
</View>
|
|
59
|
+
) : null}</Presence>
|
|
60
|
+
<Modal title={shown ? m.whatsNewTitle(shown.version) : ""} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
|
|
61
|
+
open={open} onOpenChange={(next) => { if (!next) closeDialog(); }}>
|
|
62
|
+
<Modal.Content>
|
|
63
|
+
<ProgressLanguageProvider>
|
|
64
|
+
{shown ? <Notes colors={colors} notes={shown.notes} /> : null}
|
|
65
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end", marginTop: 8 }}>
|
|
66
|
+
<PressScale accessibilityRole="button" onPress={closeDialog}
|
|
67
|
+
style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
68
|
+
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.gotIt}</Text>
|
|
69
|
+
</PressScale>
|
|
70
|
+
</View>
|
|
71
|
+
</ProgressLanguageProvider>
|
|
72
|
+
</Modal.Content>
|
|
73
|
+
</Modal>
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Titled notes read as a short list of headings with their details; a changelog
|
|
79
|
+
// written without titles stays a bulleted list.
|
|
80
|
+
function Notes({ colors, notes }: { colors: Colors; notes: WhatsNewNote[] }) {
|
|
81
|
+
if (!notes.some((note) => note.title || note.kind)) {
|
|
82
|
+
return <View style={{ gap: 12 }}>{notes.map((note, index) => (
|
|
83
|
+
<BlockView key={index} colors={colors} block={{ kind: "item", marker: "•", depth: 0, spans: spans(note.body) }} first={index === 0} />
|
|
84
|
+
))}</View>;
|
|
85
|
+
}
|
|
86
|
+
return (
|
|
87
|
+
<View style={{ gap: 20 }}>
|
|
88
|
+
{notes.map((note, index) => (
|
|
89
|
+
<View key={index} style={{ gap: 4 }}>
|
|
90
|
+
{note.title || note.kind ? (
|
|
91
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
92
|
+
{note.kind ? <KindTag colors={colors} kind={note.kind} /> : null}
|
|
93
|
+
{note.title ? <Text accessibilityRole="header" selectable style={{ flex: 1, color: colors.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{note.title}</Text> : null}
|
|
94
|
+
</View>
|
|
95
|
+
) : null}
|
|
96
|
+
{note.body ? (
|
|
97
|
+
<Text selectable style={{ color: note.title ? colors.foregroundMuted : colors.foreground, fontSize: 13, lineHeight: 19 }}>
|
|
98
|
+
<Spans colors={colors} spans={spans(note.body)} />
|
|
99
|
+
</Text>
|
|
100
|
+
) : null}
|
|
101
|
+
</View>
|
|
102
|
+
))}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Paseo has no violet for plugins, so it's Paseo's own "merged" purple, which
|
|
108
|
+
// shares the status colors' lightness and chroma.
|
|
109
|
+
const TONE_COLOR: Record<NoteTone, (colors: Colors) => string> = {
|
|
110
|
+
violet: (colors) => isLight(colors) ? "#7347af" : "#a890d5",
|
|
111
|
+
green: (colors) => colors.statusSuccess,
|
|
112
|
+
amber: (colors) => colors.statusWarning,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Sits on the title's first line: 18px tall, 1px down in its 20px line. As wide
|
|
116
|
+
// as "Improved" at least, so the titles beside the tags start at one edge.
|
|
117
|
+
function KindTag({ colors, kind }: { colors: Colors; kind: NoteKind }) {
|
|
118
|
+
const { messages: m } = useProgressLanguage();
|
|
119
|
+
const color = TONE_COLOR[NOTE_KINDS[kind].tone](colors);
|
|
120
|
+
return (
|
|
121
|
+
<View style={{ marginTop: 1, minWidth: 64, borderRadius: 4, paddingHorizontal: 7, backgroundColor: tint(color, colors) }}>
|
|
122
|
+
<Text numberOfLines={1} style={{ textAlign: "center", color, fontSize: 11, lineHeight: 18, fontWeight: "500" }}>{m.noteKinds[kind]}</Text>
|
|
123
|
+
</View>
|
|
124
|
+
);
|
|
125
|
+
}
|
package/client/when.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Text } from "react-native";
|
|
4
|
-
import {
|
|
4
|
+
import { formatClientAgo, formatClientClock } from "./i18n";
|
|
5
5
|
import { useNarrow } from "./narrow";
|
|
6
|
+
import { useProgressLanguage } from "./language";
|
|
6
7
|
|
|
7
8
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
8
9
|
|
|
@@ -15,11 +16,12 @@ export function unbroken(text: string): string {
|
|
|
15
16
|
// "Sep 27, 8:14 PM (10h 2m ago)", inline inside other text. A narrow
|
|
16
17
|
// panel shows only "10h 2m ago"; the ticket dialog keeps the clock time.
|
|
17
18
|
export function When({ colors, iso, now }: { colors: Colors; iso: string; now: number }) {
|
|
18
|
-
const
|
|
19
|
+
const { locale, messages: m } = useProgressLanguage();
|
|
20
|
+
const ago = unbroken(formatClientAgo(locale, iso, now));
|
|
19
21
|
if (useNarrow()) return <Text>{ago}</Text>;
|
|
20
22
|
return (
|
|
21
23
|
<Text>
|
|
22
|
-
{
|
|
24
|
+
{formatClientClock(locale, iso)}<Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>{m.parenthetical(ago)}</Text>
|
|
23
25
|
</Text>
|
|
24
26
|
);
|
|
25
27
|
}
|
package/client/write-up.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { type Block, parseWriteUp, type Span, withoutTitle, writeUpExcerpt } fro
|
|
|
9
9
|
import { type Attachment, attachmentIcon } from "./attachments";
|
|
10
10
|
import { PressableRow } from "./row";
|
|
11
11
|
import { errorText } from "./errors";
|
|
12
|
+
import { useProgressLanguage } from "./language";
|
|
12
13
|
|
|
13
14
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
14
15
|
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
|
@@ -22,6 +23,7 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
|
|
|
22
23
|
workspaceDirectory: string;
|
|
23
24
|
onOpen(attachment: Attachment, group: Attachment[]): void;
|
|
24
25
|
}) {
|
|
26
|
+
const { messages: m } = useProgressLanguage();
|
|
25
27
|
const fetchPreview = useRpc(previewDeliverable);
|
|
26
28
|
const readable = Boolean(source.path && previewKind(source.path) === "text");
|
|
27
29
|
// The same query as the preview dialog, so opening the file after reading here is instant.
|
|
@@ -38,12 +40,12 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
|
|
|
38
40
|
return (
|
|
39
41
|
<View style={{ gap: 12, padding: 14, borderRadius: 8, backgroundColor: colors.surface0 }}>
|
|
40
42
|
{shown.length ? <View style={{ gap: 8 }}>{shown.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View> : null}
|
|
41
|
-
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
43
|
+
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.readError(errorText(preview.error, m.unknownError))}</Text> : null}
|
|
42
44
|
{/* The file on the left, opening it whole; expanding in place on the right. */}
|
|
43
45
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
44
|
-
<TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={
|
|
46
|
+
<TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={m.openItem(source.title)} onPress={() => onOpen(source, [source])} grow />
|
|
45
47
|
<View style={{ flex: 1 }} />
|
|
46
|
-
{excerpt.more ? <TextButton colors={colors} label={all ?
|
|
48
|
+
{excerpt.more ? <TextButton colors={colors} label={all ? m.showLess : m.readMore} onPress={() => setAll(!all)} expanded={all} /> : null}
|
|
47
49
|
</View>
|
|
48
50
|
</View>
|
|
49
51
|
);
|
|
@@ -62,7 +64,7 @@ function TextButton({ colors, icon, label, accessibilityLabel, onPress, grow = f
|
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
|
|
67
|
+
export function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
|
|
66
68
|
switch (block.kind) {
|
|
67
69
|
case "heading":
|
|
68
70
|
return (
|
|
@@ -89,8 +91,9 @@ function BlockView({ colors, block, first }: { colors: Colors; block: Block; fir
|
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
|
|
94
|
+
export function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
|
|
93
95
|
return <>{spans.map((span, index) => span.code
|
|
94
96
|
? <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>{span.text}</Text>
|
|
97
|
+
: span.strong ? <Text key={index} style={{ fontWeight: "600" }}>{span.text}</Text>
|
|
95
98
|
: <React.Fragment key={index}>{span.text}</React.Fragment>)}</>;
|
|
96
99
|
}
|