@stevecastaneda/paseo-progress 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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"];
@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
4
4
  import type { Dashboard, Question } from "../shared/dashboard";
5
5
  import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
6
6
  import { useLastPresent } from "./motion";
7
+ import { NarrowProvider } from "./narrow";
7
8
  import { BackButton, PreviewContent, previewable } from "./preview";
8
9
  import { QuestionView, useCopy } from "./questions";
9
10
  import { TicketStoryView } from "./ticket-dialog";
@@ -48,8 +49,15 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
48
49
  const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: previewable(group.current) }) });
49
50
  const openAttachment = (attachment: Attachment, from: Attachment[]) => {
50
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();
51
54
  void opener.open(attachment);
52
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;
53
61
  const copy = useCopy();
54
62
  const steps: Step[] = root ? [root, ...trail] : [];
55
63
  // Keep drawing the last step while the dialog fades out.
@@ -75,6 +83,8 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
75
83
  open={Boolean(root)} onOpenChange={(next) => { if (!next) onClose(); }}>
76
84
  {/* One Modal.Content for every step: replacing it would re-present the sheet on phones. */}
77
85
  <Modal.Content scrollable={!(preview && previewLong)}>
86
+ {/* A dialog has room for clock times, however narrow the panel that opened it. */}
87
+ <NarrowProvider value={false}>
78
88
  {preview ? (
79
89
  <PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
80
90
  onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }} backLabel={back?.label} onBack={back?.onPress}
@@ -82,7 +92,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
82
92
  ) : (
83
93
  <>
84
94
  {ticket && dashboard ? (
85
- <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}
86
96
  onOpenAttachment={openAttachment} onOpenQuestion={(id) => push({ kind: "question", id })} />
87
97
  ) : null}
88
98
  {question ? (
@@ -94,6 +104,7 @@ export function StackedDialog({ colors, root, onClose, questions, dashboard, now
94
104
  ) : null}
95
105
  </>
96
106
  )}
107
+ </NarrowProvider>
97
108
  </Modal.Content>
98
109
  </Modal>
99
110
  );
@@ -0,0 +1,13 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ // Paseo opens the Explorer sidebar at 320px and plugins can't ask for more,
4
+ // so at or below this width the panel trims what it shows to fit.
5
+ export const NARROW_MAX = 400;
6
+
7
+ const Narrow = createContext(false);
8
+
9
+ export const NarrowProvider = Narrow.Provider;
10
+
11
+ export function useNarrow(): boolean {
12
+ return useContext(Narrow);
13
+ }
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,8 +19,10 @@ 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";
25
+ import { NARROW_MAX, NarrowProvider, useNarrow } from "./narrow";
24
26
 
25
27
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
26
28
 
@@ -40,9 +42,12 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
40
42
  const query = useDashboard(host.id, workspaceId, directory);
41
43
  const result = query.data ?? null;
42
44
  useMarkPanelOpened(workspaceId, directory, Boolean(result?.configured && !result.panelOpened));
45
+ const [narrow, setNarrow] = useState(false);
43
46
 
44
47
  return (
45
- <ScrollView style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
48
+ <NarrowProvider value={narrow}>
49
+ <ScrollView onLayout={(event) => setNarrow(event.nativeEvent.layout.width <= NARROW_MAX)}
50
+ style={{ flex: 1, backgroundColor: colors.surface0 }} contentContainerStyle={{ paddingBottom: 16 }}>
46
51
  {query.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading progress" style={{ padding: 12 }} /> : null}
47
52
  {query.error ? (
48
53
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18, padding: 12 }}>
@@ -58,6 +63,7 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
58
63
  workspaceId={workspaceId} workspaceDirectory={result.root} file={result.file} navigation={navigation} /> : null}
59
64
  {result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
60
65
  </ScrollView>
66
+ </NarrowProvider>
61
67
  );
62
68
  }
63
69
 
@@ -207,7 +213,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
207
213
  style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
208
214
  <View style={{ flex: wide ? 3 : undefined, alignSelf: "stretch", minWidth: 0 }}>
209
215
  <Presence show={dashboard.questions.open.length > 0} order={3}>{dashboard.questions.open.length ? (
210
- <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} />
211
217
  ) : null}</Presence>
