paseo-follow-up 1.3.0-beta.0 → 1.3.0-beta.2

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,8 +4,8 @@ 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
- - 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.
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. Above the list: the status counts, provider limits from Smart Session when it's installed, a search over names, branches, PRs, agents and asks, and a sort menu (Priority, Recent, Oldest waiting, Name, By project).
8
+ - A workspace panel for the open workspace only, with a Home button: PRs merged, what needs you, what needs fixing, a bar with one segment per PR, and what the agent is doing now. "Open full" on Home docks it beside the agent's chat.
9
9
  - A Needs you card with one button per decision: Send repairs, Merge, Resume, Retry, or Answer.
10
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.
11
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.
@@ -17,19 +17,19 @@ Follow-up watches every pull request a Paseo workspace opens until the work is a
17
17
 
18
18
  ## Setup
19
19
 
20
- Requires Paseo 0.14+, Node 22+ and an authenticated `gh` on the daemon host. Merge uses saved Graphite CLI authentication. Open a workspace, open Follow-up from its header or Command-K, and choose Track this workspace. Project settings can track every workspace in a project. The optional Send to Paseo browser companion adds Graphite's Merge when ready switch through its existing pairing; it is not needed for anything else.
20
+ Requires Paseo 0.14+, Node 22+ and an authenticated `gh` on the daemon host. Merge uses saved Graphite CLI authentication. Follow-up follows every workspace that has an agent; Settings has per-project and per-workspace exceptions. The optional Send to Paseo browser companion adds Graphite's Merge when ready switch through its existing pairing; it is not needed for anything else.
21
21
 
22
22
  ## What it does on its own, and what it never does
23
23
 
24
- Auto-send starts on for a workspace tracked for the first time (earlier tracked workspaces keep their setting). With it off, each repair batch waits for your click; with it on, Follow-up sends batches once the main agent is idle, after fresh permission and Session Guard checks. Follow-up never merges, turns on merge when ready, requests reviewers or archives on its own, and Merge always re-checks the stack and asks again. Done needs the main agent's declaration, settled PRs and tasks, and no open questions. Answers are sent only when you click Send; an answer the agent can't take yet waits and goes out on the next check, and one Paseo didn't confirm waits for you to Resend. An optional recovery agent can rebuild missed PRs and tasks from a workspace's history; it runs only when you start it and never edits code. Deployment checks and reviewer shortcuts exist only for specifically configured repositories; elsewhere deployment stays unknown.
24
+ Auto-send ("Send repairs automatically") is on by default for every followed workspace; 1.3.0-beta.2 turned it on once for existing ones after backing up the state file. With it off, each repair batch waits for your click; with it on, Follow-up sends batches once the main agent is idle, after fresh permission and Session Guard checks. Follow-up never merges, turns on merge when ready, requests reviewers or archives on its own, and Merge always re-checks the stack and asks again. Done needs the main agent's declaration, settled PRs and tasks, and no open questions. Answers are sent only when you click Send; an answer the agent can't take yet waits and goes out on the next check, and one Paseo didn't confirm waits for you to Resend. An optional recovery agent can rebuild missed PRs and tasks from a workspace's history; it runs only when you start it and never edits code. Deployment checks and reviewer shortcuts exist only for specifically configured repositories; elsewhere deployment stays unknown.
25
25
 
26
26
  ### What agents can do for you
27
27
 
28
- Every root agent Paseo launches (tracked or not) gets Follow-up's action tools: `list_prs`, `add_reviewers`, `merge`, `enable_merge_when_ready`, `disable_merge_when_ready`, `send_repairs`, `set_auto_send`, `clear_hold`, `retry_batch`, `track`, `inbox_list`, `sign_off`, `snooze` and `set_finish_flags`. Each runs the server path its button runs, with the same guards (tracked workspace, hold, PR eligibility, merge blockers), across every tracked workspace, so an orchestrating agent can act "on all" when you ask it to. Tools that act on a list, and merge, return a dry run and run only with `confirm: true` and that plan's id; merge is marked destructive and the list tools read-only. Inbox tools act on other agents only: an agent can't sign itself off. Recovery agents, contributors and subagents never get them, and the daemon re-checks the caller on each call. Each agent action is kept in the inbox's delivery ledger as `by: "agent:<id>"`; it never counts as you writing to an agent. A sign-off is still a sign-off made on your behalf, so it clears the flag. There is no tool to untrack or archive a workspace, or to put one on hold, because Follow-up has no button for those either.
28
+ Every root agent Paseo launches (tracked or not) gets Follow-up's action tools: `list_prs`, `add_reviewers`, `merge`, `enable_merge_when_ready`, `disable_merge_when_ready`, `send_repairs`, `set_auto_send`, `clear_hold`, `retry_batch`, `track`, `inbox_list`, `sign_off`, `snooze`, `set_finish_flags` and `set_priorities` (external priorities for Home's Priority sort, kept in `plugin-data/follow-through/priorities.json`). Each runs the server path its button runs, with the same guards (tracked workspace, hold, PR eligibility, merge blockers), across every tracked workspace, so an orchestrating agent can act "on all" when you ask it to. Tools that act on a list, and merge, return a dry run and run only with `confirm: true` and that plan's id; merge is marked destructive and the list tools read-only. Inbox tools act on other agents only: an agent can't sign itself off. Recovery agents, contributors and subagents never get them, and the daemon re-checks the caller on each call. Each agent action is kept in the inbox's delivery ledger as `by: "agent:<id>"`; it never counts as you writing to an agent. A sign-off is still a sign-off made on your behalf, so it clears the flag. There is no tool to untrack or archive a workspace, or to put one on hold, because Follow-up has no button for those either.
29
29
 
30
30
  ## Data and access
31
31
 
32
- This is a trusted, unsandboxed plugin. It reads workspace metadata, saved agent history and configuration, GitHub status and existing CLI authentication, and keeps its state in `plugin-data/follow-through` on the daemon host. It registers scoped MCP tools and reversible provider hooks for tracked sessions and, while the Needs-you inbox is on, the `needs_you` tool and its Stop-hook nudge for root agents (see below). To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
32
+ This is a trusted, unsandboxed plugin. It reads workspace metadata, saved agent history and configuration, GitHub status and existing CLI authentication, and keeps its state in `plugin-data/follow-through` on the daemon host. It registers scoped MCP tools and reversible provider hooks for tracked sessions and, while the Needs-you inbox is on, the `needs_you` tool and its Stop-hook nudge for root agents (see below). To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. For the provider meters it calls the Smart Session plugin's `smart-session.subscriptions` and `smart-session.budget` RPCs at most once a minute, or reads the newest rows of its `plugin-data/smart-session/accounts-YYYY-MM.jsonl`; it never asks ZeroSub to refresh or switch. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
33
33
 
