paseo-follow-up 1.1.0-beta.1 → 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 +4 -2
- package/README.md +20 -6
- package/client/brief.tsx +232 -0
- package/client/chat.tsx +1 -1
- package/client/display.tsx +25 -24
- package/client/inbox-store.ts +33 -20
- package/client/inbox.tsx +28 -113
- 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 +23 -18
- package/index.server.ts +116 -9
- package/package.json +1 -1
- package/server/agent-hook.mjs +37 -10
- package/server/discovery.ts +10 -1
- package/server/engine.ts +4 -2
- package/server/hook-source.ts +1 -1
- package/server/inbox-classifier.ts +14 -6
- package/server/inbox.ts +251 -31
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +26 -8
- package/server/session-mcp.ts +3 -1
- package/shared/attention.ts +0 -11
- package/shared/brief.ts +359 -0
- package/shared/contracts.ts +16 -1
- package/shared/display.ts +5 -3
- package/shared/headline.ts +141 -0
- package/shared/inbox.ts +21 -14
- package/shared/questions.ts +1 -1
package/OVERVIEW.md
CHANGED
|
@@ -20,10 +20,12 @@ 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
|
|
|
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
|
|
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 for tracked sessions and, while the Needs-you inbox is on, the `needs_you` tool and its Stop-hook nudge for root agents (see below). To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
|
|
28
28
|
|
|
29
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.
|
|
30
|
+
|
|
31
|
+
The brief (beta) is stored on the agent's inbox entry and appended to its timeline as a Follow-up row; re-appending replaces that row, and the restart check ignores the `updatedAt` change the append causes because it never reads `updatedAt`. To build a brief Follow-up reads only what it already has: the agent's `needs_you` call, the permission request's title, the failed turn's error message, Session Guard's note and reset time for a paused agent, and its own PR state. It does not read or quote the agent's reply. While the inbox is on, every root Paseo agent on Claude Code or Codex gets Follow-up's MCP bridge with the `needs_you` tool only (tracked main agents keep their full set), and `plugin-data/follow-through/brief.json` records whether the inbox and the nudge are on and which agents have a permission prompt showing. Clicking an option or Retry sends one message as you; it is logged in the delivery ledger as yours, so it clears the flag. The Stop hook, part of the same reversible provider hooks Follow-up already installs, now also runs for those root Claude Code sessions: if the turn ended without `needs_you`, it blocks the stop once with a one-line request to call it, records that it did in `hook-state`, and never blocks the same turn again. It reads `brief.json`, its own per-turn marker and the time of the agent's last `needs_you` call, and nothing else. Turn it off with *Ask agents to state what they need*; turning the inbox off stops it too.
|
package/README.md
CHANGED
|
@@ -74,20 +74,34 @@ 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.
|
|
87
|
+
|
|
88
|
+
### What you need to do, front and center (1.2 beta)
|
|
89
|
+
|
|
90
|
+
When an agent needs you, Follow-up adds a **brief** at the end of its chat, right above the composer, so it's the first thing you see when you open the agent:
|
|
91
|
+
|
|
92
|
+
- **The ask in one line**, written as what you do: *Pick how the export API is rate-limited*, *Review and merge #813*, *Allow or deny: run the migration on staging*, or *Nothing needed. Daily updates are scheduled.* The same line shows on the inbox row and in the pill's tooltip, and the pill's label comes from it (*Asks*, *Merge*, *Error*, *Paused*, *Finished*).
|
|
93
|
+
- **Decisions as option rows.** Click one and it goes to the agent as your message, which clears the flag. Typing the letter or the option's label in the composer counts too. On a phone, or a window too short for the whole card, each option shows its letter and label, with what it means one tap away, so the card fits above the composer.
|
|
94
|
+
- **Only the context needed**, plus PR facts Follow-up already knows, and the one move for this kind of ask: *Squash & merge* (Follow-up's merge flow), *Retry the turn*, *Copy error* or *Sign off & next*. Permission briefs are a single line: Paseo's own prompt right below is the control.
|
|
95
|
+
- **Where it came from**, always: stated by the agent, from the permission request, Session Guard, the failed turn or Follow-up's PR state. If the agent stated nothing, the card says so and points at its last paragraph. It never guesses an ask.
|
|
96
|
+
|
|
97
|
+
The content comes from a new `needs_you` tool that every root agent gets while the inbox is on, with an instruction to call it once at the end of a turn. Contributors, recovery agents and subagents don't get it. After you answer, sign off or the agent keeps going, the card folds to one muted line, so the history still shows what was asked and what happened.
|
|
98
|
+
|
|
99
|
+
**Ask agents to state what they need** (on by default, in **Follow-up: display settings**): when a Claude Code root agent ends a turn without calling `needs_you`, Follow-up's Stop hook asks it once to call it, then lets it stop. It never asks twice in one turn, and skips subagents, contributors, recovery agents and agents waiting on a permission prompt. Codex and other providers get the instruction only. The settings screen shows how often agents stated what they need over the last 7 days.
|
|
86
100
|
|
|
87
101
|
Install the beta:
|
|
88
102
|
|
|
89
103
|
```sh
|
|
90
|
-
paseo plugin update follow-through --version 1.
|
|
104
|
+
paseo plugin update follow-through --version 1.2.0-beta.1
|
|
91
105
|
# or, for a fresh install:
|
|
92
106
|
paseo plugin install npm:paseo-follow-up@beta
|
|
93
107
|
```
|
|
@@ -103,7 +117,7 @@ Betas publish to npm's `beta` dist-tag, so `paseo plugin update` without `--vers
|
|
|
103
117
|
## How it works
|
|
104
118
|
|
|
105
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.
|
|
106
|
-
- **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.
|
|
107
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.
|
|
108
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.
|
|
109
123
|
- Answers go out only when you click Send. Follow-up never answers for you.
|
package/client/brief.tsx
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { openExternalUrl, useRpc, type PluginClientContext, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
5
|
+
import { Icon, copyText } from "@getpaseo/plugin/client/ui";
|
|
6
|
+
import { BRIEF_KIND, BRIEF_VERSION, briefSchema, collapsedLine, extrasFit, nothingNeededLine, isTight, type Brief, type BriefDecision, type BriefMove } from "../shared/brief";
|
|
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)];
|
|
11
|
+
import { briefActionRpc, browserStatusRpc } from "../shared/contracts";
|
|
12
|
+
import { errorText } from "../shared/errors";
|
|
13
|
+
import type { Workspace } from "../shared/model";
|
|
14
|
+
import { useNow } from "./kit";
|
|
15
|
+
import { PrSheet } from "./pr-sheet";
|
|
16
|
+
import { isLight, withAlpha, type Colors } from "./theme";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The brief card (design §2): the ask in one line, decisions as option rows, only the context needed, and the one
|
|
20
|
+
* move for this kind of ask. It sits at the end of the timeline, right above the composer and the Needs-you control.
|
|
21
|
+
*/
|
|
22
|
+
export type BriefDeps = {
|
|
23
|
+
signOff(agentId: string): void;
|
|
24
|
+
openNext(from: string): void;
|
|
25
|
+
openAgent(agentId: string): void;
|
|
26
|
+
workspace(id: string): Workspace | undefined;
|
|
27
|
+
/** Opens Follow-up's panel for a workspace whose record this client hasn't loaded yet. */
|
|
28
|
+
openPanel(workspaceId: string): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const mono = () => (Platform.OS === "ios" ? "Menlo" : "ui-monospace, SFMono-Regular, Menlo, monospace");
|
|
32
|
+
/** Kinds the developer has to act on wear the attention tone; a plain finish and the fallback stay neutral. */
|
|
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" };
|
|
35
|
+
const kindTone = (kind: Brief["kind"], c: Colors) => kind === "error" ? c.statusDanger : kind === "review" || kind === "done" ? c.statusSuccess : kind === "unknown" ? c.foregroundMuted : c.statusWarning;
|
|
36
|
+
const factTone = (tone: string, c: Colors) => tone === "ok" ? c.statusSuccess : tone === "warn" ? c.statusWarning : tone === "bad" ? c.statusDanger : c.foregroundMuted;
|
|
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 }) : ""; };
|
|
38
|
+
/** Web keyboard focus: a visible ring, since sending an answer is one-shot. */
|
|
39
|
+
const ring = (focused: boolean | undefined, c: Colors) => focused && Platform.OS === "web" ? ({ outlineWidth: 2, outlineStyle: "solid", outlineColor: withAlpha(c.accent, 0.6), outlineOffset: 2 } as object) : {};
|
|
40
|
+
const letters = (d: BriefDecision) => { const l = d.options.map(o => o.letter); return l.length > 1 ? `${l.slice(0, -1).join(", ")} or ${l.at(-1)}` : l[0] ?? ""; };
|
|
41
|
+
|
|
42
|
+
function Btn({ label, icon, primary, onPress, c, compact, disabled }: { label: string; icon?: string; primary?: boolean; onPress: () => void; c: Colors; compact: boolean; disabled?: boolean }) {
|
|
43
|
+
const fg = primary ? c.accentForeground : c.foreground;
|
|
44
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled }} disabled={disabled} onPress={onPress}
|
|
45
|
+
style={({ hovered, pressed, focused }: { hovered?: boolean; pressed: boolean; focused?: boolean }) => ({ ...ring(focused, c), minHeight: compact ? 44 : 32, paddingHorizontal: compact ? 14 : 12, borderRadius: 6, flexDirection: "row", alignItems: "center", gap: 6, justifyContent: "center", opacity: disabled ? 0.5 : 1,
|
|
46
|
+
backgroundColor: primary ? (pressed ? withAlpha(c.accent, 0.85) : hovered ? withAlpha(c.accent, 0.92) : c.accent) : pressed || hovered ? c.surface2 : "transparent",
|
|
47
|
+
borderWidth: primary ? 0 : 1, borderColor: c.border, flexGrow: compact ? 1 : 0 })}>
|
|
48
|
+
{icon ? <Icon name={icon} size={compact ? 16 : 14} color={fg} /> : null}
|
|
49
|
+
<Text numberOfLines={1} style={{ color: fg, fontSize: compact ? 14 : 13, lineHeight: compact ? 19 : 17, fontWeight: "600" }}>{label}</Text>
|
|
50
|
+
</Pressable>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function OptionRow({ option, recommended, sending, disabled, tight, open, onToggle, onPress, c, compact }: {
|
|
54
|
+
option: BriefDecision["options"][number]; recommended: boolean; sending: boolean; disabled: boolean; tight: boolean; open: boolean; onToggle: () => void; onPress: () => void; c: Colors; compact: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const strong = withAlpha(c.foreground, isLight(c) ? 0.38 : 0.42);
|
|
57
|
+
const showDetail = !!option.consequence && (!tight || open);
|
|
58
|
+
return <View style={{ flexDirection: "row", alignItems: "stretch", borderRadius: 8, borderWidth: 1, borderColor: recommended ? strong : c.border, opacity: disabled && !sending ? 0.5 : 1, overflow: "hidden" }}>
|
|
59
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Answer ${option.letter}: ${option.label}${recommended ? " (recommended)" : ""}`} accessibilityHint={option.consequence ?? undefined} accessibilityState={{ disabled, busy: sending }} disabled={disabled} onPress={onPress}
|
|
60
|
+
style={({ hovered, pressed, focused }: { hovered?: boolean; pressed: boolean; focused?: boolean }) => ({ ...ring(focused, c), flex: 1, flexDirection: "row", alignItems: "flex-start", gap: 10, paddingVertical: compact ? 11 : 9, paddingHorizontal: 10, minHeight: compact ? 44 : 0,
|
|
61
|
+
backgroundColor: pressed ? c.surface2 : hovered && !disabled ? withAlpha(c.foreground, 0.05) : "transparent" })}>
|
|
62
|
+
<View style={{ width: 22, height: 22, borderRadius: 5, alignItems: "center", justifyContent: "center", backgroundColor: recommended ? c.foreground : c.surface2 }}>
|
|
63
|
+
<Text style={{ fontSize: 12, fontWeight: "700", color: recommended ? c.surface1 : c.foreground }}>{option.letter}</Text>
|
|
64
|
+
</View>
|
|
65
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
66
|
+
<View style={{ flexDirection: "row", alignItems: "center", columnGap: 8, flexWrap: "wrap" }}>
|
|
67
|
+
<Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{option.label}</Text>
|
|
68
|
+
{recommended && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Recommended</Text>}
|
|
69
|
+
{sending && <Text accessibilityLiveRegion="polite" style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>Sending…</Text>}
|
|
70
|
+
</View>
|
|
71
|
+
{showDetail && <Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 18 }}>{option.consequence}</Text>}
|
|
72
|
+
</View>
|
|
73
|
+
</Pressable>
|
|
74
|
+
{tight && !!option.consequence && <Pressable accessibilityRole="button" aria-expanded={open} accessibilityLabel={`${open ? "Hide" : "Show"} what ${option.letter} means`} onPress={onToggle}
|
|
75
|
+
style={({ pressed, focused }: { pressed: boolean; focused?: boolean }) => ({ ...ring(focused, c), width: 44, alignItems: "center", justifyContent: "center", borderLeftWidth: 1, borderLeftColor: c.border, backgroundColor: pressed ? c.surface2 : "transparent" })}>
|
|
76
|
+
<View style={{ transform: [{ rotate: open ? "180deg" : "0deg" }] }}><Icon name="ChevronDown" size={16} color={c.foregroundMuted} /></View>
|
|
77
|
+
</Pressable>}
|
|
78
|
+
</View>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Collapsed({ b, c, now }: { b: Brief; c: Colors; now: number }) {
|
|
82
|
+
// F8: history keeps what was asked and what happened, as one muted line (two when an answer was picked).
|
|
83
|
+
const answered = b.state === "answered";
|
|
84
|
+
const icon = b.state === "superseded" ? "History" : "CircleCheck";
|
|
85
|
+
const color = b.state === "superseded" ? c.foregroundMuted : c.statusSuccess;
|
|
86
|
+
const right = answered && b.outcomeAt ? `sent ${clock(b.outcomeAt)}` : age(b.outcomeAt ?? b.at, now);
|
|
87
|
+
return <View accessibilityLiveRegion="polite" style={{ marginVertical: 10, flexDirection: "row", gap: 10, alignItems: answered ? "flex-start" : "center", minHeight: 40, paddingVertical: answered ? 10 : 8, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: c.border }}>
|
|
88
|
+
<View style={{ paddingTop: answered ? 2 : 0 }}><Icon name={icon} size={15} color={color} /></View>
|
|
89
|
+
{answered
|
|
90
|
+
? <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
91
|
+
<Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{b.ask}</Text>
|
|
92
|
+
<Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{b.outcome}</Text>
|
|
93
|
+
</View>
|
|
94
|
+
: <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: c.foregroundMuted, fontSize: 13, lineHeight: 18 }}>{collapsedLine(b)}</Text>}
|
|
95
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{right}</Text>
|
|
96
|
+
</View>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimelineItemProps<Brief> & { deps: BriefDeps }) {
|
|
100
|
+
const c = theme.colors; const b = item.data; const compact = layout.compact;
|
|
101
|
+
const now = useNow(60_000);
|
|
102
|
+
const { height } = useWindowDimensions();
|
|
103
|
+
const act = useRpc(briefActionRpc);
|
|
104
|
+
const browserRead = useRpc(browserStatusRpc);
|
|
105
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
106
|
+
const [error, setError] = useState<string | null>(null);
|
|
107
|
+
const [open, setOpen] = useState<Record<string, boolean>>({});
|
|
108
|
+
const [details, setDetails] = useState(false);
|
|
109
|
+
const [sheet, setSheet] = useState<{ key: string; intent: "merge" | "reviewers" } | null>(null);
|
|
110
|
+
const workspace = b.workspaceId ? deps.workspace(b.workspaceId) : undefined;
|
|
111
|
+
const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000, enabled: !!sheet });
|
|
112
|
+
if (b.state !== "open") return <Collapsed b={b} c={c} now={now} />;
|
|
113
|
+
|
|
114
|
+
const attention = c.statusWarning;
|
|
115
|
+
const acts = b.flagged && ACTS.includes(b.kind);
|
|
116
|
+
const tone = acts || b.kind === "done" || b.kind === "review" || b.kind === "error" ? kindTone(b.kind, c) : c.foregroundMuted;
|
|
117
|
+
const ageText = age(b.at, now);
|
|
118
|
+
const border = acts ? withAlpha(attention, isLight(c) ? 0.5 : 0.45) : c.border;
|
|
119
|
+
const run = async (key: string, work: () => Promise<unknown>) => {
|
|
120
|
+
if (busy) return; setBusy(key); setError(null);
|
|
121
|
+
try { await work(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
|
122
|
+
};
|
|
123
|
+
const press = (m: BriefMove) => {
|
|
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); }
|
|
125
|
+
else if (m.kind === "open_pr" && m.url) void openExternalUrl(m.url).catch(() => undefined);
|
|
126
|
+
else if (m.kind === "sign_off_next") { deps.signOff(agentId); deps.openNext(agentId); }
|
|
127
|
+
else if (m.kind === "retry") void run("retry", () => act({ agentId, briefId: b.id, move: "retry" }));
|
|
128
|
+
else if (m.kind === "copy_error" && b.mono) void copyText(b.mono).catch(() => undefined);
|
|
129
|
+
else if (m.kind === "open_subagent" && m.agentId) deps.openAgent(m.agentId);
|
|
130
|
+
};
|
|
131
|
+
// A merge runs Follow-up's own merge flow, so it needs the tracked workspace; without one it isn't offered.
|
|
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>;
|
|
141
|
+
|
|
142
|
+
if (b.slim) {
|
|
143
|
+
// One line. Permission: Paseo's own prompt below is the control (F9). Fallback: the paragraph is right above.
|
|
144
|
+
return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you: " : ""}${b.ask}. ${b.sourceLine}`}
|
|
145
|
+
style={{ marginVertical: 10, flexDirection: "row", alignItems: "center", flexWrap: compact ? "wrap" : "nowrap", gap: 8, minHeight: 40, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, borderColor: border }}>
|
|
146
|
+
<Icon name={ICON[b.kind]} size={14} color={acts ? attention : tone} />
|
|
147
|
+
{acts && <Text style={{ color: attention, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Needs you</Text>}
|
|
148
|
+
<Text style={{ flex: 1, minWidth: compact ? 160 : 0, color: acts ? c.foreground : c.foregroundMuted, fontSize: 14, lineHeight: 20, fontWeight: acts ? "600" : "400" }}>{b.ask}</Text>
|
|
149
|
+
{moves.map(m => <Btn key={m.kind} label={m.label} onPress={() => press(m)} c={c} compact={compact} />)}
|
|
150
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
|
|
151
|
+
</View>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tight = isTight(b, compact, height);
|
|
155
|
+
const extra = !!b.context || b.facts.length > 0 || b.links.length > 0;
|
|
156
|
+
const showExtra = !tight || details || extrasFit(b, height);
|
|
157
|
+
const pad = compact ? 14 : 16;
|
|
158
|
+
return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you" : word(b.kind)}: ${b.ask}`}
|
|
159
|
+
style={{ marginVertical: 10, borderRadius: 12, borderWidth: 1, borderColor: border, backgroundColor: c.surface1, overflow: "hidden" }}>
|
|
160
|
+
<View style={{ paddingHorizontal: pad, paddingTop: compact ? 12 : 14, paddingBottom: 14, gap: tight ? 10 : 12 }}>
|
|
161
|
+
<View style={{ gap: 4 }}>
|
|
162
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
|
|
163
|
+
<Icon name={ICON[b.kind]} size={14} color={tone} />
|
|
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>}
|
|
166
|
+
<View style={{ flex: 1 }} />
|
|
167
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
|
|
168
|
+
</View>
|
|
169
|
+
<Text accessibilityRole="header" style={{ color: c.foreground, fontSize: compact ? 17 : 19, lineHeight: compact ? 23 : 26, fontWeight: "600", letterSpacing: -0.2 }}>{b.ask}</Text>
|
|
170
|
+
</View>
|
|
171
|
+
|
|
172
|
+
{b.decisions.map((d, i) => <View key={d.id} style={{ gap: 8 }}>
|
|
173
|
+
<Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20 }}>{b.decisions.length > 1 ? `${i + 1}. ` : ""}{d.question}</Text>
|
|
174
|
+
{d.options.length > 0 && <View style={{ gap: 6 }}>
|
|
175
|
+
{d.options.map(o => <OptionRow key={o.letter} option={o} recommended={d.recommend === o.letter} sending={busy === `${d.id}:${o.letter}`} disabled={!!busy} tight={tight}
|
|
176
|
+
open={!!open[`${d.id}:${o.letter}`]} onToggle={() => setOpen(v => ({ ...v, [`${d.id}:${o.letter}`]: !v[`${d.id}:${o.letter}`] }))}
|
|
177
|
+
onPress={() => void run(`${d.id}:${o.letter}`, () => act({ agentId, briefId: b.id, decisionId: d.id, choice: o.letter }))} c={c} compact={compact} />)}
|
|
178
|
+
</View>}
|
|
179
|
+
{!tight && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{d.options.length ? `Or type ${letters(d)} in the composer, or reply in your own words.` : "Reply in the composer."}</Text>}
|
|
180
|
+
{tight && !d.options.length && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>Reply in the composer.</Text>}
|
|
181
|
+
</View>)}
|
|
182
|
+
|
|
183
|
+
{!!b.mono && !b.ask.endsWith(b.mono) && <View style={{ borderRadius: 8, backgroundColor: withAlpha(c.statusDanger, isLight(c) ? 0.07 : 0.1), paddingHorizontal: 10, paddingVertical: 8 }}>
|
|
184
|
+
<Text selectable numberOfLines={tight ? 3 : undefined} style={{ color: c.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>{b.mono}</Text>
|
|
185
|
+
</View>}
|
|
186
|
+
|
|
187
|
+
{extra && tight && !extrasFit(b, height) && <Pressable accessibilityRole="button" aria-expanded={details} accessibilityLabel={details ? "Hide details" : "Show details"} onPress={() => setDetails(v => !v)} hitSlop={6}
|
|
188
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start", minHeight: compact ? 44 : 24 }}>
|
|
189
|
+
<View style={{ transform: [{ rotate: details ? "90deg" : "0deg" }] }}><Icon name="ChevronRight" size={14} color={c.foregroundMuted} /></View>
|
|
190
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "500" }}>Details</Text>
|
|
191
|
+
</Pressable>}
|
|
192
|
+
|
|
193
|
+
{showExtra && !!b.context && <Text selectable numberOfLines={4} style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{b.context}</Text>}
|
|
194
|
+
|
|
195
|
+
{showExtra && (b.facts.length > 0 || b.links.length > 0) && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
|
|
196
|
+
{b.facts.slice(0, 3).map((f, i) => { const col = factTone(f.tone, c); return <View key={`f${i}`} style={{ flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8, minHeight: 24, borderRadius: 6, backgroundColor: f.tone === "muted" ? c.surface2 : withAlpha(col, isLight(c) ? 0.1 : 0.14) }}>
|
|
197
|
+
<Icon name={f.icon} size={12} color={col} />
|
|
198
|
+
<Text style={{ color: f.tone === "muted" || isLight(c) ? c.foreground : col, fontSize: 12, lineHeight: 16, fontWeight: "500" }}>{f.label}</Text>
|
|
199
|
+
</View>; })}
|
|
200
|
+
{b.links.map((l, i) => <Pressable key={`l${i}`} accessibilityRole={l.url ? "link" : "text"} disabled={!l.url} onPress={() => l.url && void openExternalUrl(l.url).catch(() => undefined)} hitSlop={compact ? 10 : 4}
|
|
201
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8, minHeight: 24, borderRadius: 6, backgroundColor: c.surface2 }}>
|
|
202
|
+
<Icon name={l.url ? "ExternalLink" : l.pr ? "GitPullRequest" : "File"} size={12} color={c.foregroundMuted} />
|
|
203
|
+
<Text selectable={!l.url} numberOfLines={1} style={{ color: c.foreground, fontSize: 12, lineHeight: 16, fontWeight: "500", maxWidth: 260 }}>{l.label}</Text>
|
|
204
|
+
</Pressable>)}
|
|
205
|
+
</View>}
|
|
206
|
+
|
|
207
|
+
{moves.length > 0 && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
208
|
+
{moves.map((m, i) => <Btn key={m.kind} label={busy === m.kind ? `${m.label}…` : m.label} icon={MOVE_ICON[m.kind]} primary={i === 0} disabled={!!busy} onPress={() => press(m)} c={c} compact={compact} />)}
|
|
209
|
+
</View>}
|
|
210
|
+
|
|
211
|
+
{!!error && <Text accessibilityRole="alert" style={{ color: c.statusDanger, fontSize: 13, lineHeight: 18 }}>{error}</Text>}
|
|
212
|
+
|
|
213
|
+
{!!b.also && <View style={{ gap: 3, borderTopWidth: 1, borderTopColor: c.border, paddingTop: 10 }}>
|
|
214
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Also, from the agent:</Text>
|
|
215
|
+
<Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20 }}>{b.also.ask}</Text>
|
|
216
|
+
{showExtra && !!b.also.context && <Text numberOfLines={3} style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{b.also.context}</Text>}
|
|
217
|
+
</View>}
|
|
218
|
+
</View>
|
|
219
|
+
<View style={{ borderTopWidth: 1, borderTopColor: c.border, paddingHorizontal: pad, paddingVertical: 8, flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
220
|
+
<Icon name="Info" size={12} color={c.foregroundMuted} />
|
|
221
|
+
<Text style={{ flex: 1, color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{b.sourceLine}</Text>
|
|
222
|
+
</View>
|
|
223
|
+
{workspace && sheet && <PrSheet workspace={workspace} prKey={sheet.key} intent={sheet.intent} theme={theme} compact={compact} browserReady={browser.data?.ready ?? false} onClose={() => setSheet(null)} />}
|
|
224
|
+
</View>;
|
|
225
|
+
}
|
|
226
|
+
|
|
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" };
|
|
228
|
+
|
|
229
|
+
export function contributeBrief(client: PluginClientContext, deps: BriefDeps) {
|
|
230
|
+
const Component = (props: PluginTimelineItemProps<Brief>) => <BriefCard {...props} deps={deps} />;
|
|
231
|
+
return client.addTimelineRenderer({ kind: BRIEF_KIND, version: BRIEF_VERSION, schema: briefSchema, Component });
|
|
232
|
+
}
|
package/client/chat.tsx
CHANGED
|
@@ -104,7 +104,7 @@ function AnswerCardView({ item, theme }: PluginTimelineItemProps<AnswerCard>) {
|
|
|
104
104
|
<View style={{ borderRadius: 10, borderWidth: 1, borderColor: c.border, backgroundColor: c.surface1, overflow: "hidden" }}>
|
|
105
105
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 6 }}>
|
|
106
106
|
<Icon name="MessageCircleReply" size={14} color={c.statusSuccess} />
|
|
107
|
-
<Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>Your answer to {card.id} · {card.title}</Text>
|
|
107
|
+
<Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>Your answer{/^D\d+$/.test(card.id) ? "" : ` to ${card.id}`} · {card.title}</Text>
|
|
108
108
|
<Pressable accessibilityRole="button" aria-expanded={expanded} accessibilityLabel={expanded ? "Hide the full message" : "Show the full message"} onPress={() => setExpanded(v => !v)} hitSlop={6}
|
|
109
109
|
style={state => ({ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, backgroundColor: state.pressed ? c.surface2 : "transparent" })}>
|
|
110
110
|
<Text style={{ color: c.foregroundMuted, fontSize: 12 }}>{expanded ? "Hide" : "Full message"}</Text>
|
package/client/display.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { PluginClientContext, PluginSidebarItemContribution, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
-
import { useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { inboxListRpc } from "../shared/contracts";
|
|
3
5
|
import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
4
6
|
import { useEffect } from "react";
|
|
5
|
-
import { ActivityIndicator,
|
|
7
|
+
import { ActivityIndicator, Text, View } from "react-native";
|
|
6
8
|
import { displayPreferences, displayRpc } from "../shared/display";
|
|
7
9
|
import { sidebarVisibility } from "./sidebar-visibility";
|
|
8
10
|
|
|
@@ -10,8 +12,20 @@ type Display = import("zod").infer<typeof displayPreferences.schema>;
|
|
|
10
12
|
const listeners = new Set<(values: Display) => void>();
|
|
11
13
|
export function subscribeDisplay(listener: (values: Display) => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
/** R2's measurement, where it's cheap to look: how often agents stated what they need on a flagged turn. */
|
|
16
|
+
export function briefShareText(stats: { days: number; total: number; tool: number; structured: number; fallback: number } | undefined): string {
|
|
17
|
+
if (!stats) return "";
|
|
18
|
+
if (!stats.total) return `No flagged turns in the last ${stats.days} days yet.`;
|
|
19
|
+
const pct = Math.round((stats.tool / stats.total) * 100);
|
|
20
|
+
const rest = [stats.structured ? `${stats.structured} from a permission, pause, error or PR` : "", stats.fallback ? `${stats.fallback} with nothing stated` : ""].filter(Boolean).join(", ");
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "Sidebar & pills" and "Needs-you inbox", rendered inside the one Follow-up settings screen. */
|
|
25
|
+
export function DisplaySections({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
14
26
|
const settings = useSettings(displayPreferences);
|
|
27
|
+
const list = useRpc(inboxListRpc);
|
|
28
|
+
const share = useQuery({ queryKey: ["follow-through", "brief-share"], queryFn: () => list({}), refetchInterval: 60_000 });
|
|
15
29
|
const values = settings.status === "ready" ? settings.values : undefined;
|
|
16
30
|
useEffect(() => {
|
|
17
31
|
if (values !== undefined) for (const listener of listeners) listener(values);
|
|
@@ -32,23 +46,24 @@ function DisplaySettings({ theme }: PluginSurfaceProps) {
|
|
|
32
46
|
|
|
33
47
|
const save = (patch: Partial<Display>) => void settings.save({ ...settings.values, ...patch }, settings.revision);
|
|
34
48
|
return (
|
|
35
|
-
|
|
36
|
-
<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.">
|
|
37
51
|
<SettingsCard>
|
|
38
|
-
<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 })} />
|
|
39
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 })} />
|
|
40
|
-
<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 })} />
|
|
41
55
|
</SettingsCard>
|
|
42
56
|
{settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
|
|
43
57
|
</SettingsSection>
|
|
44
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.">
|
|
45
59
|
<SettingsCard>
|
|
46
|
-
<SettingsSwitch label="Needs-you inbox" hint="Off:
|
|
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 })} />
|
|
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 })} />
|
|
48
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
|
+
<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 })} />
|
|
49
63
|
</SettingsCard>
|
|
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>}
|
|
50
65
|
</SettingsSection>
|
|
51
|
-
|
|
66
|
+
</>
|
|
52
67
|
);
|
|
53
68
|
}
|
|
54
69
|
|
|
@@ -59,17 +74,6 @@ export function contributeDisplay(client: PluginClientContext, item: PluginSideb
|
|
|
59
74
|
// Register synchronously, then reconcile the saved preference, so that an
|
|
60
75
|
// already-open sidebar route can resolve its surface during that first render.
|
|
61
76
|
sidebar.set(true);
|
|
62
|
-
const Settings = DisplaySettings;
|
|
63
|
-
const removeSurface = client.addScreen({ id: "display-settings", title: "Follow-up display", Component: Settings });
|
|
64
|
-
const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
|
|
65
|
-
const removeCommand = client.addCommandCenterItem({
|
|
66
|
-
id: "display-settings",
|
|
67
|
-
title: "Follow-up: display settings",
|
|
68
|
-
icon: "PanelLeft",
|
|
69
|
-
keywords: ["Follow-up", "sidebar", "pill", "header", "show", "hide", "display", "settings"],
|
|
70
|
-
context: "global",
|
|
71
|
-
onSelect({ openScreen }) { openScreen({ screenId: "display-settings" }); },
|
|
72
|
-
});
|
|
73
77
|
let disposed = false;
|
|
74
78
|
let pending = false;
|
|
75
79
|
let generation = 0;
|
|
@@ -96,8 +100,5 @@ export function contributeDisplay(client: PluginClientContext, item: PluginSideb
|
|
|
96
100
|
clearInterval(timer);
|
|
97
101
|
listeners.delete(update);
|
|
98
102
|
sidebar.dispose();
|
|
99
|
-
removeSurface();
|
|
100
|
-
removeSettings();
|
|
101
|
-
removeCommand();
|
|
102
103
|
};
|
|
103
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();
|