@stevecastaneda/paseo-progress 1.13.0 → 1.13.1

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 CHANGED
@@ -145,6 +145,13 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.13.1
149
+
150
+ - [Fixed] **Ticket tabs fit on a phone.** On a phone, a ticket's tabs used to wrap and leave Deliverables alone on a second line. They now stay in one row that you swipe sideways. When some tabs are out of sight, that edge of the row fades and shows an arrow you can tap. The tab you pick scrolls into view, and so does the one you had open when you come back to the ticket.
151
+ - [Fixed] **Open chat stays on one line.** On a phone, the Open chat link beside a ticket's agent no longer wraps. The agent's name gets shorter instead.
152
+ - [Fixed] **Larger question arrows in a phone's browser.** In the pill's popover, the arrows that step through questions and the Open Progress button now have the same finger-sized buttons in a phone's web browser as in the Paseo phone apps.
153
+ - [Fixed] **Screen readers hear which tab is selected in the desktop and web apps.** They also hear whether the finished tickets and a write-up are shown or hidden. Before, only the phone apps passed this on.
154
+
148
155
  ### 1.13.0
149
156
 
150
157
  - [New] **PDF previews.** A PDF the agent records now opens in the preview in Paseo's desktop and web apps, with a loading percentage while it arrives. Any PDF you open in a Paseo tab now shows in that tab, up to 100 MB on desktop and 25 MB on a phone. A PDF deliverable also has a button beside Copy that opens it in a tab, which is how you see it on an iPhone or iPad.
@@ -25,7 +25,7 @@ export type Step =
25
25
 
26
26
  // Paseo gives each dialog its own backdrop, so opening a second one darkens
27
27
  // the screen while the first fades out; steps swap content inside this one instead.