34
34
  The Needs-you inbox (beta) keeps one flag per root agent in `plugin-data/follow-through/inbox.json`. When a turn starts it reads the newest user message from the agent's recent timeline to tell your messages from automation, and it reads, never writes, Defer's `plugin-data/defer/queue.json`, Smart Session's `plugin-data/smart-session/compactions.json`, Session Guard's `plugin-data/session-guard/state.json` and Paseo's schedule records. An unknown or unreadable format counts as your message, which clears a flag rather than adding one. Signing off also clears Paseo's own unread dot for that agent. The first-run import and the restart check read only Paseo's agent list: an agent's last activity is its unread time or your last message to it, never Paseo's `updatedAt` (which the daemon rewrites in bulk), and they don't fetch timelines, which would load every agent. Closed and archived agents are never imported. Turning the inbox off stops all of this.
35
35
 
package/README.md CHANGED
@@ -24,9 +24,9 @@ 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: 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.
27
+ Follow-up follows every workspace as soon as it has an agent; there is nothing to turn on. Open a workspace and click the Follow-up icon in its header (or press ⌘K / Ctrl+K and choose **Follow-up: open for this workspace**) to see that workspace's panel. Its **Home** button opens Home, the list of every workspace. To leave a workspace or a project out, open the settings (**Follow-up: settings** in ⌘K): **Following** has the global switches, **Workspace exceptions** the per-workspace ones.
28
28
 
29
- The panel reads top to bottom:
29
+ The panel shows this workspace only, top to bottom: a **Home** button, the main agent's open ask (its brief, with the decision options and **Sign off**), then:
30
30
 
31
31
  - **Status.** How many PRs have merged, how many things need you, how many PRs need fixing, and a bar with one segment per PR in merge order. Below it, what the agent is doing right now.
32
32
  - **Needs you.** One row per decision, each with its button: answer the agent's question, send repairs to the main agent, merge an approved PR, resume after you stopped the agent, or retry a batch that didn't finish.
@@ -36,7 +36,7 @@ The panel reads top to bottom:
36
36
 
37
37
  The **Spec** chip in the header opens the page that sums up the workspace, usually the Claude artifact the main agent keeps up to date. The agent registers it with a tool call, Follow-up also picks up artifacts the workspace's sessions publish, and you can set, change or remove it under **Spec, agent and recovery**. Your link wins over the agent's, which wins over detection.
38
38
 
39
- ![Four Follow-up panels side by side: a workspace that needs you, one where the agent is fixing two repairs, one that has shipped, and one that is not tracked yet](docs/screenshots/follow-up-states.png)
39
+ ![Four Follow-up panels side by side: a workspace that needs you, one where the agent is fixing two repairs and one that has shipped](docs/screenshots/follow-up-states.png)
40
40
 
41
41
  ### Questions
42
42
 
@@ -64,7 +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
- On a phone the navigator opens as a tab, and the PR sheet and pill popover open as bottom sheets.
67
+ On a phone the workspace panel opens as a tab, and the PR sheet and pill popover open as bottom sheets.
68
68
 
69
69
  ![Follow-up at phone width: the panel, a PR sheet and the pill popover](docs/screenshots/follow-up-mobile.png)
70
70
 
