paseo-follow-up 1.0.1 → 1.1.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 +2 -0
- package/README.md +28 -0
- package/client/display.tsx +7 -0
- package/client/inbox-store.ts +121 -0
- package/client/inbox.tsx +407 -0
- package/client/overview.tsx +11 -1
- package/client/sidebar-view.tsx +115 -0
- package/client/theme.ts +6 -0
- package/index.client.tsx +15 -2
- package/index.server.ts +41 -2
- package/package.json +1 -1
- package/server/daemon.ts +7 -1
- package/server/inbox-classifier.ts +81 -0
- package/server/inbox-directory.ts +47 -0
- package/server/inbox.ts +469 -0
- package/shared/contracts.ts +9 -0
- package/shared/display.ts +7 -1
- package/shared/inbox.ts +322 -0
package/OVERVIEW.md
CHANGED
|
@@ -25,3 +25,5 @@ Auto-send starts off for every workspace. Until you turn it on, each repair batc
|
|
|
25
25
|
## Data and access
|
|
26
26
|
|
|
27
27
|
This is a trusted, unsandboxed plugin. It reads workspace metadata, saved agent history and configuration, GitHub status and existing CLI authentication, and keeps its state in `plugin-data/follow-through` on the daemon host. It registers scoped MCP tools and reversible provider hooks only for tracked sessions. To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
|
|
28
|
+
|
|
29
|
+
The Needs-you inbox (beta) keeps one flag per root agent in `plugin-data/follow-through/inbox.json`. When a turn starts it reads the newest user message from the agent's recent timeline to tell your messages from automation, and it reads, never writes, Defer's `plugin-data/defer/queue.json`, Smart Session's `plugin-data/smart-session/compactions.json`, Session Guard's `plugin-data/session-guard/state.json` and Paseo's schedule records. An unknown or unreadable format counts as your message, which clears a flag rather than adding one. Signing off also clears Paseo's own unread dot for that agent. The first-run import and the restart check read only Paseo's agent list: an agent's last activity is its unread time or your last message to it, never Paseo's `updatedAt` (which the daemon rewrites in bulk), and they don't fetch timelines, which would load every agent. Closed and archived agents are never imported. Turning the inbox off stops all of this.
|
package/README.md
CHANGED
|
@@ -72,6 +72,34 @@ On a phone the panel opens as a tab, and the PR sheet and pill popover open as b
|
|
|
72
72
|
|
|
73
73
|

