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 +1 -1
- package/README.md +7 -6
- package/client/brief.tsx +22 -11
- package/client/display.tsx +9 -23
- package/client/inbox-store.ts +33 -20
- package/client/inbox.tsx +24 -118
- package/client/live.tsx +22 -103
- package/client/panel.tsx +1 -1
- package/client/pill.tsx +201 -0
- package/client/settings.tsx +8 -3
- package/client/theme.ts +5 -0
- package/docs/screenshots/follow-up-pill-popover.png +0 -0
- package/index.client.tsx +17 -18
- package/package.json +1 -1
- package/server/agent-hook.mjs +1 -1
- package/server/discovery.ts +10 -1
- package/server/engine.ts +4 -2
- package/server/hook-source.ts +1 -1
- package/server/inbox.ts +51 -13
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +2 -2
- package/shared/attention.ts +0 -11
- package/shared/brief.ts +23 -8
- package/shared/display.ts +2 -2
- package/shared/headline.ts +141 -0
- package/shared/inbox.ts +11 -13
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
|
|
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
|
|
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
|
-
- **
|
|
80
|
-
- **
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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,
|
|
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" :
|
|
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" :
|
|
154
|
-
{acts && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{
|
|
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=
|
|
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} />;
|
package/client/display.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
<SettingsSection title="
|
|
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
|
|
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="
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/client/inbox-store.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
54
|
-
if (on !== enabled
|
|
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(
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
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 {
|
|
4
|
-
import { Icon
|
|
5
|
-
import {
|
|
6
|
-
import { age,
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
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 =
|
|
38
|
-
const g = shown ?
|
|
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 =
|
|
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 ?
|
|
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" }}>{
|
|
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
|
|
80
|
-
const
|
|
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 ? `${
|
|
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?.
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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); } }),
|