@@ -101,15 +101,22 @@ The Follow-up sidebar row (and ⌘K → **Follow-up: Home**) opens **Home**: eve
101
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
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
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.
104
+ - **Open full** opens the agent's chat and docks that workspace's own panel in its explorer column (its PR stack, tasks, questions, repairs, activity and **Details**). Its **Home** button goes back. On a phone, Home is the list and a row opens a full-height detail.
105
+
106
+ #### Beta 2: search, sort, status at a glance
107
+
108
+ - **Status strip**, pinned above the list: counts for **Needs you · Fixing · Running · Waiting · Quiet** (plus Later and "to archive" when there are any). A count shows only that group; press it again or **Show all** to go back. Zero counts stay, muted. The sidebar badge and the brief headline use the same counts.
109
+ - **Provider limits** under it, from the [Smart Session](https://www.npmjs.com/package/paseo-smart-session) plugin: one line per provider, such as "Claude 5h 62% · 7d 41% · resets 14:10". Amber from 80%, red at the limit; tap a line to see each ZeroSub account. The age shows when the reading is older than 15 minutes. Without Smart Session the line isn't there.
110
+ - **Search** ("Search workspaces, branches, PRs"; `/` or ⌘K → **Follow-up: Search** focuses it) matches workspace names, branches, PR numbers ("#1402" or "1402") and titles, agent and subagent titles, the project, Linear keys such as HER-1312, and the current ask. Words can come in any order, prefixes count and one typo is forgiven. Results are one ranked list (name above branch and PR above the ask, then recency) with the matched words marked and each row's group as a chip; archived workspaces come last. ↑/↓ move, Enter opens the result on the right, Esc clears.
111
+ - **Sort** (the menu beside search, remembered on this device): **Priority** (default), **Recent**, **Oldest waiting**, **Name** and **By project**. It orders rows inside each group; groups stay in place and search ignores it. Priority means urgency: blocking asks, then decisions only you make, then finished turns; inside those, a big open question first, then the nearest task deadline, then the oldest ask. When an agent has ranked workspaces with `set_priorities` in the last 6 hours, Priority uses those scores inside Needs you (blocking asks still first), each row shows the ranker's one-line reason and source, and the menu says "Ranked 12m ago"; otherwise it says "By urgency". Follow-up never writes a reason itself.
105
112
  ![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
113
 
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**.
114
+ - ⌘K: **Follow-up: Home**, **Follow-up: Search**, **Follow-up: Sign off & next**, **Follow-up: Later (tomorrow 09:00)**, **Follow-up: Next needs-you**, **Follow-up: Done while you were away**.
108
115
 
109
116
  Install the beta:
110
117
 
111
118
  ```sh
112
- paseo plugin update follow-through --version 1.3.0-beta.0
119
+ paseo plugin update follow-through --version 1.3.0-beta.2
113
120
  # or, for a fresh install:
114
121
  paseo plugin install npm:paseo-follow-up@beta
115
122
  ```
@@ -125,7 +132,7 @@ Betas publish to npm's `beta` dist-tag, so `paseo plugin update` without `--vers
125
132
  ## How it works
126
133
 
127
134
  - 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.
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.
135
+ - **Follow every workspace** (on by default since 1.3.0-beta.2): every workspace with an agent is followed, and **Send repairs automatically** (also on by default) means review threads and red CI go to the agent without you. The first start of 1.3.0-beta.2 backs up `state.json` to `state.before-follow-every.json.bak`, then turns following and auto-send on for every workspace Follow-up already knew. Turn auto-send off for one workspace under **Workspace exceptions** 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.
129
136
  - 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.
130
137
  - 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.
131
138
  - Answers go out only when you click Send. Follow-up never answers for you.
@@ -139,14 +146,15 @@ Tell an agent "the PRs look good, assign reviewers on all" and it can do it. Eve
139
146
  | `list_prs` | Reads tracked PRs across all workspaces (draft, checks, threads, reviewers, merge blockers). Filters: `needs_reviewers`, `ready_to_merge`, `workspace`. |
140
147
  | `add_reviewers` | **Add reviewers**, on one PR or many. Repository defaults, or the logins you name. |
141
148
  | `merge`, `enable_merge_when_ready`, `disable_merge_when_ready` | **Merge** and the PR sheet's merge-when-ready switch, with the same plan and blockers. |
142
- | `send_repairs`, `set_auto_send`, `clear_hold`, `retry_batch`, `track` | **Send**, the **Auto-send** switch, **Resume**, **Retry**, **Track this workspace**. |
149
+ | `send_repairs`, `set_auto_send`, `clear_hold`, `retry_batch`, `track` | **Send**, the **Auto-send** switch, **Resume**, **Retry**, following a workspace again after an opt-out. |
150
+ | `set_priorities` | Stores external priorities for Home's Priority sort: per workspace a score 0–100, a one-line reason and sources (Linear, Slack, planning, GitHub, Paseo). Plans first; `confirm: true` with the plan id stores them. Used for 6 hours. |
143
151
  | `inbox_list`, `sign_off`, `snooze`, `set_finish_flags` | The Needs-you list, **Sign off**, **Later** and **Don't flag finishes**, for other agents only. |
144
152
 
145
153
  Tools that act on a list, and merge, return a dry run first and act only when called again with `confirm: true` and that plan's id. Their descriptions tell the agent to use them only when you asked. An agent can't sign itself off. Recovery agents, contributors and subagents don't get these tools, and the daemon checks the caller again on every call.
146
154
 
147
155
  ## Send to Paseo and Graphite
148
156
 
149
- With [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) on the same daemon, its composer picks the agent that registered the PR. With Send to Paseo 2.2+, Follow-up can also toggle Graphite's **Merge when ready** through your existing pairing, while Graphite is open and signed in. Each change is confirmed in Chrome, which can remember the permission per repository. See the [integration and security contract](https://github.com/tomgrin10/send-to-paseo/blob/main/docs/follow-up-integration.md).
157
+ With [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) on the same daemon, its composer picks the agent that registered the PR. With Send to Paseo 2.2+, Follow-up can also toggle Graphite's **Merge when ready** through your existing pairing, while Graphite is open and signed in. Each change is confirmed in Chrome, which can remember the permission per repository. With the next Send to Paseo release, its popover on a PR page also shows which tracked workspaces own the PR, with Follow-up's one line for each (for example "Needs you · Merge ready"), and opens the workspace's main agent in the Paseo app or web app. See the [integration and security contract](https://github.com/tomgrin10/send-to-paseo/blob/main/docs/follow-up-integration.md).
150
158
 
151
159
  ## Data and access
152
160
 
@@ -0,0 +1,257 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
2
+ import { Platform, Pressable, Text, TextInput, View } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/ui";
4
+ import { GROUP_TITLE, homeCounts, type HomeGroupId, type HomeModel } from "../shared/home";
5
+ import { FIELD_LABEL, excerpt, highlightRuns, homeSearchDocs, indexDocs, omniboxResults, workspaceProvider, type OmniboxItem, type OmniboxProvider, type Range } from "../shared/search";
6
+ import { SORT_HINT, SORT_LABEL, SORT_MODES, type SortMode } from "../shared/sort";
7
+ import { priorityNote, PRIORITY_SOURCE_LABEL } from "../shared/priorities";
8
+ import { meters, meterText, type Meter } from "../shared/usage";
9
+ import type { HomeSources } from "./home-data";
10
+ import type { Workstream } from "../shared/home";
11
+ import { Age, PrChipView } from "./home-list";
12
+ import { useMinute } from "./home-data";
13
+ import { withAlpha, type Colors } from "./theme";
14
+ import type { Theme } from "./kit";
15
+
16
+ /**
17
+ * Home's chrome above the list (1.3.0-beta.2): the omnibox, the sort menu, the status strip and the provider
18
+ * meters. It sits outside the scrolling list, so the status is visible without scrolling.
19
+ */
20
+
21
+ // ---------- the omnibox ----------
22
+
23
+ export type Omnibox = {
24
+ query: string;
25
+ setQuery(q: string): void;
26
+ /** null while the query is empty: the list shows its groups. */
27
+ results: OmniboxItem[] | null;
28
+ active: number;
29
+ move(delta: number): void;
30
+ inputRef: React.RefObject<TextInput | null>;
31
+ };
32
+
33
+ /**
34
+ * The search state for one list. The index is rebuilt only when Home's sources change, never per keystroke.
35
+ * `extraProviders` is the seam for the next build's "Ask the Overseer" row, listed after the workspace results.
36
+ */
37
+ export function useOmnibox(model: HomeModel, sources: HomeSources, extraProviders: readonly OmniboxProvider[] = []): Omnibox {
38
+ const [query, setQueryState] = useState("");
39
+ const [active, setActive] = useState(0);
40
+ const inputRef = useRef<TextInput | null>(null);
41
+ const live = useSyncExternalStore(sources.live.subscribe, sources.live.version, sources.live.version);
42
+ const paseo = useSyncExternalStore(sources.paseo.subscribe, sources.paseo.version, sources.paseo.version);
43
+ const extras = useSyncExternalStore(sources.extras.subscribe, sources.extras.version, sources.extras.version);
44
+ const searching = query.trim().length > 0;
45
+ // Built lazily: nothing is indexed until the developer types.
46
+ const index = useMemo(() => searching ? indexDocs(homeSearchDocs({ model, paseo: sources.paseo.all(), tracked: sources.live.all(), archived: sources.live.archived(), agents: sources.extras.agents() })) : null,
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ [searching, model, live, paseo, extras]);
49
+ const providers = useMemo(() => index ? [workspaceProvider(index), ...extraProviders] : [], [index, extraProviders]);
50
+ const results = useMemo(() => searching ? omniboxResults(providers, query) : null, [providers, query, searching]);
51
+ const setQuery = useCallback((q: string) => { setQueryState(q); setActive(0); }, []);
52
+ const move = useCallback((delta: number) => setActive(a => Math.max(0, Math.min((results?.length ?? 1) - 1, a + delta))), [results]);
53
+ // ⌘K "Follow-up: Search".
54
+ const token = useSyncExternalStore(sources.extras.subscribe, sources.extras.focusToken, sources.extras.focusToken);
55
+ const first = useRef(token);
56
+ useEffect(() => { if (token !== first.current) inputRef.current?.focus(); }, [token]);
57
+ return { query, setQuery, results, active: Math.min(active, Math.max(0, (results?.length ?? 1) - 1)), move, inputRef };
58
+ }
59
+
60
+ type DomKey = { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; target: unknown; preventDefault(): void };
61
+ /** `/` focuses the search while this surface is on screen (web and desktop; native has no global keys). */
62
+ export function useSlashToFocus(omnibox: Omnibox, enabled = true) {
63
+ useEffect(() => {
64
+ const doc = (globalThis as { document?: { addEventListener: (t: string, f: (e: DomKey) => void) => void; removeEventListener: (t: string, f: (e: DomKey) => void) => void } }).document;
65
+ if (!enabled || !doc || Platform.OS !== "web") return;
66
+ const onKey = (e: DomKey) => {
67
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
68
+ const target = e.target as { tagName?: string; isContentEditable?: boolean } | null;
69
+ if (target && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName ?? ""))) return;
70
+ e.preventDefault();
71
+ omnibox.inputRef.current?.focus();
72
+ };
73
+ doc.addEventListener("keydown", onKey);
74
+ return () => doc.removeEventListener("keydown", onKey);
75
+ }, [omnibox.inputRef, enabled]);
76
+ }
77
+
78
+ export function SearchField({ theme, omnibox, dense, onEnter }: { theme: Theme; omnibox: Omnibox; dense: boolean; onEnter: (item: OmniboxItem) => void }) {
79
+ const c = theme.colors;
80
+ const [focused, setFocused] = useState(false);
81
+ const { query, setQuery, results, active, move, inputRef } = omnibox;
82
+ return <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6, height: dense ? 30 : 32, paddingHorizontal: 8, borderRadius: 6,
83
+ borderWidth: 1, borderColor: focused ? withAlpha(c.foreground, 0.35) : c.border, backgroundColor: c.surface1 }}>
84
+ <Icon name="Search" size={13} color={c.foregroundMuted} />
85
+ <TextInput ref={inputRef} value={query} onChangeText={setQuery} placeholder="Search workspaces, branches, PRs" placeholderTextColor={c.foregroundMuted}
86
+ accessibilityLabel="Search workspaces, branches, PRs" autoCorrect={false} autoCapitalize="none" spellCheck={false} returnKeyType="go"
87
+ onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
88
+ onSubmitEditing={() => { const item = results?.[active]; if (item) onEnter(item); }}
89
+ onKeyPress={e => {
90
+ const key = (e.nativeEvent as { key?: string }).key;
91
+ if (key === "ArrowDown") { e.preventDefault?.(); move(1); }
92
+ else if (key === "ArrowUp") { e.preventDefault?.(); move(-1); }
93
+ else if (key === "Escape") { if (query) setQuery(""); else inputRef.current?.blur(); }
94
+ }}
95
+ style={{ flex: 1, minWidth: 0, color: c.foreground, fontSize: 13, paddingVertical: 0, ...(Platform.OS === "web" ? { outlineStyle: "none" } as object : {}) }} />
96
+ {!!query && <Pressable accessibilityRole="button" accessibilityLabel="Clear search" hitSlop={8} onPress={() => { setQuery(""); inputRef.current?.focus(); }}>
97
+ <Icon name="X" size={13} color={c.foregroundMuted} />
98
+ </Pressable>}
99
+ {!query && !focused && Platform.OS === "web" && <Text style={{ color: c.foregroundMuted, fontSize: 11, paddingHorizontal: 4, borderRadius: 3, borderWidth: 1, borderColor: c.border }}>/</Text>}
100
+ </View>;
101
+ }
102
+
103
+ // ---------- sort ----------
104
+
105
+ export function SortMenu({ theme, mode, onChange, rankedAt, touch }: { theme: Theme; mode: SortMode; onChange: (m: SortMode) => void; rankedAt: string | null; touch: boolean }) {
106
+ const c = theme.colors;
107
+ const [open, setOpen] = useState(false);
108
+ const now = useMinute();
109
+ return <View style={{ position: "relative", zIndex: 20 }}>
110
+ <Pressable accessibilityRole="button" accessibilityLabel={`Sort: ${SORT_LABEL[mode]}`} aria-expanded={open} accessibilityState={{ expanded: open }} onPress={() => setOpen(o => !o)}
111
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 4, height: touch ? 36 : 30, paddingHorizontal: 8, borderRadius: 6, backgroundColor: open || (state as { hovered?: boolean }).hovered ? c.surface2 : "transparent" })}>
112
+ <Icon name="ArrowUpDown" size={13} color={c.foregroundMuted} />
113
+ <Text numberOfLines={1} style={{ color: c.foreground, fontSize: 12 }}>{SORT_LABEL[mode]}</Text>
114
+ <Icon name="ChevronDown" size={12} color={c.foregroundMuted} />
115
+ </Pressable>
116
+ {open && <View accessibilityRole="menu" style={{ position: "absolute", top: touch ? 40 : 34, right: 0, minWidth: 220, paddingVertical: 4, borderRadius: 8, borderWidth: 1, borderColor: c.border, backgroundColor: c.surface1,
117
+ shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 8 }}>
118
+ {SORT_MODES.map(m => <Pressable key={m} accessibilityRole="menuitem" accessibilityState={{ selected: m === mode }} onPress={() => { onChange(m); setOpen(false); }}
119
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 8, minHeight: touch ? 44 : 34, paddingHorizontal: 10, backgroundColor: (state as { hovered?: boolean }).hovered ? c.surface2 : "transparent" })}>
120
+ <View style={{ width: 14, alignSelf: "flex-start", paddingTop: 2 }}>{m === mode && <Icon name="Check" size={13} color={c.foreground} />}</View>
121
+ <View style={{ flex: 1 }}>
122
+ <Text style={{ color: c.foreground, fontSize: 13, lineHeight: 17 }}>{SORT_LABEL[m]}</Text>
123
+ <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 11, lineHeight: 15 }}>{SORT_HINT[m]}</Text>
124
+ </View>
125
+ </Pressable>)}
126
+ <Text style={{ color: c.foregroundMuted, fontSize: 11, paddingHorizontal: 10, paddingTop: 6, paddingBottom: 4, marginTop: 4, borderTopWidth: 1, borderTopColor: c.border }}>Priority: {priorityNote(rankedAt, now)}</Text>
127
+ </View>}
128
+ </View>;
129
+ }
130
+
131
+ // ---------- status strip ----------
132
+
133
+ const STRIP: { id: HomeGroupId; label: string }[] = [
134
+ { id: "needs", label: "Needs you" }, { id: "fixing", label: "Fixing" }, { id: "running", label: "Running" }, { id: "waiting", label: "Waiting" }, { id: "quiet", label: "Quiet" },
135
+ ];
136
+
137
+ /**
138
+ * Counts for each group, always visible. A count filters the list to its group; the active one shows an × and
139
+ * tapping it (or "All") goes back. Zero counts are muted, never hidden, so the line doesn't jump.
140
+ */
141
+ export function StatusStrip({ theme, model, filter, onFilter, touch, dense }: { theme: Theme; model: HomeModel; filter: HomeGroupId | null; onFilter: (g: HomeGroupId | null) => void; touch: boolean; dense: boolean }) {
142
+ const c = theme.colors;
143
+ const counts = homeCounts(model);
144
+ const chip = (key: string, label: string, n: number | null, selected: boolean, onPress: () => void, tone?: string) => {
145
+ const muted = n === 0;
146
+ const color = selected ? c.foreground : muted ? withAlpha(c.foregroundMuted, 0.6) : tone ?? c.foreground;
147
+ return <Pressable key={key} accessibilityRole="button" accessibilityState={{ selected }} accessibilityLabel={n === null ? label : `${label}, ${n}${selected ? ". Showing only these; press to show all" : ". Show only these"}`} onPress={onPress}
148
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 5, height: touch ? 36 : 30, paddingHorizontal: 8, borderRadius: 15,
149
+ borderWidth: 1, borderColor: selected ? withAlpha(c.foreground, 0.4) : "transparent",
150
+ backgroundColor: selected ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
151
+ {n !== null && <Text style={{ color, fontSize: 13, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{n}</Text>}
152
+ <Text style={{ color: selected ? c.foreground : muted ? withAlpha(c.foregroundMuted, 0.6) : c.foregroundMuted, fontSize: 12 }}>{label}</Text>
153
+ {selected && <Icon name="X" size={11} color={c.foregroundMuted} />}
154
+ </Pressable>;
155
+ };
156
+ return <View accessibilityRole="toolbar" accessibilityLabel="Status" style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: dense ? 0 : 2, rowGap: 2, paddingHorizontal: dense ? 4 : 10 }}>
157
+ {STRIP.map(s => chip(s.id, s.label, counts[s.id], filter === s.id, () => onFilter(filter === s.id ? null : s.id), s.id === "needs" && counts.needs ? c.statusWarning : undefined))}
158
+ {counts.later > 0 && chip("later", "Later", counts.later, filter === "later", () => onFilter(filter === "later" ? null : "later"))}
159
+ {counts.archivable > 0 && <Text style={{ color: c.statusSuccess, fontSize: 12, paddingHorizontal: 6 }}>{counts.archivable} to archive</Text>}
160
+ </View>;
161
+ }
162
+
163
+ // ---------- provider meters ----------
164
+
165
+ const toneColor = (m: Pick<Meter, "tone">, c: Colors) => m.tone === "danger" ? c.statusDanger : m.tone === "warning" ? c.statusWarning : c.foregroundMuted;
166
+
167
+ /** "Claude 5h 62% · 7d 41% · resets 14:10". Tap for each account. Nothing when Smart Session isn't there. */
168
+ export function UsageMeters({ theme, sources, dense }: { theme: Theme; sources: HomeSources; dense: boolean }) {
169
+ const c = theme.colors;
170
+ const usage = useSyncExternalStore(sources.extras.subscribe, sources.extras.usage, sources.extras.usage);
171
+ const now = useMinute();
172
+ const list = useMemo(() => meters(usage, Math.max(now, Date.now())), [usage, now]);
173
+ const [open, setOpen] = useState<string | null>(null);
174
+ if (!list.length) return null;
175
+ const detail = list.find(m => m.family === open);
176
+ return <View style={{ paddingHorizontal: dense ? 8 : 16, gap: 4 }}>
177
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 12, rowGap: 2 }}>
178
+ {list.map(m => <Pressable key={m.family} accessibilityRole="button" accessibilityLabel={`${meterText(m, now)}. ${m.accounts.length > 1 ? `${m.accounts.length} accounts; ` : ""}show detail`}
179
+ aria-expanded={open === m.family} onPress={() => setOpen(o => o === m.family ? null : m.family)}
180
+ style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: 22 }}>
181
+ <Bar pct={Math.max(...m.windows.map(w => w.pct))} color={toneColor(m, c)} c={c} />
182
+ <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 11, fontVariant: ["tabular-nums"] }}>
183
+ {meterText(m, now)}{m.accounts.length > 1 ? ` · ${m.accounts.length} accounts` : ""}
184
+ </Text>
185
+ </Pressable>)}
186
+ </View>
187
+ {detail && <View style={{ gap: 2, paddingLeft: 4, paddingBottom: 2 }}>
188
+ {detail.accounts.map(a => <Text key={a.id} numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 11, fontVariant: ["tabular-nums"] }}>
189
+ {`${a.name}${a.isDefault ? " (default)" : ""}: ${a.windows.map(w => `${w.label} ${w.pct}%`).join(" · ")}`}
190
+ </Text>)}
191
+ </View>}
192
+ </View>;
193
+ }
194
+ function Bar({ pct, color, c }: { pct: number; color: string; c: Colors }) {
195
+ return <View style={{ width: 28, height: 4, borderRadius: 2, backgroundColor: c.border, overflow: "hidden" }}>
196
+ <View style={{ width: `${Math.min(100, pct)}%`, height: 4, backgroundColor: color === c.foregroundMuted ? withAlpha(c.foreground, 0.45) : color }} />
197
+ </View>;
198
+ }
199
+
200
+ // ---------- results ----------
201
+
202
+ export function Highlighted({ text, ranges, color, hit, mark, size, weight, lines = 1 }: { text: string; ranges: Range[]; color: string; hit: string; mark: string; size: number; weight?: "400" | "500" | "600"; lines?: number }) {
203
+ return <Text numberOfLines={lines} style={{ color, fontSize: size, fontWeight: weight ?? "400" }}>
204
+ {highlightRuns(text, ranges).map((r, i) => r.hit ? <Text key={i} style={{ color: hit, fontWeight: "600", backgroundColor: withAlpha(mark, 0.22), borderRadius: 2 }}>{r.text}</Text> : r.text)}
205
+ </Text>;
206
+ }
207
+
208
+ /** The group as a small status chip on a search result. */
209
+ export function GroupChip({ group, c }: { group: HomeGroupId | "archived"; c: Colors }) {
210
+ const label = group === "archived" ? "Archived" : group === "needs" ? "Needs you" : group === "fixing" ? "Fixing" : GROUP_TITLE[group];
211
+ const color = group === "needs" ? c.statusWarning : group === "fixing" || group === "running" ? c.foreground : c.foregroundMuted;
212
+ return <View style={{ paddingHorizontal: 6, height: 18, justifyContent: "center", borderRadius: 9, borderWidth: 1, borderColor: c.border }}>
213
+ <Text numberOfLines={1} style={{ color, fontSize: 10, lineHeight: 13 }}>{label}</Text>
214
+ </View>;
215
+ }
216
+
217
+ export function ResultRow({ item, theme, active, dense, touch, onPress, s: row }: { item: OmniboxItem; theme: Theme; active: boolean; dense: boolean; touch: boolean; onPress: () => void; s?: Workstream }) {
218
+ const c = theme.colors;
219
+ const { hit } = item;
220
+ const via = hit.via ? excerpt(hit.via.text, hit.via.ranges, dense ? 56 : 90) : null;
221
+ const archived = hit.group === "archived";
222
+ return <Pressable accessibilityRole="button" accessibilityState={{ selected: active }} accessibilityLabel={`${hit.title}${hit.via ? `, ${FIELD_LABEL[hit.via.field]}: ${hit.via.text}` : ""}`} onPress={onPress}
223
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 10, minHeight: touch ? 56 : dense ? 40 : 44, paddingHorizontal: dense ? 8 : 16, paddingVertical: 4,
224
+ borderLeftWidth: 2, borderLeftColor: active ? withAlpha(c.foreground, 0.5) : "transparent",
225
+ backgroundColor: active ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
226
+ <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
227
+ <Highlighted text={hit.title} ranges={hit.titleRanges} color={archived ? c.foregroundMuted : c.foreground} hit={c.foreground} mark={c.statusWarning} size={13} weight="500" />
228
+ {!via && row && (row.word || row.line) ? <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12 }}>{[row.word, row.line].filter(Boolean).join(" · ")}</Text> : null}
229
+ {via && <View style={{ flexDirection: "row", gap: 6, alignItems: "baseline" }}>
230
+ <Text style={{ color: c.foregroundMuted, fontSize: 11 }}>{FIELD_LABEL[hit.via!.field]}</Text>
231
+ <View style={{ flex: 1, minWidth: 0 }}><Highlighted text={via.text} ranges={via.ranges} color={c.foregroundMuted} hit={c.foreground} mark={c.statusWarning} size={12} /></View>
232
+ </View>}
233
+ </View>
234
+ {row && !dense && <PrChipView s={row} c={c} />}
235
+ <GroupChip group={hit.group} c={c} />
236
+ {row && <Age iso={row.at} c={c} size={dense ? 11 : 12} />}
237
+ </Pressable>;
238
+ }
239
+
240
+ /** The muted reason and source chip a fresh external priority adds to a row. */
241
+ export function PriorityLine({ reason, source, c, size = 12 }: { reason: string; source: { kind: keyof typeof PRIORITY_SOURCE_LABEL; label: string } | null; c: Colors; size?: number }) {
242
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 6, minWidth: 0 }}>
243
+ {source && <View style={{ paddingHorizontal: 5, height: 16, justifyContent: "center", borderRadius: 3, backgroundColor: c.surface2 }}>
244
+ <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 10, lineHeight: 13 }}>{PRIORITY_SOURCE_LABEL[source.kind]}</Text>
245
+ </View>}
246
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: c.foregroundMuted, fontSize: size, fontStyle: "italic" }}>{reason}</Text>
247
+ </View>;
248
+ }
249
+
250
+ /** The non-scrolling top of a list: search + sort, then status, then meters. */
251
+ export function ListTop({ theme, dense, title, children }: { theme: Theme; dense: boolean; title?: ReactNode; children: ReactNode }) {
252
+ const c = theme.colors;
253
+ return <View style={{ zIndex: 10, gap: 6, paddingTop: dense ? 6 : 8, paddingBottom: 6, borderBottomWidth: 1, borderBottomColor: c.border, backgroundColor: c.surface0 }}>
254
+ {title}
255
+ {children}
256
+ </View>;
257
+ }
@@ -3,9 +3,15 @@ import type { PluginClientContext } from "@getpaseo/plugin/client";
3
3
  import { buildHome, type HomeModel, type PaseoWorkspaceLite } from "../shared/home";