212
218
  <Presence show={Boolean(dashboard.stuck.length)} order={3}>{dashboard.stuck.length ? <StuckSection colors={colors} items={dashboard.stuck} now={now} /> : null}</Presence>
213
219
  <Presence show order={4}>
@@ -256,6 +262,7 @@ const historyTabs = new Map<string, HistoryTab>();
256
262
  function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceId: string; tabs: HistoryTabEntry[] }) {
257
263
  const [tab, setTab] = useState<HistoryTab>(() => historyTabs.get(workspaceId) ?? "activity");
258
264
  const [showIcons, setShowIcons] = useState(true);
265
+ const narrow = useNarrow();
259
266
  const choose = (next: HistoryTab) => {
260
267
  historyTabs.set(workspaceId, next);
261
268
  setTab(next);
@@ -264,11 +271,8 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
264
271
  return (
265
272
  <View onLayout={(event) => setShowIcons(event.nativeEvent.layout.width >= TAB_ICONS_MIN)}
266
273
  style={{ margin: 12, marginBottom: 0, ...raised(colors), borderRadius: 6, overflow: "hidden" }}>
267
- <View accessibilityRole="tablist" style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16, paddingHorizontal: 10, borderBottomWidth: 1, borderBottomColor: colors.border }}>
268
- {tabs.map((entry) => {
269
- return <Tab key={entry.id} colors={colors} label={entry.label} icon={showIcons ? entry.icon : null} count={entry.count} selected={entry.id === current.id} onPress={() => choose(entry.id)} />;
270
- })}
271
- </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 }} />
272
276
  {current.count === 0 ? (
273
277
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, paddingHorizontal: 10, paddingVertical: 8 }}>{current.empty}</Text>
274
278
  ) : current.content}
@@ -276,27 +280,6 @@ function HistoryCard({ colors, workspaceId, tabs }: { colors: Colors; workspaceI
276
280
  );
277
281
  }
278
282
 
