@stevecastaneda/paseo-progress 0.2.1 → 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 CHANGED
@@ -10,8 +10,8 @@ The dashboard shows:
10
10
  - a "possibly stale" warning after 15 minutes with no update while tickets remain, unless Paseo shows an agent in the workspace still running (for example one waiting on its subagents), and never once every ticket is done or skipped or the run is finished; working items swap their spinner for a pulsing amber warning
11
11
  - percent of estimated work done, time done of time estimated (like "1 h 30 min of 2 h"; once everything is done, how long it really took), and a bar with one segment per ticket
12
12
  - **Stuck:** blocked tickets, working tickets with no update for longer than their estimate, and blockers the agent flags
13
- - **Tickets:** estimate, status, and the working ticket's stage. A ticket that waits for another (`--waits-for T03`) shows "Waits for Ticket 03" under its title until that one is done; waiting is the order of work, so it never counts as stuck, even if the agent also marked it blocked. Press a ticket for its story in a dialog: a timeline of its stages and statuses with how long each took, notes, time worked against the estimate, and the deliverables, questions and activity that belong to it. Items tagged with `--ticket` belong exactly; older, untagged ones are matched by when they happened and marked "by time"
14
- - **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the default the agent is using. Press a question to open it in a dialog, then press **Copy Q2 B** to copy `Q2 (title): B` for your reply. Files and links the agent attached (`--file`) open like deliverables: images and text files preview in Paseo (with **Back** to the question), links open in Paseo's browser, and other files open in their default app. From the pill's popover, the preview opens over the popover, which stays open so you can go back to it. Answers stay listed under the same reference.
13
+ - **Tickets:** estimate, status, and the working ticket's stage. A ticket that waits for another (`--waits-for T03`) shows "Waits for Ticket 03" under its title until that one is done; waiting is the order of work, so it never counts as stuck, even if the agent also marked it blocked. Press a ticket for its story in a dialog. A summary on top shows its status and stage, time worked against the estimate as a bar that turns red past it, the agent's latest status line while it's working, and the agent on it. Tabs below hold the rest, each only when it has something: the ticket's write-up when it has a `--source` file (the start of it, with the rest a press away), a timeline of its stages and statuses with how long each took, and the activity, questions and deliverables that belong to it. The agent on a ticket shows by its chat title; pressing it opens that chat. Items tagged with `--ticket` belong exactly; older, untagged ones are matched by when they happened and marked "by time"
14
+ - **Questions:** each with a permanent reference (Q1, Q2, ...), lettered options, and the option the agent recommends. The agent builds nothing a question affects until you answer; it works on other things meanwhile, and stops when nothing else is left. Press a question to open it in a dialog, then press **Copy Q2 B** to copy `Q2 (title): B` for your reply. Files and links the agent attached (`--file`) open like deliverables: images and text files preview in Paseo (with **Back** to the question), links open in Paseo's browser, and other files open in their default app. From the pill's popover, the preview opens over the popover, which stays open so you can go back to it. Answers stay listed under the same reference.
15
15
  - **Activity**, **Answered** questions and **Deliverables** (press one to open it), sharing one card with a tab for each; the panel remembers the tab you picked. Each shows the newest 10, with **Show 10 older** below
16
16
 
17
17
  The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. When questions are waiting or something is stuck, a pill above the message box shows it (`3 questions · 1 stuck`). Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
@@ -72,14 +72,15 @@ Run `paseo-progress --help` for every command, and `paseo-progress <command> --h
72
72
 
73
73
  ```sh
74
74
  paseo-progress start "Loan Options snapshots" --subtitle "4 tickets"
75
- paseo-progress ticket add "Ticket 01: Saved table" --estimate 120 # Added T01
75
+ paseo-progress ticket add "Ticket 01: Saved table" --estimate 120 \
76
+ --source specs/01-saved-table.md # Added T01
76
77
  paseo-progress ticket update T01 --status working --stage Build