4
4
  import type { InboxStore } from "./inbox-store";
5
5
  import type { LiveStore } from "./live";
6
+ import type { HomeExtras } from "./home-view";
6
7
 
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 });
8
+ type Descriptor = { id: string; name?: string | null; title?: string | null; projectDisplayName?: string | null; projectCustomName?: string | null; activityAt?: string | null; archivingAt?: string | null;
9
+ gitRuntime?: { currentBranch?: string | null } | null; githubRuntime?: { pullRequest?: { number?: number; title?: string } | null } | null };
10
+ const lite = (w: Descriptor): PaseoWorkspaceLite => {
11
+ const pr = w.githubRuntime?.pullRequest;
12
+ return { id: w.id, name: w.title || w.name || "Workspace", project: w.projectCustomName || w.projectDisplayName || null, activityAt: w.activityAt ?? null,
13
+ branch: w.gitRuntime?.currentBranch ?? null, pr: pr?.title ? { number: pr.number ?? null, title: pr.title } : null };
14
+ };
9
15
 
10
16
  /**
11
17
  * Paseo's own workspace list, for the rows Follow-up doesn't track (they land in Quiet). One subscription per
@@ -49,7 +55,7 @@ export function createPaseoWorkspaces(client: PluginClientContext, hooks: { onAd
49
55
  }
50
56
  export type PaseoWorkspaces = ReturnType<typeof createPaseoWorkspaces>;
51
57
 
52
- export type HomeSources = { inbox: InboxStore; live: LiveStore; paseo: PaseoWorkspaces };
58
+ export type HomeSources = { inbox: InboxStore; live: LiveStore; paseo: PaseoWorkspaces; extras: HomeExtras };
53
59
 
54
60
  /** One shared minute clock. Ages subscribe to it themselves, so a tick never re-renders the list rows. */
