paseo-follow-up 1.2.0-beta.3 → 1.3.0-beta.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/OVERVIEW.md +3 -2
- package/README.md +18 -10
- package/client/brief.tsx +7 -6
- package/client/home-data.ts +78 -0
- package/client/home-list.tsx +185 -0
- package/client/home.tsx +350 -0
- package/client/inbox.tsx +9 -244
- package/client/navigator.tsx +113 -0
- package/client/panel.tsx +5 -84
- package/client/sidebar-view.tsx +27 -77
- package/docs/screenshots/follow-up-home.png +0 -0
- package/index.client.tsx +34 -41
- package/index.server.ts +1 -0
- package/package.json +1 -1
- package/paseo-plugin.json +1 -1
- package/server/daemon.ts +16 -2
- package/server/discovery.ts +7 -2
- package/server/inbox.ts +6 -0
- package/shared/contracts.ts +2 -0
- package/shared/home.ts +327 -0
- package/client/overview.tsx +0 -133
- package/docs/screenshots/follow-up-overview.png +0 -0
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
|
-
-
|
|
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.
|
|
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:
|
|
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
|

|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-

|
|
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
|

|
|
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
|
|
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
|
|
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
|
+

|
|
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.
|
|
112
|
+
paseo plugin update follow-through --version 1.3.0-beta.0
|
|
105
113
|
# or, for a fresh install:
|
|
106
114
|
paseo plugin install npm:paseo-follow-up@beta
|
|
107
115
|
```
|
|
@@ -117,7 +125,7 @@ Betas publish to npm's `beta` dist-tag, so `paseo plugin update` without `--vers
|
|
|
117
125
|
## How it works
|
|
118
126
|
|
|
119
127
|
- The workspace's main agent owns its PRs, tasks, questions, the spec link and the Done call. Other agents in the workspace can register PRs and check status.
|
|
120
|
-
- **Auto-send** is on for every workspace tracked for the first time (1.2.0-beta.
|
|
128
|
+
- **Auto-send** is on for every workspace tracked for the first time (1.2.0-beta.4), so review threads and red CI go to the agent without you. Workspaces tracked before keep their setting. Turn it off per workspace and each repair batch waits for your click; with it on, Follow-up sends batches itself once the agent is idle, after fresh permission and Session Guard checks.
|
|
121
129
|
- Follow-up never merges, turns on merge when ready, requests reviewers or archives on its own. **Merge** re-checks the stack, lists any PRs underneath that would merge first, and asks again.
|
|
122
130
|
- A merged PR is not the end: tasks such as production checks and open questions keep the workspace open until the main agent declares Done and everything has settled.
|
|
123
131
|
- Answers go out only when you click Send. Follow-up never answers for you.
|
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
|
-
|
|
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
|
|
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);
|