@stevecastaneda/paseo-progress 0.2.0 → 0.3.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/README.md +10 -6
- package/client/attachments.tsx +1 -1
- package/client/dialog-stack.tsx +12 -1
- package/client/narrow.tsx +13 -0
- package/client/panel.tsx +28 -37
- package/client/pill-popover.tsx +5 -3
- package/client/questions.tsx +18 -24
- package/client/tabs.tsx +65 -0
- package/client/ticket-dialog.tsx +142 -112
- package/client/time-bar.tsx +19 -0
- package/client/when.tsx +4 -1
- package/client/write-up.tsx +95 -0
- package/dist/server/cli.js +59 -30
- package/dist/server/progress-file.js +11 -4
- package/dist/shared/attachments.js +12 -6
- package/dist/shared/dashboard.js +14 -9
- package/dist/shared/events.js +14 -6
- package/dist/shared/format.js +6 -0
- package/dist/shared/show.js +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/cli.ts +62 -32
- package/server/progress-file.ts +11 -4
- package/shared/attachments.ts +21 -11
- package/shared/dashboard.ts +23 -12
- package/shared/events.ts +14 -6
- package/shared/format.ts +7 -0
- package/shared/show.ts +2 -1
- package/shared/write-up.ts +92 -0
- package/skills/paseo-progress/SKILL.md +10 -7
package/client/ticket-dialog.tsx
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type PluginWorkspacePanelProps, useAgent } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
-
import React from "react";
|
|
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 { formatMinutes } from "../shared/format";
|
|
6
|
+
import { formatMinutes, formatTimeOfDay, formatWorkDone } from "../shared/format";
|
|
7
7
|
import { type Link, ticketStory, type TimelineStep } from "../shared/ticket-story";
|
|
8
|
-
import { type Attachment, AttachmentList, deliverableAttachment } from "./attachments";
|
|
8
|
+
import { type Attachment, AttachmentList, deliverableAttachment, ticketSourceAttachment } from "./attachments";
|
|
9
9
|
import { StackedDialog } from "./dialog-stack";
|
|
10
10
|
import { IconSwap } from "./motion";
|
|
11
11
|
import { PressableRow } from "./row";
|
|
12
12
|
import { Spinner, StalledPulse, WaitingDot } from "./spinner";
|
|
13
13
|
import { StatusBadge } from "./status-badge";
|
|
14
|
-
import {
|
|
14
|
+
import { type TabEntry, TabStrip } from "./tabs";
|
|
15
|
+
import { TimeBar } from "./time-bar";
|
|
16
|
+
import { WriteUp } from "./write-up";
|
|
15
17
|
|
|
16
18
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
17
|
-
type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation
|
|
19
|
+
type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
|
|
18
20
|
|
|
19
21
|
// A ticket's story, opened from its row: where its time went, and what came out
|
|
20
22
|
// of it. Its questions and previews open in the same dialog (see StackedDialog).
|
|
@@ -33,121 +35,155 @@ export function TicketDialogs({ colors, dashboard, openId, setOpenId, now, live,
|
|
|
33
35
|
);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export function TicketStoryView({ colors, dashboard, ticket, now, live, onOpenAttachment, onOpenQuestion }: {
|
|
38
|
+
export function TicketStoryView({ colors, dashboard, ticket, now, live, context, onOpenAttachment, onOpenQuestion, onOpenAgent }: {
|
|
37
39
|
colors: Colors;
|
|
38
40
|
dashboard: Dashboard;
|
|
39
41
|
ticket: Ticket;
|
|
40
42
|
now: number;
|
|
41
43
|
live: boolean;
|
|
44
|
+
context: AttachmentContext;
|
|
42
45
|
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
43
46
|
onOpenQuestion(id: string): void;
|
|
47
|
+
// Undefined where Paseo can't navigate to an agent.
|
|
48
|
+
onOpenAgent?(agentId: string): void;
|
|
44
49
|
}) {
|
|
45
50
|
const story = ticketStory(ticket, {
|
|
46
51
|
deliverables: dashboard.deliverables,
|
|
47
52
|
questions: [...dashboard.questions.open, ...dashboard.questions.answered].sort((a, b) => Date.parse(a.askedAt) - Date.parse(b.askedAt)),
|
|
48
53
|
activity: [...dashboard.activity].reverse(),
|
|
49
54
|
}, now);
|
|
50
|
-
const
|
|
55
|
+
const over = story.workedMin - ticket.estimateMin >= 1;
|
|
51
56
|
const byTime = [...story.questions, ...story.activity].some((item) => item.link === "by-time");
|
|
57
|
+
const source = ticketSourceAttachment(ticket);
|
|
58
|
+
// The ticker is run-wide: it's about this ticket when this ticket's agent set
|
|
59
|
+
// it, or, without agents to go by, when this is the only ticket working.
|
|
60
|
+
const ticker = dashboard.ticker && ticket.status === "working" && (dashboard.ticker.by
|
|
61
|
+
? dashboard.ticker.by === ticket.agentId
|
|
62
|
+
: dashboard.tickets.filter((candidate) => candidate.status === "working").length === 1) ? dashboard.ticker.text : null;
|
|
63
|
+
// What the badge doesn't say: the stage it's in, or what it waits for.
|
|
64
|
+
const detail = ticket.waitingFor ? `for ${ticket.waitingFor.id}: ${ticket.waitingFor.title}` : ticket.status === "working" ? ticket.stage : undefined;
|
|
65
|
+
const timeline = story.timeline.map((step, index) => {
|
|
66
|
+
const last = index === story.timeline.length - 1;
|
|
67
|
+
// A current "Blocked" that's only waiting on another ticket reads as waiting, in the calm color.
|
|
68
|
+
const shown = last && ticket.waitingFor && step.status === "blocked"
|
|
69
|
+
? { ...step, label: `Waiting for ${ticket.waitingFor.id}`, waiting: true }
|
|
70
|
+
: step;
|
|
71
|
+
return <TimelineRow key={`${step.at}-${index}`} colors={colors} step={shown} last={last} now={now} live={live} />;
|
|
72
|
+
});
|
|
73
|
+
// Only tabs with something in them; the write-up leads when there is one.
|
|
74
|
+
const tabs: Array<TabEntry<StoryTab> & { content: React.ReactNode }> = [
|
|
75
|
+
...(source ? [{ id: "write-up" as const, label: "Write-up", icon: "FileText",
|
|
76
|
+
content: <WriteUp colors={colors} source={source} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory} onOpen={onOpenAttachment} /> }] : []),
|
|
77
|
+
{ id: "timeline", label: "Timeline", icon: "History", count: story.timeline.length, content: <View>{timeline}</View> },
|
|
78
|
+
...(story.activity.length ? [{ id: "activity" as const, label: "Activity", icon: "Activity", count: story.activity.length, content: (
|
|
79
|
+
<View>
|
|
80
|
+
{story.activity.map((entry) => (
|
|
81
|
+
<View key={entry.id} style={{ flexDirection: "row", gap: 12, paddingVertical: 4 }}>
|
|
82
|
+
<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"] }}>{formatTimeOfDay(entry.at, now)}</Text>
|
|
84
|
+
</View>
|
|
85
|
+
))}
|
|
86
|
+
</View>
|
|
87
|
+
) }] : []),
|
|
88
|
+
...(story.questions.length ? [{ id: "questions" as const, label: "Questions", icon: "MessageCircleQuestion", count: story.questions.length, content: (
|
|
89
|
+
<View>
|
|
90
|
+
{story.questions.map((question) => {
|
|
91
|
+
const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
|
|
92
|
+
return (
|
|
93
|
+
<PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={`Open ${question.id}`}
|
|
94
|
+
onPress={() => onOpenQuestion(question.id)}
|
|
95
|
+
style={{ flexDirection: "row", gap: 8, paddingVertical: 4, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
|
|
96
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}>{question.id}</Text>
|
|
97
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
98
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{question.title}<LinkNote colors={colors} link={question.link} /></Text>
|
|
99
|
+
{question.answer
|
|
100
|
+
? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Answered <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}</Text>{chosen ? <Text style={{ color: colors.foreground }}>: {chosen.label}</Text> : null}</Text>
|
|
101
|
+
: null}
|
|
102
|
+
</View>
|
|
103
|
+
<View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
|
|
104
|
+
</PressableRow>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</View>
|
|
108
|
+
) }] : []),
|
|
109
|
+
...(story.deliverables.length ? [{ id: "deliverables" as const, label: "Deliverables", icon: "Package", count: story.deliverables.length,
|
|
110
|
+
content: <AttachmentList colors={colors} attachments={story.deliverables.map(deliverableAttachment)} onOpen={onOpenAttachment} /> }] : []),
|
|
111
|
+
];
|
|
112
|
+
const tabKey = `${context.workspaceId}:${ticket.id}`;
|
|
113
|
+
const [chosen, setChosen] = useState<StoryTab | undefined>(() => storyTabs.get(tabKey));
|
|
114
|
+
const current = tabs.find((entry) => entry.id === chosen) ?? tabs[0];
|
|
115
|
+
const choose = (id: StoryTab) => {
|
|
116
|
+
storyTabs.set(tabKey, id);
|
|
117
|
+
setChosen(id);
|
|
118
|
+
};
|
|
52
119
|
return (
|
|
53
120
|
<View style={{ gap: 20 }}>
|
|
54
|
-
|
|
55
|
-
|
|
121
|
+
{/* The summary: where it stands, how its time compares to the estimate, and who's on it. */}
|
|
122
|
+
<View style={{ gap: 10 }}>
|
|
123
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
56
124
|
<StatusBadge colors={colors} status={ticket.status} waiting={Boolean(ticket.waitingFor)} />
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
</Text>
|
|
63
|
-
</View>
|
|
125
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{detail}</Text>
|
|
126
|
+
<Text accessibilityLabel={`Worked ${formatMinutes(story.workedMin)} of a ${formatMinutes(ticket.estimateMin)} estimate`}
|
|
127
|
+
style={{ color: over ? colors.statusDanger : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
|
|
128
|
+
{formatWorkDone(story.workedMin, ticket.estimateMin)}
|
|
129
|
+
</Text>
|
|
64
130
|
</View>
|
|
65
|
-
{ticket.
|
|
66
|
-
|
|
67
|
-
{/* Held on the first line when a long title wraps. */}
|
|
68
|
-
<View style={{ height: 18, justifyContent: "center" }}><WaitingDot color={colors.foregroundMuted} /></View>
|
|
69
|
-
<Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Waits for {ticket.waitingFor.id}: {ticket.waitingFor.title}</Text>
|
|
70
|
-
</View>
|
|
71
|
-
) : null}
|
|
131
|
+
<TimeBar colors={colors} workedMin={story.workedMin} estimateMin={ticket.estimateMin} />
|
|
132
|
+
{ticker ? <Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{ticker}</Text> : null}
|
|
72
133
|
{ticket.note ? <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{ticket.note}</Text> : null}
|
|
134
|
+
{ticket.agentId ? <AgentRow colors={colors} agentId={ticket.agentId} onOpen={onOpenAgent} /> : null}
|
|
73
135
|
</View>
|
|
74
136
|
|
|
75
|
-
<
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// A current "Blocked" that's only waiting on another ticket reads as waiting, in the calm color.
|
|
80
|
-
const shown = last && ticket.waitingFor && step.status === "blocked"
|
|
81
|
-
? { ...step, label: `Waiting for ${ticket.waitingFor.id}`, waiting: true }
|
|
82
|
-
: step;
|
|
83
|
-
return <TimelineRow key={`${step.at}-${index}`} colors={colors} step={shown} last={last} now={now} live={live} />;
|
|
84
|
-
})}
|
|
85
|
-
</View>
|
|
86
|
-
</Section>
|
|
87
|
-
|
|
88
|
-
{story.deliverables.length ? (
|
|
89
|
-
<Section colors={colors} icon="Package" title="Deliverables">
|
|
90
|
-
<AttachmentList colors={colors} attachments={story.deliverables.map(deliverableAttachment)} onOpen={onOpenAttachment} />
|
|
91
|
-
</Section>
|
|
92
|
-
) : null}
|
|
93
|
-
|
|
94
|
-
{story.questions.length ? (
|
|
95
|
-
<Section colors={colors} icon="MessageCircleQuestion" title="Questions">
|
|
96
|
-
<View style={{ gap: 2 }}>
|
|
97
|
-
{story.questions.map((question) => {
|
|
98
|
-
const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
|
|
99
|
-
return (
|
|
100
|
-
<PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={`Open ${question.id}`}
|
|
101
|
-
onPress={() => onOpenQuestion(question.id)}
|
|
102
|
-
style={{ flexDirection: "row", gap: 8, paddingVertical: 3, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
|
|
103
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}>{question.id}</Text>
|
|
104
|
-
<Text style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 18 }}>
|
|
105
|
-
{question.title}{" "}
|
|
106
|
-
{question.answer
|
|
107
|
-
? <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}{chosen ? <Text style={{ fontWeight: "400" }}> {chosen.label}</Text> : null}</Text>
|
|
108
|
-
: <Text style={{ color: question.waits ? colors.statusWarning : colors.foregroundMuted }}>Open, default {question.default}</Text>}
|
|
109
|
-
<LinkNote colors={colors} link={question.link} />
|
|
110
|
-
</Text>
|
|
111
|
-
<View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
|
|
112
|
-
</PressableRow>
|
|
113
|
-
);
|
|
114
|
-
})}
|
|
115
|
-
</View>
|
|
116
|
-
</Section>
|
|
117
|
-
) : null}
|
|
118
|
-
|
|
119
|
-
{story.activity.length ? (
|
|
120
|
-
<Section colors={colors} icon="Activity" title="Activity">
|
|
121
|
-
<View style={{ gap: 8 }}>
|
|
122
|
-
{story.activity.map((entry) => (
|
|
123
|
-
<View key={entry.id} style={{ gap: 1 }}>
|
|
124
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{entry.text}<LinkNote colors={colors} link={entry.link} /></Text>
|
|
125
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, opacity: 0.8 }}><When colors={colors} iso={entry.at} now={now} /></Text>
|
|
126
|
-
</View>
|
|
127
|
-
))}
|
|
128
|
-
</View>
|
|
129
|
-
</Section>
|
|
130
|
-
) : null}
|
|
137
|
+
<View style={{ gap: 12 }}>
|
|
138
|
+
{tabs.length > 1 ? <TabStrip colors={colors} tabs={tabs} selected={current.id} onSelect={choose} fit /> : null}
|
|
139
|
+
{current.content}
|
|
140
|
+
</View>
|
|
131
141
|
|
|
132
142
|
{byTime ? (
|
|
133
|
-
<Text style={{ color: colors.foregroundMuted, fontSize:
|
|
134
|
-
Items marked “by time” weren't tagged to {ticket.id}; they happened while it was the ticket being worked on.
|
|
143
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
144
|
+
Items marked “by time” weren't tagged to {ticket.id}; they happened while it was the {(dashboard.run?.itemLabel ?? "ticket").toLowerCase()} being worked on.
|
|
135
145
|
</Text>
|
|
136
146
|
) : null}
|
|
137
147
|
</View>
|
|
138
148
|
);
|
|
139
149
|
}
|
|
140
150
|
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
type StoryTab = "write-up" | "timeline" | "activity" | "questions" | "deliverables";
|
|
152
|
+
// The tab chosen per ticket, kept for the app session, so coming back from a
|
|
153
|
+
// preview or reopening the ticket shows the same one.
|
|
154
|
+
const storyTabs = new Map<string, StoryTab>();
|
|
155
|
+
|
|
156
|
+
// Paseo titles a chat from the first line of its first message, cut at 60
|
|
157
|
+
// characters without an ellipsis (create-agent-title.ts); add the one it left off.
|
|
158
|
+
const PASEO_TITLE_CUT = 60;
|
|
159
|
+
function agentTitle(raw: string | null): string {
|
|
160
|
+
const title = raw?.trim();
|
|
161
|
+
if (!title) return "Untitled agent";
|
|
162
|
+
return title.length === PASEO_TITLE_CUT ? `${title.trimEnd()}…` : title;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The agent on a ticket, by its chat title. Pressing it
|
|
166
|
+
// opens that chat. Shown only while Paseo knows the agent; an archived one
|
|
167
|
+
// leaves nothing to open. Its state shows only when it needs the user.
|
|
168
|
+
function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string; onOpen?(agentId: string): void }) {
|
|
169
|
+
const agent = useAgent(agentId, (snapshot) => ({
|
|
170
|
+
title: snapshot.title,
|
|
171
|
+
alert: snapshot.requiresAttention && snapshot.attentionReason === "permission" ? { text: "Needs your permission", danger: false }
|
|
172
|
+
: snapshot.status === "error" ? { text: "Stopped with an error", danger: true } : null,
|
|
173
|
+
}));
|
|
174
|
+
if (!agent) return null;
|
|
175
|
+
const title = agentTitle(agent.title);
|
|
176
|
+
const open = onOpen ? () => onOpen(agentId) : undefined;
|
|
177
|
+
// A quiet line in the summary: who's on it, and the way to their chat.
|
|
143
178
|
return (
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
</
|
|
149
|
-
{
|
|
150
|
-
|
|
179
|
+
<PressableRow colors={colors} onSurface1 disabled={!open} accessibilityRole="button" accessibilityLabel={`Open the chat with ${title}`} onPress={open}
|
|
180
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4 }}>
|
|
181
|
+
<Icon name="Bot" size={12} color={colors.foregroundMuted} />
|
|
182
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{title}</Text>
|
|
183
|
+
{agent.alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: agent.alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{agent.alert.text}</Text> : null}
|
|
184
|
+
<View style={{ flex: 1 }} />
|
|
185
|
+
{open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>Open chat</Text> : null}
|
|
186
|
+
</PressableRow>
|
|
151
187
|
);
|
|
152
188
|
}
|
|
153
189
|
|
|
@@ -167,36 +203,30 @@ function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiti
|
|
|
167
203
|
return <Icon name={icon.name} size={12} color={icon.color} />;
|
|
168
204
|
}
|
|
169
205
|
|
|
170
|
-
//
|
|
206
|
+
// One line per step: its status icon, what it was, when it began and how long
|
|
207
|
+
// it took. The step still going shows the live spinner.
|
|
171
208
|
function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step: TimelineStep & { waiting?: boolean }; last: boolean; now: number; live: boolean }) {
|
|
172
209
|
const current = last && step.minutes !== null;
|
|
173
210
|
const working = current && step.status === "working";
|
|
174
211
|
return (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
<View style={{
|
|
178
|
-
{
|
|
179
|
-
<View style={{ height: 18, justifyContent: "center" }}>
|
|
212
|
+
<View style={{ paddingVertical: 4, gap: 2 }}>
|
|
213
|
+
{/* Same 14px slot and gap as attachment rows, so text lines up across sections. */}
|
|
214
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
215
|
+
<View style={{ width: 14, alignItems: "center" }}>
|
|
180
216
|
<IconSwap swapKey={working ? (live ? "live" : "stale") : step.waiting ? "waiting" : step.status} size={12}>
|
|
181
217
|
{working ? (live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />) : <Marker colors={colors} step={step} />}
|
|
182
218
|
</IconSwap>
|
|
183
219
|
</View>
|
|
184
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
|
|
191
|
-
{formatMinutes(step.minutes)}{current ? " so far" : ""}
|
|
192
|
-
</Text>
|
|
193
|
-
) : null}
|
|
194
|
-
</View>
|
|
195
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}><When colors={colors} iso={step.at} now={now} /></Text>
|
|
196
|
-
{step.notes.map((note, index) => (
|
|
197
|
-
<Text key={index} style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, paddingTop: 2 }}>{note}</Text>
|
|
198
|
-
))}
|
|
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"] }}>{formatTimeOfDay(step.at, now)}</Text>
|
|
222
|
+
{/* A fixed column, so durations line up down the list. */}
|
|
223
|
+
<Text style={{ width: 84, textAlign: "right", color: current ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
|
|
224
|
+
{step.minutes !== null ? `${formatMinutes(step.minutes)}${current ? " so far" : ""}` : ""}
|
|
225
|
+
</Text>
|
|
199
226
|
</View>
|
|
227
|
+
{step.notes.map((note, index) => (
|
|
228
|
+
<Text key={index} style={{ paddingStart: 20, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{note}</Text>
|
|
229
|
+
))}
|
|
200
230
|
</View>
|
|
201
231
|
);
|
|
202
232
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { View } from "react-native";
|
|
4
|
+
|
|
5
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
6
|
+
|
|
7
|
+
// Time worked filling the estimate. Past the estimate the bar is full and the
|
|
8
|
+
// share over it turns red.
|
|
9
|
+
export function TimeBar({ colors, workedMin, estimateMin }: { colors: Colors; workedMin: number; estimateMin: number }) {
|
|
10
|
+
const scale = Math.max(workedMin, estimateMin);
|
|
11
|
+
const under = Math.min(workedMin, estimateMin);
|
|
12
|
+
return (
|
|
13
|
+
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants"
|
|
14
|
+
style={{ height: 4, borderRadius: 2, overflow: "hidden", flexDirection: "row", backgroundColor: colors.surface2 }}>
|
|
15
|
+
<View style={{ width: `${(under / scale) * 100}%`, backgroundColor: colors.accent }} />
|
|
16
|
+
{workedMin > estimateMin ? <View style={{ width: `${((workedMin - estimateMin) / scale) * 100}%`, backgroundColor: colors.statusDanger }} /> : null}
|
|
17
|
+
</View>
|
|
18
|
+
);
|
|
19
|
+
}
|
package/client/when.tsx
CHANGED
|
@@ -2,11 +2,14 @@ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Text } from "react-native";
|
|
4
4
|
import { formatAgo, formatClock } from "../shared/format";
|
|
5
|
+
import { useNarrow } from "./narrow";
|
|
5
6
|
|
|
6
7
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
7
8
|
|
|
8
|
-
// "Sep 27, 8:14 PM (10 h 2 min ago)", inline inside other text.
|
|
9
|
+
// "Sep 27, 8:14 PM (10 h 2 min ago)", inline inside other text. A narrow
|
|
10
|
+
// panel shows only "10 h 2 min ago"; the ticket dialog keeps the clock time.
|
|
9
11
|
export function When({ colors, iso, now }: { colors: Colors; iso: string; now: number }) {
|
|
12
|
+
if (useNarrow()) return <Text>{formatAgo(iso, now)}</Text>;
|
|
10
13
|
return (
|
|
11
14
|
<Text>
|
|
12
15
|
{formatClock(iso)} <Text style={{ color: colors.foregroundMuted, opacity: 0.7 }}>({formatAgo(iso, now)})</Text>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
|
+
import { Platform, Text, View } from "react-native";
|
|
6
|
+
import { previewKind } from "../shared/attachments";
|
|
7
|
+
import { previewDeliverable } from "../shared/rpc";
|
|
8
|
+
import { type Block, parseWriteUp, type Span, withoutTitle, writeUpExcerpt } from "../shared/write-up";
|
|
9
|
+
import { type Attachment, attachmentIcon } from "./attachments";
|
|
10
|
+
import { PressableRow } from "./row";
|
|
11
|
+
|
|
12
|
+
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
13
|
+
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
|
14
|
+
|
|
15
|
+
// What the ticket is: the start of its write-up in a quiet inset, with the
|
|
16
|
+
// rest a press away. A link or a file that isn't text shows only its name.
|
|
17
|
+
export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpen }: {
|
|
18
|
+
colors: Colors;
|
|
19
|
+
source: Attachment;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
workspaceDirectory: string;
|
|
22
|
+
onOpen(attachment: Attachment, group: Attachment[]): void;
|
|
23
|
+
}) {
|
|
24
|
+
const fetchPreview = useRpc(previewDeliverable);
|
|
25
|
+
const readable = Boolean(source.path && previewKind(source.path) === "text");
|
|
26
|
+
// The same query as the preview dialog, so opening the file after reading here is instant.
|
|
27
|
+
const preview = useQuery({
|
|
28
|
+
queryKey: ["progress", "preview", workspaceId, source.ref, source.path],
|
|
29
|
+
queryFn: () => fetchPreview({ workspaceId, workspaceDirectory, ref: source.ref }),
|
|
30
|
+
staleTime: 30_000,
|
|
31
|
+
enabled: readable,
|
|
32
|
+
});
|
|
33
|
+
const [all, setAll] = useState(false);
|
|
34
|
+
const blocks = preview.data?.kind === "text" ? parseWriteUp(preview.data.text) : [];
|
|
35
|
+
const excerpt = writeUpExcerpt(blocks, 280);
|
|
36
|
+
const shown = all ? withoutTitle(blocks) : excerpt.blocks;
|
|
37
|
+
return (
|
|
38
|
+
<View style={{ gap: 12, padding: 14, borderRadius: 8, backgroundColor: colors.surface0 }}>
|
|
39
|
+
{shown.length ? <View style={{ gap: 8 }}>{shown.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View> : null}
|
|
40
|
+
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Couldn't read it here: {preview.error.message}</Text> : null}
|
|
41
|
+
{/* The file on the left, opening it whole; expanding in place on the right. */}
|
|
42
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
43
|
+
<TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={`Open ${source.title}`} onPress={() => onOpen(source, [source])} grow />
|
|
44
|
+
<View style={{ flex: 1 }} />
|
|
45
|
+
{excerpt.more ? <TextButton colors={colors} label={all ? "Show less" : "Read more"} onPress={() => setAll(!all)} expanded={all} /> : null}
|
|
46
|
+
</View>
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A quiet text button for the inset's footer: muted, tinted on hover.
|
|
52
|
+
function TextButton({ colors, icon, label, accessibilityLabel, onPress, grow = false, expanded }: {
|
|
53
|
+
colors: Colors; icon?: string; label: string; accessibilityLabel?: string; onPress(): void; grow?: boolean; expanded?: boolean;
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} accessibilityState={expanded === undefined ? undefined : { expanded }} onPress={onPress}
|
|
57
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4, flexShrink: grow ? 1 : 0, minWidth: 0 }}>
|
|
58
|
+
{icon ? <Icon name={icon} size={12} color={colors.foregroundMuted} /> : null}
|
|
59
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{label}</Text>
|
|
60
|
+
</PressableRow>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
|
|
65
|
+
switch (block.kind) {
|
|
66
|
+
case "heading":
|
|
67
|
+
return (
|
|
68
|
+
// A step under the section's own 13px title: smaller and muted, set apart from the body by weight.
|
|
69
|
+
<Text accessibilityRole="header" selectable style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", marginTop: first ? 0 : 6 }}>
|
|
70
|
+
<Spans colors={colors} spans={block.spans} />
|
|
71
|
+
</Text>
|
|
72
|
+
);
|
|
73
|
+
case "item":
|
|
74
|
+
return (
|
|
75
|
+
<View style={{ flexDirection: "row", gap: 6, paddingStart: block.depth * 14 }}>
|
|
76
|
+
<Text style={{ minWidth: 10, color: colors.foregroundMuted, fontSize: 13, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{block.marker}</Text>
|
|
77
|
+
<Text selectable style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
case "code":
|
|
81
|
+
return (
|
|
82
|
+
<View style={{ padding: 8, borderRadius: 6, backgroundColor: colors.surface0 }}>
|
|
83
|
+
<Text selectable style={{ color: colors.foreground, fontFamily: MONO, fontSize: 12, lineHeight: 18 }}>{block.text}</Text>
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
default:
|
|
87
|
+
return <Text selectable style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
|
|
92
|
+
return <>{spans.map((span, index) => span.code
|
|
93
|
+
? <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>{span.text}</Text>
|
|
94
|
+
: <React.Fragment key={index}>{span.text}</React.Fragment>)}</>;
|
|
95
|
+
}
|