@stevecastaneda/paseo-progress 0.1.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/LICENSE +21 -0
- package/README.md +109 -0
- package/client/attachments.tsx +95 -0
- package/client/dashboard-query.ts +32 -0
- package/client/deliverables.tsx +67 -0
- package/client/dialog-stack.tsx +104 -0
- package/client/directory.ts +90 -0
- package/client/launcher.tsx +111 -0
- package/client/motion.tsx +173 -0
- package/client/panel-opened.ts +13 -0
- package/client/panel.tsx +480 -0
- package/client/pill-popover.tsx +122 -0
- package/client/pill-state.ts +34 -0
- package/client/pills.tsx +235 -0
- package/client/preview.tsx +228 -0
- package/client/questions.tsx +248 -0
- package/client/row.tsx +34 -0
- package/client/section-title.tsx +24 -0
- package/client/show-more.tsx +37 -0
- package/client/spinner.tsx +35 -0
- package/client/status-badge.tsx +30 -0
- package/client/surfaces.ts +45 -0
- package/client/ticket-dialog.tsx +203 -0
- package/client/when.tsx +15 -0
- package/dist/server/cli.js +528 -0
- package/dist/server/launcher.js +90 -0
- package/dist/server/lock.js +34 -0
- package/dist/server/paths.js +6 -0
- package/dist/server/progress-file.js +123 -0
- package/dist/shared/attachments.js +86 -0
- package/dist/shared/dashboard.js +369 -0
- package/dist/shared/events.js +117 -0
- package/dist/shared/format.js +37 -0
- package/dist/shared/show.js +72 -0
- package/images/1-panel.png +0 -0
- package/images/2-ticket.png +0 -0
- package/images/3-question.png +0 -0
- package/images/4-pill.png +0 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +19 -0
- package/npm-shrinkwrap.json +4041 -0
- package/package.json +62 -0
- package/paseo-plugin.json +7 -0
- package/server/cli.ts +541 -0
- package/server/dashboard.ts +46 -0
- package/server/launcher.ts +97 -0
- package/server/lock.ts +32 -0
- package/server/open.ts +107 -0
- package/server/paths.ts +7 -0
- package/server/progress-file.ts +141 -0
- package/server/skill.ts +85 -0
- package/shared/attachments.ts +100 -0
- package/shared/dashboard.ts +491 -0
- package/shared/events.ts +131 -0
- package/shared/format.ts +39 -0
- package/shared/rpc.ts +109 -0
- package/shared/show.ts +68 -0
- package/shared/ticket-story.ts +93 -0
- package/skills/paseo-progress/SKILL.md +79 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import type { Question } from "../shared/dashboard";
|
|
6
|
+
import { AttachmentList, type Attachment, questionAttachments } from "./attachments";
|
|
7
|
+
import { PressableRow } from "./row";
|
|
8
|
+
import { ShowMoreRow, usePaged } from "./show-more";
|
|
9
|
+
import { PressScale } from "./motion";
|
|
10
|
+
import { StackedDialog } from "./dialog-stack";
|
|
11
|
+
import { raised } from "./surfaces";
|
|
12
|
+
import { When } from "./when";
|
|
13
|
+
|
|
14
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
15
|
+
|
|
16
|
+
export function replyPrefix(question: Pick<Question, "id" | "title">): string {
|
|
17
|
+
return `${question.id} (${question.title}): `;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// What the Copy button on an option puts on the clipboard, ready to paste.
|
|
21
|
+
export function replyWithChoice(question: Pick<Question, "id" | "title">, letter: string): string {
|
|
22
|
+
return `${replyPrefix(question)}${letter}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useCopy() {
|
|
26
|
+
const toast = useToast();
|
|
27
|
+
return async function copy(question: Question, letter?: string) {
|
|
28
|
+
try {
|
|
29
|
+
await copyText(letter ? replyWithChoice(question, letter) : replyPrefix(question));
|
|
30
|
+
toast.show(letter ? `Copied "${question.id} ${letter}". Paste it to your agent.` : `Copied ${question.id} for your reply`, { variant: "success" });
|
|
31
|
+
} catch {
|
|
32
|
+
toast.error("Could not copy. Select the text and use Copy.");
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Questions waiting on the user. They sit at the top of the panel.
|
|
38
|
+
// Where a question's attachments open: the panel's workspace, and its browser.
|
|
39
|
+
export interface AttachmentContext {
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
workspaceDirectory: string;
|
|
42
|
+
navigation?: PluginWorkspacePanelProps["navigation"];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function QuestionsSection({ colors, questions, answered, now, compact, context }: {
|
|
46
|
+
colors: Colors;
|
|
47
|
+
questions: Question[];
|
|
48
|
+
// So a question answered while its dialog is open shows the answer instead of vanishing.
|
|
49
|
+
answered: Question[];
|
|
50
|
+
now: number;
|
|
51
|
+
compact: boolean;
|
|
52
|
+
context: AttachmentContext;
|
|
53
|
+
}) {
|
|
54
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
55
|
+
const copy = useCopy();
|
|
56
|
+
return (
|
|
57
|
+
<View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.accent, borderRadius: 6, overflow: "hidden" }}>
|
|
58
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
59
|
+
<Icon name="MessageCircleQuestion" size={14} color={colors.accent} />
|
|
60
|
+
<Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>Questions</Text>
|
|
61
|
+
<View style={{ borderWidth: 1, borderColor: colors.accent, backgroundColor: colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
|
|
62
|
+
<Text style={{ color: colors.accentForeground, fontSize: 11, lineHeight: 16 }}>{questions.length} waiting</Text>
|
|
63
|
+
</View>
|
|
64
|
+
<Text style={{ flexBasis: compact ? "100%" : undefined, flex: compact ? undefined : 1, textAlign: compact ? "left" : "right", color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
|
|
65
|
+
Work continues on each default until you answer, except where it waits. Press a question to see its choices and copy an answer.
|
|
66
|
+
</Text>
|
|
67
|
+
</View>
|
|
68
|
+
{questions.map((question, index) => (
|
|
69
|
+
<QuestionRow key={question.id} colors={colors} question={question} first={index === 0}
|
|
70
|
+
onOpen={() => setOpenId(question.id)} onCopy={() => void copy(question)} />
|
|
71
|
+
))}
|
|
72
|
+
<StackedDialog colors={colors} root={openId ? { kind: "question", id: openId } : null} onClose={() => setOpenId(null)}
|
|
73
|
+
questions={[...questions, ...answered]} now={now} context={context} />
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Settled questions, one line each (title and the choice). Press a row for the rest.
|
|
79
|
+
// Rows only: the panel's tabbed card supplies the frame.
|
|
80
|
+
export function AnsweredQuestionsList({ colors, questions, now, context }: {
|
|
81
|
+
colors: Colors;
|
|
82
|
+
questions: Question[];
|
|
83
|
+
now: number;
|
|
84
|
+
context: AttachmentContext;
|
|
85
|
+
}) {
|
|
86
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
87
|
+
const paged = usePaged(questions);
|
|
88
|
+
return (
|
|
89
|
+
<View>
|
|
90
|
+
{paged.shown.map((question, index) => {
|
|
91
|
+
const answer = question.answer!;
|
|
92
|
+
const chosen = question.options.find((option) => option.letter === answer.choice);
|
|
93
|
+
return (
|
|
94
|
+
<View key={question.id} style={{ borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
95
|
+
<PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} details`} onPress={() => setOpenId(question.id)}
|
|
96
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 10, paddingVertical: 8 }}>
|
|
97
|
+
{/* Room for two-digit ids so titles line up; longer ones widen instead of wrapping. */}
|
|
98
|
+
<Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30, flexShrink: 0 }}>{question.id}</Text>
|
|
99
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
100
|
+
{question.title}{" "}
|
|
101
|
+
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{answer.choice}</Text>
|
|
102
|
+
{chosen ? <Text style={{ color: colors.foreground }}> {chosen.label}</Text> : null}
|
|
103
|
+
</Text>
|
|
104
|
+
<Icon name="ChevronRight" size={14} color={colors.foregroundMuted} />
|
|
105
|
+
</PressableRow>
|
|
106
|
+
</View>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
<ShowMoreRow colors={colors} total={questions.length} paged={paged} />
|
|
110
|
+
<StackedDialog colors={colors} root={openId ? { kind: "question", id: openId } : null} onClose={() => setOpenId(null)}
|
|
111
|
+
questions={questions} now={now} context={context} />
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function AnswerSummary({ colors, question, now }: { colors: Colors; question: Question; now: number }) {
|
|
117
|
+
const answer = question.answer!;
|
|
118
|
+
const chosen = question.options.find((option) => option.letter === answer.choice);
|
|
119
|
+
return (
|
|
120
|
+
<View style={{ gap: 2 }}>
|
|
121
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>
|
|
122
|
+
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{answer.choice}</Text>
|
|
123
|
+
{chosen ? ` ${chosen.label}.` : ""}
|
|
124
|
+
{answer.words ? <Text style={{ color: colors.foregroundMuted }}> “{answer.words}”</Text> : null}
|
|
125
|
+
<Text style={{ color: colors.foregroundMuted }}>
|
|
126
|
+
{answer.changedCourse ? ` Differs from the default (${question.default}), so work changes course.` : " Same as the default, so no change of course."}
|
|
127
|
+
</Text>
|
|
128
|
+
</Text>
|
|
129
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
|
|
130
|
+
Answer recorded <When colors={colors} iso={answer.at} now={now} />{question.raisedBy ? `, raised by ${question.raisedBy}` : ""}
|
|
131
|
+
</Text>
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function QuestionRow({ colors, question, first, onOpen, onCopy }: {
|
|
137
|
+
colors: Colors;
|
|
138
|
+
question: Question;
|
|
139
|
+
first: boolean;
|
|
140
|
+
onOpen(): void;
|
|
141
|
+
onCopy(): void;
|
|
142
|
+
}) {
|
|
143
|
+
return (
|
|
144
|
+
<View style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 10, borderTopWidth: first ? 0 : 1, borderTopColor: colors.border }}>
|
|
145
|
+
<PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} for your reply`} onPress={onCopy} hitSlop={6}
|
|
146
|
+
outerStyle={{ alignSelf: "flex-start", marginTop: 1 }}
|
|
147
|
+
style={({ pressed }) => ({
|
|
148
|
+
paddingHorizontal: 6,
|
|
149
|
+
borderRadius: 999,
|
|
150
|
+
backgroundColor: pressed ? colors.surface2 : colors.surface1,
|
|
151
|
+
...raised(colors),
|
|
152
|
+
})}>
|
|
153
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 17, fontWeight: "600" }}>{question.id}</Text>
|
|
154
|
+
</PressScale>
|
|
155
|
+
<PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} choices`} onPress={onOpen}
|
|
156
|
+
style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "flex-start", gap: 8, borderRadius: 4, margin: -4, padding: 4 }}>
|
|
157
|
+
<Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
|
|
158
|
+
<DefaultBadge colors={colors} value={question.default} waits={question.waits} />
|
|
159
|
+
{/* Optical: centers the 14px chevron on the 19px first line. */}
|
|
160
|
+
<View style={{ paddingTop: 2.5 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
|
|
161
|
+
</PressableRow>
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A question dialog's body, also shown inside a ticket's dialog. `children` go at the end.
|
|
167
|
+
export function QuestionView({ colors, question, now, onCopy, onOpenAttachment, children }: {
|
|
168
|
+
colors: Colors;
|
|
169
|
+
question: Question;
|
|
170
|
+
now: number;
|
|
171
|
+
onCopy(letter: string): void;
|
|
172
|
+
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
173
|
+
children?: React.ReactNode;
|
|
174
|
+
}) {
|
|
175
|
+
return (
|
|
176
|
+
<View style={{ gap: 10 }}>
|
|
177
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
178
|
+
<Text style={{ flex: 1, color: colors.foreground, fontSize: 14, lineHeight: 20 }}>{question.question}</Text>
|
|
179
|
+
{!question.answer ? <DefaultBadge colors={colors} value={question.default} waits={question.waits} /> : null}
|
|
180
|
+
</View>
|
|
181
|
+
{question.answer ? <AnswerSummary colors={colors} question={question} now={now} /> : null}
|
|
182
|
+
<QuestionDetail colors={colors} question={question} now={now} onSurface1 onCopy={onCopy} onOpenAttachment={onOpenAttachment} />
|
|
183
|
+
{children}
|
|
184
|
+
</View>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A question that waits shows its default in amber: the agent is not acting on it.
|
|
189
|
+
export function DefaultBadge({ colors, value, waits }: { colors: Colors; value: string; waits: boolean }) {
|
|
190
|
+
return (
|
|
191
|
+
<View style={{ flexDirection: "row", borderWidth: 1, borderColor: waits ? colors.statusWarning : colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
|
|
192
|
+
<Text style={{ color: waits ? colors.statusWarning : colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
|
|
193
|
+
Default <Text style={{ color: waits ? colors.statusWarning : colors.foreground, fontWeight: "600" }}>{value}</Text>{waits ? ", waits for you" : ""}
|
|
194
|
+
</Text>
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// `onSurface1` when drawn on a surface1 background (Paseo's dialogs), so the
|
|
200
|
+
// Copy buttons still stand off it.
|
|
201
|
+
export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment, onSurface1 = false }: {
|
|
202
|
+
colors: Colors;
|
|
203
|
+
question: Question;
|
|
204
|
+
now: number;
|
|
205
|
+
onCopy(letter: string): void;
|
|
206
|
+
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
207
|
+
onSurface1?: boolean;
|
|
208
|
+
}) {
|
|
209
|
+
const button = onSurface1 ? { rest: colors.surface2, pressed: colors.surface1 } : { rest: colors.surface1, pressed: colors.surface2 };
|
|
210
|
+
return (
|
|
211
|
+
<View style={{ gap: 8, paddingTop: 2 }}>
|
|
212
|
+
{question.options.map((option) => {
|
|
213
|
+
const isDefault = option.letter === question.default;
|
|
214
|
+
// Once answered, the border marks the choice made; before that, the default.
|
|
215
|
+
const chosen = question.answer?.choice === option.letter;
|
|
216
|
+
const borderColor = question.answer ? (chosen ? colors.statusSuccess : colors.border) : isDefault ? colors.accent : colors.border;
|
|
217
|
+
return (
|
|
218
|
+
<View key={option.letter} style={{ flexDirection: "row", alignItems: "flex-start", gap: 8, padding: 8, borderRadius: 12, borderWidth: 1, borderColor }}>
|
|
219
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600", width: 14 }}>{option.letter}</Text>
|
|
220
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
221
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
|
|
222
|
+
{option.label}
|
|
223
|
+
{chosen ? <Text style={{ color: colors.statusSuccess }}> Your answer</Text> : null}
|
|
224
|
+
{isDefault ? <Text style={{ color: question.answer ? colors.foregroundMuted : colors.accent }}> Default</Text> : null}
|
|
225
|
+
</Text>
|
|
226
|
+
{option.consequence ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{option.consequence}</Text> : null}
|
|
227
|
+
</View>
|
|
228
|
+
{!question.answer ? (
|
|
229
|
+
<PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} ${option.letter} for your agent`}
|
|
230
|
+
onPress={() => onCopy(option.letter)} hitSlop={4}
|
|
231
|
+
style={({ pressed }) => ({ flexDirection: "row", alignItems: "center", gap: 4, paddingLeft: 6, paddingRight: 8, paddingVertical: 3, borderRadius: 4, ...raised(colors), backgroundColor: pressed ? button.pressed : button.rest })}>
|
|
232
|
+
<Icon name="Copy" size={12} color={colors.foreground} />
|
|
233
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16 }}>Copy {question.id} {option.letter}</Text>
|
|
234
|
+
</PressScale>
|
|
235
|
+
) : null}
|
|
236
|
+
</View>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
239
|
+
{question.background ? (
|
|
240
|
+
<Text selectable style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{question.background}</Text>
|
|
241
|
+
) : null}
|
|
242
|
+
<AttachmentList colors={colors} attachments={questionAttachments(question)} onOpen={onOpenAttachment} />
|
|
243
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
|
|
244
|
+
Asked <When colors={colors} iso={question.askedAt} now={now} />{question.raisedBy ? `, raised by ${question.raisedBy}` : ""}
|
|
245
|
+
</Text>
|
|
246
|
+
</View>
|
|
247
|
+
);
|
|
248
|
+
}
|
package/client/row.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import React, { useState } from "react";
|
|
3
|
+
import { Pressable, type PressableProps, type ViewStyle } from "react-native";
|
|
4
|
+
|
|
5
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
6
|
+
|
|
7
|
+
// One instant tint for every pressable row, so rows that look alike respond alike.
|
|
8
|
+
// Each step moves one surface up; `onSurface1` rows (dialogs, the popover, the
|
|
9
|
+
// working ticket) start a step higher.
|
|
10
|
+
export function rowTint(colors: Colors, state: { hovered: boolean; pressed: boolean }, onSurface1 = false): string {
|
|
11
|
+
if (onSurface1) return state.pressed ? colors.border : state.hovered ? colors.surface2 : colors.surface1;
|
|
12
|
+
return state.pressed ? colors.surface2 : state.hovered ? colors.surface1 : "transparent";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A full-width row that opens something. `resting` is its background at rest.
|
|
16
|
+
export function PressableRow({ colors, onSurface1 = false, transparentAtRest = true, style, children, ...props }: Omit<PressableProps, "style" | "children"> & {
|
|
17
|
+
colors: Colors;
|
|
18
|
+
onSurface1?: boolean;
|
|
19
|
+
// Rows on a surface1 background rest transparent; the working ticket rests on surface1.
|
|
20
|
+
transparentAtRest?: boolean;
|
|
21
|
+
style?: ViewStyle;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
const [hovered, setHovered] = useState(false);
|
|
25
|
+
return (
|
|
26
|
+
<Pressable {...props} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
|
|
27
|
+
style={({ pressed }) => {
|
|
28
|
+
const tint = rowTint(colors, { hovered, pressed }, onSurface1);
|
|
29
|
+
return [style, { backgroundColor: !hovered && !pressed && transparentAtRest ? "transparent" : tint }];
|
|
30
|
+
}}>
|
|
31
|
+
{children}
|
|
32
|
+
</Pressable>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Text, View, type ViewStyle } from "react-native";
|
|
5
|
+
|
|
6
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
7
|
+
|
|
8
|
+
// A card's heading: a 14px Lucide icon beside the 13px semibold title.
|
|
9
|
+
export function SectionTitle({ colors, icon, title, color, style, children }: {
|
|
10
|
+
colors: Colors;
|
|
11
|
+
icon: string;
|
|
12
|
+
title: string;
|
|
13
|
+
color?: string;
|
|
14
|
+
style?: ViewStyle;
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<View style={[{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 10, paddingVertical: 8 }, style]}>
|
|
19
|
+
<Icon name={icon} size={14} color={color ?? colors.foregroundMuted} />
|
|
20
|
+
<Text accessibilityRole="header" style={{ color: color ?? colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{title}</Text>
|
|
21
|
+
{children}
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Text } from "react-native";
|
|
5
|
+
import { IconSwap } from "./motion";
|
|
6
|
+
import { PressableRow } from "./row";
|
|
7
|
+
|
|
8
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
9
|
+
|
|
10
|
+
export const PAGE = 10;
|
|
11
|
+
|
|
12
|
+
// Long newest-first lists show 10 at a time; each press loads 10 older. New
|
|
13
|
+
// items arrive at the top, so the latest stay in view.
|
|
14
|
+
export function usePaged<T>(items: T[]): { shown: T[]; older: number; showOlder(): void; showFewer(): void } {
|
|
15
|
+
const [limit, setLimit] = useState(PAGE);
|
|
16
|
+
return {
|
|
17
|
+
shown: items.slice(0, limit),
|
|
18
|
+
older: Math.max(0, items.length - limit),
|
|
19
|
+
showOlder: () => setLimit(limit + PAGE),
|
|
20
|
+
showFewer: () => setLimit(PAGE),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The row under a paged list: "Show 10 older", or "Show fewer" once all are shown.
|
|
25
|
+
export function ShowMoreRow({ colors, total, paged }: { colors: Colors; total: number; paged: ReturnType<typeof usePaged> }) {
|
|
26
|
+
const more = paged.older > 0 ? { label: `Show ${Math.min(paged.older, PAGE)} older`, icon: "ChevronDown", onPress: paged.showOlder }
|
|
27
|
+
: total > PAGE ? { label: "Show fewer", icon: "ChevronUp", onPress: paged.showFewer }
|
|
28
|
+
: null;
|
|
29
|
+
if (!more) return null;
|
|
30
|
+
return (
|
|
31
|
+
<PressableRow colors={colors} accessibilityRole="button" onPress={more.onPress}
|
|
32
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
33
|
+
<IconSwap swapKey={more.icon} size={14}><Icon name={more.icon} size={14} color={colors.foregroundMuted} /></IconSwap>
|
|
34
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{more.label}</Text>
|
|
35
|
+
</PressableRow>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import React, { useEffect, useRef } from "react";
|
|
3
|
+
import { ActivityIndicator, Animated, Easing, Platform, View } from "react-native";
|
|
4
|
+
import { nativeDriver } from "./motion";
|
|
5
|
+
|
|
6
|
+
// The system spinner, sized to sit beside a line of text. iOS draws it only
|
|
7
|
+
// at 20pt, so it's scaled into the slot there.
|
|
8
|
+
export function Spinner({ color, size = 12 }: { color: string; size?: number }) {
|
|
9
|
+
return (
|
|
10
|
+
<View accessibilityLabel="Working" style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
|
|
11
|
+
{Platform.OS === "ios"
|
|
12
|
+
? <ActivityIndicator size="small" color={color} style={{ transform: [{ scale: size / 20 }] }} />
|
|
13
|
+
: <ActivityIndicator size={size} color={color} />}
|
|
14
|
+
</View>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Shown in place of the spinner once the dashboard is stale: the work may have
|
|
19
|
+
// stopped, so the icon asks for attention instead of looking busy.
|
|
20
|
+
export function StalledPulse({ color, size = 12 }: { color: string; size?: number }) {
|
|
21
|
+
const opacity = useRef(new Animated.Value(1)).current;
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const loop = Animated.loop(Animated.sequence([
|
|
24
|
+
Animated.timing(opacity, { toValue: 0.35, duration: 700, easing: Easing.inOut(Easing.ease), useNativeDriver: nativeDriver() }),
|
|
25
|
+
Animated.timing(opacity, { toValue: 1, duration: 700, easing: Easing.inOut(Easing.ease), useNativeDriver: nativeDriver() }),
|
|
26
|
+
]));
|
|
27
|
+
loop.start();
|
|
28
|
+
return () => loop.stop();
|
|
29
|
+
}, [opacity]);
|
|
30
|
+
return (
|
|
31
|
+
<Animated.View accessibilityLabel="Possibly stalled" style={{ width: size, height: size, opacity }}>
|
|
32
|
+
<Icon name="TriangleAlert" size={size} color={color} />
|
|
33
|
+
</Animated.View>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Text, View } from "react-native";
|
|
4
|
+
import type { TicketStatus } from "../shared/events";
|
|
5
|
+
|
|
6
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
7
|
+
|
|
8
|
+
export const STATUS_LABEL: Record<TicketStatus, string> = {
|
|
9
|
+
not_started: "Not started",
|
|
10
|
+
working: "Working",
|
|
11
|
+
blocked: "Blocked",
|
|
12
|
+
done: "Done",
|
|
13
|
+
skipped: "Skipped",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// `waiting`: held for another ticket. Shown as calm as "Not started", whatever the status.
|
|
17
|
+
export function StatusBadge({ colors, status, waiting = false }: { colors: Colors; status: TicketStatus; waiting?: boolean }) {
|
|
18
|
+
const tone = waiting ? { fg: colors.foregroundMuted, bg: "transparent", border: colors.border } : {
|
|
19
|
+
done: { fg: colors.statusSuccess, bg: "transparent", border: colors.statusSuccess },
|
|
20
|
+
working: { fg: colors.accentForeground, bg: colors.accent, border: colors.accent },
|
|
21
|
+
blocked: { fg: colors.statusDanger, bg: "transparent", border: colors.statusDanger },
|
|
22
|
+
skipped: { fg: colors.foregroundMuted, bg: "transparent", border: colors.border },
|
|
23
|
+
not_started: { fg: colors.foregroundMuted, bg: "transparent", border: colors.border },
|
|
24
|
+
}[status];
|
|
25
|
+
return (
|
|
26
|
+
<View style={{ borderWidth: 1, borderColor: tone.border, backgroundColor: tone.bg, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
|
|
27
|
+
<Text style={{ color: tone.fg, fontSize: 11, lineHeight: 16 }}>{waiting ? "Waiting" : STATUS_LABEL[status]}</Text>
|
|
28
|
+
</View>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import type { ViewStyle } from "react-native";
|
|
3
|
+
|
|
4
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
5
|
+
|
|
6
|
+
// Relative luminance of a #rgb/#rrggbb or rgb() color, or null when unreadable.
|
|
7
|
+
function luminance(color: string): number | null {
|
|
8
|
+
let rgb: number[] | null = null;
|
|
9
|
+
const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
10
|
+
if (hex) {
|
|
11
|
+
const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d) : hex[1].match(/../g)!;
|
|
12
|
+
rgb = digits.map((d) => parseInt(d, 16));
|
|
13
|
+
} else {
|
|
14
|
+
const fn = color.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
|
|
15
|
+
if (fn) rgb = fn.slice(1, 4).map(Number);
|
|
16
|
+
}
|
|
17
|
+
if (!rgb) return null;
|
|
18
|
+
const [r, g, b] = rgb.map((v) => {
|
|
19
|
+
const c = v / 255;
|
|
20
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
21
|
+
});
|
|
22
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isLight(colors: Colors): boolean | null {
|
|
26
|
+
const value = luminance(colors.surface0);
|
|
27
|
+
return value === null ? null : value > 0.4;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A 1px ring just inside an image: pure black or white at 10%, never a tinted gray.
|
|
31
|
+
export function imageOutline(colors: Colors): { borderWidth: number; borderColor: string } {
|
|
32
|
+
return { borderWidth: 1, borderColor: isLight(colors) === false ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Depth for cards and bordered buttons: a transparent ring plus lift in light
|
|
36
|
+
// themes, one white ring in dark ones. Dividers and state borders stay borders.
|
|
37
|
+
export function raised(colors: Colors): ViewStyle {
|
|
38
|
+
const light = isLight(colors);
|
|
39
|
+
if (light === null) return { borderWidth: 1, borderColor: colors.border };
|
|
40
|
+
return {
|
|
41
|
+
boxShadow: light
|
|
42
|
+
? "0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 2px -1px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(0, 0, 0, 0.04)"
|
|
43
|
+
: "0px 0px 0px 1px rgba(255, 255, 255, 0.08)",
|
|
44
|
+
};
|
|
45
|
+
}
|