55
61
  const clock = { now: Date.now(), listeners: new Set<() => void>(), timer: null as ReturnType<typeof setInterval> | null };
@@ -62,12 +68,15 @@ export const useMinute = () => useSyncExternalStore(subscribeClock, () => clock.
62
68
 
63
69
  /** Home's model from the three shared stores. Rebuilt when one of them changes or the minute ticks. */
64
70
  export function useHomeModel(sources: HomeSources): HomeModel {
65
- const { inbox, live, paseo } = sources;
71
+ const { inbox, live, paseo, extras } = sources;
66
72
  const a = useSyncExternalStore(inbox.subscribe, inbox.version, inbox.version);
67
73
  const b = useSyncExternalStore(live.subscribe, live.version, live.version);
68
74
  const c = useSyncExternalStore(paseo.subscribe, paseo.version, paseo.version);
75
+ // Only the sort and priorities rebuild the model; agent titles and usage don't touch it.
76
+ const sort = useSyncExternalStore(extras.subscribe, extras.sort, extras.sort);
77
+ const priorities = useSyncExternalStore(extras.subscribe, extras.priorities, extras.priorities);
69
78
  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]);
79
+ return useMemo(() => buildHome({ rows: inbox.rows(), tracked: live.all(), paseo: paseo.all(), now: Date.now(), sort, priorities }), [a, b, c, now, sort, priorities, inbox, live, paseo]);
71
80
  }
