paseo-follow-up 1.2.0-beta.0 → 1.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -20,7 +20,7 @@ Requires Paseo 0.14+, Node 22+ and an authenticated `gh` on the daemon host. Mer
20
20
 
21
21
  ## What it does on its own, and what it never does
22
22
 
23
- Auto-send starts off for every workspace. Until you turn it on, 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.
23
+ 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
24
 
25
25
  ## Data and access
26
26
 
package/README.md CHANGED
@@ -74,15 +74,16 @@ On a phone the panel opens as a tab, and the PR sheet and pill popover open as b
74
74
 
75
75
  ## Needs-you inbox (beta)
76
76
 
77
- The 1.1 beta adds an inbox for your agents. An agent lands in **Needs you** when its turn ends, when it asks a permission or question, when it errors, when Session Guard pauses it and needs a decision, or when Follow-up sees its PR's checks fail, new review threads, or the PR merge. It stays there until you **sign it off** or **write to it yourself**, from any device. Opening it, reading it and Paseo marking it read change nothing.
77
+ The 1.1 beta adds an inbox for your agents. An agent lands in **Needs you** when its turn ends, when it asks a permission or question, when it errors, when Session Guard pauses it and needs a decision, when one of its PRs has nobody asked to review it, or when the PR merges. Red CI and review threads on a tracked workspace are the agent's work: with auto-send on (the default for newly tracked workspaces) Follow-up sends the repair to the agent and nothing flags; they flag only when you have to act (auto-send off, a hold, or a stuck repair). A turn that ends with `needs_you` kind=done ("Nothing needed") doesn't flag and leaves one muted line in the chat, and while Paseo still shows an agent running, a finished turn adds nothing. It stays there until you **sign it off** or **write to it yourself**, from any device. Opening it, reading it and Paseo marking it read change nothing.
78
78
 
79
- - **Sign off**: the ✓ on a row, the pill above the composer (it shows the reason and how long the agent has waited; **More** has *Sign off & next*, *Later…*, *Mute this turn* and *Don't flag finishes*), `/ok` in the composer (`/ok next` opens the next one), or ⌘K → `ok` → **Sign off & next**. Every sign-off can be undone for 24 hours.
80
- - **Needs you N** in Paseo's sidebar header opens the inbox on Follow-up's screen. It shows only while something needs you, unless you turn on *Show the sidebar item at zero*.
79
+ - **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
+ - **Sign off** in one step: the ✓ on a row, `/ok` in the composer (`/ok next` opens the next one), or ⌘K → **Follow-up: sign off & next**. Every sign-off can be undone for 24 hours.
81
+ - **One sidebar item, Follow-up**, badged with how many agents need you (hidden at zero). It opens the inbox and your tracked workspaces.
81
82
  - **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.
82
83
  - **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.
83
84
  - **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.
84
85
  - On Paseo builds with sidebar views (Paseo `main` after 2026-11-07, expected in the next release), **Settings > Sidebar > Sidebar view > Follow-up: Needs you** puts the inbox in the sidebar, with your workspace list folded below it.
85
- - Turn the whole inbox off in **Follow-up: display settings** (⌘K). Off means nothing is flagged and no pill or sidebar item is shown.
86
+ - 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.
86
87
 
87
88
  ### What you need to do, front and center (1.2 beta)
88
89
 
@@ -100,7 +101,7 @@ The content comes from a new `needs_you` tool that every root agent gets while t
100
101
  Install the beta:
101
102
 
102
103
  ```sh
103
- paseo plugin update follow-through --version 1.2.0-beta.0
104
+ paseo plugin update follow-through --version 1.2.0-beta.1
104
105
  # or, for a fresh install:
105
106
  paseo plugin install npm:paseo-follow-up@beta
106
107
  ```
@@ -116,7 +117,7 @@ Betas publish to npm's `beta` dist-tag, so `paseo plugin update` without `--vers
116
117
  ## How it works
117
118
 
118
119
  - 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.
119
- - **Auto-send** is off for every newly tracked workspace, so each repair batch waits for your click. Turn it on per workspace and Follow-up sends batches itself once the agent is idle, after fresh permission and Session Guard checks.
120
+ - **Auto-send** is on for every workspace tracked for the first time (1.2.0-beta.1), 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.
120
121
  - 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.
121
122
  - 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.
122
123
  - Answers go out only when you click Send. Follow-up never answers for you.
package/client/brief.tsx CHANGED
@@ -3,8 +3,11 @@ import { Platform, Pressable, Text, View, useWindowDimensions } from "react-nati
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { openExternalUrl, useRpc, type PluginClientContext, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
5
5
  import { Icon, copyText } from "@getpaseo/plugin/client/ui";
6
- import { BRIEF_KIND, BRIEF_LABEL, BRIEF_VERSION, briefSchema, collapsedLine, extrasFit, isTight, type Brief, type BriefDecision, type BriefMove } from "../shared/brief";
6
+ import { BRIEF_KIND, BRIEF_VERSION, briefSchema, collapsedLine, extrasFit, nothingNeededLine, isTight, type Brief, type BriefDecision, type BriefMove } from "../shared/brief";
7
7
  import { age } from "../shared/inbox";
8
+ import { briefKey, HEADLINE_WORD } from "../shared/headline";
9
+ /** The card's kind word comes from the same table as the pill and the Needs-you row. */
10
+ const word = (kind: Brief["kind"]) => HEADLINE_WORD[briefKey(kind)];
8
11
  import { briefActionRpc, browserStatusRpc } from "../shared/contracts";
9
12
  import { errorText } from "../shared/errors";
10
13
  import type { Workspace } from "../shared/model";
@@ -27,8 +30,8 @@ export type BriefDeps = {
27
30
 
28
31
  const mono = () => (Platform.OS === "ios" ? "Menlo" : "ui-monospace, SFMono-Regular, Menlo, monospace");
29
32
  /** Kinds the developer has to act on wear the attention tone; a plain finish and the fallback stay neutral. */
30
- const ACTS: Brief["kind"][] = ["decision", "action", "review", "permission", "paused", "error"];
31
- const ICON: Record<Brief["kind"], string> = { decision: "MessageCircleQuestion", action: "Hand", review: "GitPullRequest", permission: "ShieldQuestion", paused: "CirclePause", error: "CircleX", done: "CircleCheck", unknown: "CircleDashed" };
33
+ const ACTS: Brief["kind"][] = ["decision", "action", "review", "reviewers", "permission", "paused", "error"];
34
+ const ICON: Record<Brief["kind"], string> = { decision: "MessageCircleQuestion", action: "Hand", review: "GitPullRequest", reviewers: "UserPlus", permission: "ShieldQuestion", paused: "CirclePause", error: "CircleX", done: "CircleCheck", unknown: "CircleDashed" };
32
35
  const kindTone = (kind: Brief["kind"], c: Colors) => kind === "error" ? c.statusDanger : kind === "review" || kind === "done" ? c.statusSuccess : kind === "unknown" ? c.foregroundMuted : c.statusWarning;
33
36
  const factTone = (tone: string, c: Colors) => tone === "ok" ? c.statusSuccess : tone === "warn" ? c.statusWarning : tone === "bad" ? c.statusDanger : c.foregroundMuted;
34
37
  const clock = (iso: string | null) => { if (!iso) return ""; const d = new Date(iso); return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }) : ""; };
@@ -103,7 +106,7 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
103
106
  const [error, setError] = useState<string | null>(null);
104
107
  const [open, setOpen] = useState<Record<string, boolean>>({});
105
108
  const [details, setDetails] = useState(false);
106
- const [sheet, setSheet] = useState<string | null>(null);
109
+ const [sheet, setSheet] = useState<{ key: string; intent: "merge" | "reviewers" } | null>(null);
107
110
  const workspace = b.workspaceId ? deps.workspace(b.workspaceId) : undefined;
108
111
  const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000, enabled: !!sheet });
