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
package/client/panel.tsx CHANGED
@@ -1,82 +1,271 @@
1
- import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { useRpc, openExternalUrl, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
1
+ import { useState } from "react";
2
+ import { ScrollView, Text, View } from "react-native";
3
+ import { Icon, useToast } from "@getpaseo/plugin/client/ui";
4
+ import { useRpc, useTabState, openExternalUrl, type PluginHostProps, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
4
5
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
5
- import { getRpc, controlRpc, refreshRpc, activateRpc, sendRpc, browserStatusRpc, checkPrActionRpc, recoverRpc } from "../shared/contracts";
6
- import { orderedStacks, settledForDisplay } from "../shared/presentation";
6
+ import { z } from "zod";
7
+ import { getRpc, controlRpc, refreshRpc, activateRpc, browserStatusRpc, recoverRpc, sendRpc } from "../shared/contracts";
7
8
  import { summary, type Workspace } from "../shared/model";
8
9
  import { followUpObligations, recoveryBatch } from "../shared/follow-up";
9
- import { FollowTask } from "./tasks";
10
- import { Button, Section, StackGroup, StatusLabel, colorFor } from "./components";
10
+ import { settledForDisplay } from "../shared/presentation";
11
+ import { ago, sendBlocker, shortRefs } from "../shared/attention";
12
+ import { Button, Card, CardRow, FoldRow, IconButton, errorText, text, useNow, type Theme } from "./kit";
13
+ import { Reveal, Skeleton, Spinner } from "./motion";
14
+ import { WorkspaceHeader } from "./header";
15
+ import { NeedsYouCard, overviewKey, workspaceKey } from "./needs-you";
16
+ import { StacksCard } from "./stacks";
17
+ import { TasksCard } from "./tasks";
18
+ import { PrSheet } from "./pr-sheet";
19
+ import { ActivityCard, QuestionSheet } from "./questions";
20
+ import { SpecRow, SpecSheet } from "./spec";
21
+ import { mergedColor, tint } from "./theme";
11
22
 
12
- export const workspaceKey = (id: string) => ["follow-through", "workspace", id];
13
- export const overviewKey = ["follow-through", "overview"];
14
- export function FollowPanel(props: PluginWorkspacePanelProps) {
23
+ export { overviewKey, workspaceKey };
24
+
25
+ export type PanelLinks = { openSettings: () => void; openOverview: () => void; openLarge?: (workspaceId: string) => void };
26
+ type PanelProps = PluginWorkspacePanelProps & PanelLinks;
27
+
28
+ export function FollowPanel(props: PanelProps) {
15
29
  const read = useRpc(getRpc);
16
30
  const query = useQuery({ queryKey: workspaceKey(props.workspaceId), queryFn: () => read({ workspaceId: props.workspaceId }), refetchInterval: 5000 });
17
- const base = { flex: 1, backgroundColor: props.theme.colors.surface0 };
18
- if (query.isPending) return <View style={[base, { padding: 16, gap: 10 }]}><ActivityIndicator color={props.theme.colors.accent} /><Text style={{ color: props.theme.colors.foregroundMuted }}>Loading…</Text></View>;
19
- if (query.error && !query.data?.workspace) return <View style={[base, { padding: 16, gap: 12 }]}><Text accessibilityRole="alert" style={{ color: props.theme.colors.statusDanger }}>Could not read workspace state. Refresh to retry.</Text><Button title="Retry" onPress={() => void query.refetch()} theme={props.theme} /></View>;
31
+ const c = props.theme.colors;
32
+ const base = { flex: 1, backgroundColor: c.surface0 };
33
+ if (query.isPending) return <PanelSkeleton theme={props.theme} compact={props.layout.compact} />;
34
+ if (query.error && !query.data?.workspace) return <View style={[base, { padding: props.layout.compact ? 16 : 12 }]}>
35
+ <Card theme={props.theme} compact={props.layout.compact} icon="CloudOff" title="Can't reach Follow-up" style={{ marginHorizontal: 0, marginTop: 0 }}>
36
+ <CardRow theme={props.theme}>
37
+ <Text accessibilityRole="alert" style={[text(c).meta, { marginBottom: 10 }]}>The plugin didn't answer: {errorText(query.error)}. It may still be starting; your tracked work is saved on the host.</Text>
38
+ <Button title="Try again" icon="RefreshCw" theme={props.theme} compact={props.layout.compact} loading={query.isRefetching} onPress={() => void query.refetch()} />
39
+ </CardRow>
40
+ </Card>
41
+ </View>;
20
42
  if (!query.data?.workspace) return <StartTracking {...props} loadError={query.data?.error} />;
21
- return <WorkspaceView {...props} workspace={query.data.workspace} loadError={query.error ? "Refresh failed. Showing saved data." : query.data.error} />;
43
+ return <PanelWorkspace {...props} workspace={query.data.workspace} loadError={query.error ? "Refresh failed. Showing the last saved state." : query.data.error} />;
22
44
  }
23
45
 
24
- function StartTracking({ theme, workspaceId, layout, loadError }: PluginWorkspacePanelProps & { loadError?: string | null }) {
25
- const activate = useRpc(activateRpc); const cache = useQueryClient();
26
- const start = useMutation({ mutationFn: () => activate({ workspaceId }), onSuccess: () => { void cache.invalidateQueries({ queryKey: workspaceKey(workspaceId) }); void cache.invalidateQueries({ queryKey: overviewKey }); } });
27
- return <View style={{ flex: 1, backgroundColor: theme.colors.surface0, padding: 16, gap: 12 }}><Icon name="GitPullRequest" size={24} color={theme.colors.accent} /><Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "600" }}>Follow this workspace</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 }}>Track PRs and follow-through. Auto starts off.</Text>{(loadError || start.error) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{loadError ?? start.error?.message}</Text>}<Button title={start.isPending ? "Starting…" : "Track this workspace"} primary theme={theme} compact={layout.compact} disabled={start.isPending} onPress={() => start.mutate()} /></View>;
46
+ function PanelSkeleton({ theme, compact }: { theme: Theme; compact: boolean }) {
47
+ const c = theme.colors; const pad = compact ? 16 : 12;
48
+ return <View accessibilityLabel="Loading Follow-up" style={{ flex: 1, backgroundColor: c.surface0 }}>
49
+ <View style={{ padding: pad, gap: 10, borderBottomWidth: 1, borderBottomColor: c.border }}>
50
+ <Skeleton width="55%" height={14} color={c.surface2} />
51
+ <Skeleton width="85%" height={9} color={c.surface2} />
52
+ <Skeleton width="70%" height={9} color={c.surface2} />
53
+ <Skeleton width="100%" height={6} color={c.surface2} radius={3} />
54
+ </View>
55
+ {[0, 1].map(i => <View key={i} style={{ margin: pad, marginBottom: 0, padding: 12, gap: 10, borderRadius: 8, borderWidth: 1, borderColor: c.border }}>
56
+ <Skeleton width="35%" height={10} color={c.surface2} />
57
+ <Skeleton width="90%" height={9} color={c.surface2} />
58
+ <Skeleton width="75%" height={9} color={c.surface2} />
59
+ </View>)}
60
+ </View>;
28
61
  }
29
62
 