72
81
 
73
82
  /** Rebuilds a value at most every `ms` while mounted (used for the brief pane's source ages). */
@@ -1,7 +1,9 @@
1
- import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
2
  import { FlatList, Pressable, Text, View } from "react-native";
3
3
  import { Icon } from "@getpaseo/plugin/client/ui";
4
- import { quietFolds, type Badge, type HomeGroupId, type HomeModel, type Workstream } from "../shared/home";
4
+ import { GROUP_TITLE, quietFolds, type Badge, type HomeGroupId, type HomeModel, type Workstream } from "../shared/home";
5
+ import type { OmniboxItem } from "../shared/search";
6
+ import { PriorityLine, ResultRow } from "./home-chrome";
5
7
  import { age } from "../shared/inbox";
6
8
  import { headlineColor, mergedColor, runningColor, tint, toneColor, withAlpha, type Colors } from "./theme";
7
9
  import { useMinute } from "./home-data";
@@ -9,7 +11,7 @@ import { Spinner } from "./motion";
9
11
  import type { Theme } from "./kit";
10
12
 
11
13
  /**
12
- * The one list: Home's left side, the docked navigator in the explorer, and (on hosts that have it) the sidebar
14
+ * The one list: Home's left side and (on hosts that have it) the sidebar
13
15
  * view. Virtualised; rows are memoised on their signature and ages tick on their own, so a minute tick or a poll
14
16
  * that changes one row redraws only that row.
15
17
  */