109
112
  if (b.state !== "open") return <Collapsed b={b} c={c} now={now} />;
@@ -118,7 +121,7 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
118
121
  try { await work(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
119
122
  };
120
123
  const press = (m: BriefMove) => {
121
- if (m.kind === "merge" && m.prKey) { if (workspace?.prs.some(p => p.key === m.prKey)) setSheet(m.prKey); else if (b.workspaceId) deps.openPanel(b.workspaceId); }
124
+ if ((m.kind === "merge" || m.kind === "add_reviewers") && m.prKey) { if (workspace?.prs.some(p => p.key === m.prKey)) setSheet({ key: m.prKey, intent: m.kind === "merge" ? "merge" : "reviewers" }); else if (b.workspaceId) deps.openPanel(b.workspaceId); }
122
125
  else if (m.kind === "open_pr" && m.url) void openExternalUrl(m.url).catch(() => undefined);
123
126
  else if (m.kind === "sign_off_next") { deps.signOff(agentId); deps.openNext(agentId); }
124
127
  else if (m.kind === "retry") void run("retry", () => act({ agentId, briefId: b.id, move: "retry" }));
@@ -126,7 +129,15 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
126
129
  else if (m.kind === "open_subagent" && m.agentId) deps.openAgent(m.agentId);
127
130
  };
128
131
  // A merge runs Follow-up's own merge flow, so it needs the tracked workspace; without one it isn't offered.
129
- const moves = b.moves.filter(m => m.kind !== "merge" || !!b.workspaceId);
132
+ const moves = b.moves.filter(m => (m.kind !== "merge" && m.kind !== "add_reviewers") || !!b.workspaceId);
133
+
134
+ // F7: "Nothing needed" is one muted line, no buttons. Paseo's own running and idle state says the rest.
135
+ if (b.kind === "done" && b.source === "agent" && !b.flagged) return <View accessibilityRole="summary" accessibilityLabel={nothingNeededLine(b.ask)}
136
+ style={{ marginVertical: 6, flexDirection: "row", alignItems: "center", gap: 8, minHeight: 28, paddingHorizontal: 4 }}>
137
+ <Icon name="Check" size={13} color={c.foregroundMuted} />
138
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: c.foregroundMuted, fontSize: 13, lineHeight: 18 }}>{nothingNeededLine(b.ask)}</Text>
139
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
140
+ </View>;
130
141
 
