paseo-follow-up 1.2.0-beta.4 → 1.3.0-beta.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/OVERVIEW.md CHANGED
@@ -4,14 +4,15 @@ Follow-up watches every pull request a Paseo workspace opens until the work is a
4
4
 
5
5
  ## What you see
6
6
 
7
- - A workspace panel that leads with status: PRs merged, what needs you, what needs fixing, a bar with one segment per PR, and what the agent is doing now.
7
+ - Home: every workspace grouped by what it needs from you (Needs you, Follow-up is fixing, Running, Waiting on others, Quiet), with the selected one beside the list so you can allow a permission, answer, merge, reply or sign off in place, and a brief with what Follow-up did while you were away. "Open full" opens the agent and docks the same list in the workspace's explorer column.
8
+ - In that navigator, the open workspace's row leads with status: PRs merged, what needs you, what needs fixing, a bar with one segment per PR, and what the agent is doing now.
8
9
  - A Needs you card with one button per decision: Send repairs, Merge, Resume, Retry, or Answer.
9
10
  - Questions from the main agent, each with a permanent ref (Q1, Q2…), lettered options, the agent's recommendation and what it does while it waits. Pick an option, add a note and Send it to the agent, or Copy it instead. Answered questions stay listed under their ref, and the question mode setting (Stop, Work around, Keep building) tells agents what to do until you answer.
10
11
  - A Spec link in the header to the page that sums up the workspace, usually the Claude artifact the main agent keeps up to date.
11
12
  - An Activity card: repairs sent and finished with how long they took, merges, PR actions and questions.
12
13
  - Each stack drawn top to base like Graphite, merged PRs folded away, and a sheet per PR with checks, reviewers and Merge.
13
14
  - Tasks for work that outlives a merge, manual or scheduled after a merge or deploy, with an optional delay.
14
- - A composer pill and a sidebar count that appear only when something needs you; the pill steps through open questions so you can answer without leaving the chat. One screen lists every tracked workspace by who is up next, and a larger view opens a workspace in two columns.
15
+ - A composer pill and a sidebar count that appear only when something needs you; the pill steps through open questions so you can answer without leaving the chat. Home lists every workspace by who is up next, and a larger view opens a workspace in two columns.
15
16
  - Compact rows for Follow-up's own tool calls in agent chats, a card for each repair batch, and a card when the agent declares Done.
16
17
 
17
18
  ## Setup
package/README.md CHANGED
@@ -24,7 +24,7 @@ Needs Node 22+ and an authenticated GitHub CLI (`gh`) on the daemon host. Merge
24
24
 
25
25
  ## Use
26
26
 
27
- Open a workspace, click the Follow-up icon in its header (or press ⌘K / Ctrl+K and choose **Follow-up: open for this workspace**), then **Track this workspace**. To track every workspace in a project, open the settings (the gear in the panel, or **Follow-up: project settings** in ⌘K) and turn the project on.
27
+ Open a workspace, click the Follow-up icon in its header (or press ⌘K / Ctrl+K and choose **Follow-up: dock the navigator here**), then **Track this workspace**. To track every workspace in a project, open the settings (the gear in the panel, or **Follow-up: project settings** in ⌘K) and turn the project on.
28
28
 
29
29
  The panel reads top to bottom:
30
30
 
@@ -64,11 +64,7 @@ In the agent's chat, Follow-up's own tool calls collapse into one-line rows, you
64
64
 
65
65
  ![Follow-up rows and cards in an agent chat](docs/screenshots/follow-up-chat.png)
66
66
 
67
- **Follow-up: all tracked workspaces** (or the sidebar row) lists every tracked workspace, grouped by who's up next, with each stack's bar and its Spec link.
68
-
69
- ![The tracked workspaces screen grouped into Needs you, Agent working, Waiting and Done](docs/screenshots/follow-up-overview.png)
70
-
71
- On a phone the panel opens as a tab, and the PR sheet and pill popover open as bottom sheets.
67
+ On a phone the navigator opens as a tab, and the PR sheet and pill popover open as bottom sheets.
72
68
 
73
69
  ![Follow-up at phone width: the panel, a PR sheet and the pill popover](docs/screenshots/follow-up-mobile.png)
74
70
 
@@ -78,11 +74,11 @@ The 1.1 beta adds an inbox for your agents. An agent lands in **Needs you** when
78
74
 