@@ -22,7 +24,9 @@ type Entry =
22
24
  | { type: "header"; key: string; group: HomeGroupId; title: string; count: number; open: boolean }
23
25
  | { type: "row"; key: string; s: Workstream }
24
26
  | { type: "fold"; key: string; project: string; count: number; archivable: number; open: boolean }
25
- | { type: "more"; key: string; project: string; left: number };
27
+ | { type: "more"; key: string; project: string; left: number }
28
+ | { type: "result"; key: string; item: OmniboxItem; index: number }
29
+ | { type: "label"; key: string; text: string };
26
30
 
27
31
  export type HomeListProps = {
28
32
  model: HomeModel;
@@ -32,15 +36,23 @@ export type HomeListProps = {
32
36
  touch: boolean;
33
37
  selectedKey: string | null;
34
38
  onSelect: (key: string) => void;
35
- /** The brief row's one-liner. Absent: no brief row (the navigator). */
39
+ /** The brief row's one-liner. Absent: no brief row (the sidebar view). */
36
40
  briefLine?: string | null;
37
- /** Content shown under the selected row (the navigator's expanded row). */
41
+ /** Content shown under the selected row (an expanded row). */
38
42
  renderExpanded?: (s: Workstream) => ReactNode;
39
43
  header?: ReactNode;
40
44
  footer?: ReactNode;
45
+ /** Chrome above the list that never scrolls away: search, sort, status, limits. */
46
+ top?: ReactNode;
47
+ /** While searching: one flat ranked list instead of the groups. */
48
+ results?: OmniboxItem[] | null;
49
+ activeResult?: number;
50
+ onResult?: (item: OmniboxItem) => void;
51
+ /** The status strip's filter: only this group, expanded. */
52
+ filter?: HomeGroupId | null;
41
53
  };
42
54
 
43
- export function HomeList({ model, theme, dense = false, touch, selectedKey, onSelect, briefLine, renderExpanded, header, footer }: HomeListProps) {
55
+ export function HomeList({ model, theme, dense = false, touch, selectedKey, onSelect, briefLine, renderExpanded, header, footer, top, results, activeResult = 0, onResult, filter = null }: HomeListProps) {
44
56
  const c = theme.colors;
45
57
  const [closed, setClosed] = useState<Set<string>>(new Set(["quiet"]));
46
58
  const [openProjects, setOpenProjects] = useState<Set<string>>(new Set());
@@ -50,9 +62,20 @@ export function HomeList({ model, theme, dense = false, touch, selectedKey, onSe
50
62
 
51
63
  const data = useMemo(() => {
52
64
  const out: Entry[] = [];
53
- if (briefLine !== undefined) out.push({ type: "brief", key: BRIEF_KEY });
65
+ if (results) {
66
+ // Search ignores the sort and the groups: ranked by match, archived ones last under their own label.
67
+ let archivedShown = false;
68
+ results.forEach((item, index) => {
69
+ if (item.hit.group === "archived" && !archivedShown) { archivedShown = true; out.push({ type: "label", key: "l:archived", text: "Archived" }); }
70
+ out.push({ type: "result", key: `r:${item.provider}:${item.key}`, item, index });
71
+ });
72
+ if (!results.length) out.push({ type: "label", key: "l:none", text: "No workspace, branch or PR matches." });
73
+ return out;
74
+ }
75
+ if (briefLine !== undefined && !filter) out.push({ type: "brief", key: BRIEF_KEY });
54
76
  for (const g of model.groups) {
55
- const open = !closed.has(g.id);
77
+ if (filter && g.id !== filter) continue;
78
+ const open = !!filter || !closed.has(g.id);
56
79
  out.push({ type: "header", key: `h:${g.id}`, group: g.id, title: g.title, count: g.items.length, open });
57
80
  if (!open) continue;
58
81
  if (g.id !== "quiet") { for (const s of g.items) out.push({ type: "row", key: s.key, s }); continue; }
@@ -66,10 +89,15 @@ export function HomeList({ model, theme, dense = false, touch, selectedKey, onSe
66
89
  }
67
90
  }
68
91
  return out;
69
- }, [model, closed, openProjects, pages, briefLine]);
92
+ if (filter && !out.length) out.push({ type: "label", key: "l:empty", text: `Nothing in ${GROUP_TITLE[filter as HomeGroupId]} right now.` });
93
+ return out;
94
+ }, [model, closed, openProjects, pages, briefLine, results, filter]);
70
95
 
71
96
  const render = useCallback(({ item }: { item: Entry }) => {
72
97
  switch (item.type) {
98
+ case "label": return <Text accessibilityRole={item.key === "l:archived" ? "header" : undefined} style={{ color: c.foregroundMuted, fontSize: item.key === "l:archived" ? 11 : 13, fontWeight: item.key === "l:archived" ? "600" : "400",
99
+ letterSpacing: item.key === "l:archived" ? 0.6 : 0, textTransform: item.key === "l:archived" ? "uppercase" : "none", paddingHorizontal: dense ? 8 : 16, paddingTop: 12, paddingBottom: 4 }}>{item.text}</Text>;
100
+ case "result": return <ResultRow item={item.item} s={model.byKey.get(item.item.key)} theme={theme} active={item.index === activeResult} dense={dense} touch={touch} onPress={() => onResult?.(item.item)} />;
73
101
  case "brief": return <BriefRow theme={theme} line={briefLine ?? ""} selected={selectedKey === null || selectedKey === BRIEF_KEY} touch={touch} onSelect={onSelect} />;
74
102
  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
103
  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)}
@@ -91,12 +119,20 @@ export function HomeList({ model, theme, dense = false, touch, selectedKey, onSe
91
119
  </View>;
92
120
  }
93
121
  }
94
- }, [theme, c, dense, touch, selectedKey, onSelect, briefLine, renderExpanded, toggle, toggleProject]);
122
+ }, [theme, c, dense, touch, selectedKey, onSelect, briefLine, renderExpanded, toggle, toggleProject, activeResult, onResult, model]);
95
123
 
96
- return <FlatList data={data} keyExtractor={e => e.key} renderItem={render} extraData={selectedKey}
124
+ const listRef = useRef<FlatList<Entry>>(null);
125
+ // Keep the keyboard-active result in view.
126
+ useEffect(() => {
127
+ if (!results?.length) return;
128
+ const at = data.findIndex(e => e.type === "result" && e.index === activeResult);
129
+ if (at >= 0) listRef.current?.scrollToIndex({ index: at, viewPosition: 0.5, animated: false });
130
+ }, [activeResult, results, data]);
131
+ const list = <FlatList ref={listRef} data={data} keyExtractor={e => e.key} renderItem={render} extraData={`${selectedKey}:${activeResult}`} onScrollToIndexFailed={() => undefined}
97
132
  initialNumToRender={30} maxToRenderPerBatch={30} windowSize={9} removeClippedSubviews={false}
98
133
  ListHeaderComponent={header ? <>{header}</> : null} ListFooterComponent={footer ? <>{footer}</> : <View style={{ height: 32 }} />}
99
- style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} />;
134
+ style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} keyboardShouldPersistTaps="handled" />;
135
+ return top ? <View style={{ flex: 1, minHeight: 0 }}>{top}{list}</View> : list;
100
136
  }