131
142
  if (b.slim) {
132
143
  // One line. Permission: Paseo's own prompt below is the control (F9). Fallback: the paragraph is right above.
@@ -144,14 +155,14 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
144
155
  const extra = !!b.context || b.facts.length > 0 || b.links.length > 0;
145
156
  const showExtra = !tight || details || extrasFit(b, height);
146
157
  const pad = compact ? 14 : 16;
147
- return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you" : BRIEF_LABEL[b.kind]}: ${b.ask}`}
158
+ return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you" : word(b.kind)}: ${b.ask}`}
148
159
  style={{ marginVertical: 10, borderRadius: 12, borderWidth: 1, borderColor: border, backgroundColor: c.surface1, overflow: "hidden" }}>
149
160
  <View style={{ paddingHorizontal: pad, paddingTop: compact ? 12 : 14, paddingBottom: 14, gap: tight ? 10 : 12 }}>
150
161
  <View style={{ gap: 4 }}>
151
162
  <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
152
163
  <Icon name={ICON[b.kind]} size={14} color={tone} />
153
- <Text style={{ color: acts ? attention : tone, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{acts ? "Needs you" : BRIEF_LABEL[b.kind]}</Text>
154
- {acts && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{BRIEF_LABEL[b.kind]}</Text>}
164
+ <Text style={{ color: acts ? attention : tone, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{acts ? "Needs you" : word(b.kind)}</Text>
165
+ {acts && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{word(b.kind)}</Text>}
155
166
  <View style={{ flex: 1 }} />
156
167
  <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
157
168
  </View>
@@ -209,11 +220,11 @@ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimeline
209
220
  <Icon name="Info" size={12} color={c.foregroundMuted} />
210
221
  <Text style={{ flex: 1, color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{b.sourceLine}</Text>
211
222
  </View>
212
- {workspace && sheet && <PrSheet workspace={workspace} prKey={sheet} intent="merge" theme={theme} compact={compact} browserReady={browser.data?.ready ?? false} onClose={() => setSheet(null)} />}
223
+ {workspace && sheet && <PrSheet workspace={workspace} prKey={sheet.key} intent={sheet.intent} theme={theme} compact={compact} browserReady={browser.data?.ready ?? false} onClose={() => setSheet(null)} />}
213
224
  </View>;
214
225
  }
215
226
 
216
- const MOVE_ICON: Record<BriefMove["kind"], string> = { merge: "GitMerge", open_pr: "ExternalLink", sign_off_next: "ArrowRight", retry: "RotateCcw", copy_error: "Copy", open_subagent: "SquareArrowOutUpRight" };
227
+ const MOVE_ICON: Record<BriefMove["kind"], string> = { merge: "GitMerge", open_pr: "ExternalLink", sign_off_next: "ArrowRight", retry: "RotateCcw", copy_error: "Copy", open_subagent: "SquareArrowOutUpRight", add_reviewers: "UserPlus" };
217
228
 
218
229
  export function contributeBrief(client: PluginClientContext, deps: BriefDeps) {
219
230
  const Component = (props: PluginTimelineItemProps<Brief>) => <BriefCard {...props} deps={deps} />;
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
4
4
  import { inboxListRpc } from "../shared/contracts";
5
5
  import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
6
6
  import { useEffect } from "react";
7
- import { ActivityIndicator, ScrollView, Text, View } from "react-native";
7
+ import { ActivityIndicator, Text, View } from "react-native";
8
8
  import { displayPreferences, displayRpc } from "../shared/display";
9
9
  import { sidebarVisibility } from "./sidebar-visibility";
10
10
 
@@ -21,7 +21,8 @@ export function briefShareText(stats: { days: number; total: number; tool: numbe
21
21
  return `Last ${stats.days} days: agents stated what they need on ${stats.tool} of ${stats.total} flagged turns (${pct}%).${rest ? ` ${rest[0].toUpperCase()}${rest.slice(1)}.` : ""}`;
22
22
  }
23
23
 
24
- function DisplaySettings({ theme }: PluginSurfaceProps) {
24
+ /** "Sidebar & pills" and "Needs-you inbox", rendered inside the one Follow-up settings screen. */
25
+ export function DisplaySections({ theme }: Pick<PluginSurfaceProps, "theme">) {
25
26
  const settings = useSettings(displayPreferences);
26
27
  const list = useRpc(inboxListRpc);
27
28
  const share = useQuery({ queryKey: ["follow-through", "brief-share"], queryFn: () => list({}), refetchInterval: 60_000 });
@@ -45,25 +46,24 @@ function DisplaySettings({ theme }: PluginSurfaceProps) {
45
46
 
46
47
  const save = (patch: Partial<Display>) => void settings.save({ ...settings.values, ...patch }, settings.revision);
47
48
  return (
48
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
49
- <SettingsSection title="Where Follow-up appears" info="Every entry point opens the same panel. Command-K (Ctrl-K on Windows and Linux) works whatever you hide here.">
49
+ <>
50
+ <SettingsSection title="Sidebar & pills" info="Where Follow-up shows itself. Command-K (Ctrl-K on Windows and Linux) works whatever you hide here.">
50
51
  <SettingsCard>
51
- <SettingsSwitch label="Sidebar row" hint="Opens all tracked workspaces, with a count when some need you." value={settings.values.showSidebar} disabled={settings.saving} onValueChange={(showSidebar) => save({ showSidebar })} />
52
+ <SettingsSwitch label="Sidebar item" hint="Opens Follow-up with Needs you first. Its badge counts the agents that need you." value={settings.values.showSidebar} disabled={settings.saving} onValueChange={(showSidebar) => save({ showSidebar })} />
52
53
  <SettingsSwitch label="Workspace header button" hint="An icon in each workspace header. A dot appears when that workspace needs you." value={settings.values.enable_header_button} disabled={settings.saving} onValueChange={(enable_header_button) => save({ enable_header_button })} />
53
- <SettingsSwitch label="Composer pill" hint="Shows above the message box only while the workspace needs you." value={settings.values.enable_composer_pill} disabled={settings.saving} onValueChange={(enable_composer_pill) => save({ enable_composer_pill })} />
54
+ <SettingsSwitch label="Composer pill" hint="One pill above the message box, only while that agent or its workspace needs you." value={settings.values.enable_composer_pill} disabled={settings.saving} onValueChange={(enable_composer_pill) => save({ enable_composer_pill })} />
54
55
  </SettingsCard>
55
56
  {settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
56
57
  </SettingsSection>
57
58
  <SettingsSection title="Needs-you inbox (beta)" info="Agents stay in Needs you until you sign them off or write to them. Opening an agent doesn't clear it.">
58
59
  <SettingsCard>
59
- <SettingsSwitch label="Needs-you inbox" hint="Off: nothing is flagged, and the pill and sidebar item are hidden." value={settings.values.enable_inbox} disabled={settings.saving} onValueChange={(enable_inbox) => save({ enable_inbox })} />
60
- <SettingsSwitch label="Show the sidebar item at zero" hint="By default the Needs you item appears only while something needs you." value={settings.values.enable_inbox_header_when_empty} disabled={settings.saving || !settings.values.enable_inbox} onValueChange={(enable_inbox_header_when_empty) => save({ enable_inbox_header_when_empty })} />
60
+ <SettingsSwitch label="Needs-you inbox" hint="Off: no agent is flagged. The pill still shows workspace items, and the sidebar badge counts workspaces instead." value={settings.values.enable_inbox} disabled={settings.saving} onValueChange={(enable_inbox) => save({ enable_inbox })} />
61
61
  <SettingsSwitch label="Flag merged and closed PRs" hint="At the lowest priority, as the cue to archive." value={settings.values.enable_inbox_pr_closed} disabled={settings.saving || !settings.values.enable_inbox} onValueChange={(enable_inbox_pr_closed) => save({ enable_inbox_pr_closed })} />
62
62
  <SettingsSwitch label="Ask agents to state what they need" hint="A Claude Code agent that ends a turn without saying what you need is asked once to state it, for the card at the end of its chat. Other providers only get the instruction." value={settings.values.enable_brief_nudge} disabled={settings.saving || !settings.values.enable_inbox} onValueChange={(enable_brief_nudge) => save({ enable_brief_nudge })} />
63
63
  </SettingsCard>
64
64
  {settings.values.enable_inbox && !!share.data?.briefStats && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17, paddingHorizontal: 4 }}>{briefShareText(share.data.briefStats)}</Text>}
65
65
  </SettingsSection>
66
- </ScrollView>
66
+ </>
67
67
  );
68
68
  }
69
69
 
@@ -74,17 +74,6 @@ export function contributeDisplay(client: PluginClientContext, item: PluginSideb
74
74
  // Register synchronously, then reconcile the saved preference, so that an
75
75
  // already-open sidebar route can resolve its surface during that first render.
76
76
  sidebar.set(true);
77
- const Settings = DisplaySettings;
78
- const removeSurface = client.addScreen({ id: "display-settings", title: "Follow-up display", Component: Settings });
79
- const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
80
- const removeCommand = client.addCommandCenterItem({
81
- id: "display-settings",
82
- title: "Follow-up: display settings",
83
- icon: "PanelLeft",
84
- keywords: ["Follow-up", "sidebar", "pill", "header", "show", "hide", "display", "settings"],
85
- context: "global",
86
- onSelect({ openScreen }) { openScreen({ screenId: "display-settings" }); },
87
- });
88
77
  let disposed = false;
89
78
  let pending = false;
90
79
  let generation = 0;
@@ -111,8 +100,5 @@ export function contributeDisplay(client: PluginClientContext, item: PluginSideb
111
100
  clearInterval(timer);
112
101
  listeners.delete(update);
113
102
  sidebar.dispose();
114
- removeSurface();
115
- removeSettings();
116
- removeCommand();
117
103
  };
118
104
  }
@@ -20,7 +20,8 @@ export function createInboxStore(client: PluginClientContext) {
20
20
  let failures = 0;
21
21
  let version = 0;
22
22
  let enabled = true;
23
- let headerWhenEmpty = false;
23
+ /** Agents with a sign-off or snooze on the wire. A second identical click (or a double-mounted popover) is dropped. */
24
+ const inflight = new Set<string>();
24
25
  let pending: { agentId: string; seq: number }[] = [];
25
26
  let cameBack: string[] = [];
26
27
  let recent: Recent | null = null;
@@ -50,8 +51,8 @@ export function createInboxStore(client: PluginClientContext) {
50
51
  };
51
52
  const refresh = () => { if (timer) clearTimeout(timer); void poll(); };
52
53
  const unsubscribe = subscribeDisplay(values => {
53
- const on = values.enable_inbox !== false; const header = values.enable_inbox_header_when_empty === true;
54
- if (on !== enabled || header !== headerWhenEmpty) { enabled = on; headerWhenEmpty = header; emit(); if (on) refresh(); }
54
+ const on = values.enable_inbox !== false;
55
+ if (on !== enabled) { enabled = on; emit(); if (on) refresh(); }
55
56
  });
56
57
  void poll();
57
58
 
@@ -62,11 +63,26 @@ export function createInboxStore(client: PluginClientContext) {
62
63
  };
63
64
  const titleOf = (id: string) => { const r = data?.rows.find(x => x.agentId === id) ?? data?.unsorted?.find(x => x.agentId === id); return r ? r.workspaceTitle && r.multiRoot ? `${r.workspaceTitle} › ${r.title}` : r.workspaceTitle ?? r.title : "agent"; };
64
65
 
66
+ const signOffNow = async (agentIds: string[], options: { muteTurn?: boolean }) => {
67
+ const seqs = Object.fromEntries((data?.rows ?? []).filter(r => agentIds.includes(r.agentId)).map(r => [r.agentId, r.seq]));
68
+ const at = new Date().toISOString();
69
+ patch(agentIds, r => ({ ...r, needs: null, snooze: null, signedOffAt: at, undoable: true, muteTurn: !!options.muteTurn || r.muteTurn }));
70
+ recent = { agentIds, label: agentIds.length === 1 ? titleOf(agentIds[0]) : `${agentIds.length} agents`, at: Date.now(), batch: agentIds.length > 1 };
71
+ emit();
72
+ try {
73
+ const result = await act({ action: options.muteTurn ? "mute_turn" : "sign_off", agentIds, seqs });
74
+ cameBack = [...cameBack, ...result.results.filter(r => r.cameBack).map(r => r.agentId)];
75
+ } catch {
76
+ pending = [...pending.filter(p => !agentIds.includes(p.agentId)), ...agentIds.map(agentId => ({ agentId, seq: seqs[agentId] ?? 0 }))];
77
+ return;
78
+ }
79
+ refresh();
80
+ };
81
+
65
82
  return {
66
83
  subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
67
84
  version: () => version,
68
85
  enabled: () => enabled && (data?.enabled ?? true),
69
- headerWhenEmpty: () => headerWhenEmpty,
70
86
  data: () => data,
71
87
  rows: () => (enabled && data?.enabled ? data.rows : []),
72
88
  offlineSince: () => offlineSince,
@@ -78,19 +94,20 @@ export function createInboxStore(client: PluginClientContext) {
78
94
  stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); unsubscribe(); },
79
95
 
80
96
  /** Sign off now; queue for replay when the host can't be reached. */
81
- async signOff(agentIds: string[], options: { muteTurn?: boolean } = {}) {
82
- const seqs = Object.fromEntries((data?.rows ?? []).filter(r => agentIds.includes(r.agentId)).map(r => [r.agentId, r.seq]));
83
- const at = new Date().toISOString();
84
- patch(agentIds, r => ({ ...r, needs: null, snooze: null, signedOffAt: at, undoable: true, muteTurn: !!options.muteTurn || r.muteTurn }));
85
- recent = { agentIds, label: agentIds.length === 1 ? titleOf(agentIds[0]) : `${agentIds.length} agents`, at: Date.now(), batch: agentIds.length > 1 };
86
- emit();
97
+ async signOff(requested: string[], options: { muteTurn?: boolean } = {}) {
98
+ const agentIds = requested.filter(id => !inflight.has(id));
99
+ if (!agentIds.length) return;
100
+ for (const id of agentIds) inflight.add(id);
101
+ try { await signOffNow(agentIds, options); } finally { for (const id of agentIds) inflight.delete(id); }
102
+ },
103
+ async snooze(requested: string[], until: string) {
104
+ const agentIds = requested.filter(id => !inflight.has(id));
105
+ if (!agentIds.length) return;
106
+ for (const id of agentIds) inflight.add(id);
87
107
  try {
88
- const result = await act({ action: options.muteTurn ? "mute_turn" : "sign_off", agentIds, seqs });
89
- cameBack = [...cameBack, ...result.results.filter(r => r.cameBack).map(r => r.agentId)];
90
- } catch {
91
- pending = [...pending.filter(p => !agentIds.includes(p.agentId)), ...agentIds.map(agentId => ({ agentId, seq: seqs[agentId] ?? 0 }))];
92
- return;
93
- }
108
+ patch(agentIds, r => ({ ...r, snooze: { until, at: new Date().toISOString() } }));
109
+ await act({ action: "snooze", agentIds, until });
110
+ } finally { for (const id of agentIds) inflight.delete(id); }
94
111
  refresh();
95
112
  },
96
113
  async undo(agentIds?: string[]) {
@@ -102,10 +119,6 @@ export function createInboxStore(client: PluginClientContext) {
102
119
  if (batch) await act({ action: "batch_undo" }); else if (target.length) await act({ action: "undo", agentIds: target });
103
120
  refresh();
104
121
  },
105
- async snooze(agentIds: string[], until: string) {
106
- patch(agentIds, r => ({ ...r, snooze: { until, at: new Date().toISOString() } }));
107
- await act({ action: "snooze", agentIds, until }); refresh();
108
- },
109
122
  async setFinishFlags(agentId: string, value: boolean) {
110
123
  patch([agentId], r => ({ ...r, enable_finish_flags: value }));
111
124
  await act({ action: "set_finish_flags", agentIds: [agentId], value }); refresh();
package/client/inbox.tsx CHANGED
@@ -1,12 +1,12 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
2
2
  import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
3
- import type { PluginButtonIconProps, PluginButtonRegistration, PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
4
- import { Icon, SidebarRow } from "@getpaseo/plugin/client/ui";
5
- import { BRIEF_LABEL, BRIEF_WORD, briefLine } from "../shared/brief";
6
- import { age, displayReason, nextAfter, REASON_LABEL, REASON_WORD, rowTitle, sectionRows, snoozeUntil, wakeLabel, type InboxRow, type Reason } from "../shared/inbox";
3
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/ui";
5
+ import { flagHeadline, HEADLINE_ICON, HEADLINE_TONE, HEADLINE_WORD, type Headline } from "../shared/headline";
6
+ import { age, nextAfter, rowTitle, sectionRows, snoozeUntil, wakeLabel, type InboxRow } from "../shared/inbox";
7
7
  import { Button, IconButton, useNow, type Theme } from "./kit";
8
8
  import { Spinner } from "./motion";
9
- import { mergedColor, raised, runningColor, type Colors } from "./theme";
9
+ import { headlineColor, raised, runningColor } from "./theme";
10
10
  import type { InboxStore } from "./inbox-store";
11
11
 
12
12
  export const INBOX_SCREEN = "follow-through";
@@ -17,25 +17,16 @@ export function useInbox<T>(store: InboxStore, select: () => T): T {
17
17
  return select();
18
18
  }
19
19
 
20
- export function reasonGlyph(reason: Reason, c: Colors): { name: string; color: string } {
21
- switch (reason) {
22
- case "permission": return { name: "CircleHelp", color: c.statusWarning };
23
- case "error": return { name: "CircleX", color: c.statusDanger };
24
- case "paused": return { name: "CirclePause", color: c.statusWarning };
25
- case "ci": return { name: "GitPullRequestClosed", color: c.statusDanger };
26
- case "review": return { name: "MessageSquare", color: c.statusWarning };
27
- case "finished": return { name: "CircleCheck", color: c.statusSuccess };
28
- case "pr_closed": return { name: "GitMerge", color: mergedColor(c) };
29
- }
30
- }
20
+ /** The row's headline, the same one the pill and the brief card use. A snoozed row keeps its reason, in muted tones. */
21
+ export const rowHeadline = (row: InboxRow, now: number): Headline | null => flagHeadline({ ...row, snooze: null }, now);
31
22
  const running = (row: InboxRow) => ["running", "initializing"].includes(row.status);
32
23
  const label = (row: InboxRow) => { const t = rowTitle(row); return t.agent ? `${t.primary} › ${t.agent}` : t.primary; };
33
24
 
34
25
  /** The status glyph. A flagged agent that is also running gets a small spinner on the glyph, not a second dot. */
35
26
  function Glyph({ row, theme, size }: { row: InboxRow; theme: Theme; size: number }) {
36
27
  const c = theme.colors;
37
- const shown = displayReason(row.needs);
38
- const g = shown ? reasonGlyph(shown.reason, c) : row.resumesAt ? { name: "CirclePause", color: c.foregroundMuted } : row.snooze ? { name: "AlarmClock", color: c.foregroundMuted } : row.signedOffAt && !running(row) ? { name: "Check", color: c.statusSuccess } : null;
28
+ const shown = rowHeadline(row, Date.now());
29
+ const g = shown ? { name: HEADLINE_ICON[shown.key], color: headlineColor(HEADLINE_TONE[shown.key], c) } : row.resumesAt ? { name: "CirclePause", color: c.foregroundMuted } : row.snooze ? { name: "AlarmClock", color: c.foregroundMuted } : row.signedOffAt && !running(row) ? { name: "Check", color: c.statusSuccess } : null;
39
30
  return <View style={{ width: size + 4, height: size + 4, alignItems: "center", justifyContent: "center" }}>
40
31
  {g ? <Icon name={g.name} size={size} color={g.color} /> : <View accessibilityLabel="Running" style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: runningColor(c) }} />}
41
32
  {g && running(row) && !row.snooze && <View accessibilityLabel="Running" style={{ position: "absolute", right: -3, bottom: -3, width: 11, height: 11, borderRadius: 6, backgroundColor: c.surface0, alignItems: "center", justifyContent: "center" }}>
@@ -60,7 +51,7 @@ export function InboxRowView({ row, theme, now, dense, touch, active, onOpen, on
60
51
  const [hover, setHover] = useState(false);
61
52
  const [focused, setFocused] = useState(false);
62
53
  const title = rowTitle(row);
63
- const shown = displayReason(row.needs);
54
+ const shown = rowHeadline(row, now);
64
55
  const primary = dense ? 12 : 13; const secondary = dense ? 11 : 12;
65
56
  // Change 2: when "Title › Agent" won't fit, the agent name moves to the reason line so neither name is hidden.
66
57
  // Estimated from the text length (≈0.56em per glyph) against the room left after the glyph, age and buttons.
@@ -69,23 +60,22 @@ export function InboxRowView({ row, theme, now, dense, touch, active, onOpen, on
69
60
  const more = (row.needs?.reasons.length ?? 0) - 1;
70
61
  const days = row.needs ? now - Date.parse(row.needs.since) > 86_400_000 : false;
71
62
  const right = trailing ?? (row.needs ? age(row.needs.since, now) : row.signedOffAt ? age(row.signedOffAt, now) : row.resumesAt ? `resumes ${wakeLabel({ until: row.resumesAt, at: "" }, now)}` : running(row) ? age(row.lastActivityAt, now) : "");
72
- const reasonColor = shown && !row.snooze ? reasonGlyph(shown.reason, c).color : c.foregroundMuted;
63
+ const reasonColor = shown && !row.snooze ? headlineColor(HEADLINE_TONE[shown.key], c) : c.foregroundMuted;
73
64
  const second: ReactNode[] = [];
74
65
  if (title.agent && narrow) second.push(<Text key="a" style={{ color: c.foreground }}>{title.agent} · </Text>);
75
- if (shown) second.push(<Text key="r" style={{ color: reasonColor, fontWeight: "600" }}>{row.brief?.state === "open" && shown.reason === "finished" ? BRIEF_LABEL[row.brief.kind] : REASON_LABEL[shown.reason]}</Text>);
66
+ if (shown) second.push(<Text key="r" style={{ color: reasonColor, fontWeight: "600" }}>{HEADLINE_WORD[shown.key]}</Text>);
76
67
  else if (row.signedOffAt && !running(row)) second.push(<Text key="s" style={{ color: c.statusSuccess }}>Signed off</Text>);
77
68
  else if (row.resumesAt) second.push(<Text key="p">Paused at the limit</Text>);
78
69
  else if (running(row)) second.push(<Text key="w">Working</Text>);
79
- // §4: an open brief's ask is the reason line, so the row, the card and the composer say the same thing.
80
- const asked = row.brief && shown ? briefLine(row.brief) : null;
81
- const detail = asked ?? shown?.detail;
70
+ // §4: the headline's line (an open brief's ask, else the reason's detail), so the row, the card and the pill say the same thing.
71
+ const detail = shown && shown.line !== HEADLINE_WORD[shown.key] ? shown.line : null;
82
72
  if (detail) second.push(<Text key="d">{` · ${detail}`}</Text>);
83
73
  if (more > 0) second.push(<Text key="m">{` · +${more}`}</Text>);
84
74
  if (!row.enable_finish_flags && !shown) second.push(<Text key="f">{second.length ? " · " : ""}Finishes not flagged</Text>);
85
75
  if (row.delegatedFrom && !shown) second.push(<Text key="g">{second.length ? " · " : ""}Delegated</Text>);
86
76
  const quiet = !row.needs || !!row.snooze;
87
77
  const showCheck = !!onSignOff && !selecting && (touch || hover || focused || !dense);
88
- const a11y = [label(row), shown ? `${REASON_LABEL[shown.reason]}${detail ? `: ${detail}` : ""}` : "", right ? `waiting ${right}` : ""].filter(Boolean).join(". ");
78
+ const a11y = [label(row), shown ? `${HEADLINE_WORD[shown.key]}${detail ? `: ${detail}` : ""}` : "", right ? `waiting ${right}` : ""].filter(Boolean).join(". ");
89
79
  return <View onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
90
80
  {...({ onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), onFocus: () => setFocused(true), onBlur: () => setFocused(false) } as object)}
91
81
  style={{ flexDirection: "row", alignItems: "center", minHeight: touch ? 56 : dense ? 40 : 48, paddingLeft: dense ? 8 : 12, paddingRight: dense ? 4 : 8, borderRadius: dense ? 6 : 0, backgroundColor: active || moreOpen ? c.surface2 : hover && onOpen ? c.surface1 : "transparent" }}>
@@ -104,7 +94,7 @@ export function InboxRowView({ row, theme, now, dense, touch, active, onOpen, on
104
94
  {title.primary}{title.agent && !narrow ? <Text style={{ color: c.foregroundMuted }}>{` › ${title.agent}`}</Text> : null}
105
95
  </Text>
106
96
  </View>
107
- {second.length > 0 && <Text numberOfLines={shown?.reason === "permission" || shown?.reason === "paused" ? 2 : 1} style={{ color: c.foregroundMuted, fontSize: secondary, lineHeight: secondary + 4 }}>{second}</Text>}
97
+ {second.length > 0 && <Text numberOfLines={shown?.key === "permission" || shown?.key === "paused" ? 2 : 1} style={{ color: c.foregroundMuted, fontSize: secondary, lineHeight: secondary + 4 }}>{second}</Text>}
108
98
  </View>
109
99
  </Pressable>
110
100
  <View style={{ flexDirection: "row", alignItems: "center", gap: 2, marginLeft: 6 }}>
@@ -279,115 +269,31 @@ export function InboxSections({ store, theme, touch, dense, nav, runningCap = 8
279
269
  </View>;
280
270
  }
281
271
 
282
- /**
283
- * Sidebar header item: "Needs you N". Registered once, synchronously (the host resolves sidebar items
284
- * when the installation mounts); it renders nothing at zero unless the developer keeps it always on.
285
- */
272
+ /** Whether the Phase 2 sidebar view is mounted: it lists the same agents, so the sidebar item's badge would repeat it. */
286
273
  const sidebarViews = { mounted: 0, listeners: new Set<() => void>() };
287
274
  const subscribeSidebarViews = (fn: () => void) => { sidebarViews.listeners.add(fn); return () => { sidebarViews.listeners.delete(fn); }; };
288
- /** Called by the Phase 2 sidebar view: while it is the sidebar, the header item would repeat the list right below it. */
289
275
  export function useSidebarViewMounted() {
290
276
  useEffect(() => {
291
277
  sidebarViews.mounted += 1; sidebarViews.listeners.forEach(fn => fn());
292
278
  return () => { sidebarViews.mounted -= 1; sidebarViews.listeners.forEach(fn => fn()); };
293
279
  }, []);
294
280
  }
295
-
296
- export function contributeHeaderItem(client: PluginClientContext, store: InboxStore) {
297
- function NeedsYouItem({ currentScreen, openScreen, theme }: PluginSidebarItemProps) {
298
- const count = useInbox(store, () => sectionRows(store.rows(), "needs", Date.now()).length);
299
- const inSidebarView = useSyncExternalStore(subscribeSidebarViews, () => sidebarViews.mounted > 0, () => false);
300
- const visible = useInbox(store, () => store.enabled() && (count > 0 || store.headerWhenEmpty())) && !inSidebarView;
301
- const c = theme.colors;
302
- if (!visible) return null;
303
- return <SidebarRow icon="Inbox" label="Needs you" active={currentScreen?.screenId === INBOX_SCREEN && currentScreen.params.section === "needs"} onPress={() => openScreen({ screenId: INBOX_SCREEN, params: { section: "needs" } })}
304
- trailing={count > 0 ? <View accessibilityLabel={`${count} need${count === 1 ? "s" : ""} you`} style={{ minWidth: 18, height: 18, paddingHorizontal: 5, borderRadius: 9, alignItems: "center", justifyContent: "center", backgroundColor: c.statusWarning }}>
305
- <Text style={{ color: c.surface0, fontSize: 11, lineHeight: 14, fontWeight: "700", fontVariant: ["tabular-nums"] }}>{count}</Text>
306
- </View> : undefined} />;
307
- }
308
- return client.addSidebarHeaderItem({ id: "needs-you", title: "Needs you", Component: NeedsYouItem });
309
- }
310
-
311
- /**
312
- * Above the composer, one split control: the status ("Needs you · finished 12m") signs off on click,
313
- * and "More" beside it opens "Sign off & next", "Later…", "Mute this turn" and the finish switch.
314
- * Paseo has no split pill and rejects an empty pill label, so these are two adjacent pills. After a sign-off the status pill turns
315
- * into "Signed off · Undo" for 6 s.
316
- */
317
- export function contributeInboxPills(client: PluginClientContext, store: InboxStore, openNext: (from: string) => void) {
318
- const pills = new Map<string, { workspaceId: string; key: string; status: PluginButtonRegistration; menu: PluginButtonRegistration | null }>();
319
- let undoTimer: ReturnType<typeof setTimeout> | null = null;
320
- const statusIcon = (agentId: string) => function StatusIcon(props: PluginButtonIconProps) {
321
- const reason = useInbox(store, () => { const r = store.rows().find(x => x.agentId === agentId); return r?.needs ? displayReason(r.needs)?.reason ?? null : null; });
322
- const c = props.theme.colors;
323
- if (!reason) return <Icon name="Undo2" size={props.size} color={c.foregroundMuted} />;
324
- // A check, tinted with the reason's colour: the label says why, the icon says what a click does.
325
- return <Icon name="Check" size={props.size} color={reasonGlyph(reason, c).color} />;
326
- };
327
- const menuItems = (row: InboxRow) => [
328
- { kind: "item" as const, id: "next", title: "Sign off & next", icon: "ArrowRight", behavior: { kind: "action" as const, onPress() { void store.signOff([row.agentId]); openNext(row.agentId); } } },
329
- { kind: "item" as const, id: "later", title: "Later…", icon: "AlarmClock", behavior: { kind: "menu" as const, items: ([["hour", "In 1 hour"], ["tonight", "Tonight 18:00"], ["tomorrow", "Tomorrow 09:00"], ["next-event", "Next event"]] as const).map(([id, title]) => ({ kind: "item" as const, id, title, behavior: { kind: "action" as const, onPress() { void store.snooze([row.agentId], snoozeUntil(id, new Date())); } } })) } },
330
- { kind: "item" as const, id: "mute", title: "Sign off & mute this turn", icon: "BellOff", visible: running(row), behavior: { kind: "action" as const, onPress() { void store.signOff([row.agentId], { muteTurn: true }); } } },
331
- { kind: "separator" as const, id: "sep" },
332
- { kind: "item" as const, id: "finishes", title: row.enable_finish_flags ? "Don't flag finishes" : "Flag finishes again", icon: row.enable_finish_flags ? "BellMinus" : "Bell", behavior: { kind: "action" as const, onPress() { void store.setFinishFlags(row.agentId, !row.enable_finish_flags); } } },
333
- ];
334
- const publish = () => {
335
- const now = Date.now();
336
- const recent = store.recent();
337
- const wanted = new Map<string, { row: InboxRow; label: string; title: string; undo: boolean }>();
338
- for (const row of store.rows()) {
339
- if (!row.workspaceId) continue;
340
- const shown = displayReason(row.needs);
341
- if (shown && !row.snooze) {
342
- // §4: with an open brief the label comes from its kind and the tooltip carries its ask, so the control never disagrees with the card.
343
- const brief = row.brief?.state === "open" ? row.brief : null;
344
- const word = brief && (shown.reason === "finished" || brief.kind === shown.reason) ? BRIEF_WORD[brief.kind] : REASON_WORD[shown.reason];
345
- const status = brief ? `Needs you · ${briefLine(brief)}` : `Needs you · ${REASON_WORD[shown.reason].toLowerCase()} ${age(row.needs!.since, now)}`;
346
- wanted.set(row.agentId, { row, undo: false, label: `${word} · ${age(row.needs!.since, now)}`, title: store.data()?.firstRun ? `${status}. Click to sign off. ${RULE}` : `${status}. Click to sign off.` });
347
- } else if (recent && recent.agentIds.includes(row.agentId) && now - recent.at < 6_000) {
348
- wanted.set(row.agentId, { row, undo: true, label: "Undo sign-off", title: "Signed off. Click to undo." });
349
- }
350
- }
351
- for (const [agentId, entry] of pills) {
352
- const next = wanted.get(agentId);
353
- if (!next || next.row.workspaceId !== entry.workspaceId) { entry.status.remove(); entry.menu?.remove(); pills.delete(agentId); }
354
- }
355
- for (const [agentId, next] of wanted) try {
356
- const key = `${next.label}|${next.title}|${next.undo}|${next.row.enable_finish_flags}|${running(next.row)}`;
357
- const status = { title: next.title, label: next.label, behavior: { kind: "action" as const, onPress() { if (next.undo) void store.undo([agentId]); else void store.signOff([agentId]); } } };
358
- const entry = pills.get(agentId);
359
- if (!entry) {
360
- const registration = client.addComposerPill({ id: "needs-you", workspaceId: next.row.workspaceId!, agentId, button: { ...status, icon: statusIcon(agentId) } });
361
- const menu = next.undo ? null : client.addComposerPill({ id: "needs-you-menu", workspaceId: next.row.workspaceId!, agentId, button: { title: "More sign-off options", label: "More", icon: "Ellipsis", behavior: { kind: "menu", items: menuItems(next.row) } } });
362
- pills.set(agentId, { workspaceId: next.row.workspaceId!, key, status: registration, menu });
363
- } else if (entry.key !== key) {
364
- entry.status.update(status);
365
- if (next.undo) { entry.menu?.remove(); entry.menu = null; }
366
- else if (entry.menu) entry.menu.update({ behavior: { kind: "menu", items: menuItems(next.row) } });
367
- else entry.menu = client.addComposerPill({ id: "needs-you-menu", workspaceId: next.row.workspaceId!, agentId, button: { title: "More sign-off options", label: "More", icon: "Ellipsis", behavior: { kind: "menu", items: menuItems(next.row) } } });
368
- entry.key = key;
369
- }
370
- } catch { /* One agent's pill must never hide the others. */ }
371
- if (recent && Date.now() - recent.at < 6_000) { if (undoTimer) clearTimeout(undoTimer); undoTimer = setTimeout(publish, 6_000 - (Date.now() - recent.at) + 50); }
372
- };
373
- const unsubscribe = store.subscribe(publish);
374
- // Ages count up between polls.
375
- const ticker = setInterval(publish, 60_000);
376
- return () => { unsubscribe(); clearInterval(ticker); if (undoTimer) clearTimeout(undoTimer); for (const p of pills.values()) { p.status.remove(); p.menu?.remove(); } pills.clear(); };
281
+ export function useSidebarViewActive(): boolean {
282
+ return useSyncExternalStore(subscribeSidebarViews, () => sidebarViews.mounted > 0, () => false);
377
283
  }
378
284
 
379
285
  /** ⌘K items and /ok. The keyboard path is ⌘K, "ok", Enter. */
380
286
  export function contributeInboxCommands(client: PluginClientContext, store: InboxStore, openNext: (from: string | null) => void) {
381
287
  const removers = [
382
- client.addCommandCenterItem({ id: "inbox-sign-off-next", title: "Sign off & next", icon: "ArrowRight", keywords: ["ok", "done", "ack", "needs you", "sign off", "next"], context: "agent",
288
+ client.addCommandCenterItem({ id: "inbox-sign-off-next", title: "Follow-up: sign off & next", icon: "ArrowRight", keywords: ["ok", "done", "ack", "needs you", "sign off", "next"], context: "agent",
383
289
  onSelect({ agent }) { void store.signOff([agent.id]); openNext(agent.id); } }),
384
- client.addCommandCenterItem({ id: "inbox-sign-off", title: "Sign off", icon: "Check", keywords: ["ok", "done", "ack", "needs you", "sign off"], context: "agent",
290
+ client.addCommandCenterItem({ id: "inbox-sign-off", title: "Follow-up: sign off", icon: "Check", keywords: ["ok", "done", "ack", "needs you", "sign off"], context: "agent",
385
291
  onSelect({ agent }) { void store.signOff([agent.id]); } }),
386
- client.addCommandCenterItem({ id: "inbox-later", title: "Later: tomorrow 09:00", icon: "AlarmClock", keywords: ["ok", "snooze", "later", "needs you"], context: "agent",
292
+ client.addCommandCenterItem({ id: "inbox-later", title: "Follow-up: later, tomorrow 09:00", icon: "AlarmClock", keywords: ["ok", "snooze", "later", "needs you"], context: "agent",
387
293
  onSelect({ agent }) { void store.snooze([agent.id], snoozeUntil("tomorrow", new Date())); } }),
388
- client.addCommandCenterItem({ id: "inbox-undo", title: "Undo last sign-off", icon: "Undo2", keywords: ["ok", "undo", "needs you", "sign off"], context: "global",
294
+ client.addCommandCenterItem({ id: "inbox-undo", title: "Follow-up: undo last sign-off", icon: "Undo2", keywords: ["ok", "undo", "needs you", "sign off"], context: "global",
389
295
  onSelect() { void store.undo(); } }),
390
- client.addCommandCenterItem({ id: "inbox-next", title: "Next needing me", icon: "Inbox", keywords: ["ok", "next", "needs you", "inbox"], context: "global",
296
+ client.addCommandCenterItem({ id: "inbox-next", title: "Follow-up: next agent that needs you", icon: "Inbox", keywords: ["ok", "next", "needs you", "inbox"], context: "global",
391
297
  onSelect() { openNext(null); } }),
392
298
  client.addSlashCommand({ name: "ok", description: "Sign off this agent (Needs you). /ok next opens the next one", argumentHint: "[next]", context: "agent",
393
299
  onSubmit({ agent, args }) { void store.signOff([agent.id]); if (/^\s*next\b/i.test(args)) openNext(agent.id); } }),