|
|
74
74
|
|
|
75
|
+
## Needs-you inbox (beta)
|
|
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.
|
|
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*.
|
|
81
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- 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
|
+
|
|
87
|
+
Install the beta:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
paseo plugin update follow-through --version 1.1.0-beta.1
|
|
91
|
+
# or, for a fresh install:
|
|
92
|
+
paseo plugin install npm:paseo-follow-up@beta
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Go back to the stable release (the inbox file stays on disk and is ignored by 1.0.1):
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
paseo plugin update follow-through --version 1.0.1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Betas publish to npm's `beta` dist-tag, so `paseo plugin update` without `--version`, and scheduled updates that follow `latest`, never pick one up.
|
|
102
|
+
|
|
75
103
|
## How it works
|
|
76
104
|
|
|
77
105
|
- 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.
|
package/client/display.tsx
CHANGED
|
@@ -41,6 +41,13 @@ function DisplaySettings({ theme }: PluginSurfaceProps) {
|
|
|
41
41
|
</SettingsCard>
|
|
42
42
|
{settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
|
|
43
43
|
</SettingsSection>
|
|
44
|
+
<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.">
|
|
45
|
+
<SettingsCard>
|
|
46
|
+
<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 })} />
|
|
47
|
+
<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 })} />
|
|
48
|
+
<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 })} />
|
|
49
|
+
</SettingsCard>
|
|
50
|
+
</SettingsSection>
|
|
44
51
|
</ScrollView>
|
|
45
52
|
);
|
|
46
53
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { inboxActRpc, inboxListRpc } from "../shared/contracts";
|
|
3
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
4
|
+
import type { InboxRow } from "../shared/inbox";
|
|
5
|
+
import { subscribeDisplay } from "./display";
|
|
6
|
+
|
|
7
|
+
type List = RpcOutput<typeof inboxListRpc>;
|
|
8
|
+
export type Recent = { agentIds: string[]; label: string; at: number; batch: boolean };
|
|
9
|
+
const POLL_MS = 4_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One shared poll of the inbox for every surface on this client. Sign-offs apply optimistically;
|
|
13
|
+
* while the host is unreachable they queue with the seq they were made against and replay on
|
|
14
|
+
* reconnect. The daemon drops a replay whose agent moved on, and that agent is reported as
|
|
15
|
+
* "came back while offline" rather than silently cleared.
|
|
16
|
+
*/
|
|
17
|
+
export function createInboxStore(client: PluginClientContext) {
|
|
18
|
+
let data: List | null = null;
|
|
19
|
+
let offlineSince: number | null = null;
|
|
20
|
+
let failures = 0;
|
|
21
|
+
let version = 0;
|
|
22
|
+
let enabled = true;
|
|
23
|
+
let headerWhenEmpty = false;
|
|
24
|
+
let pending: { agentId: string; seq: number }[] = [];
|
|
25
|
+
let cameBack: string[] = [];
|
|
26
|
+
let recent: Recent | null = null;
|
|
27
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
28
|
+
let stopped = false;
|
|
29
|
+
const listeners = new Set<() => void>();
|
|
30
|
+
const emit = () => { version++; for (const l of listeners) l(); };
|
|
31
|
+
const rpcList = (): Promise<List> => client.rpc(inboxListRpc, {});
|
|
32
|
+
const act = (input: RpcInput<typeof inboxActRpc>) => client.rpc(inboxActRpc, input);
|
|
33
|
+
|
|
34
|
+
const poll = async () => {
|
|
35
|
+
try {
|
|
36
|
+
if (pending.length) {
|
|
37
|
+
const queued = pending; pending = [];
|
|
38
|
+
const result = await act({ action: "sign_off", agentIds: queued.map(p => p.agentId), seqs: Object.fromEntries(queued.map(p => [p.agentId, p.seq])) });
|
|
39
|
+
cameBack = [...cameBack, ...result.results.filter(r => r.cameBack).map(r => r.agentId)];
|
|
40
|
+
}
|
|
41
|
+
const next = await rpcList();
|
|
42
|
+
if (stopped) return;
|
|
43
|
+
data = next; offlineSince = null; failures = 0;
|
|
44
|
+
// Rows signed off while offline stay hidden until the replay lands.
|
|
45
|
+
emit();
|
|
46
|
+
} catch {
|
|
47
|
+
failures++;
|
|
48
|
+
if (failures >= 2 && offlineSince === null) { offlineSince = Date.now(); emit(); }
|
|
49
|
+
} finally { if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); }
|
|
50
|
+
};
|
|
51
|
+
const refresh = () => { if (timer) clearTimeout(timer); void poll(); };
|
|
52
|
+
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(); }
|
|
55
|
+
});
|
|
56
|
+
void poll();
|
|
57
|
+
|
|
58
|
+
const patch = (agentIds: string[], change: (row: InboxRow) => InboxRow) => {
|
|
59
|
+
if (!data) return;
|
|
60
|
+
data = { ...data, rows: data.rows.map(r => agentIds.includes(r.agentId) ? change(r) : r) };
|
|
61
|
+
emit();
|
|
62
|
+
};
|
|
63
|
+
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
|
+
return {
|
|
66
|
+
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
67
|
+
version: () => version,
|
|
68
|
+
enabled: () => enabled && (data?.enabled ?? true),
|
|
69
|
+
headerWhenEmpty: () => headerWhenEmpty,
|
|
70
|
+
data: () => data,
|
|
71
|
+
rows: () => (enabled && data?.enabled ? data.rows : []),
|
|
72
|
+
offlineSince: () => offlineSince,
|
|
73
|
+
cameBack: () => cameBack,
|
|
74
|
+
clearCameBack() { cameBack = []; emit(); },
|
|
75
|
+
recent: () => recent,
|
|
76
|
+
clearRecent() { recent = null; emit(); },
|
|
77
|
+
refresh,
|
|
78
|
+
stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); unsubscribe(); },
|
|
79
|
+
|
|
80
|
+
/** 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();
|
|
87
|
+
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
|
+
}
|
|
94
|
+
refresh();
|
|
95
|
+
},
|
|
96
|
+
async undo(agentIds?: string[]) {
|
|
97
|
+
const target = agentIds ?? recent?.agentIds ?? [];
|
|
98
|
+
const batch = !agentIds && recent?.batch;
|
|
99
|
+
recent = null;
|
|
100
|
+
pending = pending.filter(p => !target.includes(p.agentId));
|
|
101
|
+
emit();
|
|
102
|
+
if (batch) await act({ action: "batch_undo" }); else if (target.length) await act({ action: "undo", agentIds: target });
|
|
103
|
+
refresh();
|
|
104
|
+
},
|
|
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
|
+
async setFinishFlags(agentId: string, value: boolean) {
|
|
110
|
+
patch([agentId], r => ({ ...r, enable_finish_flags: value }));
|
|
111
|
+
await act({ action: "set_finish_flags", agentIds: [agentId], value }); refresh();
|
|
112
|
+
},
|
|
113
|
+
async unsortedSignOff(agentIds?: string[]) {
|
|
114
|
+
if (data) { data = { ...data, unsorted: agentIds ? data.unsorted?.filter(r => !agentIds.includes(r.agentId)) ?? null : null }; emit(); }
|
|
115
|
+
await act({ action: "unsorted_sign_off", ...(agentIds ? { agentIds } : {}) }); refresh();
|
|
116
|
+
},
|
|
117
|
+
async unsortedFlag(agentIds: string[]) { await act({ action: "unsorted_flag", agentIds }); refresh(); },
|
|
118
|
+
async batchUndo() { await act({ action: "batch_undo" }); refresh(); },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export type InboxStore = ReturnType<typeof createInboxStore>;
|
package/client/inbox.tsx
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
|
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 { age, displayReason, nextAfter, REASON_LABEL, REASON_WORD, rowTitle, sectionRows, snoozeUntil, wakeLabel, type InboxRow, type Reason } from "../shared/inbox";
|
|
6
|
+
import { Button, IconButton, useNow, type Theme } from "./kit";
|
|
7
|
+
import { Spinner } from "./motion";
|
|
8
|
+
import { mergedColor, raised, runningColor, type Colors } from "./theme";
|
|
9
|
+
import type { InboxStore } from "./inbox-store";
|
|
10
|
+
|
|
11
|
+
export const INBOX_SCREEN = "follow-through";
|
|
12
|
+
const RULE = "Agents stay here until you sign them off or reply. Opening one doesn't clear it.";
|
|
13
|
+
|
|
14
|
+
export function useInbox<T>(store: InboxStore, select: () => T): T {
|
|
15
|
+
useSyncExternalStore(store.subscribe, store.version, store.version);
|
|
16
|
+
return select();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function reasonGlyph(reason: Reason, c: Colors): { name: string; color: string } {
|
|
20
|
+
switch (reason) {
|
|
21
|
+
case "permission": return { name: "CircleHelp", color: c.statusWarning };
|
|
22
|
+
case "error": return { name: "CircleX", color: c.statusDanger };
|
|
23
|
+
case "paused": return { name: "CirclePause", color: c.statusWarning };
|
|
24
|
+
case "ci": return { name: "GitPullRequestClosed", color: c.statusDanger };
|
|
25
|
+
case "review": return { name: "MessageSquare", color: c.statusWarning };
|
|
26
|
+
case "finished": return { name: "CircleCheck", color: c.statusSuccess };
|
|
27
|
+
case "pr_closed": return { name: "GitMerge", color: mergedColor(c) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const running = (row: InboxRow) => ["running", "initializing"].includes(row.status);
|
|
31
|
+
const label = (row: InboxRow) => { const t = rowTitle(row); return t.agent ? `${t.primary} › ${t.agent}` : t.primary; };
|
|
32
|
+
|
|
33
|
+
/** The status glyph. A flagged agent that is also running gets a small spinner on the glyph, not a second dot. */
|
|
34
|
+
function Glyph({ row, theme, size }: { row: InboxRow; theme: Theme; size: number }) {
|
|
35
|
+
const c = theme.colors;
|
|
36
|
+
const shown = displayReason(row.needs);
|
|
37
|
+
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;
|
|
38
|
+
return <View style={{ width: size + 4, height: size + 4, alignItems: "center", justifyContent: "center" }}>
|
|
39
|
+
{g ? <Icon name={g.name} size={size} color={g.color} /> : <View accessibilityLabel="Running" style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: runningColor(c) }} />}
|
|
40
|
+
{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" }}>
|
|
41
|
+
<Spinner color={runningColor(c)} size={8} label="Running" />
|
|
42
|
+
</View>}
|
|
43
|
+
</View>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RowProps = {
|
|
47
|
+
row: InboxRow; theme: Theme; now: number; dense: boolean; touch: boolean; active?: boolean;
|
|
48
|
+
onOpen?: () => void; onSignOff?: () => void; onMore?: () => void; moreOpen?: boolean;
|
|
49
|
+
selecting?: boolean; checked?: boolean; onCheck?: () => void; trailing?: string; onUndo?: () => void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One agent: line 1 is the title and age, line 2 the reason and its detail. In a narrow row the
|
|
54
|
+
* "› Agent" part of the title moves to the start of line 2, so a long workspace title never hides both names.
|
|
55
|
+
*/
|
|
56
|
+
export function InboxRowView({ row, theme, now, dense, touch, active, onOpen, onSignOff, onMore, moreOpen, selecting, checked, onCheck, trailing, onUndo }: RowProps) {
|
|
57
|
+
const c = theme.colors;
|
|
58
|
+
const [width, setWidth] = useState(0);
|
|
59
|
+
const [hover, setHover] = useState(false);
|
|
60
|
+
const [focused, setFocused] = useState(false);
|
|
61
|
+
const title = rowTitle(row);
|
|
62
|
+
const shown = displayReason(row.needs);
|
|
63
|
+
const primary = dense ? 12 : 13; const secondary = dense ? 11 : 12;
|
|
64
|
+
// Change 2: when "Title › Agent" won't fit, the agent name moves to the reason line so neither name is hidden.
|
|
65
|
+
// Estimated from the text length (≈0.56em per glyph) against the room left after the glyph, age and buttons.
|
|
66
|
+
const room = width - (dense ? 34 : 40) - (touch ? 132 : 88);
|
|
67
|
+
const narrow = width > 0 && !!title.agent && (title.primary.length + title.agent.length + 3) * primary * 0.56 > room;
|
|
68
|
+
const more = (row.needs?.reasons.length ?? 0) - 1;
|
|
69
|
+
const days = row.needs ? now - Date.parse(row.needs.since) > 86_400_000 : false;
|
|
70
|
+
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) : "");
|
|
71
|
+
const reasonColor = shown && !row.snooze ? reasonGlyph(shown.reason, c).color : c.foregroundMuted;
|
|
72
|
+
const second: ReactNode[] = [];
|
|
73
|
+
if (title.agent && narrow) second.push(<Text key="a" style={{ color: c.foreground }}>{title.agent} · </Text>);
|
|
74
|
+
if (shown) second.push(<Text key="r" style={{ color: reasonColor, fontWeight: "600" }}>{REASON_LABEL[shown.reason]}</Text>);
|
|
75
|
+
else if (row.signedOffAt && !running(row)) second.push(<Text key="s" style={{ color: c.statusSuccess }}>Signed off</Text>);
|
|
76
|
+
else if (row.resumesAt) second.push(<Text key="p">Paused at the limit</Text>);
|
|
77
|
+
else if (running(row)) second.push(<Text key="w">Working</Text>);
|
|
78
|
+
if (shown?.detail) second.push(<Text key="d">{` · ${shown.detail}`}</Text>);
|
|
79
|
+
if (more > 0) second.push(<Text key="m">{` · +${more}`}</Text>);
|
|
80
|
+
if (!row.enable_finish_flags && !shown) second.push(<Text key="f">{second.length ? " · " : ""}Finishes not flagged</Text>);
|
|
81
|
+
if (row.delegatedFrom && !shown) second.push(<Text key="g">{second.length ? " · " : ""}Delegated</Text>);
|
|
82
|
+
const quiet = !row.needs || !!row.snooze;
|
|
83
|
+
const showCheck = !!onSignOff && !selecting && (touch || hover || focused || !dense);
|
|
84
|
+
const a11y = [label(row), shown ? `${REASON_LABEL[shown.reason]}${shown.detail ? `: ${shown.detail}` : ""}` : "", right ? `waiting ${right}` : ""].filter(Boolean).join(". ");
|
|
85
|
+
return <View onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
|
|
86
|
+
{...({ onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), onFocus: () => setFocused(true), onBlur: () => setFocused(false) } as object)}
|
|
87
|
+
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" }}>
|
|
88
|
+
{selecting && <Pressable accessibilityRole="checkbox" accessibilityState={{ checked }} aria-checked={checked} accessibilityLabel={`Select ${label(row)}`} onPress={onCheck} hitSlop={6}
|
|
89
|
+
style={{ width: touch ? 44 : 24, height: touch ? 44 : 28, alignItems: "center", justifyContent: "center" }}>
|
|
90
|
+
<View style={{ width: 14, height: 14, borderRadius: 3, borderWidth: 1.5, borderColor: checked ? c.accent : c.foregroundMuted, backgroundColor: checked ? c.accent : "transparent", alignItems: "center", justifyContent: "center" }}>
|
|
91
|
+
{checked && <Icon name="Check" size={10} color={c.accentForeground} />}
|
|
92
|
+
</View>
|
|
93
|
+
</Pressable>}
|
|
94
|
+
<Pressable accessibilityRole="button" accessibilityLabel={a11y} accessibilityHint={onOpen ? "Opens the agent" : undefined} disabled={!onOpen && !selecting} onPress={selecting ? onCheck : onOpen}
|
|
95
|
+
style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: dense ? 8 : 10, paddingVertical: dense ? 4 : 7 }}>
|
|
96
|
+
<Glyph row={row} theme={theme} size={dense ? 14 : 16} />
|
|
97
|
+
<View style={{ flex: 1, minWidth: 0, gap: 1 }}>
|
|
98
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
99
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, color: quiet ? c.foregroundMuted : c.foreground, fontSize: primary, lineHeight: primary + 4, fontWeight: quiet ? "400" : "500" }}>
|
|
100
|
+
{title.primary}{title.agent && !narrow ? <Text style={{ color: c.foregroundMuted }}>{` › ${title.agent}`}</Text> : null}
|
|
101
|
+
</Text>
|
|
102
|
+
</View>
|
|
103
|
+
{second.length > 0 && <Text numberOfLines={shown?.reason === "permission" || shown?.reason === "paused" ? 2 : 1} style={{ color: c.foregroundMuted, fontSize: secondary, lineHeight: secondary + 4 }}>{second}</Text>}
|
|
104
|
+
</View>
|
|
105
|
+
</Pressable>
|
|
106
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, marginLeft: 6 }}>
|
|
107
|
+
{!!right && <Text numberOfLines={1} style={{ color: days ? c.statusWarning : c.foregroundMuted, fontSize: secondary, fontVariant: ["tabular-nums"], marginRight: onSignOff || onMore ? 2 : 4 }}>{right}</Text>}
|
|
108
|
+
{onUndo && <Button theme={theme} compact={touch} kind="ghost" icon="Undo2" title="Undo" accessibilityLabel={`Undo sign-off for ${label(row)}`} onPress={onUndo} />}
|
|
109
|
+
{onMore && !selecting && <IconButton theme={theme} compact={touch} icon="Ellipsis" label={`More for ${label(row)}`} onPress={onMore} />}
|
|
110
|
+
{showCheck && <SignOffButton theme={theme} touch={touch} label={`Sign off ${label(row)}`} onPress={onSignOff!} />}
|
|
111
|
+
</View>
|
|
112
|
+
</View>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function SignOffButton({ theme, touch, label: name, onPress }: { theme: Theme; touch: boolean; label: string; onPress: () => void }) {
|
|
116
|
+
const c = theme.colors;
|
|
117
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={name} onPress={onPress} hitSlop={touch ? 6 : 4}
|
|
118
|
+
style={state => ({ width: touch ? 44 : 28, height: touch ? 44 : 28, alignItems: "center", justifyContent: "center" })}>
|
|
119
|
+
{state => <View style={{ width: touch ? 32 : 24, height: touch ? 32 : 24, borderRadius: 6, borderWidth: 1, borderColor: c.border, alignItems: "center", justifyContent: "center", backgroundColor: state.pressed ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface2 : c.surface1 }}>
|
|
120
|
+
<Icon name="Check" size={touch ? 16 : 14} color={c.foreground} />
|
|
121
|
+
</View>}
|
|
122
|
+
</Pressable>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The ⋯ actions for one row, inline under it: Later…, mute this turn, and the per-agent finish switch. */
|
|
126
|
+
function RowMenu({ row, store, theme, touch, close }: { row: InboxRow; store: InboxStore; theme: Theme; touch: boolean; close: () => void }) {
|
|
127
|
+
const c = theme.colors;
|
|
128
|
+
const later = (choice: "hour" | "tonight" | "tomorrow" | "next-event") => { void store.snooze([row.agentId], snoozeUntil(choice, new Date())); close(); };
|
|
129
|
+
return <View style={{ marginLeft: touch ? 12 : 34, marginRight: 8, marginBottom: 6, paddingVertical: 8, paddingHorizontal: 10, gap: 8, borderRadius: 8, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.border }}>
|
|
130
|
+
{row.needs && !row.snooze && <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
|
|
131
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, marginRight: 2 }}>Later</Text>
|
|
132
|
+
<Button theme={theme} compact={touch} title="In 1 hour" onPress={() => later("hour")} />
|
|
133
|
+
<Button theme={theme} compact={touch} title="Tonight 18:00" onPress={() => later("tonight")} />
|
|
134
|
+
<Button theme={theme} compact={touch} title="Tomorrow 09:00" onPress={() => later("tomorrow")} />
|
|
135
|
+
<Button theme={theme} compact={touch} title="Next event" onPress={() => later("next-event")} />
|
|
136
|
+
</View>}
|
|
137
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
|
|
138
|
+
{running(row) && <Button theme={theme} compact={touch} icon="BellOff" title="Sign off & mute this turn" onPress={() => { void store.signOff([row.agentId], { muteTurn: true }); close(); }} />}
|
|
139
|
+
<Button theme={theme} compact={touch} icon={row.enable_finish_flags ? "BellMinus" : "Bell"} title={row.enable_finish_flags ? "Don't flag finishes" : "Flag finishes again"}
|
|
140
|
+
accessibilityHint="Permission prompts and errors still flag this agent" onPress={() => { void store.setFinishFlags(row.agentId, !row.enable_finish_flags); close(); }} />
|
|
141
|
+
</View>
|
|
142
|
+
</View>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The undo bar: in-surface, so it can carry an Undo action that host toasts can't. Lasts 6 s. */
|
|
146
|
+
export function UndoBar({ store, theme, touch }: { store: InboxStore; theme: Theme; touch: boolean }) {
|
|
147
|
+
const c = theme.colors;
|
|
148
|
+
const recent = useInbox(store, () => store.recent());
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!recent) return;
|
|
151
|
+
const id = setTimeout(() => store.clearRecent(), Math.max(0, 6_000 - (Date.now() - recent.at)));
|
|
152
|
+
return () => clearTimeout(id);
|
|
153
|
+
}, [recent, store]);
|
|
154
|
+
if (!recent) return null;
|
|
155
|
+
return <View accessibilityRole="alert" accessibilityLiveRegion="polite" style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: touch ? 52 : 40, paddingLeft: 12, paddingRight: 4, borderRadius: 8, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.border, ...raised(c) }}>
|
|
156
|
+
<Icon name="CircleCheck" size={15} color={c.statusSuccess} />
|
|
157
|
+
<Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 12 }}>Signed off <Text style={{ color: c.foregroundMuted }}>{recent.label}</Text></Text>
|
|
158
|
+
<Button theme={theme} compact={touch} kind="ghost" icon="Undo2" title="Undo" onPress={() => void store.undo()} />
|
|
159
|
+
</View>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function SectionHeader({ theme, title, count, open, onToggle, tone, right, dense }: { theme: Theme; title: string; count: number; open: boolean; onToggle: () => void; tone?: string; right?: ReactNode; dense: boolean }) {
|
|
163
|
+
const c = theme.colors;
|
|
164
|
+
return <View style={{ flexDirection: "row", alignItems: "center", minHeight: dense ? 28 : 32, paddingLeft: dense ? 4 : 0 }}>
|
|
165
|
+
<Pressable accessibilityRole="button" aria-expanded={open} accessibilityState={{ expanded: open }} accessibilityLabel={`${title}, ${count}. ${open ? "Collapse" : "Expand"}`} onPress={onToggle}
|
|
166
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 6, minHeight: dense ? 28 : 32 }}>
|
|
167
|
+
<Icon name={open ? "ChevronDown" : "ChevronRight"} size={13} color={c.foregroundMuted} />
|
|
168
|
+
<Text accessibilityRole="header" style={{ color: tone ?? c.foreground, fontSize: dense ? 12 : 13, fontWeight: "600" }}>{title}</Text>
|
|
169
|
+
<Text style={{ color: tone ?? c.foregroundMuted, fontSize: dense ? 11 : 12, fontVariant: ["tabular-nums"] }}>{count}</Text>
|
|
170
|
+
</Pressable>
|
|
171
|
+
{right}
|
|
172
|
+
</View>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type InboxNav = { openAgent?: (row: InboxRow) => void; activeAgentId?: string | null };
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The inbox itself: Needs you, Later, Running and Signed off today, with multi-select, first run and
|
|
179
|
+
* empty states. Rendered at the top of Follow-up's screen today, and as the sidebar view on hosts that have one.
|
|
180
|
+
*/
|
|
181
|
+
export function InboxSections({ store, theme, touch, dense, nav, runningCap = 8 }: { store: InboxStore; theme: Theme; touch: boolean; dense: boolean; nav: InboxNav; runningCap?: number }) {
|
|
182
|
+
const c = theme.colors;
|
|
183
|
+
const now = useNow(30_000);
|
|
184
|
+
useInbox(store, () => store.version());
|
|
185
|
+
const data = store.data();
|
|
186
|
+
const rows = store.rows();
|
|
187
|
+
const [open, setOpen] = useState({ needs: true, later: true, running: true, signed: false, unsorted: false });
|
|
188
|
+
const [menu, setMenu] = useState<string | null>(null);
|
|
189
|
+
const [selecting, setSelecting] = useState(false);
|
|
190
|
+
const [checked, setChecked] = useState<Set<string>>(new Set());
|
|
191
|
+
const [confirmAll, setConfirmAll] = useState(false);
|
|
192
|
+
const [showAllRunning, setShowAllRunning] = useState(false);
|
|
193
|
+
const toggle = (k: keyof typeof open) => setOpen(o => ({ ...o, [k]: !o[k] }));
|
|
194
|
+
if (!store.enabled()) return null;
|
|
195
|
+
const needs = sectionRows(rows, "needs", now);
|
|
196
|
+
const later = sectionRows(rows, "later", now);
|
|
197
|
+
const live = sectionRows(rows, "running", now);
|
|
198
|
+
const signed = sectionRows(rows, "signed", now);
|
|
199
|
+
const unsorted = data?.unsorted ?? null;
|
|
200
|
+
const offline = store.offlineSince();
|
|
201
|
+
const cameBack = store.cameBack();
|
|
202
|
+
const pick = (id: string) => setChecked(s => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
|
203
|
+
const exitSelect = () => { setSelecting(false); setChecked(new Set()); };
|
|
204
|
+
const finishedOnly = needs.filter(r => r.needs!.reasons.every(x => x.reason === "finished" || x.reason === "pr_closed"));
|
|
205
|
+
const nextWake = later.map(r => r.snooze!).filter(s => /^\d{4}-/.test(s.until)).sort((a, b) => a.until.localeCompare(b.until))[0];
|
|
206
|
+
const row = (r: InboxRow, extra: Partial<RowProps> = {}) => <View key={r.agentId}>
|
|
207
|
+
<InboxRowView row={r} theme={theme} now={now} dense={dense} touch={touch} active={nav.activeAgentId === r.agentId}
|
|
208
|
+
onOpen={nav.openAgent ? () => nav.openAgent!(r) : undefined} moreOpen={menu === r.agentId} onMore={() => setMenu(menu === r.agentId ? null : r.agentId)} {...extra} />
|
|
209
|
+
{menu === r.agentId && <RowMenu row={r} store={store} theme={theme} touch={touch} close={() => setMenu(null)} />}
|
|
210
|
+
</View>;
|
|
211
|
+
const gap = dense ? 2 : 6;
|
|
212
|
+
return <View style={{ gap }}>
|
|
213
|
+
{offline !== null && <Text accessibilityRole="alert" style={{ color: c.statusWarning, fontSize: 12, paddingHorizontal: dense ? 8 : 0 }}>Can't reach the host. Showing what was loaded before; sign-offs sync when it's back.</Text>}
|
|
214
|
+
{cameBack.length > 0 && <Pressable accessibilityRole="button" onPress={() => store.clearCameBack()} style={{ flexDirection: "row", gap: 6, alignItems: "center", paddingHorizontal: dense ? 8 : 0 }}>
|
|
215
|
+
<Icon name="Info" size={13} color={c.statusWarning} />
|
|
216
|
+
<Text style={{ flex: 1, color: c.foreground, fontSize: 12 }}>{cameBack.length === 1 ? "1 agent" : `${cameBack.length} agents`} came back while offline, so their sign-off didn't apply. Dismiss</Text>
|
|
217
|
+
</Pressable>}
|
|
218
|
+
{data?.error && <Text style={{ color: c.statusWarning, fontSize: 12, paddingHorizontal: dense ? 8 : 0 }}>{data.error}</Text>}
|
|
219
|
+
|
|
220
|
+
<SectionHeader theme={theme} dense={dense} title="Needs you" count={needs.length} tone={needs.length ? undefined : c.foregroundMuted} open={open.needs} onToggle={() => toggle("needs")}
|
|
221
|
+
right={needs.length > 0 && <View style={{ flexDirection: "row" }}>
|
|
222
|
+
{selecting && finishedOnly.length > 0 && <Button theme={theme} compact={touch} kind="ghost" title="Select finished" onPress={() => setChecked(new Set(finishedOnly.map(r => r.agentId)))} />}
|
|
223
|
+
<IconButton theme={theme} compact={touch} icon={selecting ? "X" : "ListChecks"} label={selecting ? "Stop selecting" : "Select agents"} onPress={() => { if (selecting) exitSelect(); else { setMenu(null); setSelecting(true); } }} />
|
|
224
|
+
</View>} />
|
|
225
|
+
{open.needs && <>
|
|
226
|
+
{needs.map(r => row(r, { onSignOff: () => void store.signOff([r.agentId]), selecting, checked: checked.has(r.agentId), onCheck: () => pick(r.agentId) }))}
|
|
227
|
+
{needs.length === 0 && <View style={{ paddingVertical: dense ? 8 : 10, paddingHorizontal: dense ? 8 : 0, gap: 3 }}>
|
|
228
|
+
<Text style={{ color: c.foreground, fontSize: dense ? 12 : 13, fontWeight: "600" }}>Nothing needs you</Text>
|
|
229
|
+
<Text style={{ color: c.foregroundMuted, fontSize: dense ? 11 : 12, lineHeight: dense ? 15 : 17 }}>{RULE}{nextWake ? ` Next wake-up: ${wakeLabel(nextWake, now)}.` : ""}</Text>
|
|
230
|
+
</View>}
|
|
231
|
+
</>}
|
|
232
|
+
{selecting && <View style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: touch ? 52 : 40, paddingHorizontal: 10, borderRadius: 8, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.border }}>
|
|
233
|
+
<Text style={{ flex: 1, color: c.foreground, fontSize: 12, fontVariant: ["tabular-nums"] }}>{checked.size} selected</Text>
|
|
234
|
+
<Button theme={theme} compact={touch} title="Later · tomorrow 09:00" disabled={!checked.size} onPress={() => { void store.snooze([...checked], snoozeUntil("tomorrow", new Date())); exitSelect(); }} />
|
|
235
|
+
<Button theme={theme} compact={touch} kind="primary" icon="Check" title={`Sign off ${checked.size || ""}`.trim()} disabled={!checked.size} onPress={() => { void store.signOff([...checked]); exitSelect(); }} />
|
|
236
|
+
</View>}
|
|
237
|
+
|
|
238
|
+
{data?.firstRun && unsorted && <View style={{ marginTop: 4, padding: 12, gap: 8, borderRadius: 8, borderWidth: 1, borderColor: c.border, backgroundColor: c.surface1 }}>
|
|
239
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 17 }}><Text style={{ color: c.foreground, fontWeight: "600" }}>Agents stay in Needs you until you sign them off or reply.</Text> Opening one doesn't clear it. Sign off with ✓, /ok or ⌘K.</Text>
|
|
240
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 17 }}><Text style={{ color: c.foreground, fontWeight: "600" }}>Unsorted · {unsorted.length} older idle agent{unsorted.length === 1 ? "" : "s"}</Text> were idle for over 7 days, so they weren't added.</Text>
|
|
241
|
+
{confirmAll
|
|
242
|
+
? <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
|
|
243
|
+
<Text style={{ color: c.foreground, fontSize: 12 }}>Sign off all {unsorted.length}? One Undo restores them for 24 h.</Text>
|
|
244
|
+
<Button theme={theme} compact={touch} kind="primary" title={`Sign off ${unsorted.length}`} onPress={() => { setConfirmAll(false); void store.unsortedSignOff(); }} />
|
|
245
|
+
<Button theme={theme} compact={touch} kind="ghost" title="Cancel" onPress={() => setConfirmAll(false)} />
|
|
246
|
+
</View>
|
|
247
|
+
: <View style={{ flexDirection: "row", gap: 8 }}>
|
|
248
|
+
<Button theme={theme} compact={touch} icon="List" title={open.unsorted ? "Hide" : "Review"} onPress={() => toggle("unsorted")} />
|
|
249
|
+
<Button theme={theme} compact={touch} title={`Sign off ${unsorted.length}…`} onPress={() => setConfirmAll(true)} />
|
|
250
|
+
</View>}
|
|
251
|
+
{open.unsorted && <View style={{ marginHorizontal: -8 }}>
|
|
252
|
+
{unsorted.slice(0, 50).map(r => <InboxRowView key={r.agentId} row={r} theme={theme} now={now} dense touch={touch} trailing={age(r.lastActivityAt, now)}
|
|
253
|
+
onOpen={nav.openAgent ? () => nav.openAgent!(r) : undefined} onSignOff={() => void store.unsortedSignOff([r.agentId])} />)}
|
|
254
|
+
{unsorted.length > 50 && <Text style={{ color: c.foregroundMuted, fontSize: 11, paddingHorizontal: 8 }}>and {unsorted.length - 50} more</Text>}
|
|
255
|
+
</View>}
|
|
256
|
+
</View>}
|
|
257
|
+
{data?.batchUndo && !unsorted && <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: dense ? 8 : 0 }}>
|
|
258
|
+
<Text style={{ flex: 1, color: c.foregroundMuted, fontSize: 12 }}>Signed off {data.batchUndo.count} at once · {age(data.batchUndo.at, now)} ago</Text>
|
|
259
|
+
<Button theme={theme} compact={touch} kind="ghost" icon="Undo2" title="Undo all" onPress={() => void store.batchUndo()} />
|
|
260
|
+
</View>}
|
|
261
|
+
|
|
262
|
+
{later.length > 0 && <>
|
|
263
|
+
<SectionHeader theme={theme} dense={dense} title="Later" count={later.length} open={open.later} onToggle={() => toggle("later")} />
|
|
264
|
+
{open.later && later.map(r => row(r, { trailing: wakeLabel(r.snooze!, now), onSignOff: () => void store.signOff([r.agentId]) }))}
|
|
265
|
+
</>}
|
|
266
|
+
{live.length > 0 && <>
|
|
267
|
+
<SectionHeader theme={theme} dense={dense} title="Running" count={live.length} open={open.running} onToggle={() => toggle("running")} />
|
|
268
|
+
{open.running && (showAllRunning ? live : live.slice(0, runningCap)).map(r => row(r))}
|
|
269
|
+
{open.running && live.length > runningCap && !showAllRunning && <Button theme={theme} compact={touch} kind="ghost" title={`+${live.length - runningCap} more`} onPress={() => setShowAllRunning(true)} />}
|
|
270
|
+
</>}
|
|
271
|
+
{signed.length > 0 && <>
|
|
272
|
+
<SectionHeader theme={theme} dense={dense} title="Signed off today" count={signed.length} open={open.signed} onToggle={() => toggle("signed")} />
|
|
273
|
+
{open.signed && signed.map(r => row(r, { onUndo: r.undoable ? () => void store.undo([r.agentId]) : undefined, onMore: undefined }))}
|
|
274
|
+
</>}
|
|
275
|
+
</View>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Sidebar header item: "Needs you N". Registered once, synchronously (the host resolves sidebar items
|
|
280
|
+
* when the installation mounts); it renders nothing at zero unless the developer keeps it always on.
|
|
281
|
+
*/
|
|
282
|
+
const sidebarViews = { mounted: 0, listeners: new Set<() => void>() };
|
|
283
|
+
const subscribeSidebarViews = (fn: () => void) => { sidebarViews.listeners.add(fn); return () => { sidebarViews.listeners.delete(fn); }; };
|
|
284
|
+
/** Called by the Phase 2 sidebar view: while it is the sidebar, the header item would repeat the list right below it. */
|
|
285
|
+
export function useSidebarViewMounted() {
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
sidebarViews.mounted += 1; sidebarViews.listeners.forEach(fn => fn());
|
|
288
|
+
return () => { sidebarViews.mounted -= 1; sidebarViews.listeners.forEach(fn => fn()); };
|
|
289
|
+
}, []);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function contributeHeaderItem(client: PluginClientContext, store: InboxStore) {
|
|
293
|
+
function NeedsYouItem({ currentScreen, openScreen, theme }: PluginSidebarItemProps) {
|
|
294
|
+
const count = useInbox(store, () => sectionRows(store.rows(), "needs", Date.now()).length);
|
|
295
|
+
const inSidebarView = useSyncExternalStore(subscribeSidebarViews, () => sidebarViews.mounted > 0, () => false);
|
|
296
|
+
const visible = useInbox(store, () => store.enabled() && (count > 0 || store.headerWhenEmpty())) && !inSidebarView;
|
|
297
|
+
const c = theme.colors;
|
|
298
|
+
if (!visible) return null;
|
|
299
|
+
return <SidebarRow icon="Inbox" label="Needs you" active={currentScreen?.screenId === INBOX_SCREEN && currentScreen.params.section === "needs"} onPress={() => openScreen({ screenId: INBOX_SCREEN, params: { section: "needs" } })}
|
|
300
|
+
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 }}>
|
|
301
|
+
<Text style={{ color: c.surface0, fontSize: 11, lineHeight: 14, fontWeight: "700", fontVariant: ["tabular-nums"] }}>{count}</Text>
|
|
302
|
+
</View> : undefined} />;
|
|
303
|
+
}
|
|
304
|
+
return client.addSidebarHeaderItem({ id: "needs-you", title: "Needs you", Component: NeedsYouItem });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Above the composer, one split control: the status ("Needs you · finished 12m") signs off on click,
|
|
309
|
+
* and "More" beside it opens "Sign off & next", "Later…", "Mute this turn" and the finish switch.
|
|
310
|
+
* 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
|
|
311
|
+
* into "Signed off · Undo" for 6 s.
|
|
312
|
+
*/
|
|
313
|
+
export function contributeInboxPills(client: PluginClientContext, store: InboxStore, openNext: (from: string) => void) {
|
|
314
|
+
const pills = new Map<string, { workspaceId: string; key: string; status: PluginButtonRegistration; menu: PluginButtonRegistration | null }>();
|
|
315
|
+
let undoTimer: ReturnType<typeof setTimeout> | null = null;
|
|
316
|
+
const statusIcon = (agentId: string) => function StatusIcon(props: PluginButtonIconProps) {
|
|
317
|
+
const reason = useInbox(store, () => { const r = store.rows().find(x => x.agentId === agentId); return r?.needs ? displayReason(r.needs)?.reason ?? null : null; });
|
|
318
|
+
const c = props.theme.colors;
|
|
319
|
+
if (!reason) return <Icon name="Undo2" size={props.size} color={c.foregroundMuted} />;
|
|
320
|
+
// A check, tinted with the reason's colour: the label says why, the icon says what a click does.
|
|
321
|
+
return <Icon name="Check" size={props.size} color={reasonGlyph(reason, c).color} />;
|
|
322
|
+
};
|
|
323
|
+
const menuItems = (row: InboxRow) => [
|
|
324
|
+
{ 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); } } },
|
|
325
|
+
{ 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())); } } })) } },
|
|
326
|
+
{ 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 }); } } },
|
|
327
|
+
{ kind: "separator" as const, id: "sep" },
|
|
328
|
+
{ 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); } } },
|
|
329
|
+
];
|
|
330
|
+
const publish = () => {
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const recent = store.recent();
|
|
333
|
+
const wanted = new Map<string, { row: InboxRow; label: string; title: string; undo: boolean }>();
|
|
334
|
+
for (const row of store.rows()) {
|
|
335
|
+
if (!row.workspaceId) continue;
|
|
336
|
+
const shown = displayReason(row.needs);
|
|
337
|
+
if (shown && !row.snooze) {
|
|
338
|
+
const status = `Needs you · ${REASON_WORD[shown.reason].toLowerCase()} ${age(row.needs!.since, now)}`;
|
|
339
|
+
wanted.set(row.agentId, { row, undo: false, label: `${REASON_WORD[shown.reason]} · ${age(row.needs!.since, now)}`, title: store.data()?.firstRun ? `${status}. Click to sign off. ${RULE}` : `${status}. Click to sign off.` });
|
|
340
|
+
} else if (recent && recent.agentIds.includes(row.agentId) && now - recent.at < 6_000) {
|
|
341
|
+
wanted.set(row.agentId, { row, undo: true, label: "Undo sign-off", title: "Signed off. Click to undo." });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const [agentId, entry] of pills) {
|
|
345
|
+
const next = wanted.get(agentId);
|
|
346
|
+
if (!next || next.row.workspaceId !== entry.workspaceId) { entry.status.remove(); entry.menu?.remove(); pills.delete(agentId); }
|
|
347
|
+
}
|
|
348
|
+
for (const [agentId, next] of wanted) try {
|
|
349
|
+
const key = `${next.label}|${next.undo}|${next.row.enable_finish_flags}|${running(next.row)}`;
|
|
350
|
+
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]); } } };
|
|
351
|
+
const entry = pills.get(agentId);
|
|
352
|
+
if (!entry) {
|
|
353
|
+
const registration = client.addComposerPill({ id: "needs-you", workspaceId: next.row.workspaceId!, agentId, button: { ...status, icon: statusIcon(agentId) } });
|
|
354
|
+
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) } } });
|
|
355
|
+
pills.set(agentId, { workspaceId: next.row.workspaceId!, key, status: registration, menu });
|
|
356
|
+
} else if (entry.key !== key) {
|
|
357
|
+
entry.status.update(status);
|
|
358
|
+
if (next.undo) { entry.menu?.remove(); entry.menu = null; }
|
|
359
|
+
else if (entry.menu) entry.menu.update({ behavior: { kind: "menu", items: menuItems(next.row) } });
|
|
360
|
+
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) } } });
|
|
361
|
+
entry.key = key;
|
|
362
|
+
}
|
|
363
|
+
} catch { /* One agent's pill must never hide the others. */ }
|
|
364
|
+
if (recent && Date.now() - recent.at < 6_000) { if (undoTimer) clearTimeout(undoTimer); undoTimer = setTimeout(publish, 6_000 - (Date.now() - recent.at) + 50); }
|
|
365
|
+
};
|
|
366
|
+
const unsubscribe = store.subscribe(publish);
|
|
367
|
+
// Ages count up between polls.
|
|
368
|
+
const ticker = setInterval(publish, 60_000);
|
|
369
|
+
return () => { unsubscribe(); clearInterval(ticker); if (undoTimer) clearTimeout(undoTimer); for (const p of pills.values()) { p.status.remove(); p.menu?.remove(); } pills.clear(); };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** ⌘K items and /ok. The keyboard path is ⌘K, "ok", Enter. */
|
|
373
|
+
export function contributeInboxCommands(client: PluginClientContext, store: InboxStore, openNext: (from: string | null) => void) {
|
|
374
|
+
const removers = [
|
|
375
|
+
client.addCommandCenterItem({ id: "inbox-sign-off-next", title: "Sign off & next", icon: "ArrowRight", keywords: ["ok", "done", "ack", "needs you", "sign off", "next"], context: "agent",
|
|
376
|
+
onSelect({ agent }) { void store.signOff([agent.id]); openNext(agent.id); } }),
|
|
377
|
+
client.addCommandCenterItem({ id: "inbox-sign-off", title: "Sign off", icon: "Check", keywords: ["ok", "done", "ack", "needs you", "sign off"], context: "agent",
|
|
378
|
+
onSelect({ agent }) { void store.signOff([agent.id]); } }),
|
|
379
|
+
client.addCommandCenterItem({ id: "inbox-later", title: "Later: tomorrow 09:00", icon: "AlarmClock", keywords: ["ok", "snooze", "later", "needs you"], context: "agent",
|
|
380
|
+
onSelect({ agent }) { void store.snooze([agent.id], snoozeUntil("tomorrow", new Date())); } }),
|
|
381
|
+
client.addCommandCenterItem({ id: "inbox-undo", title: "Undo last sign-off", icon: "Undo2", keywords: ["ok", "undo", "needs you", "sign off"], context: "global",
|
|
382
|
+
onSelect() { void store.undo(); } }),
|
|
383
|
+
client.addCommandCenterItem({ id: "inbox-next", title: "Next needing me", icon: "Inbox", keywords: ["ok", "next", "needs you", "inbox"], context: "global",
|
|
384
|
+
onSelect() { openNext(null); } }),
|
|
385
|
+
client.addSlashCommand({ name: "ok", description: "Sign off this agent (Needs you). /ok next opens the next one", argumentHint: "[next]", context: "agent",
|
|
386
|
+
onSubmit({ agent, args }) { void store.signOff([agent.id]); if (/^\s*next\b/i.test(args)) openNext(agent.id); } }),
|
|
387
|
+
];
|
|
388
|
+
return () => { for (const r of removers) r(); };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** "Sign off & next" target, honouring D9 and skipping the agent just signed off. */
|
|
392
|
+
export function nextTarget(store: InboxStore, from: string | null): InboxRow | null {
|
|
393
|
+
return nextAfter(store.rows(), from, Date.now());
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Follow-up's screen relays "& next": opening the screen with `next` hands off to the host's navigation. */
|
|
397
|
+
export function useNextRelay(store: InboxStore, params: Record<string, string>, openAgent: ((row: InboxRow) => void) | undefined) {
|
|
398
|
+
const done = useRef<string | null>(null);
|
|
399
|
+
const ready = useInbox(store, () => !!store.data());
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
const token = params.next;
|
|
402
|
+
if (!token || !openAgent || !ready || done.current === token) return;
|
|
403
|
+
done.current = token;
|
|
404
|
+
const target = nextTarget(store, params.from ?? null);
|
|
405
|
+
if (target) openAgent(target);
|
|
406
|
+
}, [params.next, params.from, ready, openAgent, store]);
|
|
407
|
+
}
|