paseo-follow-up 0.3.6 → 0.4.0

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.
Files changed (63) hide show
  1. package/OVERVIEW.md +19 -7
  2. package/README.md +70 -79
  3. package/THIRD_PARTY_NOTICES.md +33 -0
  4. package/assets/LICENSE.lucide +43 -0
  5. package/assets/icon.png +0 -0
  6. package/assets/icon.svg +1 -0
  7. package/client/button-visibility.ts +2 -1
  8. package/client/chat.tsx +163 -0
  9. package/client/display.tsx +12 -17
  10. package/client/header.tsx +92 -0
  11. package/client/keys.ts +3 -0
  12. package/client/kit.tsx +119 -0
  13. package/client/large.tsx +58 -0
  14. package/client/live.tsx +159 -0
  15. package/client/motion.tsx +93 -0
  16. package/client/needs-you.tsx +121 -0
  17. package/client/overview.tsx +111 -16
  18. package/client/panel.tsx +257 -68
  19. package/client/pr-controls.tsx +50 -33
  20. package/client/pr-sheet.tsx +63 -0
  21. package/client/questions.tsx +307 -0
  22. package/client/settings.tsx +55 -20
  23. package/client/spec.tsx +113 -0
  24. package/client/stacks.tsx +128 -0
  25. package/client/tasks.tsx +113 -22
  26. package/client/theme.ts +69 -0
  27. package/docs/screenshots/follow-up-chat.png +0 -0
  28. package/docs/screenshots/follow-up-hero.png +0 -0
  29. package/docs/screenshots/follow-up-large.png +0 -0
  30. package/docs/screenshots/follow-up-mobile.png +0 -0
  31. package/docs/screenshots/follow-up-overview.png +0 -0
  32. package/docs/screenshots/follow-up-pill.png +0 -0
  33. package/docs/screenshots/follow-up-pr.png +0 -0
  34. package/docs/screenshots/follow-up-questions.png +0 -0
  35. package/docs/screenshots/follow-up-states.png +0 -0
  36. package/index.client.tsx +47 -16
  37. package/index.server.ts +53 -0
  38. package/package.json +4 -2
  39. package/paseo-plugin.json +11 -3
  40. package/server/agent-hook.mjs +2 -0
  41. package/server/daemon.ts +37 -6
  42. package/server/discovery.ts +6 -3
  43. package/server/engine.ts +39 -0
  44. package/server/hook-source.ts +1 -1
  45. package/server/mcp-source.ts +1 -1
  46. package/server/mcp.mjs +12 -2
  47. package/server/questions.ts +112 -0
  48. package/server/spec.ts +102 -0
  49. package/shared/activity.ts +56 -0
  50. package/shared/attention.ts +212 -0
  51. package/shared/chat.ts +130 -0
  52. package/shared/contracts.ts +21 -0
  53. package/shared/display.ts +2 -1
  54. package/shared/errors.ts +5 -0
  55. package/shared/model.ts +10 -0
  56. package/shared/pr-actions.ts +8 -1
  57. package/shared/questions.ts +123 -0
  58. package/shared/spec.ts +46 -0
  59. package/skills/follow-through/SKILL.md +4 -0
  60. package/client/components.tsx +0 -58
  61. package/client/navigation.tsx +0 -23
  62. package/docs/screenshots/follow-up-settings.png +0 -0
  63. package/docs/screenshots/follow-up-workspace.png +0 -0