79
75
  - **One pill per agent** above the composer, only while something needs you there. It leads with the one thing that matters (*Decision 4m*, *Permission 2m*, *Send 2 fixes*, *Merge #812*, *Add reviewers #1499*, *Finished 12m*), using the same order as the brief card and the Needs-you row. Click it for **Sign off**, **Sign off & next**, **Later…**, the workspace's questions and PR actions, *Mute this turn*, *Don't flag finishes* and *Open Follow-up*. After a sign-off it reads *Signed off · Undo* for 6 s.
80
76
  - **Sign off** in one step: the ✓ on a row, `/ok` in the composer (`/ok next` opens the next one), or ⌘K → **Follow-up: sign off & next**. Every sign-off can be undone for 24 hours.
81
- - **One sidebar item, Follow-up**, badged with how many agents need you (hidden at zero). It opens the inbox and your tracked workspaces.
77
+ - **One sidebar item, Follow-up**, badged with how many agents need you (hidden at zero). It opens Home.
82
78
  - **Messages from automation don't count as yours**: agent-to-agent messages, Follow-up's own repairs, Defer, Smart Session and Session Guard continuations, and schedules leave the flag in place. Their turn end flags as usual. A script calling `paseo send` without an agent envelope counts as you until Paseo reports who sent a message.
83
79
  - **Don't flag finishes** (per agent, in the row's ⋯ or the composer's **More**): for agents that run on a schedule, such as a plugin auto-update. Permission prompts and errors still flag.
84
80
  - **First run** imports what Paseo marks unread and idle agents from the last 7 days you never replied to. Older idle agents wait in a one-time *Unsorted* list with a confirmed *Sign off all* that one Undo reverses.
85
- - On Paseo builds with sidebar views (Paseo `main` after 2026-11-07, expected in the next release), **Settings > Sidebar > Sidebar view > Follow-up: Needs you** puts the inbox in the sidebar, with your workspace list folded below it.
81
+ - On Paseo builds with sidebar views (Paseo `main` after 2026-11-07, expected in the next release), **Settings > Sidebar > Sidebar view > Follow-up: Needs you** puts Home's list in the sidebar.
86
82
  - Everything is in one settings page, **Settings > Follow-up** (⌘K → **Follow-up: settings**): Projects, Questions, Sidebar & pills, and the Needs-you inbox. Turn the whole inbox off there. Off means nothing is flagged and no pill or sidebar item is shown.
87
83
 
88
84
  ### What you need to do, front and center (1.2 beta)
@@ -98,10 +94,22 @@ The content comes from a new `needs_you` tool that every root agent gets while t
98
94
 
99
95
  **Ask agents to state what they need** (on by default, in **Follow-up: display settings**): when a Claude Code root agent ends a turn without calling `needs_you`, Follow-up's Stop hook asks it once to call it, then lets it stop. It never asks twice in one turn, and skips subagents, contributors, recovery agents and agents waiting on a permission prompt. Codex and other providers get the instruction only. The settings screen shows how often agents stated what they need over the last 7 days.
100
96
 
97
+ ### Home (1.3 beta)
98
+
99
+ The Follow-up sidebar row (and ⌘K → **Follow-up: Home**) opens **Home**: every workspace on the left, the one you pick on the right, answered without leaving it. It replaces the old overview and tracked-workspaces screens.
100
+
101
+ - **The list**, grouped by what it needs from you: **Morning brief**, **Needs you** (blocking asks first: permissions, errors, questions; then decisions only you make, like merge and add reviewers; then finished turns to sign off, oldest first), **Follow-up is fixing** (repairs in flight), **Running**, **Waiting on others**, **Later** (snoozed) and **Quiet**, folded by project and paged, with merged and signed-off work marked ready to archive. Each row has the last ask or action, up to two badges, the PR with its checks, and the age.
102
+ - **The detail pane** for the selected row: the brief (the ask, decisions as buttons), Allow / Deny for a pending permission, merge and add-reviewer actions, **Sign off**, **Sign off & next**, **Later…**, the agent's last few steps (read only for that agent), and a reply box. Sending a reply is you writing to the agent, so it clears Needs you. Only one button on screen is filled.
103
+ - **The brief pane** when nothing is selected: a headline that names the first move, who you're waiting on, what cleared since the last workday, **Done while you were away** (what Follow-up did on its own, with a link to the full log) and how fresh Paseo and GitHub data is.
104
+ - **Open full** opens the agent's chat and docks the **navigator** in that workspace's explorer column: the same list, compact, with this workspace's row expanded to its decisions, PR stack, tasks left, repair budget, activity and **Details** (the larger view). Its **Home** button goes back. The Follow-up header button re-docks it. On a phone, Home is the list and a row opens a full-height detail.
105
+ ![Home: the list grouped into Needs you, Follow-up is fixing, Running and Waiting on others, with a permission selected and Allow / Deny, Sign off and the agent's last steps on the right](docs/screenshots/follow-up-home.png)
106
+
107
+ - ⌘K: **Follow-up: Home**, **Follow-up: Sign off & next**, **Follow-up: Later (tomorrow 09:00)**, **Follow-up: Next needs-you**, **Follow-up: Done while you were away**.
108
+
101
109
  Install the beta:
102
110
 
103
111
  ```sh
104
- paseo plugin update follow-through --version 1.2.0-beta.4
112
+ paseo plugin update follow-through --version 1.3.0-beta.1
105
113
  # or, for a fresh install:
106
114
  paseo plugin install npm:paseo-follow-up@beta
107
115
  ```
package/client/brief.tsx CHANGED
@@ -96,7 +96,8 @@ function Collapsed({ b, c, now }: { b: Brief; c: Colors; now: number }) {
96
96
  </View>;
97
97
  }
98
98
 
99
- export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimelineItemProps<Brief> & { deps: BriefDeps }) {
99
+ /** `embedded`: inside Home's detail pane, which has its own reply box and action bar, so the card drops its moves. */
100
+ export function BriefCard({ item, theme, layout, agentId, deps, embedded = false }: PluginTimelineItemProps<Brief> & { deps: BriefDeps; embedded?: boolean }) {
100
101
  const c = theme.colors; const b = item.data; const compact = layout.compact;
101
102
  const now = useNow(60_000);
102
103
  const { height } = useWindowDimensions();
@@ -146,7 +147,7 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
146
147
  <Icon name={ICON[b.kind]} size={14} color={acts ? attention : tone} />
147
148
  {acts && <Text style={{ color: attention, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Needs you</Text>}
148
149
  <Text style={{ flex: 1, minWidth: compact ? 160 : 0, color: acts ? c.foreground : c.foregroundMuted, fontSize: 14, lineHeight: 20, fontWeight: acts ? "600" : "400" }}>{b.ask}</Text>
149
- {moves.map(m => <Btn key={m.kind} label={m.label} onPress={() => press(m)} c={c} compact={compact} />)}
150
+ {!embedded && moves.map(m => <Btn key={m.kind} label={m.label} onPress={() => press(m)} c={c} compact={compact} />)}
150
151
  <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
151
152
  </View>;
152
153
  }
@@ -156,7 +157,7 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
156
157
  const showExtra = !tight || details || extrasFit(b, height);
157
158
  const pad = compact ? 14 : 16;
158
159
  return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you" : word(b.kind)}: ${b.ask}`}
159
- style={{ marginVertical: 10, borderRadius: 12, borderWidth: 1, borderColor: border, backgroundColor: c.surface1, overflow: "hidden" }}>
160
+ style={{ marginVertical: embedded ? 0 : 10, borderRadius: 12, borderWidth: 1, borderColor: border, backgroundColor: c.surface1, overflow: "hidden" }}>
160
161
  <View style={{ paddingHorizontal: pad, paddingTop: compact ? 12 : 14, paddingBottom: 14, gap: tight ? 10 : 12 }}>
161
162
  <View style={{ gap: 4 }}>
162
163
  <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
@@ -176,8 +177,8 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
176
177
  open={!!open[`${d.id}:${o.letter}`]} onToggle={() => setOpen(v => ({ ...v, [`${d.id}:${o.letter}`]: !v[`${d.id}:${o.letter}`] }))}
177
178
  onPress={() => void run(`${d.id}:${o.letter}`, () => act({ agentId, briefId: b.id, decisionId: d.id, choice: o.letter }))} c={c} compact={compact} />)}
178
179
  </View>}
179
- {!tight && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{d.options.length ? `Or type ${letters(d)} in the composer, or reply in your own words.` : "Reply in the composer."}</Text>}
180
- {tight && !d.options.length && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>Reply in the composer.</Text>}
180
+ {!tight && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{embedded ? d.options.length ? "Or answer in your own words below." : "Reply below." : d.options.length ? `Or type ${letters(d)} in the composer, or reply in your own words.` : "Reply in the composer."}</Text>}
181
+ {tight && !d.options.length && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{embedded ? "Reply below." : "Reply in the composer."}</Text>}
181
182
  </View>)}
