@stevecastaneda/paseo-progress 1.14.2 → 1.14.4

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.
Files changed (67) hide show
  1. package/README.md +28 -17
  2. package/client/attachments.tsx +122 -28
  3. package/client/blocks.tsx +33 -8
  4. package/client/chat-row-view.tsx +19 -6
  5. package/client/chat-row.ts +13 -2
  6. package/client/dashboard-query.ts +9 -4
  7. package/client/deliverables.tsx +82 -17
  8. package/client/dialog-stack.tsx +104 -42
  9. package/client/directory.ts +1 -3
  10. package/client/errors.ts +6 -1
  11. package/client/history.tsx +233 -79
  12. package/client/i18n.ts +10 -5
  13. package/client/launcher.tsx +57 -15
  14. package/client/locales/en.ts +28 -22
  15. package/client/locales/zh-CN.ts +18 -17
  16. package/client/messages.ts +1 -0
  17. package/client/motion.tsx +38 -14
  18. package/client/panel-opened.ts +3 -1
  19. package/client/panel.tsx +442 -160
  20. package/client/pdf.tsx +18 -4
  21. package/client/pieces.ts +6 -1
  22. package/client/pill-popover.tsx +108 -23
  23. package/client/pills.tsx +35 -12
  24. package/client/preview.tsx +265 -79
  25. package/client/questions.tsx +173 -52
  26. package/client/row.tsx +14 -3
  27. package/client/section-title.tsx +11 -2
  28. package/client/settings.tsx +76 -24
  29. package/client/show-more.tsx +23 -6
  30. package/client/spinner.tsx +11 -7
  31. package/client/status-badge.tsx +9 -7
  32. package/client/tabs.tsx +98 -20
  33. package/client/ticket-dialog.tsx +241 -73
  34. package/client/time-bar.tsx +5 -2
  35. package/client/video.tsx +15 -6
  36. package/client/whats-new.tsx +94 -36
  37. package/client/when.tsx +2 -1
  38. package/client/write-up.tsx +79 -12
  39. package/dist/server/cli.js +199 -101
  40. package/index.client.tsx +28 -13
  41. package/index.server.ts +19 -1
  42. package/package.json +15 -14
  43. package/server/cli.ts +72 -32
  44. package/server/dashboard.ts +9 -13
  45. package/server/launcher.ts +33 -17
  46. package/server/lock.ts +14 -7
  47. package/server/open.ts +32 -98
  48. package/server/progress-file.ts +6 -6
  49. package/server/question-mode.ts +12 -6
  50. package/server/skill.ts +22 -16
  51. package/server/store.ts +26 -41
  52. package/server/system.ts +113 -0
  53. package/server/tool.ts +89 -44
  54. package/server/whats-new.ts +25 -13
  55. package/shared/attachments.ts +40 -8
  56. package/shared/blocks.ts +9 -6
  57. package/shared/dashboard.ts +46 -29
  58. package/shared/events.ts +12 -10
  59. package/shared/markdown.ts +16 -8
  60. package/shared/question-modes.ts +8 -5
  61. package/shared/rpc.ts +9 -3
  62. package/shared/show.ts +20 -8
  63. package/shared/ticket-story.ts +1 -3
  64. package/skills/paseo-progress/SKILL.md +7 -0
  65. package/tsconfig.json +3 -10
  66. package/client/page-opener.ts +0 -9
  67. 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>({ colors, tabs, selected, onSelect, narrow = false, overflow = "wrap", style }: {
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 = () => { if (revealed.current !== selected) reveal(selected); };
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 key={entry.id} colors={colors} narrow={narrow} entry={iconless ? { ...entry, icon: null } : entry} selected={entry.id === selected}
83
- onPress={() => { onSelect(entry.id); if (scroll) reveal(entry.id); }}
84
- onLayout={scroll ? (event) => { widths.current.set(entry.id, event.nativeEvent.layout.width); settle(); } : undefined} />
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 ref={scroller} horizontal showsHorizontalScrollIndicator={false} accessibilityRole="tablist" scrollEventThrottle={16}
91
- onLayout={(event) => { measure({ viewport: event.nativeEvent.layout.width }); settle(); }}
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 accessibilityRole="tablist"
138
+ <View
139
+ accessibilityRole="tablist"
104
140
  // One row is 32px (8 + 18 + 6); anything taller wrapped.
105
- onLayout={overflow === "fit" ? (event) => { if (!iconless && event.nativeEvent.layout.height > 40) setWrappedFor(ids); } : undefined}
106
- style={[{ flexDirection: "row", flexWrap: "wrap", columnGap: gap, ...rule }, style]}>
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 accessibilityElementsHidden importantForAccessibility="no-hide-descendants" onPress={onPress} hitSlop={8}
124
- style={{ width: 16, justifyContent: "center", alignItems: side === "right" ? "flex-end" : "flex-start", backgroundColor: colors.surface0 }}>
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>({ colors, narrow, entry, selected, onPress, onLayout }: {
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 accessibilityRole="tab" aria-selected={selected} onPress={onPress} onLayout={onLayout}
143
- onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
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", alignItems: "center", gap: narrow ? 4 : 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
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" }}>{entry.label}</Text>
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 style={{ color: selected ? colors.foreground : colors.foregroundMuted, fontSize: 10, lineHeight: 14, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{entry.count}</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>
@@ -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({ colors, dashboard, openId, setOpenId, now, live, compact, context }: {
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 colors={colors} root={openId ? { kind: "ticket", id: openId } : null} onClose={() => setOpenId(null)}
37
- questions={[...dashboard.questions.open, ...dashboard.questions.answered]} dashboard={dashboard} now={now} live={live} compact={compact} context={context} />
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({ colors, dashboard, ticket, now, live, compact, context, onOpenAttachment, onOpenQuestion, onOpenAgent }: {
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(ticket, {
57
- deliverables: dashboard.deliverables,
58
- questions: [...dashboard.questions.open, ...dashboard.questions.answered].sort((a, b) => Date.parse(a.askedAt) - Date.parse(b.askedAt)),
59
- activity: [...dashboard.activity].reverse(),
60
- }, now);
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 = dashboard.ticker && ticket.status === "working" && (dashboard.ticker.by
67
- ? dashboard.ticker.by === ticket.agentId
68
- : dashboard.tickets.filter((candidate) => candidate.status === "working").length === 1) ? dashboard.ticker.text : null;
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 = last && ticket.waitingFor && step.status === "blocked"
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 ? [{ id: "write-up" as const, label: m.writeUp, icon: "FileText",
82
- content: <WriteUp colors={colors} source={source} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory} onOpen={onOpenAttachment} /> }] : []),
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 ? [{ id: "activity" as const, label: m.activity, icon: "Activity", count: story.activity.length, content: (
85
- <View>
86
- {story.activity.map((entry) => (
87
- <View key={entry.id} style={{ flexDirection: "row", gap: 12, paddingVertical: 4 }}>
88
- <Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{entry.text}<LinkNote colors={colors} link={entry.link} /></Text>
89
- <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{formatClientTimeOfDay(locale, entry.at, now)}</Text>
90
- </View>
91
- ))}
92
- </View>
93
- ) }] : []),
94
- ...(story.questions.length ? [{ id: "questions" as const, label: m.questions, icon: "MessageCircleQuestion", count: story.questions.length, content: (
95
- <View>
96
- {story.questions.map((question) => {
97
- const chosen = question.answer ? question.options.find((option) => option.letter === question.answer!.choice) : undefined;
98
- return (
99
- <PressableRow key={question.id} colors={colors} onSurface1 accessibilityRole="button" accessibilityLabel={m.openItem(question.id)}
100
- onPress={() => onOpenQuestion(question.id)}
101
- style={{ flexDirection: "row", gap: 8, paddingVertical: 4, paddingHorizontal: 4, marginHorizontal: -4, borderRadius: 4 }}>
102
- <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"], minWidth: 30 }}>{question.id}</Text>
103
- <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
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
- <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
110
- </PressableRow>
111
- );
112
- })}
113
- </View>
114
- ) }] : []),
115
- ...(story.deliverables.length ? [{ id: "deliverables" as const, label: m.deliverables, icon: "Package", count: story.deliverables.length,
116
- content: <AttachmentList colors={colors} attachments={story.deliverables.map(deliverableAttachment)} onOpen={onOpenAttachment} /> }] : []),
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 }}>{detail}</Text>
132
- <Text accessibilityLabel={m.workedEstimate(formatClientMinutes(locale, story.workedMin), formatClientMinutes(locale, ticket.estimateMin))}
133
- style={{ color: over ? colors.statusDanger : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
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 ? <Text accessibilityLiveRegion="polite" style={{ color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{ticker}</Text> : null}
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 colors={colors} onSurface1 disabled={!open} accessibilityRole="button" accessibilityLabel={m.openAgentChat(title)} onPress={open}
188
- style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4 }}>
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 }}>{title}</Text>
191
- {alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{alert.text}</Text> : null}
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 ? <Text numberOfLines={1} style={{ flexShrink: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
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 = step.label.kind === "added" ? { name: "CirclePlus", color: colors.foregroundMuted }
208
- : step.status === "done" ? { name: "CircleCheck", color: colors.statusSuccess }
209
- : step.status === "blocked" ? { name: "Ban", color: colors.statusDanger }
210
- : step.status === "skipped" ? { name: "CircleSlash", color: colors.foregroundMuted }
211
- : { name: "CircleDot", color: colors.accent };
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({ colors, step, last, now, live }: { colors: Colors; step: TimelineStep & { waiting?: boolean }; last: boolean; now: number; live: boolean }) {
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 ? (live ? <Spinner color={colors.accent} /> : <StalledPulse color={colors.statusWarning} />) : <Marker colors={colors} step={step} />}
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 }}>{timelineLabel(m, step.label)}</Text>
231
- <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatClientTimeOfDay(locale, step.at, now)}</Text>
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 numberOfLines={1} style={{ minWidth: 84, textAlign: "right", color: current ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
234
- {step.minutes !== null ? current ? m.soFar(formatClientMinutes(locale, step.minutes)) : formatClientMinutes(locale, step.minutes) : ""}
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 }}>{note}</Text>
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
  );
@@ -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 accessibilityElementsHidden importantForAccessibility="no-hide-descendants"
14
- style={{ height: 4, borderRadius: 2, overflow: "hidden", flexDirection: "row", backgroundColor: colors.surface2 }}>
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
- onloadedmetadata: (() => void) | null;
11
- onerror: (() => void) | null;
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
- video.onloadedmetadata = () => resolve(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : null);
23
- video.onerror = () => resolve(null);
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
- video.onloadedmetadata = video.onerror = null;
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 ? <video src={url} controls playsInline aria-label={label} style={{ width: "100%", height: "100%", display: "block", objectFit: "contain" }} /> : null}
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
  }