@@ -0,0 +1,92 @@
1
+ import { Pressable, Text, View } from "react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
+ import type { Workspace } from "../shared/model";
4
+ import { ago, headline, liveLine, stackBar, type LiveIcon } from "../shared/attention";
5
+ import { IconButton, Switch, text, type Theme } from "./kit";
6
+ import { IconSwap, Spinner } from "./motion";
7
+ import { segmentPaint, type Colors } from "./theme";
8
+ import { SpecChip } from "./spec";
9
+
10
+ /** PR status older than this means the poll has stopped working; say so instead of looking current. */
11
+ export const STALE_MS = 10 * 60_000;
12
+
13
+ /** One segment per PR in stack order, coloured by state. The signature of the panel; a segment opens its PR. */
14
+ export function StackBar({ workspace, theme, height = 6, onOpen }: { workspace: Pick<Workspace, "prs">; theme: Theme; height?: number; onOpen?: (key: string) => void }) {
15
+ const bar = stackBar(workspace);
16
+ if (!bar.total) return null;
17
+ return <View accessibilityRole="progressbar" accessibilityLabel={`${bar.merged} of ${bar.total} PRs merged`} accessibilityValue={{ min: 0, max: bar.total, now: bar.merged }}
18
+ style={{ flexDirection: "row", gap: 2, height, borderRadius: height / 2, overflow: onOpen ? "visible" : "hidden" }}>
19
+ {bar.segments.map((s, i) => {
20
+ const paint = segmentPaint(theme.colors, s.tone);
21
+ const r = height / 2, first = i === 0, last = i === bar.segments.length - 1;
22
+ const fill = { flex: 1, justifyContent: "center" as const, alignItems: "center" as const, overflow: "hidden" as const, backgroundColor: paint.fill, borderTopLeftRadius: first ? r : 0, borderBottomLeftRadius: first ? r : 0, borderTopRightRadius: last ? r : 0, borderBottomRightRadius: last ? r : 0 };
23
+ // A short centred dash marks the state without filling the slot, so unmerged work never reads as done.
24
+ const mark = Math.max(2, Math.round(height / 3));
25
+ const line = paint.line ? <View style={{ width: "36%", minWidth: 6, height: mark, borderRadius: mark / 2, backgroundColor: paint.line }} /> : null;
26
+ return onOpen
27
+ ? <Pressable key={s.key} accessibilityRole="button" accessibilityLabel={`Open pull request ${s.number}`} hitSlop={{ top: 8, bottom: 8 }} onPress={() => onOpen(s.key)} style={state => [fill, (state as { hovered?: boolean }).hovered ? { opacity: 0.75 } : null]}>{line}</Pressable>
28
+ : <View key={s.key} style={fill}>{line}</View>;
29
+ })}
30
+ </View>;
31
+ }
32
+
33
+ function LiveGlyph({ icon, colors }: { icon: LiveIcon; colors: Colors }) {
34
+ switch (icon) {
35
+ case "spinner": return <Spinner color={colors.accent} size={12} />;
36
+ case "done": return <Icon name="CircleCheck" size={12} color={colors.statusSuccess} />;
37
+ case "paused": return <Icon name="CirclePause" size={12} color={colors.statusWarning} />;
38
+ case "alert": return <Icon name="TriangleAlert" size={12} color={colors.statusWarning} />;
39
+ case "waiting": return <Icon name="Clock" size={12} color={colors.foregroundMuted} />;
40
+ default: return <Icon name="Circle" size={12} color={colors.foregroundMuted} />;
41
+ }
42
+ }
43
+
44
+ export function WorkspaceHeader({ workspace: w, theme, compact, now, onSettings, onRefresh, refreshing, onAuto, autoPending, notice, onOpenPr, resize, hideTitle = false }: {
45
+ workspace: Workspace; theme: Theme; compact: boolean; now: number; onSettings: () => void; onRefresh: () => void; refreshing: boolean; onAuto: (next: boolean) => void; autoPending: boolean; notice: string | null; onOpenPr?: (key: string) => void;
46
+ /** Expand to the larger view, or collapse back to the panel. Absent where the panel is already as wide as it gets. */
47
+ resize?: { icon: "Maximize2" | "Minimize2"; label: string; onPress: () => void } | null;
48
+ /** The larger view's screen bar already names the workspace. */
49
+ hideTitle?: boolean;
50
+ }) {
51
+ const c = theme.colors; const t = text(c);
52
+ const parts = headline(w, now);
53
+ const live = liveLine(w, now);
54
+ const checked = w.prs.map(p => p.snapshot?.observedAt).filter((v): v is string => !!v).sort().at(-1);
55
+ const stale = !!checked && w.prs.some(p => p.snapshot?.state === "OPEN") && now - Date.parse(checked) > STALE_MS;
56
+ const toneOf = (tone?: string) => tone === "warning" ? c.statusWarning : tone === "danger" ? c.statusDanger : tone === "success" ? c.statusSuccess : undefined;
57
+ const pad = compact ? 16 : 12;
58
+ return <View style={{ paddingHorizontal: pad, paddingTop: compact ? 14 : 12, paddingBottom: 10, gap: 6, borderBottomWidth: 1, borderBottomColor: c.border }}>
59
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
60
+ {/* On phones the host header right above already shows the workspace name. */}
61
+ {compact || hideTitle ? <Text numberOfLines={2} style={[t.meta, { flex: 1, paddingTop: 2 }, hideTitle && !compact ? { fontSize: 13, lineHeight: 19, color: c.foreground } : null]}>{w.goal}</Text>
62
+ : <Text accessibilityRole="header" style={[t.title, { flex: 1 }]}>{w.title}</Text>}
63
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 2, marginVertical: compact ? -12 : -4, marginRight: compact ? -10 : -4 }}>
64
+ {!!w.spec?.url && <View style={{ marginRight: 4 }}><SpecChip workspace={w} theme={theme} compact={compact} /></View>}
65
+ {resize && <IconButton icon={resize.icon} label={resize.label} onPress={resize.onPress} theme={theme} compact={compact} />}
66
+ <IconButton icon="Settings" label="Follow-up settings" onPress={onSettings} theme={theme} compact={compact} />
67
+ </View>
68
+ </View>
69
+ {!compact && !hideTitle && w.goal && w.goal !== w.title && <Text numberOfLines={2} style={t.meta}>{w.goal}</Text>}
70
+ <Text accessibilityLiveRegion="polite" style={[t.meta, { color: c.foregroundMuted, marginTop: 2 }]}>
71
+ {parts.map((part, i) => <Text key={part.text} style={toneOf(part.tone) ? { color: toneOf(part.tone), fontWeight: "600" } : { color: c.foreground }}>{i ? <Text style={{ color: c.foregroundMuted, fontWeight: "400" }}>{" · "}</Text> : null}{part.text}</Text>)}
72
+ </Text>
73
+ <StackBar workspace={w} theme={theme} onOpen={onOpenPr} />
74
+ <View style={{ flexDirection: "row", gap: 6, marginTop: 2 }}>
75
+ <View style={{ paddingTop: 3 }}><IconSwap swapKey={live.icon} size={12}><LiveGlyph icon={live.icon} colors={c} /></IconSwap></View>
76
+ <Text accessibilityLiveRegion="polite" style={[t.meta, { flex: 1, color: live.icon === "spinner" ? c.foreground : c.foregroundMuted }]}>
77
+ {live.text}{live.since ? <Text style={{ color: c.foregroundMuted }}>{` · ${ago(live.since, now)}`}</Text> : null}
78
+ </Text>
79
+ </View>
80
+ {notice && <View accessibilityRole="alert" style={{ flexDirection: "row", gap: 6 }}>
81
+ <View style={{ paddingTop: 3 }}><Icon name="TriangleAlert" size={12} color={c.statusWarning} /></View>
82
+ <Text style={[t.meta, { flex: 1, color: c.statusWarning }]}>{notice}</Text>
83
+ </View>}
84
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4, marginTop: 2, minHeight: compact ? 44 : 28 }}>
85
+ <Text numberOfLines={1} style={[t.small, { flexShrink: 1 }, stale ? { color: c.statusWarning } : null]}>{checked ? `Checked ${ago(checked, now)}${stale ? " · may be out of date" : ""}` : "Checks GitHub every 15s"}</Text>
86
+ <IconButton icon="RefreshCw" label="Refresh PR status" onPress={onRefresh} theme={theme} compact={compact} disabled={refreshing} spinning={refreshing} />
87
+ <View style={{ flex: 1 }} />
88
+ <Switch theme={theme} compact={compact} label="Auto-send" accessibilityLabel="Auto-send repairs" hint="Send repair batches to the main agent on its own when the agent is idle. Off means each batch waits for your click."
89
+ value={w.enable_auto_sends} disabled={autoPending} onChange={onAuto} />
90
+ </View>
91
+ </View>;
92
+ }
package/client/keys.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** React Query keys shared by every surface that reads Follow-up state. */
2
+ export const workspaceKey = (id: string) => ["follow-through", "workspace", id];
3
+ export const overviewKey = ["follow-through", "overview"];
package/client/kit.tsx ADDED
@@ -0,0 +1,119 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/ui";
4
+ import { PressScale, Spinner } from "./motion";
5
+ import { raised, tint, withAlpha, type Colors, type Theme } from "./theme";
6
+
7
+ export type { Theme } from "./theme";
8
+
9
+ /** Re-render on an interval so "3m ago" keeps counting between polls. */
10
+ export function useNow(intervalMs = 30_000): number {
11
+ const [now, setNow] = useState(() => Date.now());
12
+ useEffect(() => { const id = setInterval(() => setNow(Date.now()), intervalMs); return () => clearInterval(id); }, [intervalMs]);
13
+ return now;
14
+ }
15
+
16
+ export { errorText } from "../shared/errors";
17
+
18
+ export const text = (colors: Colors) => ({
19
+ title: { color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" as const },
20
+ body: { color: colors.foreground, fontSize: 13, lineHeight: 18 },
21
+ meta: { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontVariant: ["tabular-nums" as const] },
22
+ small: { color: colors.foregroundMuted, fontSize: 11, lineHeight: 15, fontVariant: ["tabular-nums" as const] },
23
+ label: { color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" as const },
24
+ });
25
+
26
+ type ButtonKind = "primary" | "secondary" | "ghost" | "danger";
27
+ /** One button vocabulary for the whole plugin: 32px on desktop with a hit slop, 44px on touch layouts. */
28
+ export function Button({ title, onPress, theme, kind = "secondary", disabled = false, loading = false, icon, compact = false, accessibilityLabel, accessibilityHint, grow = false }: {
29
+ title: string; onPress: () => void; theme: Theme; kind?: ButtonKind; disabled?: boolean; loading?: boolean; icon?: string; compact?: boolean; accessibilityLabel?: string; accessibilityHint?: string; grow?: boolean;
30
+ }) {
31
+ const c = theme.colors;
32
+ const fg = kind === "primary" ? c.accentForeground : kind === "danger" ? c.statusDanger : c.foreground;
33
+ const inactive = disabled || loading;
34
+ return <PressScale accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? title} accessibilityHint={accessibilityHint} accessibilityState={{ disabled: inactive, busy: loading }}
35
+ disabled={inactive} onPress={onPress} hitSlop={compact ? 0 : 4} outerStyle={grow ? { flexGrow: 1 } : { alignSelf: "flex-start" }}
36
+ style={({ pressed, hovered }) => ({
37
+ minHeight: compact ? 44 : 32, paddingHorizontal: compact ? 14 : 11, borderRadius: compact ? 8 : 6, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6,
38
+ opacity: disabled ? 0.45 : 1,
39
+ ...(kind === "primary" ? { backgroundColor: pressed ? withAlpha(c.accent, 0.85) : hovered ? withAlpha(c.accent, 0.92) : c.accent }
40
+ : kind === "ghost" ? { backgroundColor: pressed ? c.surface2 : hovered ? c.surface1 : "transparent" }
41
+ : { ...raised(c), backgroundColor: pressed ? c.surface2 : hovered ? c.surface2 : c.surface1 }),
42
+ })}>
43
+ {loading ? <Spinner color={fg} size={12} /> : icon ? <Icon name={icon} size={compact ? 16 : 14} color={fg} /> : null}
44
+ <Text numberOfLines={1} style={{ color: fg, fontSize: compact ? 14 : 12, lineHeight: compact ? 19 : 16, fontWeight: "600" }}>{title}</Text>
45
+ </PressScale>;
46
+ }
47
+
48
+ /** A square icon-only control with an accessible name, for settings and refresh. */
49
+ export function IconButton({ icon, label, onPress, theme, compact = false, disabled = false, spinning = false, color }: { icon: string; label: string; onPress: () => void; theme: Theme; compact?: boolean; disabled?: boolean; spinning?: boolean; color?: string }) {
50
+ const c = theme.colors;
51
+ const size = compact ? 44 : 28;
52
+ return <PressScale accessibilityRole="button" accessibilityLabel={label} disabled={disabled} onPress={onPress} hitSlop={compact ? 0 : 6}
53
+ style={({ pressed, hovered }) => ({ width: size, height: size, borderRadius: 6, alignItems: "center", justifyContent: "center", backgroundColor: pressed ? c.surface2 : hovered ? c.surface1 : "transparent", opacity: disabled && !spinning ? 0.45 : 1 })}>
54
+ {spinning ? <Spinner color={color ?? c.foregroundMuted} size={14} label={label} /> : <Icon name={icon} size={compact ? 18 : 15} color={color ?? c.foregroundMuted} />}
55
+ </PressScale>;
56
+ }
57
+
58
+ export type CardTone = "plain" | "attention" | "success";
59
+ /** A raised container with an icon title. Attention cards trade the ring for a warning border. */
60
+ export function Card({ theme, icon, title, tone = "plain", count, right, children, compact = false, style }: {
61
+ theme: Theme; icon: string; title: string; tone?: CardTone; count?: string | number; right?: ReactNode; children: ReactNode; compact?: boolean; style?: StyleProp<ViewStyle>;
62
+ }) {
63
+ const c = theme.colors;
64
+ const accent = tone === "attention" ? c.statusWarning : tone === "success" ? c.statusSuccess : null;
65
+ return <View style={[{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 8, overflow: "hidden", backgroundColor: c.surface0 },
66
+ accent ? { borderWidth: 1, borderColor: withAlpha(accent, 0.7) } : raised(c), style]}>
67
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 7, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 8 }}>
68
+ <Icon name={icon} size={14} color={accent ?? c.foregroundMuted} />
69
+ <Text accessibilityRole="header" style={{ color: accent ?? c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{title}</Text>
70
+ {count !== undefined && count !== "" && <Chip theme={theme} color={accent ?? c.foregroundMuted} label={String(count)} />}
71
+ <View style={{ flex: 1 }} />
72
+ {right}
73
+ </View>
74
+ {children}
75
+ </View>;
76
+ }
77
+
78
+ export function Chip({ theme, label, color, icon }: { theme: Theme; label: string; color: string; icon?: string }) {
79
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, paddingVertical: 1, borderRadius: 999, backgroundColor: tint(color, theme.colors) }}>
80
+ {icon && <Icon name={icon} size={10} color={color} />}
81
+ <Text numberOfLines={1} style={{ color, fontSize: 11, lineHeight: 16, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{label}</Text>
82
+ </View>;
83
+ }
84
+
85
+ /** A labelled switch. `value: null` means the real state is unknown and the control is disabled. */
86
+ export function Switch({ theme, label, value, onChange, disabled = false, accessibilityLabel, hint, compact = false }: { theme: Theme; label: string; value: boolean | null; onChange: (next: boolean) => void; disabled?: boolean; accessibilityLabel?: string; hint?: string; compact?: boolean }) {
87
+ const c = theme.colors;
88
+ const on = value === true;
89
+ const inactive = disabled || value === null;
90
+ return <Pressable accessibilityRole="switch" aria-checked={value === null ? "mixed" : on} accessibilityLabel={accessibilityLabel ?? label} accessibilityHint={hint}
91
+ accessibilityState={{ checked: value === null ? "mixed" : on, disabled: inactive }} accessibilityValue={{ text: value === null ? "Unknown" : on ? "On" : "Off" }}
92
+ disabled={inactive} onPress={() => onChange(!on)} hitSlop={compact ? 0 : 4}
93
+ style={({ pressed }) => ({ minHeight: compact ? 44 : 28, flexDirection: "row", alignItems: "center", gap: 8, opacity: inactive ? 0.5 : pressed ? 0.8 : 1 })}>
94
+ <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{label}</Text>
95
+ <View style={{ width: 30, height: 18, borderRadius: 9, backgroundColor: on ? c.accent : c.surface2, borderWidth: 1, borderColor: on ? c.accent : c.border, justifyContent: "center" }}>
96
+ <View style={{ width: 12, height: 12, borderRadius: 6, marginLeft: value === null ? 8 : on ? 14 : 2, backgroundColor: on ? c.accentForeground : value === null ? c.border : c.foregroundMuted }} />
97
+ </View>
98
+ </Pressable>;
99
+ }
100
+
101
+ /** A row inside a card: hairline on top, hover tint when pressable. */
102
+ export function CardRow({ theme, onPress, accessibilityLabel, children, style, first = false }: { theme: Theme; onPress?: () => void; accessibilityLabel?: string; children: ReactNode; style?: StyleProp<ViewStyle>; first?: boolean }) {
103
+ const c = theme.colors;
104
+ const base: ViewStyle = { paddingHorizontal: 12, paddingVertical: 9, borderTopWidth: first ? 0 : 1, borderTopColor: c.border };
105
+ if (!onPress) return <View style={[base, style]}>{children}</View>;
106
+ return <Pressable accessibilityRole="button" accessibilityLabel={accessibilityLabel} onPress={onPress}
107
+ style={state => [base, { backgroundColor: state.pressed ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" }, style]}>{children}</Pressable>;
108
+ }
109
+
110
+ /** The fold row that hides finished items: "3 merged · Show". */
111
+ export function FoldRow({ theme, label, open, onPress, icon, iconColor }: { theme: Theme; label: string; open: boolean; onPress: () => void; icon: string; iconColor: string }) {
112
+ const c = theme.colors;
113
+ return <Pressable accessibilityRole="button" aria-expanded={open} accessibilityState={{ expanded: open }} accessibilityLabel={`${open ? "Hide" : "Show"} ${label}`} onPress={onPress}
114
+ style={state => ({ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, borderTopColor: c.border, backgroundColor: state.pressed ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" })}>
115
+ <View style={{ width: 16, alignItems: "center" }}><Icon name={icon} size={13} color={iconColor} /></View>
116
+ <Text style={{ flex: 1, color: c.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{label}</Text>
117
+ <Icon name={open ? "ChevronUp" : "ChevronDown"} size={14} color={c.foregroundMuted} />
118
+ </Pressable>;
119
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect } from "react";
2
+ import { Platform, Text, View } from "react-native";
3
+ import { useRpc, type PluginScreenProps } from "@getpaseo/plugin/client";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { getRpc } from "../shared/contracts";
6
+ import { Button, Card, CardRow, errorText, text } from "./kit";
7
+ import { Skeleton } from "./motion";
8
+ import { WorkspaceView, useSessionFolds, workspaceKey, type PanelLinks } from "./panel";
9
+
10
+ export const LARGE_SCREEN = "follow-through-workspace";
11
+
12
+ /**
13
+ * The panel opened as a Paseo screen: the host supplies the page, header and close button, and the
14
+ * layout switches to two columns. Collapse (or Esc) returns to the workspace, where the panel still is.
15
+ */
16
+ export function FollowLarge(props: PluginScreenProps & PanelLinks) {
17
+ const { theme, layout, params, navigation } = props;
18
+ const workspaceId = params.workspaceId ?? "";
19
+ const c = theme.colors;
20
+ const read = useRpc(getRpc);
21
+ const query = useQuery({ queryKey: workspaceKey(workspaceId), queryFn: () => read({ workspaceId }), refetchInterval: 5000, enabled: !!workspaceId });
22
+ const folds = useSessionFolds(workspaceId);
23
+ const back = () => navigation?.openWorkspace({ workspaceId });
24
+
25
+ // Esc goes back, unless a sheet or menu has focus or the user is typing. Web only: native has its own back.
26
+ useEffect(() => {
27
+ type Key = { key: string; defaultPrevented: boolean; metaKey: boolean; ctrlKey: boolean; altKey: boolean; target: { isContentEditable?: boolean; tagName?: string } | null; preventDefault(): void };
28
+ type Doc = { addEventListener(type: "keydown", fn: (event: Key) => void): void; removeEventListener(type: "keydown", fn: (event: Key) => void): void; querySelector(selector: string): unknown };
29
+ const doc = (globalThis as { document?: Doc }).document;
30
+ if (Platform.OS !== "web" || !doc || !navigation) return;
31
+ const onKey = (event: Key) => {
32
+ if (event.key !== "Escape" || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
33
+ if (event.target && (event.target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName ?? ""))) return;
34
+ if (doc.querySelector('[role="dialog"], [aria-modal="true"]')) return;
35
+ event.preventDefault();
36
+ navigation.openWorkspace({ workspaceId });
37
+ };
38
+ doc.addEventListener("keydown", onKey);
39
+ return () => doc.removeEventListener("keydown", onKey);
40
+ }, [navigation, workspaceId]);
41
+
42
+ if (query.isPending && workspaceId) return <View accessibilityLabel="Loading Follow-up" style={{ flex: 1, backgroundColor: c.surface0, padding: 24, gap: 12, maxWidth: 1240, width: "100%", alignSelf: "center" }}>
43
+ <Skeleton width="30%" height={16} color={c.surface2} /><Skeleton width="60%" height={10} color={c.surface2} /><Skeleton width="100%" height={6} color={c.surface2} radius={3} />
44
+ </View>;
45
+ const w = query.data?.workspace;
46
+ if (!w) return <View style={{ flex: 1, backgroundColor: c.surface0, padding: layout.compact ? 16 : 24 }}>
47
+ <Card theme={theme} compact={layout.compact} icon="GitPullRequest" title="Nothing to show here" style={{ marginHorizontal: 0, marginTop: 0, maxWidth: 520 }}>
48
+ <CardRow theme={theme}>
49
+ <Text style={[text(c).meta, { marginBottom: 10 }]}>{query.error ? `Couldn't read Follow-up: ${errorText(query.error)}` : "This workspace isn't tracked by Follow-up, or it no longer exists."}</Text>
50
+ {navigation && workspaceId ? <Button theme={theme} icon="ArrowLeft" title="Back to the workspace" onPress={back} /> : null}
51
+ </CardRow>
52
+ </Card>
53
+ </View>;
54
+ return <WorkspaceView theme={theme} layout={layout} host={props.host} navigation={navigation} workspace={w} large
55
+ loadError={query.error ? "Refresh failed. Showing the last saved state." : query.data?.error ?? null}
56
+ openSettings={props.openSettings} openOverview={props.openOverview} folds={folds}
57
+ resize={navigation ? { icon: "Minimize2", label: "Back to the workspace (Esc)", onPress: back } : null} />;
58
+ }
@@ -0,0 +1,159 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useRpc, type PluginButtonContentProps, type PluginButtonIconProps, type PluginButtonRegistration, type PluginClientContext, type PluginSidebarItemProps } from "@getpaseo/plugin/client";
4
+ import { Icon, SidebarRow } from "@getpaseo/plugin/client/ui";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ import { useState } from "react";
7
+ import { getRpc, listRpc, browserStatusRpc } from "../shared/contracts";
8
+ import type { Workspace } from "../shared/model";
9
+ import { needsYou, pillFor } from "../shared/attention";
10
+ import { NeedsYouList, workspaceKey } from "./needs-you";
11
+ import { Button, errorText, text } from "./kit";
12
+ import { IconSwap } from "./motion";
13
+ import { PrSheet } from "./pr-sheet";
14
+ import { QuestionSheet, QuestionStepper } from "./questions";
15
+ import { SpecChip } from "./spec";
16
+ import { isOpen as questionOpen } from "../shared/questions";
17
+
18
+ const POLL_MS = 8_000;
19
+
20
+ /** One shared poll of tracked workspaces for the pills, header-button dots and the sidebar count. */
21
+ export function createLiveStore(client: PluginClientContext) {
22
+ let workspaces = new Map<string, Workspace>();
23
+ let version = 0;
24
+ const listeners = new Set<() => void>();
25
+ let timer: ReturnType<typeof setTimeout> | null = null;
26
+ let stopped = false;
27
+ const emit = () => { version++; for (const l of listeners) l(); };
28
+ const poll = async () => {
29
+ try {
30
+ const result = await client.rpc(listRpc, {});
31
+ if (stopped) return;
32
+ workspaces = new Map(result.workspaces.filter(w => !w.archived).map(w => [w.id, w]));
33
+ emit();
34
+ } catch { /* Keep the last answer through restarts and disconnects. */ }
35
+ finally { if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); }
36
+ };
37
+ void poll();
38
+ return {
39
+ subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
40
+ version: () => version,
41
+ get: (id: string) => workspaces.get(id),
42
+ all: () => [...workspaces.values()],
43
+ refresh: () => { if (timer) clearTimeout(timer); void poll(); },
44
+ stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); },
45
+ };
46
+ }
47
+ export type LiveStore = ReturnType<typeof createLiveStore>;
48
+
49
+ function useLive<T>(store: LiveStore, select: () => T): T {
50
+ useSyncExternalStore(store.subscribe, store.version, store.version);
51
+ return select();
52
+ }
53
+
54
+ /** Composer pills appear on a tracked workspace's main agent only while something needs the developer. */
55
+ export function contributePills(client: PluginClientContext, store: LiveStore, openPanel: (workspaceId: string) => void) {
56
+ const pills = new Map<string, { workspaceId: string; label: string; urgent: boolean; pill: PluginButtonRegistration }>();
57
+
58
+ function PillIcon(props: PluginButtonIconProps) {
59
+ const kind = useLive(store, () => {
60
+ const w = store.get(props.workspaceId);
61
+ if (!w) return "attention";
62
+ const items = needsYou(w);
63
+ if (items.some(i => ["hold", "retry", "check", "answer"].includes(i.kind))) return "urgent";
64
+ if (items.some(i => i.kind === "question" && i.state === "stopped")) return "stopped";
65
+ return items.every(i => i.kind === "question") ? "question" : "attention";
66
+ });
67
+ const c = props.theme.colors;
68
+ return <IconSwap swapKey={kind} size={props.size}>
69
+ <Icon name={kind === "urgent" ? "OctagonAlert" : kind === "stopped" ? "CirclePause" : kind === "question" ? "MessageCircleQuestion" : "CircleAlert"} size={props.size} color={kind === "urgent" ? c.statusDanger : c.statusWarning} />
70
+ </IconSwap>;
71
+ }
72
+
73
+ function PillContent(props: PluginButtonContentProps) {
74
+ const c = props.theme.colors; const t = text(c);
75
+ const read = useRpc(getRpc); const browserRead = useRpc(browserStatusRpc);
76
+ const query = useQuery({ queryKey: workspaceKey(props.workspaceId), queryFn: () => read({ workspaceId: props.workspaceId }), refetchInterval: 5000 });
77
+ const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000 });
78
+ const [sheet, setSheet] = useState<{ key: string; intent: "merge" | "reviewers" } | null>(null);
79
+ const [question, setQuestion] = useState<string | null>(null);
80
+ const w = query.data?.workspace ?? store.get(props.workspaceId);
81
+ const touch = props.layout.compact;
82
+ const asking = !!w?.questions.some(questionOpen);
83
+ const others = w ? needsYou(w).filter(i => i.kind !== "question") : [];
84
+ const open = () => { props.close(); openPanel(props.workspaceId); };
85
+ // On phones the host sheet's own header already pads the top; tuck the content up to it.
86
+ return <View style={{ width: 360, maxWidth: "100%", gap: 10, marginTop: touch ? -12 : 0 }}>
87
+ {w ? <>
88
+ {/* Open questions take the whole popover, one at a time; the rest is a line and a click away. */}
89
+ {asking ? <>
90
+ <QuestionStepper workspace={w} theme={props.theme} compact={touch} bodyMaxHeight={touch ? undefined : 220} onDetails={setQuestion} onAnswered={() => store.refresh()} />
91
+ {others.length > 0 && <Text numberOfLines={2} style={[t.small, { paddingTop: 8, borderTopWidth: 1, borderTopColor: c.border }]}>Also needs you: {others.map(i => i.title).join(" · ")}</Text>}
92
+ </> : <>
93
+ {/* On phones the sheet already carries the button's title. */}
94
+ {!touch && <View style={{ gap: 2 }}>
95
+ <Text style={t.label}>{others.length === 1 ? "Follow-up needs you" : `Follow-up · ${others.length} need you`}</Text>
96
+ <Text numberOfLines={1} style={t.meta}>{w.title}</Text>
97
+ </View>}
98
+ <NeedsYouList workspace={w} theme={props.theme} compact={touch} flush
99
+ actions={{ after: () => { store.refresh(); }, openPr: (key, intent) => setSheet({ key, intent }), openQuestion: setQuestion }} />
100
+ </>}
101
+ <PrSheet workspace={w} prKey={sheet?.key ?? null} intent={sheet?.intent ?? null} theme={props.theme} compact={touch} browserReady={browser.data?.ready ?? false} onClose={() => { setSheet(null); store.refresh(); }} />
102
+ <QuestionSheet workspace={w} questionId={question} theme={props.theme} compact={touch} onClose={() => { setQuestion(null); store.refresh(); }} />
103
+ </> : <Text style={t.meta}>{query.error ? `Couldn't read Follow-up: ${errorText(query.error)}` : "Loading…"}</Text>}
104
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
105
+ <View style={{ flex: 1 }}><Button theme={props.theme} compact={touch} grow icon="PanelRight" title="Open Follow-up" onPress={open} /></View>
106
+ {w?.spec?.url && <SpecChip workspace={w} theme={props.theme} compact={touch} />}
107
+ </View>
108
+ </View>;
109
+ }
110
+
111
+ const publish = () => {
112
+ const wanted = new Map<string, { workspaceId: string; label: string; urgent: boolean }>();
113
+ for (const w of store.all()) {
114
+ const pill = pillFor(w);
115
+ if (pill) wanted.set(w.mainAgentId, { workspaceId: w.id, label: pill.label, urgent: pill.urgent });
116
+ }
117
+ for (const [agentId, entry] of pills) {
118
+ const next = wanted.get(agentId);
119
+ if (!next || next.workspaceId !== entry.workspaceId) { entry.pill.remove(); pills.delete(agentId); }
120
+ }
121
+ for (const [agentId, next] of wanted) {
122
+ const entry = pills.get(agentId);
123
+ const asking = /^Question |questions$/.test(next.label);
124
+ const title = asking ? "Follow-up: the agent asked you a question" : next.urgent ? "Follow-up: something is blocked" : "Follow-up needs you";
125
+ if (!entry) {
126
+ const pill = client.addComposerPill({ id: "follow-up-attention", workspaceId: next.workspaceId, agentId, button: { title, label: next.label, icon: PillIcon, behavior: { kind: "popover", Content: PillContent } } });
127
+ pills.set(agentId, { workspaceId: next.workspaceId, label: next.label, urgent: next.urgent, pill });
128
+ } else if (entry.label !== next.label || entry.urgent !== next.urgent) {
129
+ entry.pill.update({ label: next.label, title });
130
+ entry.label = next.label; entry.urgent = next.urgent;
131
+ }
132
+ }
133
+ };
134
+ const unsubscribe = store.subscribe(publish);
135
+ return () => { unsubscribe(); for (const { pill } of pills.values()) pill.remove(); pills.clear(); };
136
+ }
137
+
138
+ /** The header button's icon: a pull-request glyph with a warning dot while the workspace needs you. */
139
+ export function headerIcon(store: LiveStore) {
140
+ return function HeaderIcon(props: PluginButtonIconProps) {
141
+ const need = useLive(store, () => { const w = store.get(props.workspaceId); return w ? needsYou(w).length : 0; });
142
+ return <View style={{ width: props.size, height: props.size }}>
143
+ <Icon name="GitPullRequest" size={props.size} color={props.color} />
144
+ {need > 0 && <View accessibilityLabel={`${need} need you`} style={{ position: "absolute", top: -2, right: -3, width: 7, height: 7, borderRadius: 4, backgroundColor: props.theme.colors.statusWarning, borderWidth: 1.5, borderColor: props.theme.colors.surface0 }} />}
145
+ </View>;
146
+ };
147
+ }
148
+
149
+ /** The sidebar row, with a count of workspaces that need you. */
150
+ export function sidebarItem(store: LiveStore, screenId: string) {
151
+ return function FollowSidebarItem({ currentScreen, openScreen, theme }: PluginSidebarItemProps) {
152
+ const need = useLive(store, () => store.all().filter(w => needsYou(w).length > 0).length);
153
+ const c = theme.colors;
154
+ return <SidebarRow icon="GitPullRequest" active={currentScreen?.screenId === screenId} onPress={() => openScreen({ screenId })}
155
+ trailing={need > 0 ? <View accessibilityLabel={`${need} workspace${need === 1 ? "" : "s"} need you`} style={{ minWidth: 18, height: 18, paddingHorizontal: 5, borderRadius: 9, alignItems: "center", justifyContent: "center", backgroundColor: c.statusWarning }}>
156
+ <Text style={{ color: c.surface0, fontSize: 11, lineHeight: 14, fontWeight: "700", fontVariant: ["tabular-nums"] }}>{need}</Text>
157
+ </View> : undefined} />;
158
+ };
159
+ }
@@ -0,0 +1,93 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
+ import { AccessibilityInfo, ActivityIndicator, Animated, Easing, Platform, Pressable, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
3
+
4
+ // The native driver is unavailable on web, where react-native-web animates in JS.
5
+ const native = () => Platform.OS !== "web";
6
+ const out = Easing.bezier(0.16, 1, 0.3, 1);
7
+
8
+ export function useReducedMotion(): boolean {
9
+ const [reduced, setReduced] = useState(false);
10
+ useEffect(() => {
11
+ let live = true;
12
+ void AccessibilityInfo.isReduceMotionEnabled().then(value => { if (live) setReduced(value); }).catch(() => undefined);
13
+ const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setReduced);
14
+ return () => { live = false; subscription.remove(); };
15
+ }, []);
16
+ return reduced;
17
+ }
18
+
19
+ type PressState = { pressed: boolean; hovered: boolean };
20
+ /** A pressable that eases to 97% while held. Hover and press tint come from the caller's style function. */
21
+ export function PressScale({ style, outerStyle, children, ...props }: Omit<PressableProps, "style" | "children"> & {
22
+ style?: StyleProp<ViewStyle> | ((state: PressState) => StyleProp<ViewStyle>);
23
+ outerStyle?: StyleProp<ViewStyle>;
24
+ children: ReactNode | ((state: PressState) => ReactNode);
25
+ }) {
26
+ const reduced = useReducedMotion();
27
+ const scale = useRef(new Animated.Value(1)).current;
28
+ const [pressed, setPressed] = useState(false);
29
+ const [hovered, setHovered] = useState(false);
30
+ const to = (value: number) => { if (!reduced) Animated.timing(scale, { toValue: value, duration: 140, easing: out, useNativeDriver: native() }).start(); };
31
+ const state = { pressed, hovered };
32
+ return <Pressable {...props} style={outerStyle}
33
+ onHoverIn={event => { setHovered(true); props.onHoverIn?.(event); }}
34
+ onHoverOut={event => { setHovered(false); props.onHoverOut?.(event); }}
35
+ onPressIn={event => { setPressed(true); to(0.97); props.onPressIn?.(event); }}
36
+ onPressOut={event => { setPressed(false); to(1); props.onPressOut?.(event); }}>
37
+ <Animated.View style={[typeof style === "function" ? style(state) : style, { transform: [{ scale }] }]}>
38
+ {typeof children === "function" ? children(state) : children}
39
+ </Animated.View>
40
+ </Pressable>;
41
+ }
42
+
43
+ /** Cross-fades an icon when its key changes: the new one grows in from 40%. Nothing moves on first render. */
44
+ export function IconSwap({ swapKey, size, children }: { swapKey: string; size: number; children: ReactNode }) {
45
+ const reduced = useReducedMotion();
46
+ const enter = useRef(new Animated.Value(1)).current;
47
+ const first = useRef(true);
48
+ useLayoutEffect(() => {
49
+ if (first.current) { first.current = false; return; }
50
+ if (reduced) return;
51
+ enter.setValue(0);
52
+ const animation = Animated.timing(enter, { toValue: 1, duration: 260, easing: out, useNativeDriver: native() });
53
+ animation.start();
54
+ return () => animation.stop();
55
+ }, [swapKey]);
56
+ return <Animated.View style={{ width: size, height: size, alignItems: "center", justifyContent: "center", opacity: enter, transform: [{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) }] }}>{children}</Animated.View>;
57
+ }
58
+
59
+ /** Content that arrives after load rises 6px into place. Reduced motion shows it at once. */
60
+ export function Reveal({ children, delay = 0 }: { children: ReactNode; delay?: number }) {
61
+ const reduced = useReducedMotion();
62
+ const value = useRef(new Animated.Value(reduced ? 1 : 0)).current;
63
+ useEffect(() => {
64
+ if (reduced) { value.setValue(1); return; }
65
+ const animation = Animated.timing(value, { toValue: 1, duration: 280, delay, easing: out, useNativeDriver: native() });
66
+ animation.start();
67
+ return () => animation.stop();
68
+ }, [reduced]);
69
+ return <Animated.View style={{ opacity: value, transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [6, 0] }) }] }}>{children}</Animated.View>;
70
+ }
71
+
72
+ /** The system spinner, sized to sit beside a line of text. */
73
+ export function Spinner({ color, size = 12, label = "Working" }: { color: string; size?: number; label?: string }) {
74
+ return <View accessibilityLabel={label} style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
75
+ {Platform.OS === "ios" ? <ActivityIndicator size="small" color={color} style={{ transform: [{ scale: size / 20 }] }} /> : <ActivityIndicator size={size} color={color} />}
76
+ </View>;
77
+ }
78
+
79
+ /** A soft breathing placeholder bar for loading states. Static under reduced motion. */
80
+ export function Skeleton({ width, height = 10, color, radius = 4 }: { width: number | `${number}%`; height?: number; color: string; radius?: number }) {
81
+ const reduced = useReducedMotion();
82
+ const value = useRef(new Animated.Value(0.55)).current;
83
+ useEffect(() => {
84
+ if (reduced) return;
85
+ const loop = Animated.loop(Animated.sequence([
86
+ Animated.timing(value, { toValue: 1, duration: 800, easing: Easing.inOut(Easing.ease), useNativeDriver: native() }),
87
+ Animated.timing(value, { toValue: 0.55, duration: 800, easing: Easing.inOut(Easing.ease), useNativeDriver: native() }),
88
+ ]));
89
+ loop.start();
90
+ return () => loop.stop();
91
+ }, [reduced]);
92
+ return <Animated.View style={{ width, height, borderRadius: radius, backgroundColor: color, opacity: value }} />;
93
+ }