28
- export function StackedDialog({ colors, root, onClose, questions = [], dashboard, now, live = true, context }: {
28
+ export function StackedDialog({ colors, root, onClose, questions = [], dashboard, now, live = true, compact = false, context }: {
29
29
  colors: Colors;
30
30
  root: Step | null;
31
31
  onClose(): void;
@@ -34,6 +34,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
34
34
  dashboard?: Dashboard;
35
35
  now: number;
36
36
  live?: boolean;
37
+ // Paseo's phone-sized layout; the ticket's tabs scroll sideways there.
38
+ compact?: boolean;
37
39
  context: AttachmentContext;
38
40
  }) {
39
41
  // Paseo's Modal keeps drawing its content while it fades out, so closing keeps the steps.
@@ -103,7 +105,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
103
105
  ) : (
104
106
  <>
105
107
  {ticket && dashboard ? (
106
- <TicketStoryView colors={colors} dashboard={dashboard} ticket={ticket} now={now} live={live} context={context} onOpenAgent={openAgent}
108
+ <TicketStoryView colors={colors} dashboard={dashboard} ticket={ticket} now={now} live={live} compact={compact} context={context} onOpenAgent={openAgent}
107
109
  onOpenAttachment={openAttachment} onOpenQuestion={(id) => push({ kind: "question", id })} />
108
110
  ) : null}
109
111
  {question ? (
package/client/panel.tsx CHANGED
@@ -253,7 +253,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
253
253
  ) : (
254
254
  <TicketList colors={colors} dashboard={dashboard} live={live} now={now} workspaceId={workspaceId} onOpen={setOpenTicket} />
255
255
  )}
256
- <TicketDialogs colors={colors} dashboard={dashboard} openId={openTicket} setOpenId={setOpenTicket} now={now} live={live} context={attachmentContext} />
256
+ <TicketDialogs colors={colors} dashboard={dashboard} openId={openTicket} setOpenId={setOpenTicket} now={now} live={live} compact={compact} context={attachmentContext} />
257
257
  </Card>
258
258
  </Presence>
259
259
  </View>
@@ -468,7 +468,7 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
468
468
  const chevron = shown ? "ChevronUp" : "ChevronDown";
469
469
  const narrow = useNarrow();
470
470
  return (
471
- <PressableRow colors={colors} accessibilityRole="button" accessibilityState={{ expanded: shown }}
471
+ <PressableRow colors={colors} accessibilityRole="button" aria-expanded={shown}
472
472
  accessibilityLabel={m.toggleFinished(shown, label)} onPress={onPress}
473
473
  style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
474
474
  <View style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
@@ -32,8 +32,8 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
32
32
  const colors = theme.colors;
33
33
  // Paseo draws the desktop popover on surface1 and the phone sheet on surface0.
34
34
  const onSurface1 = !layout.compact;
35
- // The phone apps are pressed with a finger.
36
- const touch = layout.platform !== "web";
35
+ // Pressed with a finger: the phone apps, and any phone-sized screen, browsers included.
36
+ const touch = layout.compact || layout.platform !== "web";
37
37
  const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
38
38
  const query = useDashboard(host.id, workspaceId, directory);
39
39
  const dashboard = query.data?.configured ? query.data.dashboard : null;
@@ -210,7 +210,7 @@ function StepButton({ colors, touch, onSurface1, icon, label, target, onPress }:
210
210
  const button = onSurface1 ? { rest: colors.surface2, pressed: colors.surface1 } : { rest: colors.surface1, pressed: colors.surface2 };
211
211
  const size = stepSize(touch);
212
212
  return (
213
- <PressScale accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled: !target }} disabled={!target}
213
+ <PressScale accessibilityRole="button" accessibilityLabel={label} disabled={!target}
214
214
  onPress={target ? () => onPress(target) : undefined} hitSlop={touch ? stepSlop : undefined}
215
215
  style={({ pressed }) => ({ width: size, height: size, alignItems: "center", justifyContent: "center", borderRadius: touch ? 8 : 4, opacity: target ? 1 : 0.35, ...raised(colors),
216
216
  backgroundColor: pressed ? button.pressed : button.rest })}>
package/client/tabs.tsx CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/ui";
3
- import React, { useState } from "react";
4
- import { Pressable, Text, View } from "react-native";
3
+ import React, { useRef, useState } from "react";
4
+ import { type LayoutChangeEvent, Pressable, ScrollView, Text, View } from "react-native";
5
+ import { useReducedMotion } from "./motion";
6
+ import { withAlpha } from "./surfaces";
5
7
 
6
8
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
7
9
 
@@ -13,39 +15,131 @@ export interface TabEntry<Id extends string> {
13
15
  count?: number;
14
16
  }
15
17
 
18
+ // What the row does when its tabs don't fit on one line: wrap onto a second line,
19
+ // drop the icons first and wrap only if that's still not enough, or scroll
20
+ // sideways in one row, as on phones, where a faded edge and an arrow show there's more.
21
+ export type TabOverflow = "wrap" | "fit" | "scroll";
22
+
16
23
  // A row of tabs over a bottom rule, as in the panel's history card and the
17
24
  // ticket dialog. Narrow tightens the spacing so three tabs fit the 320px sidebar.
18
- // `fit` drops the icons when the tabs would otherwise wrap onto a second line.
19
- export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect, narrow = false, fit = false, style }: {
25
+ export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect, narrow = false, overflow = "wrap", style }: {
20
26
  colors: Colors;
21
27
  tabs: Array<TabEntry<Id>>;
22
28
  selected: Id;
23
29
  onSelect(id: Id): void;
24
30
  narrow?: boolean;
25
- fit?: boolean;
31
+ overflow?: TabOverflow;
26
32
  style?: object;
27
33
  }) {
28
34
  // Set once the tabs wrapped with icons, for this set of tabs.
29
35
  const ids = tabs.map((entry) => entry.id).join();
30
36
  const [wrappedFor, setWrappedFor] = useState<string | null>(null);
31
- const iconless = fit && wrappedFor === ids;
37
+ const scroller = useRef<ScrollView>(null);
38
+ const widths = useRef(new Map<string, number>());
39
+ // The tab the row last scrolled to; null until the first scroll, which jumps.
40
+ const revealed = useRef<string | null>(null);
41
+ const size = useRef({ viewport: 0, content: 0, offset: 0 });
42
+ const [hidden, setHidden] = useState({ before: false, after: false });
43
+ const reduced = useReducedMotion();
44
+ const scroll = overflow === "scroll";
45
+ // Scrolling rows go without icons, so less of the row is hidden.
46
+ const iconless = scroll || (overflow === "fit" && wrappedFor === ids);
47
+ const gap = narrow ? 10 : 16;
48
+ const rule = { borderBottomWidth: 1, borderBottomColor: colors.border };
49
+ // The next scrolling row is a new scroll view, which starts at the left.
50
+ if (!scroll) {
51
+ size.current.offset = 0;
52
+ revealed.current = null;
53
+ if (hidden.before || hidden.after) setHidden({ before: false, after: false });
54
+ }
55
+ // Centers a tab in the visible part of the row, so the ones beside it show too.
56
+ // Positions come from widths, since a tab that moves without resizing never reports it.
57
+ const reveal = (id: string) => {
58
+ const upTo = tabs.slice(0, tabs.findIndex((entry) => entry.id === id) + 1).map((entry) => widths.current.get(entry.id));
59
+ const { viewport } = size.current;
60
+ if (!upTo.length || !viewport || upTo.includes(undefined)) return;
61
+ const width = upTo.pop()!;
62
+ const x = upTo.reduce((sum: number, w) => sum + w! + gap, 0);
63
+ scroller.current?.scrollTo({ x: Math.max(0, x + width / 2 - viewport / 2), animated: !reduced && revealed.current !== null });
64
+ revealed.current = id;
65
+ };
66
+ // 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); };
68
+ // A chevron press moves the row most of its width that way.
69
+ const page = (direction: 1 | -1) => {
70
+ const { viewport, content, offset } = size.current;
71
+ const x = Math.max(0, Math.min(offset + direction * (viewport - 48), content - viewport));
72
+ scroller.current?.scrollTo({ x, animated: !reduced });
73
+ };
74
+ // Which ends have tabs scrolled out of sight, so those edges fade.
75
+ const measure = (next: Partial<typeof size.current>) => {
76
+ const s = Object.assign(size.current, next);
77
+ const before = s.offset > 1;
78
+ const after = s.offset + s.viewport < s.content - 1;
79
+ if (before !== hidden.before || after !== hidden.after) setHidden({ before, after });
80
+ };
81
+ 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} />
85
+ ));
86
+ if (scroll) {
87
+ // Scrolling rows sit on Paseo's phone sheet, which is surface0.
88
+ return (
89
+ <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(); }}
92
+ onContentSizeChange={(width) => measure({ content: width })}
93
+ onScroll={(event) => measure({ offset: event.nativeEvent.contentOffset.x })}
94
+ contentContainerStyle={{ flexGrow: 1, flexDirection: "row", columnGap: gap, ...rule }}>
95
+ {items}
96
+ </ScrollView>
97
+ {hidden.before ? <EdgeFade colors={colors} side="left" onPress={() => page(-1)} /> : null}
98
+ {hidden.after ? <EdgeFade colors={colors} side="right" onPress={() => page(1)} /> : null}
99
+ </View>
100
+ );
101
+ }
32
102
  return (
33
103
  <View accessibilityRole="tablist"
34
104
  // One row is 32px (8 + 18 + 6); anything taller wrapped.
35
- onLayout={fit ? (event) => { if (!iconless && event.nativeEvent.layout.height > 40) setWrappedFor(ids); } : undefined}
36
- style={[{ flexDirection: "row", flexWrap: "wrap", columnGap: narrow ? 10 : 16, borderBottomWidth: 1, borderBottomColor: colors.border }, style]}>
37
- {tabs.map((entry) => (
38
- <Tab key={entry.id} colors={colors} narrow={narrow} entry={iconless ? { ...entry, icon: null } : entry} selected={entry.id === selected} onPress={() => onSelect(entry.id)} />
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]}>
107
+ {items}
108
+ </View>
109
+ );
110
+ }
111
+
112
+ const FADE_SLICES = 12;
113
+
114
+ // Fades a row that scrolls on past this edge, with a chevron at the edge, so the
115
+ // cue shows even when the next tab sits wholly out of sight. The slices stand in
116
+ // for a gradient, which React Native Web lacks (see pill-popover.tsx).
117
+ function EdgeFade({ colors, side, onPress }: { colors: Colors; side: "left" | "right"; onPress(): void }) {
118
+ return (
119
+ <View pointerEvents="box-none" style={{ position: "absolute", top: 0, bottom: 0, [side]: 0, flexDirection: side === "right" ? "row" : "row-reverse" }}>
120
+ {Array.from({ length: FADE_SLICES }, (_, i) => (
121
+ <View key={i} pointerEvents="none" style={{ width: 2, backgroundColor: withAlpha(colors.surface0, (i + 1) / (FADE_SLICES + 1)) ?? undefined }} />
39
122
  ))}
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 }}>
125
+ <Icon name={side === "right" ? "ChevronRight" : "ChevronLeft"} size={14} color={colors.foregroundMuted} />
126
+ </Pressable>
40
127
  </View>