182
183
 
183
184
  {!!b.mono && !b.ask.endsWith(b.mono) && <View style={{ borderRadius: 8, backgroundColor: withAlpha(c.statusDanger, isLight(c) ? 0.07 : 0.1), paddingHorizontal: 10, paddingVertical: 8 }}>
@@ -204,7 +205,7 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
204
205
  </Pressable>)}
205
206
  </View>}
206
207
 
207
- {moves.length > 0 && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
208
+ {!embedded && moves.length > 0 && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
208
209
  {moves.map((m, i) => <Btn key={m.kind} label={busy === m.kind ? `${m.label}…` : m.label} icon={MOVE_ICON[m.kind]} primary={i === 0} disabled={!!busy} onPress={() => press(m)} c={c} compact={compact} />)}
209
210
  </View>}
210
211
 
@@ -0,0 +1,78 @@
1
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
2
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
3
+ import { buildHome, type HomeModel, type PaseoWorkspaceLite } from "../shared/home";
4
+ import type { InboxStore } from "./inbox-store";
5
+ import type { LiveStore } from "./live";
6
+
7
+ type Descriptor = { id: string; name?: string | null; title?: string | null; projectDisplayName?: string | null; projectCustomName?: string | null; activityAt?: string | null; archivingAt?: string | null };
8
+ const lite = (w: Descriptor): PaseoWorkspaceLite => ({ id: w.id, name: w.title || w.name || "Workspace", project: w.projectCustomName || w.projectDisplayName || null, activityAt: w.activityAt ?? null });
9
+
10
+ /**
11
+ * Paseo's own workspace list, for the rows Follow-up doesn't track (they land in Quiet). One subscription per
12
+ * client, shared with the header buttons. `onAdd` and `onRemove` let the entry point register header buttons.
13
+ */
14
+ export function createPaseoWorkspaces(client: PluginClientContext, hooks: { onAdd?: (id: string) => void; onRemove?: (id: string) => void } = {}) {
15
+ const map = new Map<string, PaseoWorkspaceLite>();
16
+ let version = 0;
17
+ let stopped = false;
18
+ let release: (() => Promise<void>) | null = null;
19
+ const listeners = new Set<() => void>();
20
+ let pending: ReturnType<typeof setTimeout> | null = null;
21
+ // Coalesce bursts (a page load, many updates) into one emit so Home rebuilds once.
22
+ const emit = () => { if (pending) return; pending = setTimeout(() => { pending = null; version++; for (const l of listeners) l(); }, 50); };
23
+ const put = (w: Descriptor) => { if (w.archivingAt) { remove(w.id); return; } map.set(w.id, lite(w)); hooks.onAdd?.(w.id); emit(); };
24
+ const remove = (id: string) => { if (map.delete(id)) emit(); hooks.onRemove?.(id); };
25
+ const unsubscribe = client.paseo.workspaces.subscribe(update => {
26
+ if (update.kind === "remove") remove(update.id);
27
+ else put(update.workspace as Descriptor);
28
+ });
29
+ void (async () => {
30
+ const page = await client.paseo.workspaces.list({ subscribe: {}, page: { limit: 200 } });
31
+ if (stopped) { await page.subscription.release(); return; }
32
+ release = () => page.subscription.release();
33
+ page.entries.forEach(w => put(w as Descriptor));
34
+ let cursor = page.pageInfo.nextCursor;
35
+ while (cursor && !stopped) {
36
+ const next = await client.paseo.workspaces.list({ page: { limit: 200, cursor } });
37
+ next.entries.forEach(w => put(w as Descriptor));
38
+ if (next.pageInfo.nextCursor === cursor) break;
39
+ cursor = next.pageInfo.nextCursor;
40
+ }
41
+ })().catch(() => undefined);
42
+ return {
43
+ subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
44
+ version: () => version,
45
+ all: () => [...map.values()],
46
+ get: (id: string) => map.get(id),
47
+ async stop() { stopped = true; if (pending) clearTimeout(pending); listeners.clear(); unsubscribe(); await release?.(); },
48
+ };
49
+ }
50
+ export type PaseoWorkspaces = ReturnType<typeof createPaseoWorkspaces>;
51
+
52
+ export type HomeSources = { inbox: InboxStore; live: LiveStore; paseo: PaseoWorkspaces };
53
+
54
+ /** One shared minute clock. Ages subscribe to it themselves, so a tick never re-renders the list rows. */
55
+ const clock = { now: Date.now(), listeners: new Set<() => void>(), timer: null as ReturnType<typeof setInterval> | null };
56
+ function subscribeClock(listener: () => void) {
57
+ clock.listeners.add(listener);
58
+ if (!clock.timer) clock.timer = setInterval(() => { clock.now = Date.now(); for (const l of clock.listeners) l(); }, 60_000);
59
+ return () => { clock.listeners.delete(listener); if (!clock.listeners.size && clock.timer) { clearInterval(clock.timer); clock.timer = null; } };
60
+ }
61
+ export const useMinute = () => useSyncExternalStore(subscribeClock, () => clock.now, () => clock.now);
62
+
63
+ /** Home's model from the three shared stores. Rebuilt when one of them changes or the minute ticks. */
64
+ export function useHomeModel(sources: HomeSources): HomeModel {
65
+ const { inbox, live, paseo } = sources;
66
+ const a = useSyncExternalStore(inbox.subscribe, inbox.version, inbox.version);
67
+ const b = useSyncExternalStore(live.subscribe, live.version, live.version);
68
+ const c = useSyncExternalStore(paseo.subscribe, paseo.version, paseo.version);
69
+ const now = useMinute();
70
+ return useMemo(() => buildHome({ rows: inbox.rows(), tracked: live.all(), paseo: paseo.all(), now: Date.now() }), [a, b, c, now, inbox, live, paseo]);
71
+ }
72
+
73
+ /** Rebuilds a value at most every `ms` while mounted (used for the brief pane's source ages). */
74
+ export function useTick(ms: number): number {
75
+ const [n, setN] = useState(0);
76
+ useEffect(() => { const id = setInterval(() => setN(x => x + 1), ms); return () => clearInterval(id); }, [ms]);
77
+ return n;
78
+ }
@@ -0,0 +1,185 @@
1
+ import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
2
+ import { FlatList, Pressable, Text, View } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/ui";
4
+ import { quietFolds, type Badge, type HomeGroupId, type HomeModel, type Workstream } from "../shared/home";
5
+ import { age } from "../shared/inbox";
6
+ import { headlineColor, mergedColor, runningColor, tint, toneColor, withAlpha, type Colors } from "./theme";
7
+ import { useMinute } from "./home-data";
8
+ import { Spinner } from "./motion";
9
+ import type { Theme } from "./kit";
10
+
11
+ /**
12
+ * The one list: Home's left side, the docked navigator in the explorer, and (on hosts that have it) the sidebar
13
+ * view. Virtualised; rows are memoised on their signature and ages tick on their own, so a minute tick or a poll
14
+ * that changes one row redraws only that row.
15
+ */
16
+
17
+ const QUIET_PAGE = 20;
18
+ export const BRIEF_KEY = "brief";
19
+
20
+ type Entry =
21
+ | { type: "brief"; key: string }
22
+ | { type: "header"; key: string; group: HomeGroupId; title: string; count: number; open: boolean }
23
+ | { type: "row"; key: string; s: Workstream }
24
+ | { type: "fold"; key: string; project: string; count: number; archivable: number; open: boolean }
25
+ | { type: "more"; key: string; project: string; left: number };
26
+
27
+ export type HomeListProps = {
28
+ model: HomeModel;
29
+ theme: Theme;
30
+ /** Navigator and sidebar density: one-line rows without badges. */
31
+ dense?: boolean;
32
+ touch: boolean;
33
+ selectedKey: string | null;
34
+ onSelect: (key: string) => void;
35
+ /** The brief row's one-liner. Absent: no brief row (the navigator). */
36
+ briefLine?: string | null;
37
+ /** Content shown under the selected row (the navigator's expanded row). */
38
+ renderExpanded?: (s: Workstream) => ReactNode;
39
+ header?: ReactNode;
40
+ footer?: ReactNode;
41
+ };
42
+
43
+ export function HomeList({ model, theme, dense = false, touch, selectedKey, onSelect, briefLine, renderExpanded, header, footer }: HomeListProps) {
44
+ const c = theme.colors;
45
+ const [closed, setClosed] = useState<Set<string>>(new Set(["quiet"]));
46
+ const [openProjects, setOpenProjects] = useState<Set<string>>(new Set());
47
+ const [pages, setPages] = useState<Record<string, number>>({});
48
+ const toggle = useCallback((key: string) => setClosed(s => { const n = new Set(s); if (n.has(key)) n.delete(key); else n.add(key); return n; }), []);
49
+ const toggleProject = useCallback((p: string) => setOpenProjects(s => { const n = new Set(s); if (n.has(p)) n.delete(p); else n.add(p); return n; }), []);
50
+
51
+ const data = useMemo(() => {
52
+ const out: Entry[] = [];
53
+ if (briefLine !== undefined) out.push({ type: "brief", key: BRIEF_KEY });
54
+ for (const g of model.groups) {
55
+ const open = !closed.has(g.id);
56
+ out.push({ type: "header", key: `h:${g.id}`, group: g.id, title: g.title, count: g.items.length, open });
57
+ if (!open) continue;
58
+ if (g.id !== "quiet") { for (const s of g.items) out.push({ type: "row", key: s.key, s }); continue; }
59
+ for (const f of quietFolds(g.items)) {
60
+ const fopen = openProjects.has(f.project);
61
+ out.push({ type: "fold", key: `f:${f.project}`, project: f.project, count: f.items.length, archivable: f.archivable, open: fopen });
62
+ if (!fopen) continue;
63
+ const shown = (pages[f.project] ?? 1) * QUIET_PAGE;
64
+ for (const s of f.items.slice(0, shown)) out.push({ type: "row", key: s.key, s });
65
+ if (f.items.length > shown) out.push({ type: "more", key: `m:${f.project}`, project: f.project, left: f.items.length - shown });
66
+ }
67
+ }
68
+ return out;
69
+ }, [model, closed, openProjects, pages, briefLine]);
70
+
71
+ const render = useCallback(({ item }: { item: Entry }) => {
72
+ switch (item.type) {
73
+ case "brief": return <BriefRow theme={theme} line={briefLine ?? ""} selected={selectedKey === null || selectedKey === BRIEF_KEY} touch={touch} onSelect={onSelect} />;
74
+ case "header": return <GroupHeader theme={theme} title={item.title} count={item.count} open={item.open} tone={item.group === "needs" && item.count ? c.statusWarning : undefined} dense={dense} touch={touch} onPress={() => toggle(item.group)} />;
75
+ case "fold": return <Pressable accessibilityRole="button" aria-expanded={item.open} accessibilityState={{ expanded: item.open }} accessibilityLabel={`${item.project}, ${item.count}. ${item.open ? "Collapse" : "Expand"}`} onPress={() => toggleProject(item.project)}
76
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 8, minHeight: touch ? 44 : dense ? 28 : 34, paddingHorizontal: dense ? 8 : 16, backgroundColor: (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
77
+ <Icon name={item.open ? "ChevronDown" : "ChevronRight"} size={13} color={c.foregroundMuted} />
78
+ <Text numberOfLines={1} style={{ color: c.foreground, fontSize: dense ? 12 : 13, fontWeight: "500" }}>{item.project}</Text>
79
+ <Text style={{ color: c.foregroundMuted, fontSize: dense ? 11 : 12, fontVariant: ["tabular-nums"] }}>{item.count}</Text>
80
+ {item.archivable > 0 && <Text numberOfLines={1} style={{ color: c.statusSuccess, fontSize: dense ? 11 : 12 }}>· {item.archivable} ready to archive</Text>}
81
+ </Pressable>;
82
+ case "more": return <Pressable accessibilityRole="button" onPress={() => setPages(p => ({ ...p, [item.project]: (p[item.project] ?? 1) + 1 }))}
83
+ style={{ minHeight: touch ? 44 : 30, justifyContent: "center", paddingHorizontal: dense ? 30 : 42 }}>
84
+ <Text style={{ color: c.foregroundMuted, fontSize: 12 }}>Show {Math.min(QUIET_PAGE, item.left)} more of {item.left}</Text>
85
+ </Pressable>;
86
+ case "row": {
87
+ const selected = selectedKey === item.key;
88
+ return <View>
89
+ <Row s={item.s} theme={theme} dense={dense} touch={touch} selected={selected} onSelect={onSelect} sig={item.s.sig} />
90
+ {selected && renderExpanded ? renderExpanded(item.s) : null}
91
+ </View>;
92
+ }
93
+ }
94
+ }, [theme, c, dense, touch, selectedKey, onSelect, briefLine, renderExpanded, toggle, toggleProject]);
95
+
96
+ return <FlatList data={data} keyExtractor={e => e.key} renderItem={render} extraData={selectedKey}
97
+ initialNumToRender={30} maxToRenderPerBatch={30} windowSize={9} removeClippedSubviews={false}
98
+ ListHeaderComponent={header ? <>{header}</> : null} ListFooterComponent={footer ? <>{footer}</> : <View style={{ height: 32 }} />}
99
+ style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} />;
100
+ }
101
+
102
+ function GroupHeader({ theme, title, count, open, tone, dense, touch, onPress }: { theme: Theme; title: string; count: number; open: boolean; tone?: string; dense: boolean; touch: boolean; onPress: () => void }) {
103
+ const c = theme.colors;
104
+ return <Pressable accessibilityRole="button" aria-expanded={open} accessibilityState={{ expanded: open }} accessibilityLabel={`${title}, ${count}. ${open ? "Collapse" : "Expand"}`} onPress={onPress}
105
+ style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: touch ? 44 : dense ? 28 : 34, paddingHorizontal: dense ? 8 : 16, paddingTop: dense ? 4 : 8 }}>
106
+ {dense && <Icon name={open ? "ChevronDown" : "ChevronRight"} size={12} color={c.foregroundMuted} />}
107
+ <Text accessibilityRole="header" style={{ color: tone ?? c.foregroundMuted, fontSize: 11, lineHeight: 14, fontWeight: "600", letterSpacing: 0.6, textTransform: "uppercase" }}>{title}</Text>
108
+ <Text style={{ color: tone ?? c.foregroundMuted, fontSize: 11, lineHeight: 14, fontVariant: ["tabular-nums"] }}>{count}</Text>
109
+ {!dense && !open && <Icon name="ChevronRight" size={12} color={c.foregroundMuted} />}
110
+ </Pressable>;
111
+ }
112
+
113
+ function BriefRow({ theme, line, selected, touch, onSelect }: { theme: Theme; line: string; selected: boolean; touch: boolean; onSelect: (key: string) => void }) {
114
+ const c = theme.colors;
115
+ return <Pressable accessibilityRole="button" accessibilityState={{ selected }} accessibilityLabel={`Morning brief. ${line}`} onPress={() => onSelect(BRIEF_KEY)}
116
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 10, minHeight: touch ? 52 : 40, paddingHorizontal: 16, backgroundColor: selected ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
117
+ <Icon name="Sun" size={15} color={c.foregroundMuted} />
118
+ <Text style={{ color: c.foreground, fontSize: 13, fontWeight: "600" }}>Morning brief</Text>
119
+ <Text numberOfLines={1} style={{ flex: 1, color: c.foregroundMuted, fontSize: 12 }}>{line}</Text>
120
+ </Pressable>;
121
+ }
122
+
123
+ export function glyphColor(s: Pick<Workstream, "tone">, c: Colors): string {
124
+ return s.tone === "running" ? runningColor(c) : s.tone === "success" ? c.statusSuccess : headlineColor(s.tone, c);
125
+ }
126
+ const badgeColor = (b: Badge, c: Colors) => b.tone === "danger" ? c.statusDanger : b.tone === "warning" ? c.statusWarning : b.tone === "success" ? c.statusSuccess : b.tone === "merged" ? mergedColor(c) : c.foregroundMuted;
127
+
128
+ export function BadgeView({ b, c }: { b: Badge; c: Colors }) {
129
+ const col = badgeColor(b, c);
130
+ return <View style={{ paddingHorizontal: 6, height: 20, justifyContent: "center", borderRadius: 4, backgroundColor: b.tone === "muted" ? c.surface2 : tint(col, c) }}>
131
+ <Text numberOfLines={1} style={{ color: col, fontSize: 11, lineHeight: 14, fontWeight: "500" }}>{b.text}</Text>
132
+ </View>;
133
+ }
134
+
135
+ export function PrChipView({ s, c }: { s: Workstream; c: Colors }) {
136
+ if (!s.pr) return null;
137
+ const checks = s.pr.checks;
138
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, height: 20, borderRadius: 4, borderWidth: 1, borderColor: c.border }}>
139
+ <Icon name="GitPullRequest" size={11} color={toneColor(c, s.pr.tone)} />
140
+ <Text style={{ color: c.foreground, fontSize: 11, lineHeight: 14, fontVariant: ["tabular-nums"] }}>{s.pr.label}</Text>
141
+ {checks && <Text style={{ color: checks.tone === "muted" ? c.foregroundMuted : badgeColor({ text: "", tone: checks.tone }, c), fontSize: 11, lineHeight: 14 }}>{checks.text}</Text>}
142
+ </View>;
143
+ }
144
+
145
+ /** The age on the right. It re-renders on the shared minute clock without touching its row. */
146
+ export function Age({ iso, c, size = 12 }: { iso: string | null; c: Colors; size?: number }) {
147
+ const now = useMinute();
148
+ const text = age(iso, Math.max(now, Date.now()));
149
+ return text ? <Text style={{ color: c.foregroundMuted, fontSize: size, fontVariant: ["tabular-nums"], minWidth: 26, textAlign: "right" }}>{text}</Text> : null;
150
+ }
151
+
152
+ type RowProps = { s: Workstream; sig: string; theme: Theme; dense: boolean; touch: boolean; selected: boolean; onSelect: (key: string) => void };
153
+ const Row = memo(function Row({ s, theme, dense, touch, selected, onSelect }: RowProps) {
154
+ const c = theme.colors;
155
+ const color = glyphColor(s, c);
156
+ const blocking = s.group === "needs" && s.tier === 0;
157
+ const muted = s.group === "quiet" || s.group === "later";
158
+ const line = [s.word && s.group !== "needs" ? s.word : null, s.line].filter(Boolean).join(" · ");
159
+ const label = [s.title + (s.agent ? ` › ${s.agent}` : ""), s.word, s.line].filter(Boolean).join(". ");
160
+ return <Pressable accessibilityRole="button" accessibilityState={{ selected }} accessibilityLabel={label} onPress={() => onSelect(s.key)}
161
+ style={state => ({ flexDirection: "row", alignItems: dense ? "flex-start" : "center", gap: dense ? 8 : 10, minHeight: touch ? 56 : dense ? 36 : 38, paddingLeft: dense ? 8 : 16, paddingRight: dense ? 8 : 16, paddingVertical: dense ? 6 : 0,
162
+ borderLeftWidth: 2, borderLeftColor: blocking ? withAlpha(color, 0.9) : "transparent",
163
+ backgroundColor: selected ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
164
+ <View style={{ width: 16, alignItems: "center", paddingTop: dense ? 1 : 0 }}>
165
+ {s.tone === "running" && s.icon === "LoaderCircle" ? <Spinner color={color} size={13} label="Running" /> : <Icon name={s.icon} size={15} color={color} />}
166
+ </View>
167
+ {dense ? <View style={{ flex: 1, minWidth: 0, gap: 1 }}>
168
+ <Text numberOfLines={1} style={{ color: muted ? c.foregroundMuted : c.foreground, fontSize: 13, lineHeight: 18, fontWeight: muted ? "400" : "500" }}>{s.title}{s.agent ? <Text style={{ color: c.foregroundMuted }}>{` › ${s.agent}`}</Text> : null}</Text>
169
+ {(!!s.word || !!s.line) && <Text numberOfLines={2} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>
170
+ {s.word && s.group === "needs" ? <Text style={{ color, fontWeight: "600" }}>{s.word}</Text> : null}{s.word && s.group === "needs" && s.line ? " · " : ""}{line}
171
+ </Text>}
172
+ {touch && (s.pr || s.badges.length > 0) && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 4 }}>
173
+ {s.badges.map(b => <BadgeView key={b.text} b={b} c={c} />)}<PrChipView s={s} c={c} />
174
+ </View>}
175
+ </View> : <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 10 }}>
176
+ <Text numberOfLines={1} style={{ flexShrink: 0, maxWidth: "55%", color: muted ? c.foregroundMuted : c.foreground, fontSize: 13, fontWeight: muted ? "400" : "500" }}>{s.title}{s.agent ? <Text style={{ color: c.foregroundMuted, fontWeight: "400" }}>{` › ${s.agent}`}</Text> : null}</Text>
177
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: c.foregroundMuted, fontSize: 12 }}>
178
+ {s.word && s.group === "needs" ? <Text style={{ color }}>{s.word}</Text> : null}{s.word && s.group === "needs" && s.line ? " · " : ""}{line}
179
+ </Text>
180
+ {s.badges.map(b => <BadgeView key={b.text} b={b} c={c} />)}
181
+ <PrChipView s={s} c={c} />
182
+ </View>}
183
+ <Age iso={s.at} c={c} size={dense ? 11 : 12} />
184
+ </Pressable>;
185
+ }, (a, b) => a.sig === b.sig && a.selected === b.selected && a.theme === b.theme && a.dense === b.dense && a.touch === b.touch && a.onSelect === b.onSelect);