@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
package/client/panel.tsx
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { DeliverablesList } from "./deliverables";
|
|
4
|
+
import { LauncherBanner, SkillBanner } from "./launcher";
|
|
5
|
+
import { AnsweredQuestionsList, QuestionsSection } from "./questions";
|
|
6
|
+
import { Spinner, StalledPulse } from "./spinner";
|
|
7
|
+
import { IconSwap, Presence, StaggerRoot, nativeDriver } from "./motion";
|
|
8
|
+
import { SectionTitle } from "./section-title";
|
|
9
|
+
import { raised } from "./surfaces";
|
|
10
|
+
import { When } from "./when";
|
|
11
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { ActivityIndicator, Animated, Platform, Pressable, ScrollView, Text, View } from "react-native";
|
|
13
|
+
import { shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
|
|
14
|
+
import { PROGRESS_FILE, type TicketStatus } from "../shared/events";
|
|
15
|
+
import { formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
|
|
16
|
+
import { markPanelOpened } from "../shared/rpc";
|
|
17
|
+
import { useDashboard } from "./dashboard-query";
|
|
18
|
+
import { StatusBadge } from "./status-badge";
|
|
19
|
+
import { TicketDialogs } from "./ticket-dialog";
|
|
20
|
+
import { PressableRow } from "./row";
|
|
21
|
+
import { ShowMoreRow, usePaged } from "./show-more";
|
|
22
|
+
import { notePanelOpened } from "./panel-opened";
|
|
23
|
+
|
|
24
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const mono = () => (Platform.OS === "ios" ? "Menlo" : "monospace");
|
|
28
|
+
|
|
29
|
+
export function ProgressPanel(props: PluginWorkspacePanelProps) {
|
|
30
|
+
return <WorkspaceProgress key={`${props.host.id}:${props.workspaceId}`} {...props} />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: PluginWorkspacePanelProps) {
|
|
34
|
+
const colors = theme.colors;
|
|
35
|
+
const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
|
|
36
|
+
// Paseo marks the workspace running while any of its agents is, including a parent
|
|
37
|
+
// waiting on subagents that record nothing themselves.
|
|
38
|
+
const agentRunning = useWorkspace(workspaceId, (workspace) => workspace.status === "running") ?? false;
|
|
39
|
+
const query = useDashboard(host.id, workspaceId, directory);
|
|
40
|
+
const result = query.data ?? null;
|
|
41
|
+
useMarkPanelOpened(workspaceId, directory, Boolean(result?.configured && !result.panelOpened));
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
|
|
45
|
+
{query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading progress" style={{ padding: 12 }} /> : null}
|
|
46
|
+
{query.error ? (
|
|
47
|
+
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
|
|
48
|
+
Could not read progress: {query.error.message}
|
|
49
|
+
</Text>
|
|
50
|
+
) : null}
|
|
51
|
+
<LauncherBanner colors={colors} host={host} />
|
|
52
|
+
<SkillBanner colors={colors} host={host} />
|
|
53
|
+
{/* Fades in like the dashboard's sections instead of popping in after the spinner. */}
|
|
54
|
+
<Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} /> : null}</Presence>
|
|
55
|
+
{/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
|
|
56
|
+
{result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
|
|
57
|
+
workspaceId={workspaceId} workspaceDirectory={directory ?? ""} navigation={navigation} /> : null}
|
|
58
|
+
</ScrollView>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Once a run exists, opening the panel retires the "Progress" pill for
|
|
63
|
+
// this worktree, now and after restarts.
|
|
64
|
+
function useMarkPanelOpened(workspaceId: string, directory: string | null, needed: boolean) {
|
|
65
|
+
const mark = useRpc(markPanelOpened);
|
|
66
|
+
const sent = useRef(false);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!needed || !directory || sent.current) return;
|
|
69
|
+
sent.current = true;
|
|
70
|
+
// Tell the pill only once the file exists, so its next poll agrees.
|
|
71
|
+
mark({ workspaceId, workspaceDirectory: directory })
|
|
72
|
+
.then(() => notePanelOpened(workspaceId))
|
|
73
|
+
.catch(() => { sent.current = false; });
|
|
74
|
+
}, [needed, directory, workspaceId, mark]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function EmptyState({ colors }: { colors: Colors }) {
|
|
78
|
+
return (
|
|
79
|
+
<View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
|
|
80
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
81
|
+
<Icon name="ListChecks" size={18} color={colors.foregroundMuted} />
|
|
82
|
+
<Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 14, lineHeight: 20, fontWeight: "500" }}>
|
|
83
|
+
No progress recorded yet
|
|
84
|
+
</Text>
|
|
85
|
+
</View>
|
|
86
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
87
|
+
An agent working in this worktree records progress with the <Text style={{ color: colors.foreground }}>paseo-progress</Text> command. It shows up here as soon as the first run starts.
|
|
88
|
+
</Text>
|
|
89
|
+
<View style={{ backgroundColor: colors.surface1, borderRadius: 6, padding: 10 }}>
|
|
90
|
+
<Text selectable style={{ color: colors.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>
|
|
91
|
+
{`paseo-progress start "Build the export feature"\npaseo-progress ticket add "Ticket 01: Export button" --estimate 60`}
|
|
92
|
+
</Text>
|
|
93
|
+
</View>
|
|
94
|
+
{/* So a new .scratch folder in the repo isn't a surprise. */}
|
|
95
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
96
|
+
Progress is saved in <Text style={{ color: colors.foreground }}>.scratch/progress.jsonl</Text> in this worktree. The plugin adds a <Text style={{ color: colors.foreground }}>.gitignore</Text> there, so git leaves its files alone.
|
|
97
|
+
</Text>
|
|
98
|
+
</View>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A 12px icon hung beside 12/18 text. Top-aligned with an optical nudge so the
|
|
103
|
+
// icon stays on the first line when the text wraps.
|
|
104
|
+
function StatusLine({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
|
105
|
+
return (
|
|
106
|
+
<View style={{ flexDirection: "row", gap: 6 }}>
|
|
107
|
+
<View style={{ paddingTop: 3 }}>{icon}</View>
|
|
108
|
+
<View style={{ flex: 1, minWidth: 0 }}>{children}</View>
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Re-renders on an interval so "x min ago" keeps counting between polls.
|
|
114
|
+
function useNow(intervalMs: number): number {
|
|
115
|
+
const [now, setNow] = useState(() => Date.now());
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
|
118
|
+
return () => clearInterval(id);
|
|
119
|
+
}, [intervalMs]);
|
|
120
|
+
return now;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const WIDE_MIN = 760;
|
|
124
|
+
|
|
125
|
+
function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, navigation }: {
|
|
126
|
+
colors: Colors;
|
|
127
|
+
dashboard: Dashboard;
|
|
128
|
+
agentRunning: boolean;
|
|
129
|
+
compact: boolean;
|
|
130
|
+
workspaceId: string;
|
|
131
|
+
workspaceDirectory: string;
|
|
132
|
+
navigation: PluginWorkspacePanelProps["navigation"];
|
|
133
|
+
}) {
|
|
134
|
+
const now = useNow(15_000);
|
|
135
|
+
// Quiet isn't stale while an agent in the workspace is still running.
|
|
136
|
+
const stale = dashboard.stale && !agentRunning;
|
|
137
|
+
const hasHistory = dashboard.activity.length + dashboard.questions.answered.length + dashboard.deliverables.length > 0;
|
|
138
|
+
const live = !stale;
|
|
139
|
+
// Two columns when opened as a wide tab; one in the narrow Explorer pane.
|
|
140
|
+
const [wide, setWide] = useState(false);
|
|
141
|
+
const attachmentContext = { workspaceId, workspaceDirectory, navigation };
|
|
142
|
+
const [openTicket, setOpenTicket] = useState<string | null>(null);
|
|
143
|
+
return (
|
|
144
|
+
<StaggerRoot>
|
|
145
|
+
<Presence show order={0}>
|
|
146
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 10, gap: 4, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
147
|
+
<Text accessibilityRole="header" style={{ color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" }}>
|
|
148
|
+
{dashboard.run?.title ?? "Progress"}
|
|
149
|
+
</Text>
|
|
150
|
+
{dashboard.run?.subtitle ? (
|
|
151
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{dashboard.run.subtitle}</Text>
|
|
152
|
+
) : null}
|
|
153
|
+
{dashboard.headline.length ? (
|
|
154
|
+
<Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
155
|
+
{dashboard.headline.map((part, index) => (
|
|
156
|
+
<Text key={part.text} style={part.tone === "danger" ? { color: colors.statusDanger } : undefined}>
|
|
157
|
+
{index ? ", " : ""}{part.text}
|
|
158
|
+
</Text>
|
|
159
|
+
))}
|
|
160
|
+
</Text>
|
|
161
|
+
) : null}
|
|
162
|
+
{/* One status slot: the icon morphs from spinner (or stalled pulse) to the check on finish. */}
|
|
163
|
+
{dashboard.run?.finished || dashboard.ticker ? (
|
|
164
|
+
<StatusLine icon={
|
|
165
|
+
<IconSwap swapKey={dashboard.run?.finished ? "finished" : live ? "live" : "stale"} size={12}>
|
|
166
|
+
{dashboard.run?.finished ? <Icon name="CircleCheck" size={12} color={colors.statusSuccess} />
|
|
167
|
+
: live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />}
|
|
168
|
+
</IconSwap>
|
|
169
|
+
}>
|
|
170
|
+
{dashboard.run?.finished ? (
|
|
171
|
+
<>
|
|
172
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
173
|
+
<Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>Finished</Text> <When colors={colors} iso={dashboard.run.finished.at} now={now} />
|
|
174
|
+
</Text>
|
|
175
|
+
<Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>{dashboard.run.finished.outcome}</Text>
|
|
176
|
+
</>
|
|
177
|
+
) : (
|
|
178
|
+
<Text accessibilityLiveRegion="polite" style={{ color: colors.foreground, fontSize: 12, lineHeight: 18 }}>{dashboard.ticker!.text}</Text>
|
|
179
|
+
)}
|
|
180
|
+
</StatusLine>
|
|
181
|
+
) : null}
|
|
182
|
+
{/* A finished run takes no more updates, so its finish time already says this. */}
|
|
183
|
+
{dashboard.updatedAt && !dashboard.run?.finished ? (
|
|
184
|
+
<StatusLine icon={<Icon name="Clock" size={12} color={colors.foregroundMuted} />}>
|
|
185
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
186
|
+
Last updated <When colors={colors} iso={dashboard.updatedAt} now={now} />
|
|
187
|
+
</Text>
|
|
188
|
+
</StatusLine>
|
|
189
|
+
) : null}
|
|
190
|
+
</View>
|
|
191
|
+
</Presence>
|
|
192
|
+
<Presence show={Boolean(stale && dashboard.updatedAt)} order={1}>{stale && dashboard.updatedAt ? (
|
|
193
|
+
<View accessibilityRole="alert" style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 10, borderWidth: 1, borderColor: colors.statusWarning, borderRadius: 6 }}>
|
|
194
|
+
{/* Optical: centers the 14px icon on the first 18px line. */}
|
|
195
|
+
<View style={{ paddingTop: 2 }}><Icon name="TriangleAlert" size={14} color={colors.statusWarning} /></View>
|
|
196
|
+
<Text style={{ flex: 1, color: colors.statusWarning, fontSize: 12, lineHeight: 18 }}>
|
|
197
|
+
Possibly stale. No update for {formatMinutes(minutesSince(dashboard.updatedAt, now))}. The work may have stopped, so working items show a warning until the next update.
|
|
198
|
+
</Text>
|
|
199
|
+
</View>
|
|
200
|
+
) : null}</Presence>
|
|
201
|
+
<Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} /> : null}</Presence>
|
|
202
|
+
<Presence show={Boolean(dashboard.progress.totalMin > 0)} order={2}>{dashboard.progress.totalMin > 0 ? <ProgressBar colors={colors} progress={dashboard.progress} live={live} /> : null}</Presence>
|
|
203
|
+
<View onLayout={(event) => setWide(event.nativeEvent.layout.width >= WIDE_MIN)}
|
|
204
|
+
style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
|
|
205
|
+
<View style={{ flex: wide ? 3 : undefined, alignSelf: "stretch", minWidth: 0 }}>
|
|
206
|
+
<Presence show={dashboard.questions.open.length > 0} order={3}>{dashboard.questions.open.length ? (
|
|
207
|
+
<QuestionsSection colors={colors} questions={dashboard.questions.open} answered={dashboard.questions.answered} now={now} compact={compact || !wide} context={attachmentContext} />
|
|
208
|
+
) : null}</Presence>
|
|
209
|
+
<Presence show={Boolean(dashboard.stuck.length)} order={3}>{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}</Presence>
|
|
210
|
+
<Presence show order={4}>
|
|
211
|
+
<Card colors={colors} icon="ListChecks" title={`${dashboard.run?.itemLabel ?? "Ticket"}s`}>
|
|
212
|
+
{dashboard.tickets.length === 0 ? (
|
|
213
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
214
|
+
No {(dashboard.run?.itemLabel ?? "ticket").toLowerCase()}s yet.
|
|
215
|
+
</Text>
|
|
216
|
+
) : (
|
|
217
|
+
dashboard.tickets.map((ticket) => (
|
|
218
|
+
<TicketRow key={ticket.id} colors={colors} ticket={ticket} live={live} now={now} onOpen={() => setOpenTicket(ticket.id)}
|
|
219
|
+
deliverables={dashboard.deliverables.filter((deliverable) => deliverable.ticketId === ticket.id).length} />
|
|
220
|
+
))
|
|
221
|
+
)}
|
|
222
|
+
<TicketDialogs colors={colors} dashboard={dashboard} openId={openTicket} setOpenId={setOpenTicket} now={now} live={live} context={attachmentContext} />
|
|
223
|
+
</Card>
|
|
224
|
+
</Presence>
|
|
225
|
+
</View>
|
|
226
|
+
<View style={{ flex: wide ? 2 : undefined, alignSelf: "stretch", minWidth: 0 }}>
|
|
227
|
+
<Presence show={hasHistory} order={5}>
|
|
228
|
+
{hasHistory ? (
|
|
229
|
+
<HistoryCard colors={colors} workspaceId={workspaceId} tabs={[
|
|
230
|
+
{ id: "activity", label: "Activity", icon: "Activity", count: dashboard.activity.length, empty: "No activity yet.",
|
|
231
|
+
content: <ActivityList colors={colors} activity={dashboard.activity} now={now} /> },
|
|
232
|
+
{ id: "answered", label: "Answered", icon: "CircleCheck", count: dashboard.questions.answered.length, empty: "No answered questions yet.",
|
|
233
|
+
content: <AnsweredQuestionsList colors={colors} questions={dashboard.questions.answered} now={now} context={attachmentContext} /> },
|
|
234
|
+
{ id: "deliverables", label: "Deliverables", icon: "Package", count: dashboard.deliverables.length, empty: "No deliverables yet.",
|
|
235
|
+
content: <DeliverablesList colors={colors} deliverables={dashboard.deliverables} now={now} workspaceId={workspaceId}
|
|
236
|
+
workspaceDirectory={workspaceDirectory} navigation={navigation} /> },
|
|
237
|
+
]} />
|
|
238
|
+
) : null}
|
|
239
|
+
</Presence>
|
|
240
|
+
</View>
|
|
241
|
+
</View>
|
|
242
|
+
</StaggerRoot>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type HistoryTab = "activity" | "answered" | "deliverables";
|
|
247
|
+
type HistoryTabEntry = { id: HistoryTab; label: string; icon: string; count: number; empty: string; content: React.ReactNode };
|
|
248
|
+
// Below this width the tab icons drop, so all three labels fit in the Explorer pane.
|
|
249
|
+
const TAB_ICONS_MIN = 370;
|
|
250
|
+
|
|
251
|
+
// The tab chosen per workspace, kept for the app session so reopening the
|
|
252
|
+
// panel shows the same one.
|
|
253
|
+
const historyTabs = new Map<string, HistoryTab>();
|
|
254
|
+
|
|
255
|
+
// Activity, answered questions and deliverables share one card: they're rarely wanted at once.
|
|
256
|
+
function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceId: string; tabs: HistoryTabEntry[] }) {
|
|
257
|
+
const [tab, setTab] = useState<HistoryTab>(() => historyTabs.get(workspaceId) ?? "activity");
|
|
258
|
+
const [showIcons, setShowIcons] = useState(true);
|
|
259
|
+
const choose = (next: HistoryTab) => {
|
|
260
|
+
historyTabs.set(workspaceId, next);
|
|
261
|
+
setTab(next);
|
|
262
|
+
};
|
|
263
|
+
const current = tabs.find((entry) => entry.id === tab) ?? tabs[0];
|
|
264
|
+
return (
|
|
265
|
+
<View onLayout={(event) => setShowIcons(event.nativeEvent.layout.width >= TAB_ICONS_MIN)}
|
|
266
|
+
style={{ margin: 12, marginBottom: 0, ...raised(colors), borderRadius: 6, overflow: "hidden" }}>
|
|
267
|
+
<View accessibilityRole="tablist" style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16, paddingHorizontal: 10, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
268
|
+
{tabs.map((entry) => {
|
|
269
|
+
return <Tab key={entry.id} colors={colors} label={entry.label} icon={showIcons ? entry.icon : null} count={entry.count} selected={entry.id === current.id} onPress={() => choose(entry.id)} />;
|
|
270
|
+
})}
|
|
271
|
+
</View>
|
|
272
|
+
{current.count === 0 ? (
|
|
273
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8 }}>{current.empty}</Text>
|
|
274
|
+
) : current.content}
|
|
275
|
+
</View>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Instant feedback: tabs switch often, so hover and press change color only.
|
|
280
|
+
function Tab({ colors, label, icon, count, selected, onPress }: { colors: Colors; label: string; icon: string | null; count: number; selected: boolean; onPress(): void }) {
|
|
281
|
+
const [hovered, setHovered] = useState(false);
|
|
282
|
+
return (
|
|
283
|
+
<Pressable accessibilityRole="tab" accessibilityState={{ selected }} onPress={onPress}
|
|
284
|
+
onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
|
|
285
|
+
style={({ pressed }) => ({
|
|
286
|
+
flexDirection: "row", alignItems: "center", gap: 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
|
|
287
|
+
borderBottomWidth: 2,
|
|
288
|
+
borderBottomColor: selected ? colors.accent : pressed || hovered ? colors.border : "transparent",
|
|
289
|
+
})}>
|
|
290
|
+
{icon ? <Icon name={icon} size={14} color={selected ? colors.accent : colors.foregroundMuted} /> : null}
|
|
291
|
+
<Text style={{ color: selected || hovered ? colors.foreground : colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{label}</Text>
|
|
292
|
+
{/* Count badge: muted on both tabs; the chosen tab's number reads darker. */}
|
|
293
|
+
<View style={{ minWidth: 16, paddingHorizontal: 4, borderRadius: 4, alignItems: "center", backgroundColor: colors.surface2 }}>
|
|
294
|
+
<Text style={{ color: selected ? colors.foreground : colors.foregroundMuted, fontSize: 10, lineHeight: 14, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{count}</Text>
|
|
295
|
+
</View>
|
|
296
|
+
</Pressable>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Newest first, 10 at a time (see usePaged).
|
|
301
|
+
function ActivityList({ colors, activity, now }: { colors: Colors; activity: Activity[]; now: number }) {
|
|
302
|
+
const paged = usePaged(activity);
|
|
303
|
+
return (
|
|
304
|
+
<View>
|
|
305
|
+
{paged.shown.map((entry, index) => (
|
|
306
|
+
<View key={entry.id} style={{ paddingHorizontal: 10, paddingVertical: 8, gap: 1, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
307
|
+
{/* Muted: a log to glance at, not something to act on. */}
|
|
308
|
+
<Text selectable style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>{entry.text}</Text>
|
|
309
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, opacity: 0.8 }}>
|
|
310
|
+
<When colors={colors} iso={entry.at} now={now} />
|
|
311
|
+
</Text>
|
|
312
|
+
</View>
|
|
313
|
+
))}
|
|
314
|
+
<ShowMoreRow colors={colors} total={activity.length} paged={paged} />
|
|
315
|
+
</View>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Small and non-blocking: the rest of the dashboard still renders.
|
|
320
|
+
function IssuesNotice({ colors, issues }: { colors: Colors; issues: FileIssue[] }) {
|
|
321
|
+
const shown = issues.slice(0, 5);
|
|
322
|
+
return (
|
|
323
|
+
<View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
|
|
324
|
+
<View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
|
|
325
|
+
<Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
326
|
+
Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {PROGRESS_FILE}:{" "}
|
|
327
|
+
{shown.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}
|
|
328
|
+
{issues.length > shown.length ? `, and ${issues.length - shown.length} more` : ""}.
|
|
329
|
+
</Text>
|
|
330
|
+
</View>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function Card({ colors, icon, title, children }: { colors: Colors; icon: string; title: string; children: React.ReactNode }) {
|
|
335
|
+
return (
|
|
336
|
+
<View style={{ margin: 12, marginBottom: 0, ...raised(colors), borderRadius: 6, overflow: "hidden" }}>
|
|
337
|
+
<SectionTitle colors={colors} icon={icon} title={title} />
|
|
338
|
+
{children}
|
|
339
|
+
</View>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Dashboard["progress"]; live: boolean }) {
|
|
344
|
+
return (
|
|
345
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
346
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
347
|
+
<Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
|
|
348
|
+
<Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> of estimated work done
|
|
349
|
+
</Text>
|
|
350
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
351
|
+
{formatWorkDone(progress.doneMin, progress.totalMin, progress.tookMin)}
|
|
352
|
+
</Text>
|
|
353
|
+
</View>
|
|
354
|
+
<View accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: progress.percent }}
|
|
355
|
+
style={{ flexDirection: "row", gap: 2, height: 8, borderRadius: 4, overflow: "hidden" }}>
|
|
356
|
+
{progress.segments.map((segment) => (
|
|
357
|
+
<Segment key={segment.id} colors={colors} segment={segment} live={live} />
|
|
358
|
+
))}
|
|
359
|
+
</View>
|
|
360
|
+
</View>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function Segment({ colors, segment, live }: { colors: Colors; segment: ProgressSegment; live: boolean }) {
|
|
365
|
+
const working = segment.status === "working";
|
|
366
|
+
const color = segment.status === "done" ? colors.accent
|
|
367
|
+
: working ? colors.accent
|
|
368
|
+
: segment.status === "blocked" && !segment.waiting ? colors.statusDanger
|
|
369
|
+
: colors.surface2;
|
|
370
|
+
return (
|
|
371
|
+
<View style={{ flex: segment.estimateMin, backgroundColor: color, opacity: working ? 0.45 : 1, overflow: "hidden" }}>
|
|
372
|
+
{working && live ? <Shimmer /> : null}
|
|
373
|
+
</View>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// A soft pulse on the working segment. Stops when the dashboard is stale so a
|
|
378
|
+
// stalled run never looks live.
|
|
379
|
+
function Shimmer() {
|
|
380
|
+
const value = useRef(new Animated.Value(0)).current;
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
const loop = Animated.loop(Animated.sequence([
|
|
383
|
+
Animated.timing(value, { toValue: 1, duration: 900, useNativeDriver: nativeDriver() }),
|
|
384
|
+
Animated.timing(value, { toValue: 0, duration: 900, useNativeDriver: nativeDriver() }),
|
|
385
|
+
]));
|
|
386
|
+
loop.start();
|
|
387
|
+
return () => loop.stop();
|
|
388
|
+
}, [value]);
|
|
389
|
+
return <Animated.View style={{ flex: 1, backgroundColor: "#ffffff", opacity: value.interpolate({ inputRange: [0, 1], outputRange: [0, 0.35] }) }} />;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
|
|
394
|
+
|
|
395
|
+
function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
|
|
396
|
+
return (
|
|
397
|
+
<View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
|
|
398
|
+
<SectionTitle colors={colors} icon="OctagonAlert" title="Stuck" color={colors.statusDanger} style={{ borderBottomWidth: 1, borderBottomColor: colors.border }} />
|
|
399
|
+
{items.map((item, index) => (
|
|
400
|
+
<View key={item.key} style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: index ? 1 : 0, borderTopColor: colors.border }}>
|
|
401
|
+
{/* Optical: centers the 14px icon on the 18px title line. */}
|
|
402
|
+
<View style={{ paddingTop: 2 }}><Icon name={STUCK_ICON[item.kind]} size={14} color={colors.statusDanger} /></View>
|
|
403
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
404
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
|
|
405
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
406
|
+
{item.kind === "overdue" ? `No update for longer than its ${formatMinutes(item.estimateMin)} estimate. Last update ` : null}
|
|
407
|
+
{item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}. Since ` : null}
|
|
408
|
+
{item.kind === "manual" ? `${item.ticketId ? `${item.reason}. ` : ""}Flagged ` : null}
|
|
409
|
+
<When colors={colors} iso={item.since} now={now} />
|
|
410
|
+
</Text>
|
|
411
|
+
</View>
|
|
412
|
+
</View>
|
|
413
|
+
))}
|
|
414
|
+
</View>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Press for the ticket's story. Hover and press only tint the row: it's a list, pressed often.
|
|
419
|
+
function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors: Colors; ticket: Ticket; live: boolean; now: number; onOpen(): void; deliverables: number }) {
|
|
420
|
+
const muted = ticket.status === "skipped";
|
|
421
|
+
const working = ticket.status === "working";
|
|
422
|
+
return (
|
|
423
|
+
<PressableRow colors={colors} onSurface1={working} transparentAtRest={!working}
|
|
424
|
+
accessibilityRole="button" accessibilityLabel={`${ticket.id} details`} onPress={onOpen}
|
|
425
|
+
style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
426
|
+
<View style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
|
|
427
|
+
<IconSwap swapKey={ticket.waitingFor ? "waiting" : ticket.status === "working" ? `working-${live}` : ticket.status} size={14}><StatusIcon colors={colors} status={ticket.status} live={live} waiting={Boolean(ticket.waitingFor)} /></IconSwap>
|
|
428
|
+
</View>
|
|
429
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
430
|
+
{/* A paperclip before the title when the ticket has deliverables; they're listed in its dialog. */}
|
|
431
|
+
<View style={{ flexDirection: "row", gap: 6 }}>
|
|
432
|
+
{deliverables ? (
|
|
433
|
+
<View accessibilityLabel={deliverables === 1 ? "Has a deliverable" : `Has ${deliverables} deliverables`} style={{ height: 18, justifyContent: "center" }}>
|
|
434
|
+
<Icon name="Paperclip" size={12} color={colors.foregroundMuted} />
|
|
435
|
+
</View>
|
|
436
|
+
) : null}
|
|
437
|
+
<Text style={{ flex: 1, color: muted ? colors.foregroundMuted : colors.foreground, fontSize: 13, lineHeight: 18, textDecorationLine: muted ? "line-through" : "none" }}>
|
|
438
|
+
{ticket.title}
|
|
439
|
+
</Text>
|
|
440
|
+
</View>
|
|
441
|
+
{ticket.status === "working" && ticket.stage && ticket.stageSince ? (
|
|
442
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
443
|
+
{ticket.stage} stage started <When colors={colors} iso={ticket.stageSince} now={now} />
|
|
444
|
+
</Text>
|
|
445
|
+
) : null}
|
|
446
|
+
{ticket.status === "working" && ticket.workingSince ? (
|
|
447
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
448
|
+
Working since <When colors={colors} iso={ticket.workingSince} now={now} />
|
|
449
|
+
</Text>
|
|
450
|
+
) : null}
|
|
451
|
+
{ticket.waitingFor ? (
|
|
452
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Waits for {shortTitle(ticket.waitingFor.title)}</Text>
|
|
453
|
+
) : null}
|
|
454
|
+
{ticket.note ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{ticket.note}</Text> : null}
|
|
455
|
+
</View>
|
|
456
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{ticket.estimateMin} min</Text>
|
|
457
|
+
{/* The check mark already says done. */}
|
|
458
|
+
{/* Optical: the 20px badge centers on the 18px line. */}
|
|
459
|
+
{ticket.status === "done" ? null : <View style={{ marginTop: -1 }}><StatusBadge colors={colors} status={ticket.status} waiting={Boolean(ticket.waitingFor)} /></View>}
|
|
460
|
+
<View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
|
|
461
|
+
</PressableRow>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function StatusIcon({ colors, status, live, waiting }: { colors: Colors; status: TicketStatus; live: boolean; waiting: boolean }) {
|
|
466
|
+
if (waiting) return <Icon name="Hourglass" size={14} color={colors.foregroundMuted} />;
|
|
467
|
+
switch (status) {
|
|
468
|
+
case "done":
|
|
469
|
+
return <Icon name="Check" size={14} color={colors.statusSuccess} />;
|
|
470
|
+
case "working":
|
|
471
|
+
return live ? <Spinner color={colors.accent} size={14} /> : <StalledPulse color={colors.statusWarning} size={14} />;
|
|
472
|
+
case "blocked":
|
|
473
|
+
return <Icon name="Ban" size={14} color={colors.statusDanger} />;
|
|
474
|
+
case "skipped":
|
|
475
|
+
return <Icon name="CircleSlash" size={14} color={colors.foregroundMuted} />;
|
|
476
|
+
default:
|
|
477
|
+
return <Icon name="Circle" size={14} color={colors.foregroundMuted} />;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type PluginButtonContentProps, useWorkspace } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import React, { useRef, useState } from "react";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import type { Dashboard } from "../shared/dashboard";
|
|
6
|
+
import { useDashboard } from "./dashboard-query";
|
|
7
|
+
import { progressPanel } from "./pills";
|
|
8
|
+
import { PressScale } from "./motion";
|
|
9
|
+
import { type Attachment, isPreviewable, useAttachmentOpener } from "./attachments";
|
|
10
|
+
import { PreviewDialog, previewable } from "./preview";
|
|
11
|
+
import { DefaultBadge, QuestionDetail, useCopy } from "./questions";
|
|
12
|
+
import { raised } from "./surfaces";
|
|
13
|
+
|
|
14
|
+
type Colors = PluginButtonContentProps["theme"]["colors"];
|
|
15
|
+
|
|
16
|
+
// On desktop, Paseo caps the popover at 440px and scrolls all of it, padding included. The
|
|
17
|
+
// list scrolls on its own below that cap so Open Progress stays in view.
|
|
18
|
+
const LIST_MAX_HEIGHT = 340;
|
|
19
|
+
|
|
20
|
+
// What the pill opens: the open questions with their Copy buttons, and anything
|
|
21
|
+
// stuck, so the user can answer without leaving the chat.
|
|
22
|
+
export function AttentionPopover({ theme, host, layout, workspaceId, close, openPanel }: PluginButtonContentProps & { openPanel(): void }) {
|
|
23
|
+
const colors = theme.colors;
|
|
24
|
+
// Paseo draws the desktop popover on surface1 and the phone sheet on surface0.
|
|
25
|
+
const onSurface1 = !layout.compact;
|
|
26
|
+
// Phones open the panel as its own tab, and are pressed with a finger.
|
|
27
|
+
const touch = progressPanel().location === "workspace";
|
|
28
|
+
const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
|
|
29
|
+
const query = useDashboard(host.id, workspaceId, directory);
|
|
30
|
+
const dashboard = query.data?.configured ? query.data.dashboard : null;
|
|
31
|
+
const copy = useCopy();
|
|
32
|
+
// Previews open in a dialog over the popover, which stays open to return to.
|
|
33
|
+
// Links and other files leave Paseo, so the popover closes for those.
|
|
34
|
+
const [previewing, setPreviewing] = useState<{ attachment: Attachment; group: Attachment[] } | null>(null);
|
|
35
|
+
const group = useRef<Attachment[]>([]);
|
|
36
|
+
const opener = useAttachmentOpener({ workspaceId, workspaceDirectory: directory ?? "", onPreview: (attachment) => setPreviewing({ attachment, group: previewable(group.current) }) });
|
|
37
|
+
return (
|
|
38
|
+
<View style={{ width: 360, maxWidth: "100%", gap: 8 }}>
|
|
39
|
+
{dashboard ? (
|
|
40
|
+
<List compact={layout.compact}>
|
|
41
|
+
<AttentionList colors={colors} dashboard={dashboard} onSurface1={onSurface1} onCopy={async (question, letter) => {
|
|
42
|
+
await copy(question, letter);
|
|
43
|
+
close();
|
|
44
|
+
}} onOpenAttachment={(attachment, from) => {
|
|
45
|
+
group.current = from;
|
|
46
|
+
if (!isPreviewable(attachment)) close();
|
|
47
|
+
void opener.open(attachment);
|
|
48
|
+
}} />
|
|
49
|
+
</List>
|
|
50
|
+
) : (
|
|
51
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
52
|
+
{query.error ? `Could not read progress: ${query.error.message}` : "Loading…"}
|
|
53
|
+
</Text>
|
|
54
|
+
)}
|
|
55
|
+
<PreviewDialog colors={colors} attachment={previewing?.attachment ?? null} workspaceId={workspaceId} workspaceDirectory={directory ?? ""}
|
|
56
|
+
onClose={() => setPreviewing(null)} onOpenOnHost={(attachment) => void opener.openOnHost(attachment)}
|
|
57
|
+
gallery={previewing ? { items: previewing.group, onSelect: (attachment) => setPreviewing({ ...previewing, attachment }) } : undefined} />
|
|
58
|
+
<View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
59
|
+
{/* A full 44pt touch target on phones; desktop keeps the compact button. */}
|
|
60
|
+
<PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
|
|
61
|
+
style={({ pressed }) => ({ flexDirection: "row", justifyContent: "center", alignItems: "center", gap: touch ? 8 : 6, paddingVertical: 6, minHeight: touch ? 44 : undefined, borderRadius: touch ? 8 : 4, ...raised(colors),
|
|
62
|
+
backgroundColor: onSurface1 ? (pressed ? colors.surface1 : colors.surface2) : pressed ? colors.surface2 : colors.surface1 })}>
|
|
63
|
+
{/* Beside the chat on desktop; on a phone it opens as its own tab, shown with the tab's icon. */}
|
|
64
|
+
<Icon name={touch ? "ListChecks" : "PanelRight"} size={touch ? 16 : 12} color={colors.foreground} />
|
|
65
|
+
<Text style={{ color: colors.foreground, fontSize: touch ? 15 : 12, lineHeight: touch ? 20 : 16, fontWeight: "600" }}>Open Progress</Text>
|
|
66
|
+
</PressScale>
|
|
67
|
+
</View>
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// On a phone the popover is a sheet that already scrolls, and sizes itself to
|
|
73
|
+
// whichever scroll view reports last. Paseo 0.10.1 turns a ScrollView inside it
|
|
74
|
+
// into one of those, so a short list would shrink the sheet to its title.
|
|
75
|
+
function List({ compact, children }: { compact: boolean; children: React.ReactNode }) {
|
|
76
|
+
if (compact) return <View style={{ gap: 12 }}>{children}</View>;
|
|
77
|
+
return <ScrollView style={{ maxHeight: LIST_MAX_HEIGHT }} contentContainerStyle={{ gap: 12 }}>{children}</ScrollView>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAttachment }: {
|
|
81
|
+
colors: Colors;
|
|
82
|
+
dashboard: Dashboard;
|
|
83
|
+
onSurface1: boolean;
|
|
84
|
+
onCopy(question: Dashboard["questions"]["open"][number], letter: string): void;
|
|
85
|
+
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
86
|
+
}) {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
// The last question can be answered while the popover is open.
|
|
89
|
+
if (!dashboard.questions.open.length && !dashboard.stuck.length) {
|
|
90
|
+
return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Nothing needs you right now.</Text>;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{dashboard.questions.open.map((question) => (
|
|
95
|
+
<View key={question.id} style={{ gap: 6 }}>
|
|
96
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
97
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19, fontWeight: "600" }}>{question.id}</Text>
|
|
98
|
+
<Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
|
|
99
|
+
<DefaultBadge colors={colors} value={question.default} waits={question.waits} />
|
|
100
|
+
</View>
|
|
101
|
+
<QuestionDetail colors={colors} question={question} now={now} onSurface1={onSurface1} onCopy={(letter) => onCopy(question, letter)} onOpenAttachment={onOpenAttachment} />
|
|
102
|
+
</View>
|
|
103
|
+
))}
|
|
104
|
+
{dashboard.stuck.length ? (
|
|
105
|
+
<View style={{ gap: 4 }}>
|
|
106
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
107
|
+
<Icon name="OctagonAlert" size={12} color={colors.statusDanger} />
|
|
108
|
+
<Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>Stuck</Text>
|
|
109
|
+
</View>
|
|
110
|
+
{dashboard.stuck.map((item) => (
|
|
111
|
+
<Text key={item.key} style={{ color: colors.foreground, fontSize: 12, lineHeight: 17 }}>
|
|
112
|
+
{item.title}
|
|
113
|
+
<Text style={{ color: colors.foregroundMuted }}>
|
|
114
|
+
{item.kind === "overdue" ? " (no update past its estimate)" : item.kind === "blocked" ? ` (blocked${item.note ? `: ${item.note}` : ""})` : ""}
|
|
115
|
+
</Text>
|
|
116
|
+
</Text>
|
|
117
|
+
))}
|
|
118
|
+
</View>
|
|
119
|
+
) : null}
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AttentionResult } from "../shared/rpc";
|
|
2
|
+
|
|
3
|
+
// What an agent's Progress pill shows, decided in one place so its label, its
|
|
4
|
+
// icon and what pressing it does can't disagree.
|
|
5
|
+
// - "attention": questions waiting or something stuck; opens a popover.
|
|
6
|
+
// - "started": a run whose panel has never been opened; a one-time nudge.
|
|
7
|
+
// - "open": on a phone, the way into the panel while a run is open, since
|
|
8
|
+
// phones have no Explorer to open it from.
|
|
9
|
+
export type Pill =
|
|
10
|
+
| { mode: "attention"; label: string; icon: "stuck" | "questions" }
|
|
11
|
+
| { mode: "started"; label: "Progress"; icon: "started" }
|
|
12
|
+
| { mode: "open"; label: "Progress"; icon: "open" };
|
|
13
|
+
|
|
14
|
+
// What needs the user first, else the nudge, else (on a phone) the way in.
|
|
15
|
+
// Null when there's no pill.
|
|
16
|
+
export function pillFor(report: AttentionResult | undefined, where: { phone: boolean }): Pill | null {
|
|
17
|
+
if (!report?.configured) return null;
|
|
18
|
+
const { questions, stuck } = report;
|
|
19
|
+
if (questions || stuck) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
if (questions) parts.push(`${questions} ${questions === 1 ? "question" : "questions"}`);
|
|
22
|
+
if (stuck) parts.push(`${stuck} stuck`);
|
|
23
|
+
return { mode: "attention", label: parts.join(" · "), icon: stuck ? "stuck" : "questions" };
|
|
24
|
+
}
|
|
25
|
+
if (report.runOpen && !report.panelOpened) return { mode: "started", label: "Progress", icon: "started" };
|
|
26
|
+
if (report.runOpen && where.phone) return { mode: "open", label: "Progress", icon: "open" };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A worktree with nothing going on (no file, or a closed run with nothing
|
|
31
|
+
// waiting), which the pill checks less often.
|
|
32
|
+
export function isIdle(report: AttentionResult): boolean {
|
|
33
|
+
return !report.runOpen && !report.questions && !report.stuck;
|
|
34
|
+
}
|