@stevecastaneda/paseo-progress 1.14.2 → 1.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -17
- package/client/attachments.tsx +122 -28
- package/client/blocks.tsx +33 -8
- package/client/chat-row-view.tsx +19 -6
- package/client/chat-row.ts +13 -2
- package/client/dashboard-query.ts +9 -4
- package/client/deliverables.tsx +82 -17
- package/client/dialog-stack.tsx +104 -42
- package/client/directory.ts +1 -3
- package/client/errors.ts +6 -1
- package/client/history.tsx +233 -79
- package/client/i18n.ts +10 -5
- package/client/launcher.tsx +57 -15
- package/client/locales/en.ts +28 -22
- package/client/locales/zh-CN.ts +18 -17
- package/client/messages.ts +1 -0
- package/client/motion.tsx +38 -14
- package/client/panel-opened.ts +3 -1
- package/client/panel.tsx +442 -160
- package/client/pdf.tsx +18 -4
- package/client/pieces.ts +6 -1
- package/client/pill-popover.tsx +108 -23
- package/client/pills.tsx +35 -12
- package/client/preview.tsx +265 -79
- package/client/questions.tsx +173 -52
- package/client/row.tsx +14 -3
- package/client/section-title.tsx +11 -2
- package/client/settings.tsx +76 -24
- package/client/show-more.tsx +23 -6
- package/client/spinner.tsx +11 -7
- package/client/status-badge.tsx +9 -7
- package/client/tabs.tsx +98 -20
- package/client/ticket-dialog.tsx +241 -73
- package/client/time-bar.tsx +5 -2
- package/client/video.tsx +15 -6
- package/client/whats-new.tsx +94 -36
- package/client/when.tsx +2 -1
- package/client/write-up.tsx +79 -12
- package/dist/server/cli.js +199 -101
- package/index.client.tsx +28 -13
- package/index.server.ts +19 -1
- package/package.json +15 -14
- package/server/cli.ts +72 -32
- package/server/dashboard.ts +9 -13
- package/server/launcher.ts +33 -17
- package/server/lock.ts +14 -7
- package/server/open.ts +32 -98
- package/server/progress-file.ts +6 -6
- package/server/question-mode.ts +12 -6
- package/server/skill.ts +22 -16
- package/server/store.ts +26 -41
- package/server/system.ts +113 -0
- package/server/tool.ts +89 -44
- package/server/whats-new.ts +25 -13
- package/shared/attachments.ts +40 -8
- package/shared/blocks.ts +9 -6
- package/shared/dashboard.ts +46 -29
- package/shared/events.ts +12 -10
- package/shared/markdown.ts +16 -8
- package/shared/question-modes.ts +8 -5
- package/shared/rpc.ts +9 -3
- package/shared/show.ts +20 -8
- package/shared/ticket-story.ts +1 -3
- package/skills/paseo-progress/SKILL.md +1 -0
- package/tsconfig.json +3 -10
- package/client/page-opener.ts +0 -9
- package/client/page-screen.tsx +0 -97
package/client/tabs.tsx
CHANGED
|
@@ -22,7 +22,15 @@ export type TabOverflow = "wrap" | "fit" | "scroll";
|
|
|
22
22
|
|
|
23
23
|
// A row of tabs over a bottom rule, as in the panel's history card and the
|
|
24
24
|
// ticket dialog. Narrow tightens the spacing so three tabs fit the 320px sidebar.
|
|
25
|
-
export function TabStrip<Id extends string>({
|
|
25
|
+
export function TabStrip<Id extends string>({
|
|
26
|
+
colors,
|
|
27
|
+
tabs,
|
|
28
|
+
selected,
|
|
29
|
+
onSelect,
|
|
30
|
+
narrow = false,
|
|
31
|
+
overflow = "wrap",
|
|
32
|
+
style,
|
|
33
|
+
}: {
|
|
26
34
|
colors: Colors;
|
|
27
35
|
tabs: Array<TabEntry<Id>>;
|
|
28
36
|
selected: Id;
|
|
@@ -64,7 +72,9 @@ export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect,
|
|
|
64
72
|
revealed.current = id;
|
|
65
73
|
};
|
|
66
74
|
// Scrolls to the chosen tab once it can be measured, and not again until the choice changes.
|
|
67
|
-
const settle = () => {
|
|
75
|
+
const settle = () => {
|
|
76
|
+
if (revealed.current !== selected) reveal(selected);
|
|
77
|
+
};
|
|
68
78
|
// A chevron press moves the row most of its width that way.
|
|
69
79
|
const page = (direction: 1 | -1) => {
|
|
70
80
|
const { viewport, content, offset } = size.current;
|
|
@@ -79,19 +89,44 @@ export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect,
|
|
|
79
89
|
if (before !== hidden.before || after !== hidden.after) setHidden({ before, after });
|
|
80
90
|
};
|
|
81
91
|
const items = tabs.map((entry) => (
|
|
82
|
-
<Tab
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
<Tab
|
|
93
|
+
key={entry.id}
|
|
94
|
+
colors={colors}
|
|
95
|
+
narrow={narrow}
|
|
96
|
+
entry={iconless ? { ...entry, icon: null } : entry}
|
|
97
|
+
selected={entry.id === selected}
|
|
98
|
+
onPress={() => {
|
|
99
|
+
onSelect(entry.id);
|
|
100
|
+
if (scroll) reveal(entry.id);
|
|
101
|
+
}}
|
|
102
|
+
onLayout={
|
|
103
|
+
scroll
|
|
104
|
+
? (event) => {
|
|
105
|
+
widths.current.set(entry.id, event.nativeEvent.layout.width);
|
|
106
|
+
settle();
|
|
107
|
+
}
|
|
108
|
+
: undefined
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
85
111
|
));
|
|
86
112
|
if (scroll) {
|
|
87
113
|
// Scrolling rows sit on Paseo's phone sheet, which is surface0.
|
|
88
114
|
return (
|
|
89
115
|
<View style={[{ position: "relative" }, style]}>
|
|
90
|
-
<ScrollView
|
|
91
|
-
|
|
116
|
+
<ScrollView
|
|
117
|
+
ref={scroller}
|
|
118
|
+
horizontal
|
|
119
|
+
showsHorizontalScrollIndicator={false}
|
|
120
|
+
accessibilityRole="tablist"
|
|
121
|
+
scrollEventThrottle={16}
|
|
122
|
+
onLayout={(event) => {
|
|
123
|
+
measure({ viewport: event.nativeEvent.layout.width });
|
|
124
|
+
settle();
|
|
125
|
+
}}
|
|
92
126
|
onContentSizeChange={(width) => measure({ content: width })}
|
|
93
127
|
onScroll={(event) => measure({ offset: event.nativeEvent.contentOffset.x })}
|
|
94
|
-
contentContainerStyle={{ flexGrow: 1, flexDirection: "row", columnGap: gap, ...rule }}
|
|
128
|
+
contentContainerStyle={{ flexGrow: 1, flexDirection: "row", columnGap: gap, ...rule }}
|
|
129
|
+
>
|
|
95
130
|
{items}
|
|
96
131
|
</ScrollView>
|
|
97
132
|
{hidden.before ? <EdgeFade colors={colors} side="left" onPress={() => page(-1)} /> : null}
|
|
@@ -100,10 +135,18 @@ export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect,
|
|
|
100
135
|
);
|
|
101
136
|
}
|
|
102
137
|
return (
|
|
103
|
-
<View
|
|
138
|
+
<View
|
|
139
|
+
accessibilityRole="tablist"
|
|
104
140
|
// One row is 32px (8 + 18 + 6); anything taller wrapped.
|
|
105
|
-
onLayout={
|
|
106
|
-
|
|
141
|
+
onLayout={
|
|
142
|
+
overflow === "fit"
|
|
143
|
+
? (event) => {
|
|
144
|
+
if (!iconless && event.nativeEvent.layout.height > 40) setWrappedFor(ids);
|
|
145
|
+
}
|
|
146
|
+
: undefined
|
|
147
|
+
}
|
|
148
|
+
style={[{ flexDirection: "row", flexWrap: "wrap", columnGap: gap, ...rule }, style]}
|
|
149
|
+
>
|
|
107
150
|
{items}
|
|
108
151
|
</View>
|
|
109
152
|
);
|
|
@@ -120,8 +163,13 @@ function EdgeFade({ colors, side, onPress }: { colors: Colors; side: "left" | "r
|
|
|
120
163
|
{Array.from({ length: FADE_SLICES }, (_, i) => (
|
|
121
164
|
<View key={i} pointerEvents="none" style={{ width: 2, backgroundColor: withAlpha(colors.surface0, (i + 1) / (FADE_SLICES + 1)) ?? undefined }} />
|
|
122
165
|
))}
|
|
123
|
-
<Pressable
|
|
124
|
-
|
|
166
|
+
<Pressable
|
|
167
|
+
accessibilityElementsHidden
|
|
168
|
+
importantForAccessibility="no-hide-descendants"
|
|
169
|
+
onPress={onPress}
|
|
170
|
+
hitSlop={8}
|
|
171
|
+
style={{ width: 16, justifyContent: "center", alignItems: side === "right" ? "flex-end" : "flex-start", backgroundColor: colors.surface0 }}
|
|
172
|
+
>
|
|
125
173
|
<Icon name={side === "right" ? "ChevronRight" : "ChevronLeft"} size={14} color={colors.foregroundMuted} />
|
|
126
174
|
</Pressable>
|
|
127
175
|
</View>
|
|
@@ -129,7 +177,14 @@ function EdgeFade({ colors, side, onPress }: { colors: Colors; side: "left" | "r
|
|
|
129
177
|
}
|
|
130
178
|
|
|
131
179
|
// Instant feedback: tabs switch often, so hover and press change color only.
|
|
132
|
-
function Tab<Id extends string>({
|
|
180
|
+
function Tab<Id extends string>({
|
|
181
|
+
colors,
|
|
182
|
+
narrow,
|
|
183
|
+
entry,
|
|
184
|
+
selected,
|
|
185
|
+
onPress,
|
|
186
|
+
onLayout,
|
|
187
|
+
}: {
|
|
133
188
|
colors: Colors;
|
|
134
189
|
narrow: boolean;
|
|
135
190
|
entry: TabEntry<Id>;
|
|
@@ -139,19 +194,42 @@ function Tab<Id extends string>({ colors, narrow, entry, selected, onPress, onLa
|
|
|
139
194
|
}) {
|
|
140
195
|
const [hovered, setHovered] = useState(false);
|
|
141
196
|
return (
|
|
142
|
-
<Pressable
|
|
143
|
-
|
|
197
|
+
<Pressable
|
|
198
|
+
accessibilityRole="tab"
|
|
199
|
+
aria-selected={selected}
|
|
200
|
+
onPress={onPress}
|
|
201
|
+
onLayout={onLayout}
|
|
202
|
+
onHoverIn={() => setHovered(true)}
|
|
203
|
+
onHoverOut={() => setHovered(false)}
|
|
144
204
|
style={({ pressed }) => ({
|
|
145
|
-
flexDirection: "row",
|
|
205
|
+
flexDirection: "row",
|
|
206
|
+
alignItems: "center",
|
|
207
|
+
gap: narrow ? 4 : 6,
|
|
208
|
+
paddingTop: 8,
|
|
209
|
+
paddingBottom: 6,
|
|
210
|
+
marginBottom: -1,
|
|
146
211
|
borderBottomWidth: 2,
|
|
147
212
|
borderBottomColor: selected ? colors.accent : pressed || hovered ? colors.border : "transparent",
|
|
148
|
-
})}
|
|
213
|
+
})}
|
|
214
|
+
>
|
|
149
215
|
{entry.icon ? <Icon name={entry.icon} size={14} color={selected ? colors.accent : colors.foregroundMuted} /> : null}
|
|
150
|
-
<Text style={{ color: selected || hovered ? colors.foreground : colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>
|
|
216
|
+
<Text style={{ color: selected || hovered ? colors.foreground : colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>
|
|
217
|
+
{entry.label}
|
|
218
|
+
</Text>
|
|
151
219
|
{/* Count badge: muted on both tabs; the chosen tab's number reads darker. */}
|
|
152
220
|
{entry.count !== undefined ? (
|
|
153
221
|
<View style={{ minWidth: 16, paddingHorizontal: 4, borderRadius: 4, alignItems: "center", backgroundColor: colors.surface2 }}>
|
|
154
|
-
<Text
|
|
222
|
+
<Text
|
|
223
|
+
style={{
|
|
224
|
+
color: selected ? colors.foreground : colors.foregroundMuted,
|
|
225
|
+
fontSize: 10,
|
|
226
|
+
lineHeight: 14,
|
|
227
|
+
fontWeight: "600",
|
|
228
|
+
fontVariant: ["tabular-nums"],
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
{entry.count}
|
|
232
|
+
</Text>
|
|
155
233
|
</View>
|
|
156
234
|
) : null}
|
|
157
235
|
</Pressable>
|
package/client/ticket-dialog.tsx
CHANGED
|
@@ -22,7 +22,16 @@ type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navi
|
|
|
22
22
|
|
|
23
23
|
// A ticket's story, opened from its row: where its time went, and what came out
|
|
24
24
|
// of it. Its questions and previews open in the same dialog (see StackedDialog).
|
|
25
|
-
export function TicketDialogs({
|
|
25
|
+
export function TicketDialogs({
|
|
26
|
+
colors,
|
|
27
|
+
dashboard,
|
|
28
|
+
openId,
|
|
29
|
+
setOpenId,
|
|
30
|
+
now,
|
|
31
|
+
live,
|
|
32
|
+
compact,
|
|
33
|
+
context,
|
|
34
|
+
}: {
|
|
26
35
|
colors: Colors;
|
|
27
36
|
dashboard: Dashboard;
|
|
28
37
|
openId: string | null;
|
|
@@ -33,12 +42,32 @@ export function TicketDialogs({ colors, dashboard, openId, setOpenId, now, live,
|
|
|
33
42
|
context: AttachmentContext;
|
|
34
43
|
}) {
|
|
35
44
|
return (
|
|
36
|
-
<StackedDialog
|
|
37
|
-
|
|
45
|
+
<StackedDialog
|
|
46
|
+
colors={colors}
|
|
47
|
+
root={openId ? { kind: "ticket", id: openId } : null}
|
|
48
|
+
onClose={() => setOpenId(null)}
|
|
49
|
+
questions={[...dashboard.questions.open, ...dashboard.questions.answered]}
|
|
50
|
+
dashboard={dashboard}
|
|
51
|
+
now={now}
|
|
52
|
+
live={live}
|
|
53
|
+
compact={compact}
|
|
54
|
+
context={context}
|
|
55
|
+
/>
|
|
38
56
|
);
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
export function TicketStoryView({
|
|
59
|
+
export function TicketStoryView({
|
|
60
|
+
colors,
|
|
61
|
+
dashboard,
|
|
62
|
+
ticket,
|
|
63
|
+
now,
|
|
64
|
+
live,
|
|
65
|
+
compact,
|
|
66
|
+
context,
|
|
67
|
+
onOpenAttachment,
|
|
68
|
+
onOpenQuestion,
|
|
69
|
+
onOpenAgent,
|
|
70
|
+
}: {
|
|
42
71
|
colors: Colors;
|
|
43
72
|
dashboard: Dashboard;
|
|
44
73
|
ticket: Ticket;
|
|
@@ -53,67 +82,150 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, compact,
|
|
|
53
82
|
onOpenAgent?(agentId: string): void;
|
|
54
83
|
}) {
|
|
55
84
|
const { messages: m, locale } = useProgressLanguage();
|
|
56
|
-
const story = ticketStory(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
const story = ticketStory(
|
|
86
|
+
ticket,
|
|
87
|
+
{
|
|
88
|
+
deliverables: dashboard.deliverables,
|
|
89
|
+
questions: [...dashboard.questions.open, ...dashboard.questions.answered].sort((a, b) => Date.parse(a.askedAt) - Date.parse(b.askedAt)),
|
|
90
|
+
activity: [...dashboard.activity].reverse(),
|
|
91
|
+
},
|
|
92
|
+
now,
|
|
93
|
+
);
|
|
61
94
|
const over = story.workedMin - ticket.estimateMin >= 1;
|
|
62
95
|
const byTime = [...story.questions, ...story.activity].some((item) => item.link === "by-time");
|
|
63
96
|
const source = ticketSourceAttachment(ticket);
|
|
64
97
|
// The ticker is run-wide: it's about this ticket when this ticket's agent set
|
|
65
98
|
// it, or, without agents to go by, when this is the only ticket working.
|
|
66
|
-
const ticker =
|
|
67
|
-
|
|
68
|
-
|
|
99
|
+
const ticker =
|
|
100
|
+
dashboard.ticker &&
|
|
101
|
+
ticket.status === "working" &&
|
|
102
|
+
(dashboard.ticker.by ? dashboard.ticker.by === ticket.agentId : dashboard.tickets.filter((candidate) => candidate.status === "working").length === 1)
|
|
103
|
+
? dashboard.ticker.text
|
|
104
|
+
: null;
|
|
69
105
|
// What the badge doesn't say: the stage it's in, or what it waits for.
|
|
70
106
|
const detail = ticket.waitingFor ? m.waitingDetail(ticket.waitingFor.id, ticket.waitingFor.title) : ticket.status === "working" ? ticket.stage : undefined;
|
|
71
107
|
const timeline = story.timeline.map((step, index) => {
|
|
72
108
|
const last = index === story.timeline.length - 1;
|
|
73
109
|
// A current "Blocked" that's only waiting on another ticket reads as waiting, in the calm color.
|
|
74
|
-
const shown =
|
|
75
|
-
? { ...step, label: { kind: "waiting" as const, id: ticket.waitingFor.id }, waiting: true }
|
|
76
|
-
: step;
|
|
110
|
+
const shown =
|
|
111
|
+
last && ticket.waitingFor && step.status === "blocked" ? { ...step, label: { kind: "waiting" as const, id: ticket.waitingFor.id }, waiting: true } : step;
|
|
77
112
|
return <TimelineRow key={`${step.at}-${index}`} colors={colors} step={shown} last={last} now={now} live={live} />;
|
|
78
113
|
});
|
|
79
114
|
// Only tabs with something in them; the write-up leads when there is one.
|
|
80
115
|
const tabs: Array<TabEntry<StoryTab> & { content: React.ReactNode }> = [
|
|
81
|
-
...(source
|
|
82
|
-
|
|
116
|
+
...(source
|
|
117
|
+
? [
|
|
118
|
+
{
|
|
119
|
+
id: "write-up" as const,
|
|
120
|
+
label: m.writeUp,
|
|
121
|
+
icon: "FileText",
|
|
122
|
+
content: (
|
|
123
|
+
<WriteUp
|
|
124
|
+
colors={colors}
|
|
125
|
+
source={source}
|
|
126
|
+
workspaceId={context.workspaceId}
|
|
127
|
+
workspaceDirectory={context.workspaceDirectory}
|
|
128
|
+
onOpen={onOpenAttachment}
|
|
129
|
+
/>
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
: []),
|
|
83
134
|
{ id: "timeline", label: m.timeline, icon: "History", count: story.timeline.length, content: <View>{timeline}</View> },
|
|
84
|
-
...(story.activity.length
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{question.title}<LinkNote colors={colors} link={question.link} /></Text>
|
|
105
|
-
{question.answer
|
|
106
|
-
? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.answered} <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}</Text>{chosen ? <Text style={{ color: colors.foreground }}>: {chosen.label}</Text> : null}</Text>
|
|
107
|
-
: <QuestionState colors={colors} question={question} />}
|
|
135
|
+
...(story.activity.length
|
|
136
|
+
? [
|
|
137
|
+
{
|
|
138
|
+
id: "activity" as const,
|
|
139
|
+
label: m.activity,
|
|
140
|
+
icon: "Activity",
|
|
141
|
+
count: story.activity.length,
|
|
142
|
+
content: (
|
|
143
|
+
<View>
|
|
144
|
+
{story.activity.map((entry) => (
|
|
145
|
+
<View key={entry.id} style={{ flexDirection: "row", gap: 12, paddingVertical: 4 }}>
|
|
146
|
+
<Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>
|
|
147
|
+
{entry.text}
|
|
148
|
+
<LinkNote colors={colors} link={entry.link} />
|
|
149
|
+
</Text>
|
|
150
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 19, fontVariant: ["tabular-nums"] }}>
|
|
151
|
+
{formatClientTimeOfDay(locale, entry.at, now)}
|
|
152
|
+
</Text>
|
|
153
|
+
</View>
|
|
154
|
+
))}
|
|
108
155
|
</View>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
: []),
|
|
160
|
+
...(story.questions.length
|
|
161
|
+
? [
|
|
162
|
+
{
|
|
163
|
+
id: "questions" as const,
|
|
164
|
+
label: m.questions,
|
|
165
|
+
icon: "MessageCircleQuestion",
|
|
166
|
+
count: story.questions.length,
|
|
167
|
+
content: (
|
|
168
|
+
<View>
|
|
169
|
+
{story.questions.map((question) => {
|
|
170
|
+
const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
|
|
171
|
+
return (
|
|
172
|
+
<PressableRow
|
|
173
|
+
key={question.id}
|
|
174
|
+
colors={colors}
|
|
175
|
+
onSurface1
|
|
176
|
+
accessibilityRole="button"
|
|
177
|
+
accessibilityLabel={m.openItem(question.id)}
|
|
178
|
+
onPress={() => onOpenQuestion(question.id)}
|
|
179
|
+
style={{ flexDirection: "row", gap: 8, paddingVertical: 4, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}
|
|
180
|
+
>
|
|
181
|
+
<Text
|
|
182
|
+
style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}
|
|
183
|
+
>
|
|
184
|
+
{question.id}
|
|
185
|
+
</Text>
|
|
186
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
187
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
|
|
188
|
+
{question.title}
|
|
189
|
+
<LinkNote colors={colors} link={question.link} />
|
|
190
|
+
</Text>
|
|
191
|
+
{question.answer ? (
|
|
192
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
193
|
+
{m.answered} <Text style={{ color: colors.statusSuccess, fontWeight: "600" }}>{question.answer.choice}</Text>
|
|
194
|
+
{chosen ? <Text style={{ color: colors.foreground }}>: {chosen.label}</Text> : null}
|
|
195
|
+
</Text>
|
|
196
|
+
) : (
|
|
197
|
+
<QuestionState colors={colors} question={question} />
|
|
198
|
+
)}
|
|
199
|
+
</View>
|
|
200
|
+
<View style={{ paddingTop: 2 }}>
|
|
201
|
+
<Icon name="ChevronRight" size={14} color={colors.foregroundMuted} />
|
|
202
|
+
</View>
|
|
203
|
+
</PressableRow>
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
</View>
|
|
207
|
+
),
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
: []),
|
|
211
|
+
...(story.deliverables.length
|
|
212
|
+
? [
|
|
213
|
+
{
|
|
214
|
+
id: "deliverables" as const,
|
|
215
|
+
label: m.deliverables,
|
|
216
|
+
icon: "Package",
|
|
217
|
+
count: story.deliverables.length,
|
|
218
|
+
content: (
|
|
219
|
+
<AttachmentList
|
|
220
|
+
colors={colors}
|
|
221
|
+
attachments={story.deliverables.map(deliverableAttachment)}
|
|
222
|
+
root={context.workspaceDirectory}
|
|
223
|
+
onOpen={onOpenAttachment}
|
|
224
|
+
/>
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
]
|
|
228
|
+
: []),
|
|
117
229
|
];
|
|
118
230
|
const tabKey = `${context.workspaceId}:${ticket.id}`;
|
|
119
231
|
const [chosen, setChosen] = useState<StoryTab | undefined>(() => storyTabs.get(tabKey));
|
|
@@ -128,14 +240,22 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, compact,
|
|
|
128
240
|
<View style={{ gap: 10 }}>
|
|
129
241
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
130
242
|
<StatusBadge colors={colors} status={ticket.status} waiting={Boolean(ticket.waitingFor)} />
|
|
131
|
-
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
|
|
132
|
-
|
|
133
|
-
|
|
243
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
|
|
244
|
+
{detail}
|
|
245
|
+
</Text>
|
|
246
|
+
<Text
|
|
247
|
+
accessibilityLabel={m.workedEstimate(formatClientMinutes(locale, story.workedMin), formatClientMinutes(locale, ticket.estimateMin))}
|
|
248
|
+
style={{ color: over ? colors.statusDanger : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}
|
|
249
|
+
>
|
|
134
250
|
{formatClientWorkDone(locale, story.workedMin, ticket.estimateMin)}
|
|
135
251
|
</Text>
|
|
136
252
|
</View>
|
|
137
253
|
<TimeBar colors={colors} workedMin={story.workedMin} estimateMin={ticket.estimateMin} />
|
|
138
|
-
{ticker ?
|
|
254
|
+
{ticker ? (
|
|
255
|
+
<Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>
|
|
256
|
+
{ticker}
|
|
257
|
+
</Text>
|
|
258
|
+
) : null}
|
|
139
259
|
{ticket.note ? <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{ticket.note}</Text> : null}
|
|
140
260
|
{ticket.agentId ? <AgentRow colors={colors} agentId={ticket.agentId} onOpen={onOpenAgent} /> : null}
|
|
141
261
|
</View>
|
|
@@ -146,9 +266,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, compact,
|
|
|
146
266
|
</View>
|
|
147
267
|
|
|
148
268
|
{byTime ? (
|
|
149
|
-
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
150
|
-
{m.byTimeHint(ticket.id, dashboard.run?.itemLabel ?? "Ticket")}
|
|
151
|
-
</Text>
|
|
269
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{m.byTimeHint(ticket.id, dashboard.run?.itemLabel ?? "Ticket")}</Text>
|
|
152
270
|
) : null}
|
|
153
271
|
</View>
|
|
154
272
|
);
|
|
@@ -184,13 +302,30 @@ function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string
|
|
|
184
302
|
const open = onOpen ? () => onOpen(agentId) : undefined;
|
|
185
303
|
// A quiet line in the summary: who's on it, and the way to their chat.
|
|
186
304
|
return (
|
|
187
|
-
<PressableRow
|
|
188
|
-
|
|
305
|
+
<PressableRow
|
|
306
|
+
colors={colors}
|
|
307
|
+
onSurface1
|
|
308
|
+
disabled={!open}
|
|
309
|
+
accessibilityRole="button"
|
|
310
|
+
accessibilityLabel={m.openAgentChat(title)}
|
|
311
|
+
onPress={open}
|
|
312
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4 }}
|
|
313
|
+
>
|
|
189
314
|
<Icon name="Bot" size={12} color={colors.foregroundMuted} />
|
|
190
|
-
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
191
|
-
|
|
315
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
316
|
+
{title}
|
|
317
|
+
</Text>
|
|
318
|
+
{alert ? (
|
|
319
|
+
<Text numberOfLines={1} style={{ flexShrink: 0, color: alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>
|
|
320
|
+
{alert.text}
|
|
321
|
+
</Text>
|
|
322
|
+
) : null}
|
|
192
323
|
<View style={{ flex: 1 }} />
|
|
193
|
-
{open ?
|
|
324
|
+
{open ? (
|
|
325
|
+
<Text numberOfLines={1} style={{ flexShrink: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>
|
|
326
|
+
{m.openChat}
|
|
327
|
+
</Text>
|
|
328
|
+
) : null}
|
|
194
329
|
</PressableRow>
|
|
195
330
|
);
|
|
196
331
|
}
|
|
@@ -204,17 +339,34 @@ function LinkNote({ colors, link }: { colors: Colors; link: Link }) {
|
|
|
204
339
|
// Past stage steps get an accent ringed dot: work that happened, now over.
|
|
205
340
|
function Marker({ colors, step }: { colors: Colors; step: TimelineStep & { waiting?: boolean } }) {
|
|
206
341
|
if (step.waiting || (step.status === "not_started" && step.label.kind !== "added")) return <WaitingDot color={colors.foregroundMuted} />;
|
|
207
|
-
const icon =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
342
|
+
const icon =
|
|
343
|
+
step.label.kind === "added"
|
|
344
|
+
? { name: "CirclePlus", color: colors.foregroundMuted }
|
|
345
|
+
: step.status === "done"
|
|
346
|
+
? { name: "CircleCheck", color: colors.statusSuccess }
|
|
347
|
+
: step.status === "blocked"
|
|
348
|
+
? { name: "Ban", color: colors.statusDanger }
|
|
349
|
+
: step.status === "skipped"
|
|
350
|
+
? { name: "CircleSlash", color: colors.foregroundMuted }
|
|
351
|
+
: { name: "CircleDot", color: colors.accent };
|
|
212
352
|
return <Icon name={icon.name} size={12} color={icon.color} />;
|
|
213
353
|
}
|
|
214
354
|
|
|
215
355
|
// One line per step: its status icon, what it was, when it began and how long
|
|
216
356
|
// it took. The step still going shows the live spinner.
|
|
217
|
-
function TimelineRow({
|
|
357
|
+
function TimelineRow({
|
|
358
|
+
colors,
|
|
359
|
+
step,
|
|
360
|
+
last,
|
|
361
|
+
now,
|
|
362
|
+
live,
|
|
363
|
+
}: {
|
|
364
|
+
colors: Colors;
|
|
365
|
+
step: TimelineStep & { waiting?: boolean };
|
|
366
|
+
last: boolean;
|
|
367
|
+
now: number;
|
|
368
|
+
live: boolean;
|
|
369
|
+
}) {
|
|
218
370
|
const { messages: m, locale } = useProgressLanguage();
|
|
219
371
|
const current = last && step.minutes !== null;
|
|
220
372
|
const working = current && step.status === "working";
|
|
@@ -224,18 +376,34 @@ function TimelineRow({ colors, step, last, now, live }: { colors: Colors; step:
|
|
|
224
376
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
225
377
|
<View style={{ width: 14, alignItems: "center" }}>
|
|
226
378
|
<IconSwap swapKey={working ? (live ? "live" : "stale") : step.waiting ? "waiting" : step.status} size={12}>
|
|
227
|
-
{working ?
|
|
379
|
+
{working ? live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} /> : <Marker colors={colors} step={step} />}
|
|
228
380
|
</IconSwap>
|
|
229
381
|
</View>
|
|
230
|
-
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
|
|
231
|
-
|
|
382
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
|
|
383
|
+
{timelineLabel(m, step.label)}
|
|
384
|
+
</Text>
|
|
385
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
|
|
386
|
+
{formatClientTimeOfDay(locale, step.at, now)}
|
|
387
|
+
</Text>
|
|
232
388
|
{/* A fixed column, so durations line up down the list. */}
|
|
233
|
-
<Text
|
|
234
|
-
{
|
|
389
|
+
<Text
|
|
390
|
+
numberOfLines={1}
|
|
391
|
+
style={{
|
|
392
|
+
minWidth: 84,
|
|
393
|
+
textAlign: "right",
|
|
394
|
+
color: current ? colors.foreground : colors.foregroundMuted,
|
|
395
|
+
fontSize: 12,
|
|
396
|
+
lineHeight: 18,
|
|
397
|
+
fontVariant: ["tabular-nums"],
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
{step.minutes !== null ? (current ? m.soFar(formatClientMinutes(locale, step.minutes)) : formatClientMinutes(locale, step.minutes)) : ""}
|
|
235
401
|
</Text>
|
|
236
402
|
</View>
|
|
237
403
|
{step.notes.map((note, index) => (
|
|
238
|
-
<Text key={index} style={{ paddingStart: 20, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
404
|
+
<Text key={index} style={{ paddingStart: 20, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
405
|
+
{note}
|
|
406
|
+
</Text>
|
|
239
407
|
))}
|
|
240
408
|
</View>
|
|
241
409
|
);
|
package/client/time-bar.tsx
CHANGED
|
@@ -10,8 +10,11 @@ export function TimeBar({ colors, workedMin, estimateMin }: { colors: Colors; wo
|
|
|
10
10
|
const scale = Math.max(workedMin, estimateMin);
|
|
11
11
|
const under = Math.min(workedMin, estimateMin);
|
|
12
12
|
return (
|
|
13
|
-
<View
|
|
14
|
-
|
|
13
|
+
<View
|
|
14
|
+
accessibilityElementsHidden
|
|
15
|
+
importantForAccessibility="no-hide-descendants"
|
|
16
|
+
style={{ height: 4, borderRadius: 2, overflow: "hidden", flexDirection: "row", backgroundColor: colors.surface2 }}
|
|
17
|
+
>
|
|
15
18
|
<View style={{ width: `${(under / scale) * 100}%`, backgroundColor: colors.accent }} />
|
|
16
19
|
{workedMin > estimateMin ? <View style={{ width: `${((workedMin - estimateMin) / scale) * 100}%`, backgroundColor: colors.statusDanger }} /> : null}
|
|
17
20
|
</View>
|
package/client/video.tsx
CHANGED
|
@@ -7,8 +7,8 @@ interface VideoElement {
|
|
|
7
7
|
src: string;
|
|
8
8
|
videoWidth: number;
|
|
9
9
|
videoHeight: number;
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
addEventListener(type: "loadedmetadata" | "error", listener: () => void): void;
|
|
11
|
+
removeEventListener(type: "loadedmetadata" | "error", listener: () => void): void;
|
|
12
12
|
}
|
|
13
13
|
const page = globalThis as unknown as { document: { createElement(tag: "video"): VideoElement } };
|
|
14
14
|
|
|
@@ -17,14 +17,21 @@ const page = globalThis as unknown as { document: { createElement(tag: "video"):
|
|
|
17
17
|
export function videoRatio(blob: FileBlob): Promise<number | null> {
|
|
18
18
|
const url = browser.URL.createObjectURL(blob);
|
|
19
19
|
const video = page.document.createElement("video");
|
|
20
|
+
let stopListening: (() => void) | undefined;
|
|
20
21
|
return new Promise<number | null>((resolve) => {
|
|
21
22
|
setTimeout(() => resolve(null), 5_000);
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const onMetadata = () => resolve(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : null);
|
|
24
|
+
const onError = () => resolve(null);
|
|
25
|
+
video.addEventListener("loadedmetadata", onMetadata);
|
|
26
|
+
video.addEventListener("error", onError);
|
|
27
|
+
stopListening = () => {
|
|
28
|
+
video.removeEventListener("loadedmetadata", onMetadata);
|
|
29
|
+
video.removeEventListener("error", onError);
|
|
30
|
+
};
|
|
24
31
|
video.preload = "metadata";
|
|
25
32
|
video.src = url;
|
|
26
33
|
}).finally(() => {
|
|
27
|
-
|
|
34
|
+
stopListening?.();
|
|
28
35
|
video.src = "";
|
|
29
36
|
browser.URL.revokeObjectURL(url);
|
|
30
37
|
});
|
|
@@ -35,7 +42,9 @@ export function VideoPlayer({ blob, label, style }: { blob: FileBlob; label: str
|
|
|
35
42
|
const url = useObjectUrl(blob);
|
|
36
43
|
return (
|
|
37
44
|
<View style={[style, { overflow: "hidden" }]}>
|
|
38
|
-
{url ?
|
|
45
|
+
{url ? (
|
|
46
|
+
<video src={url} controls playsInline aria-label={label} style={{ width: "100%", height: "100%", display: "block", objectFit: "contain" }} />
|
|
47
|
+
) : null}
|
|
39
48
|
</View>
|
|
40
49
|
);
|
|
41
50
|
}
|