279
- // Instant feedback: tabs switch often, so hover and press change color only.
280
- function Tab({ colors, label, icon, count, selected, onPress }: { colors: Colors; label: string; icon: string | null; count: number; selected: boolean; onPress(): void }) {
281
- const [hovered, setHovered] = useState(false);
282
- return (
283
- <Pressable accessibilityRole="tab" accessibilityState={{ selected }} onPress={onPress}
284
- onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
285
- style={({ pressed }) => ({
286
- flexDirection: "row", alignItems: "center", gap: 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
287
- borderBottomWidth: 2,
288
- borderBottomColor: selected ? colors.accent : pressed || hovered ? colors.border : "transparent",
289
- })}>
290
- {icon ? <Icon name={icon} size={14} color={selected ? colors.accent : colors.foregroundMuted} /> : null}
291
- <Text style={{ color: selected || hovered ? colors.foreground : colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{label}</Text>
292
- {/* Count badge: muted on both tabs; the chosen tab's number reads darker. */}
293
- <View style={{ minWidth: 16, paddingHorizontal: 4, borderRadius: 4, alignItems: "center", backgroundColor: colors.surface2 }}>
294
- <Text style={{ color: selected ? colors.foreground : colors.foregroundMuted, fontSize: 10, lineHeight: 14, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{count}</Text>
295
- </View>
296
- </Pressable>
297
- );
298
- }
299
-
300
283
  // Newest first, 10 at a time (see usePaged).
301
284
  function ActivityList({ colors, activity, now }: { colors: Colors; activity: Activity[]; now: number }) {
302
285
  const paged = usePaged(activity);
@@ -341,11 +324,12 @@ function Card({ colors, icon, title, children }: { colors: Colors; icon: string;
341
324
  }
342
325
 
343
326
  function ProgressBar({ colors, progress, live }: { colors: Colors; progress: Dashboard["progress"]; live: boolean }) {
327
+ const narrow = useNarrow();
344
328
  return (
345
329
  <View style={{ paddingHorizontal: 12, paddingTop: 12, paddingBottom: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
346
330
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
347
331
  <Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 13, lineHeight: 18 }}>
348
- <Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> of estimated work done
332
+ <Text style={{ color: colors.foreground, fontWeight: "600" }}>{progress.percent}%</Text> {narrow ? "done" : "of estimated work done"}
349
333
  </Text>
350
334
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
351
335
  {formatWorkDone(progress.doneMin, progress.totalMin, progress.tookMin)}
@@ -393,6 +377,7 @@ function Shimmer() {
393
377
  const STUCK_ICON: Record<StuckItem["kind"], string> = { blocked: "Ban", overdue: "Hourglass", manual: "Flag" };
394
378
 
395
379
  function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem[]; now: number }) {
380
+ const narrow = useNarrow();
396
381
  return (
397
382
  <View style={{ margin: 12, marginBottom: 0, borderWidth: 1, borderColor: colors.statusDanger, borderRadius: 6, overflow: "hidden" }}>
398
383
  <SectionTitle colors={colors} icon="OctagonAlert" title="Stuck" color={colors.statusDanger} style={{ borderBottomWidth: 1, borderBottomColor: colors.border }} />
@@ -404,7 +389,7 @@ function StuckSection({ colors, items, now }: { colors: Colors; items: StuckItem
404
389
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{item.title}</Text>
405
390
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
406
391
  {item.kind === "overdue" ? `No update for longer than its ${formatMinutes(item.estimateMin)} estimate. Last update ` : null}
407
- {item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}. Since ` : null}
392
+ {item.kind === "blocked" ? `Blocked${item.note ? `: ${item.note}` : ""}${narrow ? ", " : ". Since "}` : null}
408
393
  {item.kind === "manual" ? `${item.ticketId ? `${item.reason}. ` : ""}Flagged ` : null}
409
394
  <When colors={colors} iso={item.since} now={now} />
410
395
  </Text>
@@ -450,6 +435,7 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
450
435
  const label = [done ? `${done} done` : "", skipped ? `${skipped} skipped` : ""].filter(Boolean).join(", ");
451
436
  const estimateMin = tickets.reduce((sum, ticket) => sum + (ticket.status === "done" ? ticket.estimateMin : 0), 0);
452
437
  const chevron = shown ? "ChevronUp" : "ChevronDown";
438
+ const narrow = useNarrow();
453
439
  return (
454
440
  <PressableRow colors={colors} accessibilityRole="button" accessibilityState={{ expanded: shown }}
455
441
  accessibilityLabel={`${shown ? "Hide" : "Show"} ${label}`} onPress={onPress}
@@ -458,8 +444,11 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
458
444
  <Icon name={done ? "Check" : "CircleSlash"} size={14} color={done ? colors.statusSuccess : colors.foregroundMuted} />
459
445
  </View>
460
446
  <Text style={{ flex: 1, minWidth: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{label}</Text>
461
- <View style={{ width: 12 }} />
462
- <Text style={{ minWidth: 40, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatEstimate(estimateMin) : ""}</Text>
447
+ {/* Narrow ticket rows drop their chevron, so this total would sit off their estimate column. */}
448
+ {narrow ? null : <>
449
+ <View style={{ width: 12 }} />
450
+ <Text style={{ minWidth: 40, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{estimateMin ? formatEstimate(estimateMin) : ""}</Text>
451
+ </>}
463
452
  <View style={{ paddingTop: 2 }}>
464
453
  <IconSwap swapKey={chevron} size={14}><Icon name={chevron} size={14} color={colors.foregroundMuted} /></IconSwap>
465
454
  </View>
@@ -471,12 +460,14 @@ function FinishedRow({ colors, tickets, shown, onPress }: { colors: Colors; tick
471
460
  function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors: Colors; ticket: Ticket; live: boolean; now: number; onOpen(): void; deliverables: number }) {
472
461
  const muted = ticket.status === "skipped";
473
462
  const working = ticket.status === "working";
463
+ // Narrow: tighter gaps and no chevron (the whole row still presses), so the title keeps its width.
464
+ const narrow = useNarrow();
474
465
  return (
475
466
  <PressableRow colors={colors} onSurface1={working} transparentAtRest={!working}
476
467
  accessibilityRole="button" accessibilityLabel={`${ticket.id} details`} onPress={onOpen}
477
- style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
468
+ style={{ flexDirection: "row", alignItems: "flex-start", gap: narrow ? 6 : 10, paddingHorizontal: 10, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border }}>
478
469
  {/* The icon is the only status mark on the row; the ticket dialog spells it out in a badge. */}
479
- <View accessible accessibilityLabel={ticket.waitingFor ? "Waiting" : STATUS_LABEL[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center" }}>
470
+ <View accessible accessibilityLabel={ticket.waitingFor ? "Waiting" : STATUS_LABEL[ticket.status]} style={{ width: 16, paddingTop: 2, alignItems: "center", marginRight: narrow ? 2 : 0 }}>
480
471
  <IconSwap swapKey={ticket.waitingFor || ticket.status === "not_started" ? "waiting" : ticket.status === "working" ? `working-${live}` : ticket.status} size={14}><StatusIcon colors={colors} status={ticket.status} live={live} waiting={Boolean(ticket.waitingFor)} /></IconSwap>
481
472
  </View>
482
473
  <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
@@ -490,7 +481,7 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
490
481
  ) : null}
491
482
  {ticket.status === "working" && ticket.workingSince ? (
492
483
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
493
- Working since <When colors={colors} iso={ticket.workingSince} now={now} />
484
+ {narrow ? "Started working " : "Working since "}<When colors={colors} iso={ticket.workingSince} now={now} />
494
485
  </Text>
495
486
  ) : null}
496
487
  {ticket.waitingFor ? (
@@ -503,7 +494,7 @@ function TicketRow({ colors, ticket, live, now, onOpen, deliverables }: { colors
503
494
  {deliverables ? <Icon name="Package" size={12} color={colors.foregroundMuted} /> : null}
504
495
  </View>
505
496
  <Text style={{ minWidth: 40, flexShrink: 0, textAlign: "right", color: colors.foregroundMuted, fontSize: 12, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{formatEstimate(ticket.estimateMin)}</Text>
506
- <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
497
+ {narrow ? null : <View style={{ paddingTop: 2 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>}
507
498
  </PressableRow>
508
499
  );
509
500
  }
@@ -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>
@@ -42,13 +42,12 @@ export interface AttachmentContext {
42
42
  navigation?: PluginWorkspacePanelProps["navigation"];
43
43
  }
44
44
 
45
- export function QuestionsSection({ colors, questions, answered, now, compact, context }: {
45
+ export function QuestionsSection({ colors, questions, answered, now, context }: {
46
46
  colors: Colors;
47
47
  questions: Question[];
48
48
  // So a question answered while its dialog is open shows the answer instead of vanishing.
49
49
  answered: Question[];
50
50
  now: number;
51
- compact: boolean;
52
51
  context: AttachmentContext;
53
52
  }) {
54
53
  const [openId, setOpenId] = useState<string | null>(null);
@@ -61,9 +60,6 @@ export function QuestionsSection({ colors, questions, answered, now, compact, co
61
60
  <View style={{ borderWidth: 1, borderColor: colors.accent, backgroundColor: colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
62
61
  <Text style={{ color: colors.accentForeground, fontSize: 11, lineHeight: 16 }}>{questions.length} waiting</Text>
63
62
  </View>
64
- <Text style={{ flexBasis: compact ? "100%" : undefined, flex: compact ? undefined : 1, textAlign: compact ? "left" : "right", color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
65
- Work continues on each default until you answer, except where it waits. Press a question to see its choices and copy an answer.
66
- </Text>
67
63
  </View>
68
64
  {questions.map((question, index) => (
69
65
  <QuestionRow key={question.id} colors={colors} question={question} first={index === 0}
@@ -123,7 +119,7 @@ function AnswerSummary({ colors, question, now }: { colors: Colors; question: Qu
123
119
  {chosen ? ` ${chosen.label}.` : ""}
124
120
  {answer.words ? <Text style={{ color: colors.foregroundMuted }}> “{answer.words}”</Text> : null}
125
121
  <Text style={{ color: colors.foregroundMuted }}>
126
- {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." : ""}
127
123
  </Text>
128
124
  </Text>
129
125
  <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
@@ -145,6 +141,9 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
145
141
  <PressScale accessibilityRole="button" accessibilityLabel={`Copy ${question.id} for your reply`} onPress={onCopy} hitSlop={6}
146
142
  outerStyle={{ alignSelf: "flex-start", marginTop: 1 }}
147
143
  style={({ pressed }) => ({
144
+ // One width for every id, so the questions beside them start on one edge.
145
+ minWidth: 34,
146
+ alignItems: "center",
148
147
  paddingHorizontal: 6,
149
148
  borderRadius: 999,
150
149
  backgroundColor: pressed ? colors.surface2 : colors.surface1,
@@ -154,8 +153,7 @@ function QuestionRow({ colors, question, first, onOpen, onCopy }: {
154
153
  </PressScale>
155
154
  <PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={`${question.id} choices`} onPress={onOpen}
156
155
  style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "flex-start", gap: 8, borderRadius: 4, margin: -4, padding: 4 }}>
157
- <Text style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
158
- <DefaultBadge colors={colors} value={question.default} waits={question.waits} />
156
+ <Text style={{ flex: 1, minWidth: 0, color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
159
157
  {/* Optical: centers the 14px chevron on the 19px first line. */}
160
158
  <View style={{ paddingTop: 2.5 }}><Icon name="ChevronRight" size={14} color={colors.foregroundMuted} /></View>
161
159
  </PressableRow>
@@ -174,9 +172,9 @@ export function QuestionView({ colors, question, now, onCopy, onOpenAttachment,
174
172
  }) {
175
173
  return (
176
174
  <View style={{ gap: 10 }}>
177
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
178
- <Text style={{ flex: 1, color: colors.foreground, fontSize: 14, lineHeight: 20 }}>{question.question}</Text>
179
- {!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}
180
178
  </View>
181
179
  {question.answer ? <AnswerSummary colors={colors} question={question} now={now} /> : null}
182
180
  <QuestionDetail colors={colors} question={question} now={now} onSurface1 onCopy={onCopy} onOpenAttachment={onOpenAttachment} />
@@ -185,15 +183,10 @@ export function QuestionView({ colors, question, now, onCopy, onOpenAttachment,
185
183
  );
186
184
  }
187
185
 
188
- // A question that waits shows its default in amber: the agent is not acting on it.
189
- export function DefaultBadge({ colors, value, waits }: { colors: Colors; value: string; waits: boolean }) {
190
- return (
191
- <View style={{ flexDirection: "row", borderWidth: 1, borderColor: waits ? colors.statusWarning : colors.accent, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 1 }}>
192
- <Text style={{ color: waits ? colors.statusWarning : colors.foregroundMuted, fontSize: 11, lineHeight: 16 }}>
193
- Default <Text style={{ color: waits ? colors.statusWarning : colors.foreground, fontWeight: "600" }}>{value}</Text>{waits ? ", waits for you" : ""}
194
- </Text>
195
- </View>
196
- );
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>;
197
190
  }
198
191
 
199
192
  // `onSurface1` when drawn on a surface1 background (Paseo's dialogs), so the
@@ -210,10 +203,11 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
210
203
  return (
211
204
  <View style={{ gap: 8, paddingTop: 2 }}>
212
205
  {question.options.map((option) => {
213
- const isDefault = option.letter === question.default;
214
- // 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.
215
208
  const chosen = question.answer?.choice === option.letter;
216
- 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);
217
211
  return (
218
212
  <View key={option.letter} style={{ flexDirection: "row", alignItems: "flex-start", gap: 8, padding: 8, borderRadius: 12, borderWidth: 1, borderColor }}>
219
213
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600", width: 14 }}>{option.letter}</Text>
@@ -221,7 +215,7 @@ export function QuestionDetail({ colors, question, now, onCopy, onOpenAttachment
221
215
  <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>
222
216
  {option.label}
223
217
  {chosen ? <Text style={{ color: colors.statusSuccess }}> Your answer</Text> : null}
224
- {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}
225
219
  </Text>
226
220
  {option.consequence ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{option.consequence}</Text> : null}
227
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
+ }