101
137
 
102
138
  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 }) {
@@ -169,17 +205,18 @@ const Row = memo(function Row({ s, theme, dense, touch, selected, onSelect }: Ro
169
205
  {(!!s.word || !!s.line) && <Text numberOfLines={2} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>
170
206
  {s.word && s.group === "needs" ? <Text style={{ color, fontWeight: "600" }}>{s.word}</Text> : null}{s.word && s.group === "needs" && s.line ? " · " : ""}{line}
171
207
  </Text>}
208
+ {s.priority && <PriorityLine reason={s.priority.reason} source={s.priority.source} c={c} size={11} />}
172
209
  {touch && (s.pr || s.badges.length > 0) && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 4 }}>
173
210
  {s.badges.map(b => <BadgeView key={b.text} b={b} c={c} />)}<PrChipView s={s} c={c} />
174
211
  </View>}
175
- </View> : <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 10 }}>
212
+ </View> : <View style={{ flex: 1, minWidth: 0, gap: 1, paddingVertical: s.priority ? 4 : 0 }}><View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
176
213
  <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
214
  <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: c.foregroundMuted, fontSize: 12 }}>
178
215
  {s.word && s.group === "needs" ? <Text style={{ color }}>{s.word}</Text> : null}{s.word && s.group === "needs" && s.line ? " · " : ""}{line}
179
216
  </Text>
180
217
  {s.badges.map(b => <BadgeView key={b.text} b={b} c={c} />)}
181
218
  <PrChipView s={s} c={c} />
182
- </View>}
219
+ </View>{s.priority && <View style={{ paddingRight: 40 }}><PriorityLine reason={s.priority.reason} source={s.priority.source} c={c} /></View>}</View>}
183
220
  <Age iso={s.at} c={c} size={dense ? 11 : 12} />
184
221
  </Pressable>;
185
222
  }, (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);