30
- export function WorkspaceView({ theme, layout, workspace, navigation, loadError }: PluginWorkspacePanelProps & { workspace: Workspace; loadError?: string | null }) {
31
- const control = useRpc(controlRpc); const refresh = useRpc(refreshRpc); const send = useRpc(sendRpc); const cache = useQueryClient();
32
- const runRecovery = useRpc(recoverRpc);
33
- const browserRead = useRpc(browserStatusRpc); const checkAction = useRpc(checkPrActionRpc);
34
- const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000 });
35
- const invalidated = () => { void cache.invalidateQueries({ queryKey: workspaceKey(workspace.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
36
- const change = useMutation({ mutationFn: (input: Parameters<typeof control>[0]) => control(input), onSuccess: invalidated });
37
- const update = useMutation({ mutationFn: () => refresh({ workspaceId: workspace.id }), onSuccess: invalidated });
38
- const deliver = useMutation({ mutationFn: (batchId: string) => send({ workspaceId: workspace.id, batchId }), onSuccess: invalidated });
39
- const recovery = useMutation({ mutationFn: () => runRecovery({ workspaceId: workspace.id }), onSuccess: invalidated });
40
- const inspectAction = useMutation({ mutationFn: (actionId: string) => checkAction({ workspaceId: workspace.id, actionId }), onSuccess: invalidated });
41
- const state = summary(workspace);
42
- const outstanding = followUpObligations(workspace).filter(o => !["done", "waived"].includes(o.status));
43
- const completed = followUpObligations(workspace).filter(o => ["done", "waived"].includes(o.status));
44
- const batches = workspace.batches.filter(b => !recoveryBatch(b) && !["completed", "cancelled"].includes(b.state));
45
- const lastRecovery = workspace.batches.filter(b => recoveryBatch(b) && b.state !== "cancelled").at(-1);
46
- const latest = workspace.prs.map(p => p.snapshot?.observedAt).filter(Boolean).sort().at(-1);
47
- const error = loadError ?? change.error?.message ?? update.error?.message ?? deliver.error?.message ?? null;
63
+ function StartTracking({ theme, workspaceId, layout, loadError, openSettings }: PanelProps & { loadError?: string | null }) {
64
+ const c = theme.colors; const t = text(c);
65
+ const activate = useRpc(activateRpc); const cache = useQueryClient(); const toast = useToast();
66
+ const start = useMutation({ mutationFn: () => activate({ workspaceId }), onSuccess: () => { toast.show("Tracking this workspace", { variant: "success" }); void cache.invalidateQueries({ queryKey: workspaceKey(workspaceId) }); void cache.invalidateQueries({ queryKey: overviewKey }); } });
48
67
  const compact = layout.compact;
49
- const stacks = orderedStacks(workspace.prs);
50
- const activeStacks = stacks.filter(stack => !stack.prs.every(settledForDisplay));
51
- const settledStacks = stacks.filter(stack => stack.prs.every(settledForDisplay));
52
- const labelStyle = { color: theme.colors.foregroundMuted, fontSize: 12 };
53
- const open = (link: string) => { void openExternalUrl(link).catch(() => undefined); };
54
- return <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 12, paddingBottom: 28 }}>
55
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "600", flex: 1 }}>Follow-up</Text><Pressable accessibilityRole="switch" aria-checked={workspace.enable_auto_sends} accessibilityLabel="Auto" accessibilityHint="Allow automatic sends in this workspace" accessibilityState={{ checked: workspace.enable_auto_sends, disabled: change.isPending }} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, enable_auto_sends: !workspace.enable_auto_sends })} style={{ minHeight: compact ? 44 : 36, paddingHorizontal: 10, borderRadius: 6, borderWidth: 1, borderColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.border, backgroundColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.surface0, flexDirection: "row", alignItems: "center", gap: 5, opacity: change.isPending ? 0.5 : 1 }}><Icon name={workspace.enable_auto_sends ? "Check" : "Circle"} size={13} color={workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foregroundMuted} /><Text style={{ fontSize: 13, color: workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foreground }}>Auto</Text></Pressable><Button title={update.isPending ? "Refreshing…" : "Refresh"} icon="RefreshCw" theme={theme} compact={compact} disabled={update.isPending} onPress={() => update.mutate()} /></View>
56
- <View style={{ marginTop: 8, gap: 6 }}><StatusLabel workspace={workspace} theme={theme} />{(state.status === "needs-you" || state.status === "done") && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{state.reason}</Text>}{workspace.hold && <Button title="Resume follow-up" theme={theme} compact={compact} onPress={() => change.mutate({ workspaceId: workspace.id, clearHold: true })} disabled={change.isPending} />}</View>
57
- {error && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger, fontSize: 12, marginTop: 8 }}>{error}</Text>}
58
- <Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>Keep Graphite open and signed in. Send to Paseo uses your existing connection; approve toggle changes in Chrome unless you have remembered permission.</Text><Button title="Connect Graphite" theme={theme} compact={compact} onPress={() => open("https://app.graphite.com")} /></Section>}</Section>
59
- <Section title="Tasks" count={outstanding.length} theme={theme} compact={compact}>
60
- {!outstanding.length && <Text style={labelStyle}>No follow-through tasks.</Text>}
61
- {outstanding.map(o => <FollowTask key={o.id} obligation={o} workspace={workspace} theme={theme} compact={compact} />)}
62
- {completed.length > 0 && <Section title="Completed" count={completed.length} theme={theme} compact={compact} initiallyExpanded={false}>{completed.map(o => <FollowTask key={o.id} obligation={o} workspace={workspace} theme={theme} compact={compact} />)}</Section>}
63
- </Section>
64
- <Section title="Activity" count={batches.length} theme={theme} compact={compact} initiallyExpanded={batches.length > 0}>{batches.map(b => <View key={b.id} style={{ gap: 6, paddingVertical: 9, borderTopWidth: 1, borderColor: theme.colors.border }}><View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}><Icon name={b.state === "sent" ? "Activity" : b.state === "pending" ? "Clock" : "CircleAlert"} size={14} color={b.state === "sent" ? theme.colors.accent : theme.colors.statusWarning} /><Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" }}>{b.state === "sent" ? b.recipientAgentId && b.recipientAgentId !== workspace.mainAgentId ? "Recovering" : "Agent working" : b.state === "pending" ? workspace.enable_auto_sends ? `Auto queued · ${b.tasks.length}` : `Ready · ${b.tasks.length}` : b.state === "uncertain" ? "Check delivery" : "Blocked"}</Text></View><Section title="Batch details" count={b.tasks.length} theme={theme} compact={compact} initiallyExpanded={false}>{b.tasks.map(t => <Text key={t.key} style={[labelStyle, { lineHeight: 18 }]}>{t.title}</Text>)}{b.result && <Text style={labelStyle}>{b.result}</Text>}</Section>{b.result && <Text numberOfLines={2} style={{ color: theme.colors.statusWarning, fontSize: 12, lineHeight: 18 }}>{b.result}</Text>}{b.state === "pending" && <><Button title={deliver.isPending ? "Sending…" : b.tasks.every(t => t.kind === "inventory") ? "Run recovery" : "Send batch"} theme={theme} compact={compact} disabled={deliver.isPending || workspace.runtimeBusy || !!workspace.runtimeReason || !!workspace.hold || batches.some(other => other.id !== b.id && ["sending", "sent", "blocked", "uncertain"].includes(other.state))} onPress={() => deliver.mutate(b.id)} />{workspace.runtimeBusy && <Text style={labelStyle}>Wait for active agent work to finish.</Text>}</>}{["blocked", "uncertain"].includes(b.state) && <Button title="Retry" theme={theme} compact={compact} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, retryBatchId: b.id })} />}</View>)}{!batches.length && <Text style={labelStyle}>No pending follow-up.</Text>}</Section>
65
- <Section title="Workspace" theme={theme} compact={compact} initiallyExpanded={false}>
66
- <Text style={[labelStyle, { lineHeight: 18 }]}>{workspace.goal}</Text>
67
- {navigation && <View><Button title="Open main agent" theme={theme} compact={compact} onPress={() => navigation.openAgent({ agentId: workspace.mainAgentId })} /></View>}
68
- <Text style={[labelStyle, { marginTop: 18 }]}>{latest ? `PR status checked ${new Date(latest).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "Status refresh runs even when the app is closed"}</Text>
69
- <Section title="Recovery" theme={theme} compact={compact} initiallyExpanded={false}>
70
- {recovery.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusWarning }}>{recovery.error.message}</Text>}
71
- <Button title={recovery.isPending || lastRecovery?.state === "sending" ? "Starting recovery…" : "Run recovery"} theme={theme} compact={compact} disabled={recovery.isPending || workspace.archived || !!lastRecovery && (["pending", "sending", "sent", "uncertain"].includes(lastRecovery.state) || lastRecovery.state === "blocked" && !lastRecovery.settledAt)} onPress={() => recovery.mutate()} />
72
- {lastRecovery && <View style={{ gap: 6 }}><Text style={labelStyle}>{lastRecovery.state === "sent" ? "Recovery running" : lastRecovery.state === "completed" ? "Recovery complete" : lastRecovery.state === "pending" ? "Recovery ready" : lastRecovery.state === "sending" ? "Starting recovery" : "Check recovery"}</Text>
73
- {lastRecovery.result && <Section title="Result" theme={theme} compact={compact} initiallyExpanded={false}><Text style={labelStyle}>{lastRecovery.result}</Text></Section>}
74
- {lastRecovery.recipientAgentId && navigation && <Button title="Open recovery agent" theme={theme} compact={compact} onPress={() => navigation.openAgent({ agentId: lastRecovery.recipientAgentId! })} />}
75
- {lastRecovery.state === "pending" && <Button title="Run queued recovery" theme={theme} compact={compact} disabled={deliver.isPending} onPress={() => deliver.mutate(lastRecovery.id)} />}
76
- {["blocked", "uncertain"].includes(lastRecovery.state) && <Button title="Retry recovery" theme={theme} compact={compact} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, retryBatchId: lastRecovery.id })} />}
77
- </View>}
78
- </Section>
79
- {workspace.completion && <Text style={{ fontSize: 12, color: colorFor(theme, state.status), marginTop: 8 }}>Ready to archive.</Text>}
80
- </Section>
68
+ // The empty state teaches with the panel's own rail: the three things Follow-up does, as three stack nodes.
69
+ const points: { icon: string; color: string; line: string }[] = [
70
+ { icon: "GitMerge", color: mergedColor(c), line: "Follows every PR this workspace opens, stack by stack, with review and CI state." },
71
+ { icon: "Wrench", color: c.statusDanger, line: "Gathers conflicts, failing checks and review threads into one repair batch for the main agent." },
72
+ { icon: "Check", color: c.statusSuccess, line: "Tells you when the agent declares the work done and it's safe to archive." },
73
+ ];
74
+ return <ScrollView style={{ flex: 1, backgroundColor: c.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 12, paddingTop: compact ? 20 : 16, gap: 14 }}>
75
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
76
+ <Text accessibilityRole="header" style={[t.title, { flex: 1 }]}>Track this workspace</Text>
77
+ <View style={{ marginVertical: compact ? -12 : -4, marginRight: compact ? -10 : -4 }}><IconButton icon="Settings" label="Follow-up settings" onPress={openSettings} theme={theme} compact={compact} /></View>
78
+ </View>
79
+ <View>
80
+ {points.map((p, i) => <View key={p.icon} style={{ flexDirection: "row", gap: 10 }}>
81
+ <View style={{ width: 18, alignItems: "center" }}>
82
+ <View style={{ width: 18, height: 18, borderRadius: 9, alignItems: "center", justifyContent: "center", backgroundColor: tint(p.color, c) }}><Icon name={p.icon} size={11} color={p.color} /></View>
83
+ {i < points.length - 1 && <View style={{ width: 2, flex: 1, minHeight: 10, backgroundColor: c.border }} />}
84
+ </View>
85
+ <Text style={[t.meta, { flex: 1, color: c.foreground, paddingTop: 1, paddingBottom: i < points.length - 1 ? 14 : 0 }]}>{p.line}</Text>
86
+ </View>)}
87
+ </View>
88
+ <Text style={t.meta}>Nothing is sent, merged or archived without your click. Auto-send stays off until you turn it on for this workspace.</Text>
89
+ {(loadError || start.error) && <Text accessibilityRole="alert" style={[t.meta, { color: c.statusDanger }]}>{start.error ? errorText(start.error) : loadError}</Text>}
90
+ <Button title="Track this workspace" kind="primary" icon="Plus" theme={theme} compact={compact} loading={start.isPending} onPress={() => start.mutate()} />
91
+ <Text style={t.small}>To track every workspace in a project, turn it on in Settings. Tracking data stays on this host.</Text>
81
92
  </ScrollView>;
82
93
  }
94
+
95
+ /** The main agent's current repairs: what was sent and when. Pending batches live in Needs you. */
96
+ function AgentCard({ workspace: w, theme, compact, now, openAgent }: { workspace: Workspace; theme: Theme; compact: boolean; now: number; openAgent?: (id: string) => void }) {
97
+ const c = theme.colors; const t = text(c);
98
+ const sent = w.batches.find(b => !recoveryBatch(b) && ["sending", "sent"].includes(b.state));
99
+ const queued = w.enable_auto_sends ? w.batches.find(b => !recoveryBatch(b) && b.state === "pending") : undefined;
100
+ const batch = sent ?? queued;
101
+ if (!batch) return null;
102
+ const recipient = batch.recipientAgentId && batch.recipientAgentId !== w.mainAgentId ? batch.recipientAgentId : w.mainAgentId;
103
+ const blocker = queued && !sent ? sendBlocker(w, queued) : null;
104
+ return <Card theme={theme} compact={compact} icon="Bot" title={recipient === w.mainAgentId ? "Main agent is on it" : "Recovery agent is on it"}
105
+ right={openAgent && <Button theme={theme} compact={compact} kind="ghost" icon="ArrowUpRight" title="Open" accessibilityLabel="Open the agent's chat" onPress={() => openAgent(recipient)} />}>
106
+ <CardRow theme={theme}>
107
+ <Text style={[t.meta, { marginBottom: 6 }]}>{sent ? `Sent ${batch.tasks.length} repair${batch.tasks.length === 1 ? "" : "s"} ${ago(batch.sentAt, now)}. Follow-up waits for the agent to report back.` : `${batch.tasks.length} repair${batch.tasks.length === 1 ? "" : "s"} queued · ${blocker ?? "sends when the agent is idle"}`}</Text>
108
+ {batch.tasks.map((task, i) => <View key={task.key} style={{ flexDirection: "row", gap: 6 }}>
109
+ <Text style={[t.meta, { width: 16, fontVariant: ["tabular-nums"] }]}>{i + 1}.</Text>
110
+ <Text style={[t.meta, { flex: 1 }]}>{shortRefs(task.title, w)}</Text>
111
+ </View>)}
112
+ {!!batch.result && <Text style={[t.meta, { marginTop: 6, color: c.statusWarning }]}>{batch.result}</Text>}
113
+ </CardRow>
114
+ </Card>;
115
+ }
116
+
117
+ /** The moment the loop closes: the agent declared Done and every requirement settled. */
118
+ function ShippedCard({ workspace: w, theme, compact, now }: { workspace: Workspace; theme: Theme; compact: boolean; now: number }) {
119
+ const c = theme.colors; const t = text(c);
120
+ if (summary(w, now).status !== "done" || !w.completion) return null;
121
+ const merged = w.prs.filter(p => p.snapshot?.state === "MERGED").length;
122
+ return <Reveal>
123
+ <View style={{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 10, padding: 14, gap: 10, backgroundColor: tint(c.statusSuccess, c), borderWidth: 1, borderColor: tint(c.statusSuccess, c) }}>
124
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
125
+ <View style={{ width: 30, height: 30, borderRadius: 15, alignItems: "center", justifyContent: "center", backgroundColor: c.statusSuccess }}><Icon name="Check" size={17} color={c.surface0} /></View>
126
+ <View style={{ flex: 1 }}>
127
+ <Text accessibilityRole="header" style={[t.label, { fontSize: 14, color: c.foreground }]}>Shipped</Text>
128
+ <Text style={t.meta}>{merged ? `${merged} PR${merged === 1 ? "" : "s"} merged · ` : ""}declared {ago(w.completion.declaredAt, now)}</Text>
129
+ </View>
130
+ </View>
131
+ <Text selectable style={[t.body, { color: c.foreground }]}>{w.completion.summary}</Text>
132
+ <Text style={t.meta}>Archive the workspace from its menu when you're finished with it.</Text>
133
+ </View>
134
+ </Reveal>;
135
+ }
136
+
137
+ /** Quiet extras: the main agent, optional recovery and the Graphite merge-when-ready connection. */
138
+ function WorkspaceCard({ workspace: w, theme, compact, open, toggle, openAgent, browserReady, openOverview, editSpec }: { workspace: Workspace; theme: Theme; compact: boolean; open: (key: string) => boolean; toggle: (key: string) => void; openAgent?: (id: string) => void; browserReady: boolean | null; openOverview: () => void; editSpec: () => void }) {
139
+ const c = theme.colors; const t = text(c);
140
+ const control = useRpc(controlRpc); const send = useRpc(sendRpc); const runRecovery = useRpc(recoverRpc); const cache = useQueryClient(); const toast = useToast();
141
+ const invalidate = () => { void cache.invalidateQueries({ queryKey: workspaceKey(w.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
142
+ const fail = (e: Error) => toast.error(errorText(e));
143
+ const recovery = useMutation({ mutationFn: () => runRecovery({ workspaceId: w.id }), onSuccess: invalidate, onError: fail });
144
+ const runQueued = useMutation({ mutationFn: (batchId: string) => send({ workspaceId: w.id, batchId }), onSuccess: invalidate, onError: fail });
145
+ const retry = useMutation({ mutationFn: (batchId: string) => control({ workspaceId: w.id, retryBatchId: batchId }), onSuccess: invalidate, onError: fail });
146
+ const last = w.batches.filter(b => recoveryBatch(b) && b.state !== "cancelled").at(-1);
147
+ const recoveryState = last ? last.state === "sent" ? "Recovery is running" : last.state === "completed" ? "Last recovery finished" : last.state === "pending" ? "Recovery is ready to run" : last.state === "sending" ? "Starting recovery" : "Recovery needs a check" : null;
148
+ const active = w.prs.some(p => !settledForDisplay(p));
149
+ const shown = open("workspace");
150
+ const openUrl = (url: string) => { void openExternalUrl(url).catch(() => undefined); };
151
+ return <View style={{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 8, borderWidth: 1, borderColor: c.border, overflow: "hidden" }}>
152
+ <View style={{ marginTop: -1 }}><FoldRow theme={theme} open={shown} onPress={() => toggle("workspace")} icon="SlidersHorizontal" iconColor={c.foregroundMuted} label="Spec, agent and recovery" /></View>
153
+ {shown && <>
154
+ <SpecRow workspace={w} theme={theme} compact={compact} onEdit={editSpec} />
155
+ <CardRow theme={theme}>
156
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
157
+ <View style={{ flex: 1, minWidth: 160, gap: 2 }}>
158
+ <Text style={t.label}>Main agent</Text>
159
+ <Text style={t.meta}>Owns the PRs, tasks and the Done call. Repairs go here.</Text>
160
+ </View>
161
+ {openAgent && <Button theme={theme} compact={compact} icon="MessageSquare" title="Open" accessibilityLabel="Open main agent" onPress={() => openAgent(w.mainAgentId)} />}
162
+ </View>
163
+ </CardRow>
164
+ <CardRow theme={theme}>
165
+ <View style={{ gap: 8 }}>
166
+ <View style={{ gap: 2 }}>
167
+ <Text style={t.label}>Recovery</Text>
168
+ <Text style={t.meta}>A separate agent reads this workspace's history to find PRs and tasks Follow-up missed. It never edits code or messages the main agent.</Text>
169
+ </View>
170
+ {recoveryState && <Text style={[t.meta, { color: c.foreground }]}>{recoveryState}{last?.settledAt ? ` · ${ago(last.settledAt)}` : ""}</Text>}
171
+ {!!last?.result && <Text numberOfLines={4} selectable style={t.meta}>{last.result}</Text>}
172
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
173
+ {last?.state === "pending" ? <Button title="Run queued recovery" theme={theme} compact={compact} loading={runQueued.isPending} onPress={() => runQueued.mutate(last.id)} />
174
+ : <Button title={recovery.isPending || last?.state === "sending" ? "Starting recovery…" : "Run recovery"} icon="History" theme={theme} compact={compact} loading={recovery.isPending}
175
+ disabled={recovery.isPending || w.archived || !!last && (["pending", "sending", "sent", "uncertain"].includes(last.state) || last.state === "blocked" && !last.settledAt)} onPress={() => recovery.mutate()} />}
176
+ {last?.recipientAgentId && openAgent && <Button title="Open recovery agent" kind="ghost" theme={theme} compact={compact} onPress={() => openAgent(last.recipientAgentId!)} />}
177
+ {last && ["blocked", "uncertain"].includes(last.state) && <Button title="Retry recovery" theme={theme} compact={compact} loading={retry.isPending} onPress={() => retry.mutate(last.id)} />}
178
+ </View>
179
+ </View>
180
+ </CardRow>
181
+ {active && browserReady === false && <CardRow theme={theme}>
182
+ <View style={{ gap: 8 }}>
183
+ <View style={{ gap: 2 }}>
184
+ <Text style={t.label}>Merge when ready</Text>
185
+ <Text style={t.meta}>Optional. With Send to Paseo paired and Graphite open in Chrome, each open PR gets a When ready switch. Chrome asks before each change unless you allowed it for the repository.</Text>
186
+ </View>
187
+ <Button title="Open Graphite" icon="ExternalLink" theme={theme} compact={compact} accessibilityLabel="Connect Graphite" onPress={() => openUrl("https://app.graphite.com")} />
188
+ </View>
189
+ </CardRow>}
190
+ <CardRow theme={theme}>
191
+ <Button title="All tracked workspaces" kind="ghost" icon="LayoutList" theme={theme} compact={compact} onPress={openOverview} />
192
+ </CardRow>
193
+ </>}
194
+ </View>;
195
+ }
196
+
197
+ const folds = z.object({ open: z.array(z.string()) });
198
+ function historyLabel(w: Workspace): string {
199
+ const stacks = new Set(w.prs.map(p => p.stack)).size;
200
+ const tasks = followUpObligations(w).filter(o => ["done", "waived"].includes(o.status)).length;
201
+ return [`${w.prs.length} PR${w.prs.length === 1 ? "" : "s"} in ${stacks} stack${stacks === 1 ? "" : "s"}`, tasks ? `${tasks} task${tasks === 1 ? "" : "s"} done` : null].filter(Boolean).join(" · ");
202
+ }
203
+
204
+ export type Folds = { isOpen: (key: string) => boolean; toggle: (key: string) => void };
205
+ /** Fold choices shared by a workspace's panel and its larger view during the app session. */
206
+ const sessionFolds = new Map<string, string[]>();
207
+ export function useSessionFolds(workspaceId: string): Folds {
208
+ const [open, setOpen] = useState<string[]>(() => sessionFolds.get(workspaceId) ?? []);
209
+ return { isOpen: key => open.includes(key), toggle: key => { const next = open.includes(key) ? open.filter(k => k !== key) : [...open, key]; sessionFolds.set(workspaceId, next); setOpen(next); } };
210
+ }
211
+
212
+ /** The panel keeps its folds in the tab's persisted state and mirrors them for the larger view. */
213
+ function PanelWorkspace(props: PanelProps & { workspace: Workspace; loadError?: string | null }) {
214
+ const [state, setState] = useTabState(props.tab, folds, { open: sessionFolds.get(props.workspaceId) ?? [] });
215
+ const isOpen = (key: string) => state.open.includes(key);
216
+ const toggle = (key: string) => { const next = isOpen(key) ? state.open.filter(k => k !== key) : [...state.open, key]; sessionFolds.set(props.workspaceId, next); setState({ open: next }); };
217
+ const { openLarge } = props;
218
+ return <WorkspaceView {...props} folds={{ isOpen, toggle }} resize={openLarge ? { icon: "Maximize2", label: "Open the larger view", onPress: () => openLarge(props.workspaceId) } : null} />;
219
+ }
220
+
221
+ /** Two columns from this content width: actions on the left, the stacks on the right. */
222
+ export const WIDE_MIN = 820;
223
+
224
+ export function WorkspaceView({ theme, layout, workspace: w, navigation, loadError, openSettings, openOverview, folds: { isOpen, toggle }, resize, large = false }: PluginHostProps & {
225
+ navigation?: PluginWorkspacePanelProps["navigation"]; workspace: Workspace; loadError?: string | null; openSettings: () => void; openOverview: () => void;
226
+ folds: Folds; resize: { icon: "Maximize2" | "Minimize2"; label: string; onPress: () => void } | null; large?: boolean;
227
+ }) {
228
+ const control = useRpc(controlRpc); const refresh = useRpc(refreshRpc); const browserRead = useRpc(browserStatusRpc);
229
+ const cache = useQueryClient(); const toast = useToast();
230
+ const now = useNow(30_000);
231
+ const compact = layout.compact;
232
+ const [width, setWidth] = useState(0);
233
+ const wide = !compact && width >= WIDE_MIN;
234
+ const [sheet, setSheet] = useState<{ key: string; intent: "merge" | "reviewers" | null } | null>(null);
235
+ const [question, setQuestion] = useState<string | null>(null);
236
+ const [specOpen, setSpecOpen] = useState(false);
237
+ const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000 });
238
+ const invalidated = () => { void cache.invalidateQueries({ queryKey: workspaceKey(w.id) }); void cache.invalidateQueries({ queryKey: overviewKey }); };
239
+ const auto = useMutation({ mutationFn: (next: boolean) => control({ workspaceId: w.id, enable_auto_sends: next }), onSuccess: (_, next) => { toast.show(next ? "Auto-send on for this workspace" : "Auto-send off · repairs wait for your click"); invalidated(); }, onError: e => toast.error(errorText(e)) });
240
+ const update = useMutation({ mutationFn: () => refresh({ workspaceId: w.id }), onSuccess: () => setTimeout(invalidated, 1200), onError: e => toast.error(errorText(e)) });
241
+ const openAgent = navigation ? (agentId: string) => navigation.openAgent({ agentId }) : undefined;
242
+ const done = summary(w, now).status === "done";
243
+ const openPr = (key: string) => setSheet({ key, intent: null });
244
+ // The expand button only appears while the panel is narrow; a wide panel or the larger view keeps its collapse button.
245
+ const resizeShown = compact ? null : resize?.icon === "Maximize2" && wide ? null : resize;
246
+ const header = <WorkspaceHeader workspace={w} theme={theme} compact={compact} now={now} onSettings={openSettings} onRefresh={() => update.mutate()} refreshing={update.isPending} onAuto={next => auto.mutate(next)} autoPending={auto.isPending} notice={loadError ?? null} onOpenPr={openPr} resize={resizeShown} hideTitle={large} />;
247
+ const shipped = <ShippedCard workspace={w} theme={theme} compact={compact} now={now} />;
248
+ const needs = <NeedsYouCard workspace={w} theme={theme} compact={compact} now={now} actions={{ openAgent, openPr: (key, intent) => setSheet({ key, intent }), openQuestion: setQuestion }} />;
249
+ const agent = <AgentCard workspace={w} theme={theme} compact={compact} now={now} openAgent={openAgent} />;
250
+ // Once shipped, the stack and tasks are history: one quiet fold instead of two near-empty cards.
251
+ const history = done && <View style={{ marginHorizontal: compact ? 16 : 12, marginTop: 12, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border, overflow: "hidden" }}>
252
+ <View style={{ marginTop: -1 }}><FoldRow theme={theme} open={isOpen("history")} onPress={() => toggle("history")} icon="History" iconColor={theme.colors.foregroundMuted} label={historyLabel(w)} /></View>
253
+ </View>;
254
+ const showWork = !done || isOpen("history");
255
+ const stacks = showWork && <StacksCard workspace={w} theme={theme} compact={compact} open={isOpen} toggle={toggle} onOpen={openPr} wide={wide} />;
256
+ const tasks = showWork && <TasksCard workspace={w} theme={theme} compact={compact} open={isOpen} toggle={toggle} onOpenQuestion={setQuestion} />;
257
+ const log = <ActivityCard workspace={w} theme={theme} compact={compact} now={now} open={isOpen} toggle={toggle} onOpenQuestion={setQuestion} />;
258
+ const extras = <WorkspaceCard workspace={w} theme={theme} compact={compact} open={isOpen} toggle={toggle} openAgent={openAgent} browserReady={browser.data ? browser.data.ready : null} openOverview={openOverview} editSpec={() => setSpecOpen(true)} />;
259
+ return <View onLayout={event => setWidth(event.nativeEvent.layout.width)} style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
260
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 20, ...(large ? { maxWidth: 1240, width: "100%", alignSelf: "center", paddingHorizontal: 12 } : null) }}>
261
+ {header}
262
+ {wide ? <View style={{ flexDirection: "row", alignItems: "flex-start" }}>
263
+ <View style={{ flex: 5, minWidth: 0 }}>{shipped}{needs}{agent}{history}{tasks}</View>
264
+ <View style={{ flex: 6, minWidth: 0 }}>{stacks}{log}{extras}</View>
265
+ </View> : <>{shipped}{needs}{agent}{history}{stacks}{tasks}{log}{extras}</>}
266
+ </ScrollView>
267
+ <PrSheet workspace={w} prKey={sheet?.key ?? null} intent={sheet?.intent ?? null} theme={theme} compact={compact} browserReady={browser.data?.ready ?? false} onClose={() => setSheet(null)} />
268
+ <QuestionSheet workspace={w} questionId={question} theme={theme} compact={compact} openAgent={openAgent} onClose={() => setQuestion(null)} />
269
+ <SpecSheet workspace={w} open={specOpen} theme={theme} compact={compact} onClose={() => setSpecOpen(false)} />
270
+ </View>;
271
+ }
@@ -1,22 +1,30 @@
1
- import { useState } from "react";
2
- import { Pressable, Text, View } from "react-native";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Text, View } from "react-native";
3
3
  import { useRpc } from "@getpaseo/plugin/client";
4
+ import { Icon, useToast } from "@getpaseo/plugin/client/ui";
4
5
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
6
  import { confirmPrActionRpc, preparePrActionRpc, prControlStatusRpc, requestReviewersRpc } from "../shared/contracts";
6
- import { prActionBlocks, type ActionPlan, type PrActionKind } from "../shared/pr-actions";
7
+ import { mergeBlockers, prActionBlocks, type ActionPlan, type PrActionKind } from "../shared/pr-actions";
8
+ import { mergeQueued, mergeReady } from "../shared/attention";
7
9
  import type { Pr, Workspace } from "../shared/model";
8
- import { Button, type Theme } from "./components";
10
+ import { Button, Switch, errorText, text, type Theme } from "./kit";
9
11
  import { defaultReviewers, noReviewers } from "../shared/reviewers";
10
12
 
11
- export function PrControls({ pr, workspace, theme, compact, browserReady }: { browserReady: boolean; pr: Pr; workspace: Workspace; theme: Theme; compact: boolean }) {
13
+ /**
14
+ * Merge, merge-when-ready and reviewer requests for one PR. Every write needs a click; Merge always
15
+ * prepares a fresh plan and asks again before anything reaches Graphite.
16
+ */
17
+ export function PrControls({ pr, workspace, theme, compact, browserReady, intent }: { browserReady: boolean; pr: Pr; workspace: Workspace; theme: Theme; compact: boolean; intent?: "merge" | "reviewers" | null }) {
12
18
  const read = useRpc(prControlStatusRpc); const prepare = useRpc(preparePrActionRpc); const confirm = useRpc(confirmPrActionRpc); const cache = useQueryClient();
13
19
  const requestReviewers = useRpc(requestReviewersRpc);
20
+ const toast = useToast();
21
+ const c = theme.colors; const t = text(c);
14
22
  const [plan, setPlan] = useState<ActionPlan | null>(null); const [notice, setNotice] = useState<string | null>(null);
15
23
  const statusKey = ["follow-through", "pr-controls", workspace.id, pr.key, pr.snapshot?.head];
16
24
  const status = useQuery({ queryKey: statusKey, queryFn: () => read({ workspaceId: workspace.id, prKey: pr.key }), refetchInterval: 5000, enabled: browserReady });
17
25
  const invalidate = () => { void cache.invalidateQueries({ queryKey: ["follow-through", "workspace", workspace.id] }); void cache.invalidateQueries({ queryKey: ["follow-through", "pr-controls", workspace.id] }); void cache.invalidateQueries({ queryKey: ["follow-through", "overview"] }); };
18
- const reviewRequest = useMutation({ mutationFn: () => requestReviewers({ workspaceId: workspace.id, prKey: pr.key }), onSuccess: reply => { setNotice(reply.result); invalidate(); }, onError: invalidate });
19
- const submit = useMutation({ mutationFn: (input: { planId: string; scope: "target" | "downstack" }) => confirm(input), onSuccess: reply => { setPlan(null); setNotice(reply.result); invalidate(); }, onError: invalidate });
26
+ const reviewRequest = useMutation({ mutationFn: () => requestReviewers({ workspaceId: workspace.id, prKey: pr.key }), onSuccess: reply => { setNotice(reply.result); if (reply.state === "submitted") toast.show(reply.result, { variant: "success" }); invalidate(); }, onError: invalidate });
27
+ const submit = useMutation({ mutationFn: (input: { planId: string; scope: "target" | "downstack" }) => confirm(input), onSuccess: reply => { setPlan(null); setNotice(reply.result); if (reply.state === "submitted") toast.show(reply.result, { variant: "success" }); invalidate(); }, onError: invalidate });
20
28
  const checking = useMutation({ mutationFn: (kind: PrActionKind) => prepare({ workspaceId: workspace.id, prKey: pr.key, kind }), onSuccess: result => {
21
29
  if (result.kind !== "merge" && result.prs.length === 1 && !result.blockers.length && !result.prs[0].blockers.length) submit.mutate({ planId: result.id, scope: "target" });
22
30
  else setPlan(result);
@@ -26,41 +34,50 @@ export function PrControls({ pr, workspace, theme, compact, browserReady }: { br
26
34
  const actual = browserReady ? status.data?.pr : null;
27
35
  const known = !!actual && !status.error && !status.data?.error;
28
36
  const enabled = known && actual.mergeWhenReady;
29
- const queued = actual?.merging || ["QUEUED_TO_MERGE", "WAITING_TO_MERGE", "REBASING", "HANDED_OFF_TO_EXTERNAL_MERGE_QUEUE"].includes(pr.snapshot?.graphiteMergeStatus ?? "");
37
+ const queued = actual?.merging || mergeQueued(pr);
30
38
  const recentlySubmitted = workspace.prActions.some(a => a.kind === "merge" && a.state === "submitted" && a.heads[pr.key] === pr.snapshot?.head && Date.now() - Date.parse(a.createdAt) < 30_000);
31
- const canMerge = !pr.error && pr.snapshot?.complete && pr.snapshot.state === "OPEN" && pr.snapshot.reviewDecision === "APPROVED" && !pr.snapshot.draft && !pr.snapshot.conflict && !pr.snapshot.failedChecks.length && !pr.snapshot.checksPending && !queued && !recentlySubmitted;
39
+ const canMerge = mergeReady(pr, workspace) && !actual?.merging;
32
40
  const disabled = busy || reviewRequest.isPending || pendingAction || workspace.archived;
33
41
  const missingReviewers = noReviewers(pr);
34
42
  const reviewers = defaultReviewers(pr.repo).filter(r => r.login.toLowerCase() !== pr.snapshot?.reviewers?.author?.toLowerCase());
35
43
  const begin = (kind: PrActionKind) => { setPlan(null); setNotice(null); submit.reset(); checking.reset(); checking.mutate(kind); };
36
- const muted = { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 };
37
- const error = checking.error?.message ?? submit.error?.message ?? reviewRequest.error?.message ?? (browserReady ? status.data?.error : null) ?? null;
44
+ const failure = checking.error ?? submit.error ?? reviewRequest.error;
45
+ const error = failure ? errorText(failure) : (browserReady ? status.data?.error : null) ?? null;
38
46
  const scopeBlocked = (scope: "target" | "downstack") => !!plan && (plan.blockers.length > 0 || (scope === "target" ? plan.prs.slice(-1) : plan.prs).some(p => p.blockers.length > 0));
39
47
  const act = (scope: "target" | "downstack") => { if (plan) submit.mutate({ planId: plan.id, scope }); };
40
- if (!browserReady && !canMerge && !queued && !recentlySubmitted && !busy && !error && !notice && !plan && !missingReviewers) return null;
41
- return <View style={{ gap: 5, paddingLeft: 0 }}>
42
- {missingReviewers && <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
43
- <Text style={{ ...muted, color: theme.colors.statusWarning }}>No reviewers</Text>
44
- {!pr.snapshot?.draft && reviewers.length > 0 && <Button title={reviewRequest.isPending ? "Requesting…" : "Add reviewers"} accessibilityLabel={`Add reviewers: ${reviewers.map(r => r.name).join(" and ")} for ${pr.repo} pull request ${pr.number}`} icon="UserPlus" theme={theme} compact={compact} disabled={disabled} onPress={() => { setNotice(null); reviewRequest.mutate(); }} />}
48
+ // Opening the sheet from "Merge…" starts the read-only check; the merge itself still needs the confirm click.
49
+ const started = useRef(false);
50
+ useEffect(() => { if (intent === "merge" && canMerge && !disabled && !started.current) { started.current = true; begin("merge"); } }, [intent, canMerge]);
51
+ if (pr.snapshot?.state !== "OPEN") return null;
52
+ const waitingFor = mergeBlockers(pr).filter(b => !b.startsWith("Graphite "));
53
+ return <View style={{ gap: 10 }}>
54
+ {missingReviewers && <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
55
+ <Icon name="UserX" size={14} color={c.statusWarning} />
56
+ <Text style={[t.meta, { flex: 1, minWidth: 120, color: c.statusWarning }]}>No reviewers yet</Text>
57
+ {!pr.snapshot?.draft && reviewers.length > 0 && <Button title={reviewRequest.isPending ? "Requesting…" : "Add reviewers"} accessibilityLabel={`Add reviewers: ${reviewers.map(r => r.name).join(" and ")} for ${pr.repo} pull request ${pr.number}`} icon="UserPlus" theme={theme} compact={compact} disabled={disabled} loading={reviewRequest.isPending} onPress={() => { setNotice(null); reviewRequest.mutate(); }} />}
45
58
  </View>}
46
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
47
- {browserReady && <Pressable accessibilityRole="switch" aria-checked={known ? !!enabled : "mixed"} aria-valuetext={known ? enabled ? "On" : "Off" : "Unknown"} accessibilityLabel={`Merge when ready for ${pr.repo} pull request ${pr.number}`} accessibilityValue={{ text: known ? enabled ? "On" : "Off" : "Unknown" }} accessibilityState={{ checked: !!enabled, disabled: disabled || !known }} disabled={disabled || !known} onPress={() => begin(enabled ? "disable_mwr" : "enable_mwr")} style={{ minHeight: compact ? 44 : 36, flex: 1, flexDirection: "row", gap: 7, alignItems: "center", opacity: disabled ? 0.5 : 1 }}>
48
- <View style={{ width: 30, height: 18, borderRadius: 9, backgroundColor: enabled ? theme.colors.accent : theme.colors.surface2, borderWidth: 1, borderColor: enabled ? theme.colors.accent : theme.colors.border }}><View style={{ width: 12, height: 12, borderRadius: 6, position: "absolute", top: 2, left: enabled ? 14 : 2, backgroundColor: known ? enabled ? theme.colors.accentForeground : theme.colors.foregroundMuted : theme.colors.border }} /></View>
49
- <Text style={{ ...muted, flexShrink: 1 }}>When ready{!known ? " · unknown" : ""}</Text>
50
- </Pressable>}
51
- {(canMerge || queued || recentlySubmitted || busy) && <Button title={busy ? checking.isPending ? "Checking…" : "Saving…" : queued ? "Queued" : recentlySubmitted ? "Submitted" : "Merge"} icon={queued ? "Clock" : "GitMerge"} theme={theme} compact={compact} primary={!!canMerge} disabled={disabled || !canMerge} onPress={() => begin("merge")} />}
52
- </View>
53
- {error && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{error}</Text>}
54
- {notice && <Text accessibilityLiveRegion="polite" style={muted}>{notice}</Text>}
55
- {plan && (plan.kind === "merge" || browserReady) && <View style={{ gap: 8, backgroundColor: theme.colors.surface1, borderRadius: 6, padding: 10 }}>
56
- <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600", lineHeight: 19 }}>{plan.kind === "merge" ? plan.prs.length > 1 ? "Merge the PRs underneath too?" : "Merge this PR?" : `${plan.kind === "enable_mwr" ? "Enable" : "Disable"} for downstack too?`}</Text>
57
- <Text style={muted}>{plan.kind === "merge" ? plan.prs.length > 1 ? `#${pr.number} depends on ${plan.prs.length - 1} unmerged PR${plan.prs.length === 2 ? "" : "s"}. Merge these ${plan.prs.length} PRs in order:` : "Graphite will merge using this repository’s settings and required protections." : `Change merge when ready for #${pr.number} only, or for all ${plan.prs.length} listed PRs.`}</Text>
58
- {plan.prs.map(p => <View key={p.key} style={{ gap: 2 }}><Text style={{ color: theme.colors.foreground, fontSize: 12, lineHeight: 18 }}>#{p.number} · {p.title}</Text>{p.blockers.length > 0 && <Text style={{ ...muted, color: theme.colors.statusWarning }}>{p.blockers.join(" · ")}</Text>}</View>)}
59
- {plan.blockers.map(blocker => <Text key={blocker} style={{ ...muted, color: theme.colors.statusWarning }}>{blocker}</Text>)}
60
- <View style={{ gap: 6 }}>
61
- <Button title={submit.isPending ? "Submitting…" : plan.kind === "merge" ? plan.prs.length > 1 ? `Merge all ${plan.prs.length} PRs` : `Merge #${pr.number}` : `${plan.kind === "enable_mwr" ? "Enable" : "Disable"} for all ${plan.prs.length} PRs`} primary theme={theme} compact={compact} disabled={submit.isPending || scopeBlocked("downstack")} onPress={() => act("downstack")} />
59
+ {!plan && <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
60
+ {(canMerge || queued || recentlySubmitted || busy) ? <Button title={busy ? checking.isPending ? "Checking…" : "Submitting…" : queued ? "Queued to merge" : recentlySubmitted ? "Submitted" : `Merge #${pr.number}`} icon={queued ? "Clock" : "GitMerge"} theme={theme} compact={compact} kind={canMerge ? "primary" : "secondary"} loading={busy} disabled={disabled || !canMerge} onPress={() => begin("merge")} />
61
+ : <Text style={[t.meta, { flex: 1 }]}>{waitingFor.length ? `Merge opens when it clears: ${waitingFor.join(", ").toLowerCase()}.` : "Waiting for Graphite to report it ready to merge."}</Text>}
62
+ {browserReady && <Switch theme={theme} compact={compact} label={`When ready${known ? "" : " · unknown"}`} accessibilityLabel={`Merge when ready for ${pr.repo} pull request ${pr.number}`} value={known ? !!enabled : null} disabled={disabled} onChange={() => begin(enabled ? "disable_mwr" : "enable_mwr")} />}
63
+ </View>}
64
+ {error && <Text accessibilityRole="alert" style={[t.meta, { color: c.statusDanger }]}>{error}</Text>}
65
+ {notice && <Text accessibilityLiveRegion="polite" style={t.meta}>{notice}</Text>}
66
+ {plan && (plan.kind === "merge" || browserReady) && <View accessibilityRole="summary" style={{ gap: 8 }}>
67
+ <Text accessibilityRole="header" style={[t.label, { fontSize: 14, lineHeight: 20 }]}>{plan.kind === "merge" ? plan.prs.length > 1 ? "Merge the PRs underneath too?" : "Merge this PR?" : `${plan.kind === "enable_mwr" ? "Enable" : "Disable"} for downstack too?`}</Text>
68
+ <Text style={t.meta}>{plan.kind === "merge" ? plan.prs.length > 1 ? `#${pr.number} depends on ${plan.prs.length - 1} unmerged PR${plan.prs.length === 2 ? "" : "s"}. Merge these ${plan.prs.length} PRs in order:` : "Graphite will merge using this repository’s settings and required protections." : `Change merge when ready for #${pr.number} only, or for all ${plan.prs.length} listed PRs.`}</Text>
69
+ {plan.prs.map((p, i) => <View key={p.key} style={{ flexDirection: "row", gap: 8 }}>
70
+ <Text style={[t.meta, { width: 16, textAlign: "right", fontVariant: ["tabular-nums"] }]}>{i + 1}.</Text>
71
+ <View style={{ flex: 1, gap: 2 }}>
72
+ <Text style={[t.body, { fontSize: 12, lineHeight: 17 }]}>#{p.number} · {p.title}</Text>
73
+ {p.blockers.length > 0 && <Text style={[t.meta, { color: c.statusWarning }]}>{p.blockers.join(" · ")}</Text>}
74
+ </View>
75
+ </View>)}
76
+ {plan.blockers.map(blocker => <Text key={blocker} style={[t.meta, { color: c.statusWarning }]}>{blocker}</Text>)}
77
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 2 }}>
78
+ <Button title={plan.kind === "merge" ? plan.prs.length > 1 ? `Merge all ${plan.prs.length} PRs` : `Merge #${pr.number}` : `${plan.kind === "enable_mwr" ? "Enable" : "Disable"} for all ${plan.prs.length} PRs`} kind="primary" theme={theme} compact={compact} loading={submit.isPending} disabled={submit.isPending || scopeBlocked("downstack")} onPress={() => act("downstack")} />
62
79
  {plan.kind !== "merge" && plan.prs.length > 1 && <Button title="Just this PR" theme={theme} compact={compact} disabled={submit.isPending || scopeBlocked("target")} onPress={() => act("target")} />}
63
- <Button title="Cancel" theme={theme} compact={compact} disabled={submit.isPending} onPress={() => { setPlan(null); checking.reset(); submit.reset(); }} />
80
+ <Button title="Cancel" kind="ghost" theme={theme} compact={compact} disabled={submit.isPending} onPress={() => { setPlan(null); checking.reset(); submit.reset(); }} />
64
81
  </View>
65
82
  </View>}
66
83
  </View>;
@@ -0,0 +1,63 @@
1
+ import { Text, View } from "react-native";
2
+ import { openExternalUrl } from "@getpaseo/plugin/client";
3
+ import { Icon, Modal } from "@getpaseo/plugin/client/ui";
4
+ import type { Workspace } from "../shared/model";
5
+ import { compactPrStatus, graphitePrUrl } from "../shared/presentation";
6
+ import { ago, prTone, stackOrder } from "../shared/attention";
7
+ import { Button, Chip, text, type Theme } from "./kit";
8
+ import { PrControls } from "./pr-controls";
9
+ import { toneColor } from "./theme";
10
+
11
+ function Fact({ icon, color, children, theme }: { icon: string; color?: string; children: React.ReactNode; theme: Theme }) {
12
+ const c = theme.colors;
13
+ return <View style={{ flexDirection: "row", gap: 9 }}>
14
+ <View style={{ paddingTop: 2 }}><Icon name={icon} size={14} color={color ?? c.foregroundMuted} /></View>
15
+ <Text selectable style={[text(c).meta, { flex: 1, color: color ?? c.foregroundMuted }]}>{children}</Text>
16
+ </View>;
17
+ }
18
+
19
+ /** Everything about one PR, with its merge and reviewer controls. A sheet on phones, a dialog on desktop. */
20
+ export function PrSheet({ workspace: w, prKey, intent, theme, compact, browserReady, onClose }: { workspace: Workspace; prKey: string | null; intent: "merge" | "reviewers" | null; theme: Theme; compact: boolean; browserReady: boolean; onClose: () => void }) {
21
+ const c = theme.colors; const t = text(c);
22
+ const pr = w.prs.find(p => p.key === prKey) ?? null;
23
+ const s = pr?.snapshot;
24
+ const open = (url: string) => { void openExternalUrl(url).catch(() => undefined); };
25
+ const status = pr ? compactPrStatus(pr) : null;
26
+ const tone = pr ? prTone(pr) : "unknown";
27
+ const stack = pr ? stackOrder(w.prs.filter(p => p.stack === pr.stack)) : [];
28
+ const parent = pr?.parent ? w.prs.find(p => p.key === pr.parent) : null;
29
+ const reviewers = s?.reviewers?.complete ? [...new Set([...s.reviewers.requested, ...s.reviewers.teams.map(team => `team/${team}`), ...s.reviewers.reviewed])] : [];
30
+ return <Modal title={pr ? `Pull request #${pr.number}` : "Pull request"} icon={<Icon name={tone === "merged" ? "GitMerge" : "GitPullRequest"} size={18} color={toneColor(c, tone)} />} open={!!pr} onOpenChange={next => { if (!next) onClose(); }}>
31
+ <Modal.Content contentContainerStyle={{ padding: compact ? 16 : 20, gap: 14 }}>
32
+ {pr && <>
33
+ <View style={{ gap: 6 }}>
34
+ <Text selectable style={[t.title, { fontSize: 16, lineHeight: 22 }]}>{s?.title ?? `${pr.repo} pull request`}</Text>
35
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
36
+ {status && <Chip theme={theme} label={status.label} color={toneColor(c, tone === "pending" || tone === "draft" ? "unknown" : tone)} />}
37
+ <Text style={t.meta}>{pr.repo} · {pr.stack}{stack.length > 1 ? ` · ${stack.indexOf(pr) + 1} of ${stack.length}` : ""}</Text>
38
+ </View>
39
+ </View>
40
+ <View style={{ gap: 8 }}>
41
+ {pr.error && <Fact theme={theme} icon="TriangleAlert" color={c.statusWarning}>{pr.error}</Fact>}
42
+ {parent && <Fact theme={theme} icon="CornerLeftDown">Stacked on #{parent.number} · {parent.snapshot?.title ?? parent.stack}</Fact>}
43
+ {s?.conflict && <Fact theme={theme} icon="TriangleAlert" color={c.statusDanger}>Conflicts with its base branch</Fact>}
44
+ {!!s?.failedChecks.length && <Fact theme={theme} icon="CircleX" color={c.statusDanger}>Required checks failed: {s.failedChecks.join(", ")}</Fact>}
45
+ {!!s?.otherFailedChecks?.length && <Fact theme={theme} icon="CircleX" color={c.statusWarning}>Optional checks failed (not required to merge): {s.otherFailedChecks.join(", ")}</Fact>}
46
+ {s?.state === "OPEN" && s.checksPending && <Fact theme={theme} icon="LoaderCircle">Required checks are still running</Fact>}
47
+ {!!s?.threads.length && <Fact theme={theme} icon="MessageSquare" color={c.statusWarning}>{s.threads.length} unresolved review thread{s.threads.length === 1 ? "" : "s"}</Fact>}
48
+ {s?.reviewDecision === "CHANGES_REQUESTED" && <Fact theme={theme} icon="MessageSquareWarning" color={c.statusWarning}>Changes requested</Fact>}
49
+ {reviewers.length > 0 && <Fact theme={theme} icon="Users">Reviewers: {reviewers.join(", ")}</Fact>}
50
+ {s?.deployment?.environment && <Fact theme={theme} icon="Rocket">{s.deployment.evidence}</Fact>}
51
+ {s?.observedAt && <Fact theme={theme} icon="Clock">Checked {ago(s.observedAt)}</Fact>}
52
+ </View>
53
+ {s?.state === "OPEN" && <View style={{ paddingTop: 12, borderTopWidth: 1, borderTopColor: c.border }}>
54
+ <PrControls key={`${pr.key}:${intent ?? ""}`} pr={pr} workspace={w} theme={theme} compact={compact} browserReady={browserReady} intent={intent} />
55
+ </View>}
56
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, paddingTop: 12, borderTopWidth: 1, borderTopColor: c.border }}>
57
+ <Button theme={theme} compact={compact} icon="ExternalLink" title="Open in Graphite" accessibilityLabel={`Open ${pr.repo} pull request ${pr.number} in Graphite`} onPress={() => open(graphitePrUrl(pr))} />
58
+ {s?.url && <Button theme={theme} compact={compact} kind="ghost" icon="Github" title="GitHub" accessibilityLabel={`Open ${pr.repo} pull request ${pr.number} on GitHub`} onPress={() => open(s.url)} />}
59
+ </View>
60
+ </>}
61
+ </Modal.Content>
62
+ </Modal>;
63
+ }