77
78
  paseo-progress ticket update T01 --stage Fixes
78
79
  paseo-progress ticket update T01 --status done
79
80
  paseo-progress question ask "Row spacing" "Even out the card spacing?" \
80
81
  --option "A=Even it out | Cards look balanced" \
81
82
  --option "B=Leave it | No change" \
82
- --default B --raised-by "Ticket 01 design review" # Asked Q1
83
+ --recommend A --raised-by "Ticket 01 design review" # Asked Q1
83
84
  paseo-progress question answer Q1 A --words "Even it out."
84
85
  paseo-progress deliverable add "Browser check screenshots" .scratch/shots/ --ticket T01
85
86
  paseo-progress activity add "Saved table matches the design now." --ticket T01
@@ -90,18 +91,21 @@ paseo-progress finish "Snapshots ship for all four tables"
90
91
  ```
91
92
 
92
93
  - Statuses are `not_started`, `working`, `blocked`, `done`, and `skipped`. Skipped tickets leave the totals.
94
+ - `ticket add` and `ticket update` take an optional `--source <path or link>[=<label>]`: where the ticket is written up, like its spec file or issue. It shows under the ticket's status in its dialog and opens like a deliverable. `--source ""` clears it. Tickets without one are fine.
93
95
  - A working ticket is stuck once it goes longer than its estimate without an update, timed from its last status, stage or note change, or activity tagged to it. The ticket's dialog still shows total time worked against the estimate.
94
- - `question ask --waits` marks a question the agent won't act on until you answer. Its default shows in amber.
96
+ - `question ask` needs `--recommend`, the agent's pick. The question dialog tags that option "Recommended".
95
97
  - Deliverable paths are stored relative to the worktree root. Web links open in Paseo's browser. Pressing an image (PNG, JPEG, GIF, WebP, up to 10 MB) or text deliverable (Markdown, text, logs, JSON, YAML, CSV; the first 256 KB) previews it in a dialog inside Paseo, which also works from a phone; **Open** there opens the file in its default app. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser and folders in the file manager). On macOS, **Open** sends text files to your default browser. Only recorded deliverables inside the worktree open this way; otherwise the path is copied. The copy icon on each row copies the path.
98
+ - Several agents can share one run. When another session adds a ticket, each agent sees it at the end of its next command's output (and in `show`), and the skill has it work that ticket in order after its current one. An agent only hears about tickets added since its own last update in the run, and only once.
96
99
  - `start` begins a fresh dashboard. Earlier runs stay in the file. Question references stay unique across runs.
97
100
  - `finish "<outcome>"` closes the run once every ticket is done or skipped, no question is open, and nothing is flagged stuck; otherwise it says what is left. The run stays on the dashboard, marked Finished with its outcome, and takes no more updates, so the next agent in the worktree starts a new run instead of adding to it.
98
101
 
99
102
  ## The progress file
100
103
 
101
- The progress file, `progress.jsonl`, holds one JSON event per line, each with a version (`v`), a UTC timestamp (`ts`) from the real clock, and a `type`. It is only ever appended to, so you can read or diff the history. A half-written last line is ignored. Other bad lines are skipped and listed in a small notice in the panel while the rest still renders. A lock keeps two commands from writing at once or handing out the same id.
104
+ The progress file, `progress.jsonl`, holds one JSON event per line, each with a version (`v`), a UTC timestamp (`ts`) from the real clock, and a `type`. Events written by a Paseo agent also carry its agent id (`by`). It is only ever appended to, so you can read or diff the history. A half-written last line is ignored. Other bad lines are skipped and listed in a small notice in the panel while the rest still renders. A lock keeps two commands from writing at once or handing out the same id.
102
105
 
103
106
  ## Limitations
104
107
 
108
+ - An agent hears about tickets other sessions added only when it next runs `paseo-progress`. One in the middle of a long step finds out when that step ends.
105
109
  - Nothing updates unless the agent runs `paseo-progress`. Agents need the skill, and an agent that skips the command leaves the dashboard behind.
106
110
  - The command runs on the machine that hosts the Paseo daemon, and it needs Node.js 22.18 or later there.
107
111
  - Each worktree keeps its own dashboard. There's no view across worktrees.
@@ -6,7 +6,7 @@ import { type Attachment, attachmentIcon, canPreview, shortName } from "../share
6
6
  import { PressableRow } from "./row";
7
7
  import { openDeliverable } from "../shared/rpc";
8
8
 
9
- export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName } from "../shared/attachments";
9
+ export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName, ticketSourceAttachment } from "../shared/attachments";
10
10
 
11
11
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
12
12
  type Navigation = PluginWorkspacePanelProps["navigation"];
@@ -49,8 +49,15 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
49
49
  const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: previewable(group.current) }) });
50
50
  const openAttachment = (attachment: Attachment, from: Attachment[]) => {
51
51
  group.current = from;
52
+ // A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
53
+ if (attachment.url) onClose();
52
54
  void opener.open(attachment);
53
55
  };
56
+ // Opens the agent's chat in its workspace, with the dialog out of the way.
57
+ const openAgent = context.navigation ? (agentId: string) => {
58
+ onClose();
59
+ context.navigation!.openAgent({ agentId });
60
+ } : undefined;
54
61
  const copy = useCopy();
55
62
  const steps: Step[] = root ? [root, ...trail] : [];
56
63
  // Keep drawing the last step while the dialog fades out.
@@ -85,7 +92,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
85
92
  ) : (
86
93
  <>
87
94
  {ticket && dashboard ? (
88
- <TicketStoryView colors={colors} dashboard={dashboard} ticket={ticket} now={now} live={live}
95
+ <TicketStoryView colors={colors} dashboard={dashboard} ticket={ticket} now={now} live={live} context={context} onOpenAgent={openAgent}
89
96
  onOpenAttachment={openAttachment} onOpenQuestion={(id) => push({ kind: "question", id })} />
90
97
  ) : null}
91
98
  {question ? (
package/client/panel.tsx CHANGED
@@ -10,7 +10,7 @@ import { SectionTitle } from "./section-title";
10
10
  import { raised } from "./surfaces";
11
11
  import { When } from "./when";
12
12
  import React, { useEffect, useRef, useState } from "react";
13
- import { ActivityIndicator, Animated, Platform, Pressable, ScrollView, Text, View } from "react-native";
13
+ import { ActivityIndicator, Animated, Platform, ScrollView, Text, View } from "react-native";
14
14
  import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
15
15
  import type { TicketStatus } from "../shared/events";
16
16
  import { formatEstimate, formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
@@ -19,6 +19,7 @@ import { useDashboard } from "./dashboard-query";
19
19
  import { STATUS_LABEL } from "./status-badge";
20
20
  import { TicketDialogs } from "./ticket-dialog";
21
21
  import { PressableRow } from "./row";
22
+ import { TabStrip } from "./tabs";
22
23
  import { ShowMoreRow, usePaged } from "./show-more";
23
24
  import { notePanelOpened } from "./panel-opened";
24
25
  import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
@@ -212,7 +213,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
212
213
  style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
213
214
  <View style={{ flex: wide ? 3 : undefined, alignSelf: "stretch", minWidth: 0 }}>
214
215
  <Presence show={dashboard.questions.open.length > 0} order={3}>{dashboard.questions.open.length ? (
215
- <QuestionsSection colors={colors} questions={dashboard.questions.open} answered={dashboard.questions.answered} now={now} compact={compact || !wide} context={attachmentContext} />
216
+ <QuestionsSection colors={colors} questions={dashboard.questions.open} answered={dashboard.questions.answered} now={now} context={attachmentContext} />
216
217
  ) : null}</Presence>
217
218
  <Presence show={Boolean(dashboard.stuck.length)} order={3}>{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}</Presence>
218
219
  <Presence show order={4}>
@@ -270,11 +271,8 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
270
271
  return (
271
272
  <View onLayout={(event) => setShowIcons(event.nativeEvent.layout.width >= TAB_ICONS_MIN)}
272
273
  style={{ margin: 12, marginBottom: 0, ...raised(colors), borderRadius: 6, overflow: "hidden" }}>
273
- <View accessibilityRole="tablist" style={{ flexDirection: "row", flexWrap: "wrap", columnGap: narrow ? 10 : 16, paddingHorizontal: narrow ? 8 : 10, borderBottomWidth: 1, borderBottomColor: colors.border }}>
274
- {tabs.map((entry) => {
275
- return <Tab key={entry.id} colors={colors} narrow={narrow} label={entry.label} icon={showIcons ? entry.icon : null} count={entry.count} selected={entry.id === current.id} onPress={() => choose(entry.id)} />;
276
- })}
277
- </View>
274
+ <TabStrip colors={colors} narrow={narrow} tabs={tabs.map((entry) => ({ ...entry, icon: showIcons ? entry.icon : null }))} selected={current.id} onSelect={choose}
275
+ style={{ paddingHorizontal: narrow ? 8 : 10 }} />
278
276
  {current.count === 0 ? (
279
277
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8 }}>{current.empty}</Text>
280
278
  ) : current.content}
@@ -282,28 +280,6 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
282
280
  );
283
281
  }
284
282
 
285
- // Instant feedback: tabs switch often, so hover and press change color only.
286
- // Narrow tightens the spacing so all three tabs stay on one line at the sidebar's 320px.
287
- function Tab({ colors, narrow, label, icon, count, selected, onPress }: { colors: Colors; narrow: boolean; label: string; icon: string | null; count: number; selected: boolean; onPress(): void }) {
288
- const [hovered, setHovered] = useState(false);
289
- return (
290
- <Pressable accessibilityRole="tab" accessibilityState={{ selected }} onPress={onPress}
291
- onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
292
- style={({ pressed }) => ({
293
- flexDirection: "row", alignItems: "center", gap: narrow ? 4 : 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
294
- borderBottomWidth: 2,
295
- borderBottomColor: selected ? colors.accent : pressed || hovered ? colors.border : "transparent",
296
- })}>
297
- {icon ? <Icon name={icon} size={14} color={selected ? colors.accent : colors.foregroundMuted} /> : null}
298
- <Text style={{ color: selected || hovered ? colors.foreground : colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{label}</Text>
299
- {/* Count badge: muted on both tabs; the chosen tab's number reads darker. */}
300
- <View style={{ minWidth: 16, paddingHorizontal: 4, borderRadius: 4, alignItems: "center", backgroundColor: colors.surface2 }}>
301
- <Text style={{ color: selected ? colors.foreground : colors.foregroundMuted, fontSize: 10, lineHeight: 14, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{count}</Text>
302
- </View>
303
- </Pressable>
304
- );
305
- }
306
-
307
283
  // Newest first, 10 at a time (see usePaged).
308
284
  function ActivityList({ colors, activity, now }: { colors: Colors; activity: Activity[]; now: number }) {
309
285
  const paged = usePaged(activity);
@@ -8,7 +8,7 @@ import { progressPanel } from "./pills";
8
8
  import { PressScale } from "./motion";
9
9
  import { type Attachment, isPreviewable, useAttachmentOpener } from "./attachments";
10
10
  import { PreviewDialog, previewable } from "./preview";
11
- import { DefaultBadge, QuestionDetail, useCopy } from "./questions";
11
+ import { QuestionDetail, QuestionState, useCopy } from "./questions";
12
12
  import { raised } from "./surfaces";
13
13
 
14
14
  type Colors = PluginButtonContentProps["theme"]["colors"];
@@ -97,8 +97,10 @@ export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAtt
97
97
  <View key={question.id} style={{ gap: 6 }}>
98
98
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
99
99
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19, fontWeight: "600" }}>{question.id}</Text>
100
- <Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
101
- <DefaultBadge colors={colors} value={question.default} waits={question.waits} />
100
+ <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
101
+ <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
102
+ <QuestionState colors={colors} question={question} />
103
+ </View>
102
104
  </View>
103
105
  <QuestionDetail colors={colors} question={question} now={now} onSurface1={onSurface1} onCopy={(letter) => onCopy(question, letter)} onOpenAttachment={onOpenAttachment} />
104
106
  </View>
@@ -8,7 +8,6 @@ import { PressableRow } from "./row";
8
8
  import { ShowMoreRow, usePaged } from "./show-more";
9
9
  import { PressScale } from "./motion";
10
10
  import { StackedDialog } from "./dialog-stack";
11
- import { useNarrow } from "./narrow";
12
11
  import { raised } from "./surfaces";
13
12
  import { When } from "./when";
14
13
 
@@ -43,13 +42,12 @@ export interface AttachmentContext {
43
42
  navigation?: PluginWorkspacePanelProps["navigation"];
44
43
  }
45
44
 
46
- export function QuestionsSection({ colors, questions, answered, now, compact, context }: {
45
+ export function QuestionsSection({ colors, questions, answered, now, context }: {
47
46
  colors: Colors;
48
47
  questions: Question[];
49
48
  // So a question answered while its dialog is open shows the answer instead of vanishing.
50
49
  answered: Question[];
51
50
  now: number;
52
- compact: boolean;
53
51
  context: AttachmentContext;
54
52
  }) {
55
53
  const [openId, setOpenId] = useState<string | null>(null);
@@ -62,9 +60,6 @@ export function QuestionsSection({ colors, questions, answered, now, compact, co
62
60
  <View style={{ borderWidth: 1, borderColor: colors.accent, backgroundColor: colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
63
61
  <Text style={{ color: colors.accentForeground, fontSize: 11, lineHeight: 16 }}>{questions.length} waiting</Text>
64
62
  </View>
65
- <Text style={{ flexBasis: compact ? "100%" : undefined, flex: compact ? undefined : 1, textAlign: compact ? "left" : "right", color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
66
- Work continues on each default until you answer, except where it waits. Press a question to see its choices and copy an answer.
67
- </Text>
68
63
  </View>
69
64
  {questions.map((question, index) => (
70
65
  <QuestionRow key={question.id} colors={colors} question={question} first={index === 0}
@@ -124,7 +119,7 @@ function AnswerSummary({ colors, question, now }: { colors: Colors; question: Qu
124
119
  {chosen ? ` ${chosen.label}.` : ""}
125
120
  {answer.words ? <Text style={{ color: colors.foregroundMuted }}> “{answer.words}”</Text> : null}
126
121
  <Text style={{ color: colors.foregroundMuted }}>
127
- {answer.changedCourse ? ` Differs from the default (${question.default}), so work changes course.` : " Same as the default, so no change of course."}
122
+ {answer.changedCourse ? " The agent had already built another option, so work changed course." : ""}
128
123
  </Text>
129
124
  </Text>
130
125
  <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
@@ -141,14 +136,14 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
141
136
  onOpen(): void;
142
137
  onCopy(): void;
143
138
  }) {
144
- // Narrow: the badge goes under the question, so the question keeps the row's width.
145
- const narrow = useNarrow();
146
- const badge = <DefaultBadge colors={colors} value={question.default} waits={question.waits} />;
147
139
  return (
148
140
  <View style={{ flexDirection: "row", gap: 8, paddingHorizontal: 10, paddingVertical: 10, borderTopWidth: first ? 0 : 1, borderTopColor: colors.border }}>
149
141
  <PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} for your reply`} onPress={onCopy} hitSlop={6}