41
128
  );
42
129
  }
43
130
 
44
131
  // Instant feedback: tabs switch often, so hover and press change color only.
45
- function Tab<Id extends string>({ colors, narrow, entry, selected, onPress }: { colors: Colors; narrow: boolean; entry: TabEntry<Id>; selected: boolean; onPress(): void }) {
132
+ function Tab<Id extends string>({ colors, narrow, entry, selected, onPress, onLayout }: {
133
+ colors: Colors;
134
+ narrow: boolean;
135
+ entry: TabEntry<Id>;
136
+ selected: boolean;
137
+ onPress(): void;
138
+ onLayout?(event: LayoutChangeEvent): void;
139
+ }) {
46
140
  const [hovered, setHovered] = useState(false);
47
141
  return (
48
- <Pressable accessibilityRole="tab" accessibilityState={{ selected }} onPress={onPress}
142
+ <Pressable accessibilityRole="tab" aria-selected={selected} onPress={onPress} onLayout={onLayout}
49
143
  onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
50
144
  style={({ pressed }) => ({
51
145
  flexDirection: "row", alignItems: "center", gap: narrow ? 4 : 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
@@ -22,27 +22,30 @@ 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, context }: {
25
+ export function TicketDialogs({ colors, dashboard, openId, setOpenId, now, live, compact, context }: {
26
26
  colors: Colors;
27
27
  dashboard: Dashboard;
28
28
  openId: string | null;
29
29
  setOpenId(id: string | null): void;
30
30
  now: number;
31
31
  live: boolean;
32
+ compact: boolean;
32
33
  context: AttachmentContext;
33
34
  }) {
34
35
  return (
35
36
  <StackedDialog colors={colors} root={openId ? { kind: "ticket", id: openId } : null} onClose={() => setOpenId(null)}
36
- questions={[...dashboard.questions.open, ...dashboard.questions.answered]} dashboard={dashboard} now={now} live={live} context={context} />
37
+ questions={[...dashboard.questions.open, ...dashboard.questions.answered]} dashboard={dashboard} now={now} live={live} compact={compact} context={context} />
37
38
  );
38
39
  }
39
40
 
40
- export function TicketStoryView({ colors, dashboard, ticket, now, live, context, onOpenAttachment, onOpenQuestion, onOpenAgent }: {
41
+ export function TicketStoryView({ colors, dashboard, ticket, now, live, compact, context, onOpenAttachment, onOpenQuestion, onOpenAgent }: {
41
42
  colors: Colors;
42
43
  dashboard: Dashboard;
43
44
  ticket: Ticket;
44
45
  now: number;
45
46
  live: boolean;
47
+ // Paseo's phone-sized layout.
48
+ compact: boolean;
46
49
  context: AttachmentContext;
47
50
  onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
48
51
  onOpenQuestion(id: string): void;
@@ -138,7 +141,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
138
141
  </View>
139
142
 
140
143
  <View style={{ gap: 12 }}>
141
- {tabs.length > 1 ? <TabStrip colors={colors} tabs={tabs} selected={current.id} onSelect={choose} fit /> : null}
144
+ {tabs.length > 1 ? <TabStrip colors={colors} tabs={tabs} selected={current.id} onSelect={choose} overflow={compact ? "scroll" : "fit"} /> : null}
142
145
  {current.content}
143
146
  </View>
144
147
 
@@ -187,7 +190,7 @@ function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string
187
190
  <Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{title}</Text>
188
191
  {alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{alert.text}</Text> : null}
189
192
  <View style={{ flex: 1 }} />
190
- {open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
193
+ {open ? <Text numberOfLines={1} style={{ flexShrink: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
191
194
  </PressableRow>
192
195
  );
193
196
  }
@@ -71,7 +71,7 @@ function TextButton({ colors, icon, label, accessibilityLabel, onPress, grow = f
71
71
  colors: Colors; icon?: string; label: string; accessibilityLabel?: string; onPress(): void; grow?: boolean; expanded?: boolean;
72
72
  }) {
73
73
  return (
74
- <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} accessibilityState={expanded === undefined ? undefined : { expanded }} onPress={onPress}
74
+ <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} aria-expanded={expanded} onPress={onPress}
75
75
  style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4, flexShrink: grow ? 1 : 0, minWidth: 0 }}>
76
76
  {icon ? <Icon name={icon} size={12} color={colors.foregroundMuted} /> : null}
77
77
  <Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{label}</Text>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",