150
142
  outerStyle={{ alignSelf: "flex-start", marginTop: 1 }}
151
143
  style={({ pressed }) => ({
144
+ // One width for every id, so the questions beside them start on one edge.
145
+ minWidth: 34,
146
+ alignItems: "center",
152
147
  paddingHorizontal: 6,
153
148
  borderRadius: 999,
154
149
  backgroundColor: pressed ? colors.surface2 : colors.surface1,
@@ -158,11 +153,7 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
158
153
  </PressScale>
159
154
  <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} choices`} onPress={onOpen}
160
155
  style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "flex-start", gap: 8, borderRadius: 4, margin: -4, padding: 4 }}>
161
- <View style={{ flex: 1, minWidth: 0, gap: 6, alignItems: "flex-start" }}>
162
- <Text style={{ alignSelf: "stretch", color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
163
- {narrow ? badge : null}
164
- </View>
165
- {narrow ? null : badge}
156
+ <Text style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
166
157
  {/* Optical: centers the 14px chevron on the 19px first line. */}
167
158
  <View style={{ paddingTop: 2.5 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
168
159
  </PressableRow>
@@ -181,9 +172,9 @@ export function QuestionView({ colors, question, now, onCopy, onOpenAttachment,
181
172
  }) {
182
173
  return (
183
174
  <View style={{ gap: 10 }}>
184
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
185
- <Text style={{ flex: 1, color: colors.foreground, fontSize: 14, lineHeight: 20 }}>{question.question}</Text>
186
- {!question.answer ? <DefaultBadge colors={colors} value={question.default} waits={question.waits} /> : null}
175
+ <View style={{ gap: 2 }}>
176
+ <Text style={{ color: colors.foreground, fontSize: 14, lineHeight: 20 }}>{question.question}</Text>
177
+ {!question.answer ? <QuestionState colors={colors} question={question} /> : null}
187
178
  </View>
188
179
  {question.answer ? <AnswerSummary colors={colors} question={question} now={now} /> : null}
189
180
  <QuestionDetail colors={colors} question={question} now={now} onSurface1 onCopy={onCopy} onOpenAttachment={onOpenAttachment} />
@@ -192,15 +183,10 @@ export function QuestionView({ colors, question, now, onCopy, onOpenAttachment,
192
183
  );
193
184
  }
194
185
 
195
- // A question that waits shows its default in amber: the agent is not acting on it.
196
- export function DefaultBadge({ colors, value, waits }: { colors: Colors; value: string; waits: boolean }) {
197
- return (
198
- <View style={{ flexDirection: "row", borderWidth: 1, borderColor: waits ? colors.statusWarning : colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
199
- <Text style={{ color: waits ? colors.statusWarning : colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
200
- Default <Text style={{ color: waits ? colors.statusWarning : colors.foreground, fontWeight: "600" }}>{value}</Text>{waits ? ", waits for you" : ""}
201
- </Text>
202
- </View>
203
- );
186
+ // The agent's pick, under an open question that has no lettered options to tag.
187
+ export function QuestionState({ colors, question }: { colors: Colors; question: Question }) {
188
+ if (question.options.length || !question.recommended) return null;
189
+ return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Recommended: <Text style={{ color: colors.foreground }}>{question.recommended}</Text></Text>;
204
190
  }
205
191
 
206
192
  // `onSurface1` when drawn on a surface1 background (Paseo's dialogs), so the
@@ -217,10 +203,11 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
217
203
  return (
218
204
  <View style={{ gap: 8, paddingTop: 2 }}>
219
205
  {question.options.map((option) => {
220
- const isDefault = option.letter === question.default;
221
- // Once answered, the border marks the choice made; before that, the default.
206
+ const recommended = option.letter === question.recommended;
207
+ // Once answered, the border marks the choice made; before that, the agent's recommendation.
222
208
  const chosen = question.answer?.choice === option.letter;
223
- const borderColor = question.answer ? (chosen ? colors.statusSuccess : colors.border) : isDefault ? colors.accent : colors.border;
209
+ const borderColor = question.answer ? (chosen ? colors.statusSuccess : colors.border) : recommended ? colors.accent : colors.border;
210
+ const settled = Boolean(question.answer);
224
211
  return (
225
212
  <View key={option.letter} style={{ flexDirection: "row", alignItems: "flex-start", gap: 8, padding: 8, borderRadius: 12, borderWidth: 1, borderColor }}>
226
213
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600", width: 14 }}>{option.letter}</Text>
@@ -228,7 +215,7 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
228
215
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
229
216
  {option.label}
230
217
  {chosen ? <Text style={{ color: colors.statusSuccess }}> Your answer</Text> : null}
231
- {isDefault ? <Text style={{ color: question.answer ? colors.foregroundMuted : colors.accent }}> Default</Text> : null}
218
+ {recommended ? <Text style={{ color: settled ? colors.foregroundMuted : colors.accent }}> Recommended</Text> : null}
232
219
  </Text>
233
220
  {option.consequence ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{option.consequence}</Text> : null}
234
221
  </View>
@@ -0,0 +1,65 @@
1
+ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import React, { useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+
6
+ type Colors = PluginWorkspacePanelProps["theme"]["colors"];
7
+
8
+ export interface TabEntry<Id extends string> {
9
+ id: Id;
10
+ label: string;
11
+ icon?: string | null;
12
+ // A count badge beside the label; none when undefined.
13
+ count?: number;
14
+ }
15
+
16
+ // A row of tabs over a bottom rule, as in the panel's history card and the
17
+ // 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 }: {
20
+ colors: Colors;
21
+ tabs: Array<TabEntry<Id>>;
22
+ selected: Id;
23
+ onSelect(id: Id): void;
24
+ narrow?: boolean;
25
+ fit?: boolean;
26
+ style?: object;
27
+ }) {
28
+ // Set once the tabs wrapped with icons, for this set of tabs.
29
+ const ids = tabs.map((entry) => entry.id).join();
30
+ const [wrappedFor, setWrappedFor] = useState<string | null>(null);
31
+ const iconless = fit && wrappedFor === ids;
32
+ return (
33
+ <View accessibilityRole="tablist"
34
+ // 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)} />
39
+ ))}
40
+ </View>
41
+ );
42
+ }
43
+
44
+ // 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 }) {
46
+ const [hovered, setHovered] = useState(false);
47
+ return (
48
+ <Pressable accessibilityRole="tab" accessibilityState={{ selected }} onPress={onPress}
49
+ onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
50
+ style={({ pressed }) => ({
51
+ flexDirection: "row", alignItems: "center", gap: narrow ? 4 : 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
52
+ borderBottomWidth: 2,
53
+ borderBottomColor: selected ? colors.accent : pressed || hovered ? colors.border : "transparent",
54
+ })}>
55
+ {entry.icon ? <Icon name={entry.icon} size={14} color={selected ? colors.accent : colors.foregroundMuted} /> : null}
56
+ <Text style={{ color: selected || hovered ? colors.foreground : colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{entry.label}</Text>
57
+ {/* Count badge: muted on both tabs; the chosen tab's number reads darker. */}
58
+ {entry.count !== undefined ? (
59
+ <View style={{ minWidth: 16, paddingHorizontal: 4, borderRadius: 4, alignItems: "center", backgroundColor: colors.surface2 }}>
60
+ <Text style={{ color: selected ? colors.foreground : colors.foregroundMuted, fontSize: 10, lineHeight: 14, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{entry.count}</Text>
61
+ </View>
62
+ ) : null}
63
+ </Pressable>
64
+ );
65
+ }