paseo-follow-up 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/OVERVIEW.md +15 -0
- package/README.md +76 -2
- package/client/button-visibility.ts +32 -0
- package/client/components.tsx +58 -0
- package/client/display.tsx +102 -0
- package/client/navigation.tsx +14 -0
- package/client/overview.tsx +28 -0
- package/client/panel.tsx +82 -0
- package/client/pr-controls.tsx +67 -0
- package/client/settings.tsx +27 -0
- package/client/sidebar-visibility.ts +21 -0
- package/client/tasks.tsx +44 -0
- package/docs/screenshots/follow-up-overview.png +0 -0
- package/docs/screenshots/follow-up-settings.png +0 -0
- package/docs/screenshots/follow-up-workspace.png +0 -0
- package/index.client.tsx +53 -0
- package/index.server.ts +302 -0
- package/package.json +55 -4
- package/paseo-plugin.json +12 -0
- package/scripts/build.mjs +5 -0
- package/server/agent-hook.mjs +72 -0
- package/server/agent-hooks.ts +63 -0
- package/server/agent-mcp.ts +109 -0
- package/server/browser-bridge.ts +48 -0
- package/server/daemon.ts +278 -0
- package/server/delivery.ts +1 -0
- package/server/deployment-anchor.ts +8 -0
- package/server/deployments.ts +101 -0
- package/server/discovery.ts +60 -0
- package/server/engine.ts +318 -0
- package/server/exclusions.ts +21 -0
- package/server/github.ts +126 -0
- package/server/graphite.ts +74 -0
- package/server/hook-source.ts +2 -0
- package/server/mcp-source.ts +2 -0
- package/server/mcp.mjs +162 -0
- package/server/obligations.ts +37 -0
- package/server/pr-actions.ts +210 -0
- package/server/recovery-config.ts +31 -0
- package/server/recovery-handoff.ts +35 -0
- package/server/session-guard.ts +37 -0
- package/server/store.ts +62 -0
- package/server/tasks.ts +80 -0
- package/shared/communication.ts +34 -0
- package/shared/contracts.ts +43 -0
- package/shared/display.ts +11 -0
- package/shared/follow-up.ts +15 -0
- package/shared/model.ts +120 -0
- package/shared/pr-actions.ts +47 -0
- package/shared/presentation.ts +31 -0
- package/shared/reviewers.ts +9 -0
- package/shared/settings.ts +13 -0
- package/shared/task-trigger.ts +20 -0
- package/skills/follow-through/SKILL.md +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Gringauz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/OVERVIEW.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Follow-up
|
|
2
|
+
|
|
3
|
+
Follow-up keeps PR stacks, remaining tasks and explicit agent completion attached to a Paseo workspace. It groups workspaces by whether you or an agent can act next and retains settled PR history.
|
|
4
|
+
|
|
5
|
+
Requires Paseo 0.11+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting; it is not needed for tracking, recovery or follow-up.
|
|
6
|
+
|
|
7
|
+
Open the sidebar or Command-K, then Track this workspace. Project settings can enroll existing and new workspaces with original main-agent ownership; Auto always starts off. The retained development default enables canonical Git repositories named hero-poc, while other projects require opt-in. Disable that project default to stop further enrollment without losing records.
|
|
8
|
+
|
|
9
|
+
Every main view links to the overview, project settings and display settings. You can hide the workspace button and sidebar independently and still use Command-K. The installation id and local state directory remain follow-through for compatibility.
|
|
10
|
+
|
|
11
|
+
Repair batches go to the original main agent after a click, or after explicit per-workspace Auto consent and fresh delivery checks. Recovery is a separate manual action. Done requires the main agent's declaration and settled requirements; merging and archiving remain your actions. Tasks are manual unless you explicitly schedule Now, After merge or After deploy, optionally with a delay. Deployment gates require serving-revision evidence; merges alone never prove deployment.
|
|
12
|
+
|
|
13
|
+
This trusted unsandboxed plugin reads selected workspace metadata, saved agent history/configuration, GitHub status and existing CLI authentication. State and private recovery evidence remain in plugin-data/follow-through. Scoped MCP registration and reversible provider hooks support tracked main agents; Codex's trust review remains in force. Prompts and repair context are sent to your configured agent/provider. Site-specific reviewer and Cloud Run adapters only apply to the configured repository; unsupported deployments remain unknown.
|
|
14
|
+
|
|
15
|
+
Merge, Merge when ready and reviewer requests require explicit developer controls. Auto and agent tools cannot authorize them. Follow-up has no Slack posting identity or background channel scanner. Stakeholder replies require the developer's authorization, original destination and a verified sent-message receipt from the agent.
|
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Paseo Follow-up
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/paseo-follow-up)
|
|
4
|
+
[](https://www.npmjs.com/package/paseo-follow-up)
|
|
5
|
+
[](https://paseo.sh)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Keep long-lived Paseo workspaces moving through review, deployment, and completion. Follow-up retains PR stacks and remaining tasks, batches repairs for the existing main agent, and records the agent's explicit Done declaration.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
Screenshots use synthetic workspaces and PRs.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Paseo 0.11.0 or newer and Node 22 or newer.
|
|
17
|
+
- Authenticated GitHub CLI (`gh`) on the daemon host.
|
|
18
|
+
- Saved Graphite CLI authentication for Graphite readiness and explicit Merge controls.
|
|
19
|
+
- Optional Send to Paseo browser companion for Graphite's actual Merge when ready switch. Core tracking and follow-up work without it.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
paseo plugin install paseo-follow-up
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The installation ID remains `follow-through` to preserve existing development installations, saved settings and tracked work. The public name is **Follow-up**.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
Open **Follow-up** from the sidebar or Command-K. In a workspace, use the header button or the Follow-up panel and click **Track this workspace**. The original main agent owns PRs, tasks and completion; optional history recovery runs in a separate agent only after your click.
|
|
32
|
+
|
|
33
|
+
Use **Overview**, **Project settings** and **Display settings** to move between views. Settings are also available directly from Command-K. Display settings can hide the workspace header button or sidebar entry independently; Command-K remains available.
|
|
34
|
+
|
|
35
|
+
Project settings control existing and future eligible workspaces by stable project ID. The retained development default enables canonical Git projects named `hero-poc`; other projects start off. Turning a project off stops enrollment while preserving tracked records. New enrollments always have Auto off.
|
|
36
|
+
|
|
37
|
+
With **Auto** off, review and send each repair batch yourself. Auto authorizes follow-up messages only for that workspace after fresh idle, permission and Session Guard checks. It never merges PRs, enables merge when ready, requests reviewers, starts recovery or archives workspaces.
|
|
38
|
+
|
|
39
|
+
Tasks default to manual. Explicit Now, After merge or After deploy triggers can include a delay; deployment tasks wait for verified serving-revision evidence. Merged PRs alone do not mean the work is Done. Agents retain evidence and record sent reply URLs for developer-authorized stakeholder updates.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
## Data and permissions
|
|
44
|
+
|
|
45
|
+
This is a trusted, unsandboxed plugin. It reads selected workspace metadata, agent history/configuration and GitHub PR status using your existing host credentials. Local state and private recovery evidence live under `$PASEO_HOME/plugin-data/follow-through`; credentials and provider configuration are not published or transferred.
|
|
46
|
+
|
|
47
|
+
It registers scoped Claude/Codex MCP tools and reversible provider hooks for tracked main agents. Codex keeps its normal hook trust review. Repair and recovery prompts reach your configured agent provider. Follow-up itself has no Slack posting identity and never scans channels or authorizes replies from historical mentions.
|
|
48
|
+
|
|
49
|
+
Human-operated Merge and reviewer controls perform GitHub/Graphite writes only after an explicit click and fresh eligibility checks. Unknown or uncertain results require checking before retrying. Site-specific reviewer and Cloud Run deployment adapters remain limited to their configured repository; other deployment status remains unknown rather than being inferred from a merge.
|
|
50
|
+
|
|
51
|
+
## Develop
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm ci
|
|
55
|
+
npm run verify
|
|
56
|
+
npm run test:install
|
|
57
|
+
npm pack --dry-run
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Integration checks use a separate Paseo home, synthetic agents and fixtures. Never restart your main daemon for verification. Reload only this plugin with `paseo plugin reload follow-through`.
|
|
61
|
+
|
|
62
|
+
## Releases
|
|
63
|
+
|
|
64
|
+
Release tags (`vX.Y.Z`) run `.github/workflows/publish-npm.yml`. GitHub Actions verifies the plugin and packed installation, publishes through npm Trusted Publishing, verifies registry integrity, then creates the GitHub release. No npm token is stored in repository secrets.
|
|
65
|
+
|
|
66
|
+
## Other plugins
|
|
67
|
+
|
|
68
|
+
Also available from [Tom Gringauz](https://github.com/tomgrin10):
|
|
69
|
+
|
|
70
|
+
- [Defer](https://github.com/tomgrin10/paseo-defer) — Schedule messages to agents for later delivery.
|
|
71
|
+
- [Graphite](https://github.com/tomgrin10/paseo-graphite) — Workspace stack status and PR action radar.
|
|
72
|
+
- [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) — Send GitHub and Graphite PRs to Paseo from Chrome.
|
|
73
|
+
- [Smart Session](https://github.com/tomgrin10/paseo-smart-session) — Context-aware compaction and plan usage insights.
|
|
74
|
+
- [Vitals](https://github.com/tomgrin10/paseo-vitals) — Host, Paseo, agent and Docker health.
|
|
75
|
+
- [Session Guard](https://github.com/tomgrin10/paseo-session-guard) — Pause at usage limits and resume after reset.
|
|
76
|
+
- [Transfer](https://github.com/tomgrin10/paseo-transfer) — Copy or move workspaces and agent conversations between hosts.
|
|
77
|
+
- [Transcript Search](https://github.com/tomgrin10/paseo-transcript-search) — Search active and archived conversations across hosts.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
|
|
2
|
+
import { subscribeDisplay } from "./display";
|
|
3
|
+
|
|
4
|
+
export function withButtonVisibility(client: PluginClientContext) {
|
|
5
|
+
const entries = new Set<{ reconcile(): void; remove(): void }>();
|
|
6
|
+
let enableHeader = true;
|
|
7
|
+
let enableComposer = true;
|
|
8
|
+
function gated<K extends "addHeaderButton" | "addComposerPill">(method: K, input: Parameters<PluginClientContext[K]>[0]): PluginButtonRegistration {
|
|
9
|
+
let descriptor = input.button;
|
|
10
|
+
let registration: PluginButtonRegistration | undefined;
|
|
11
|
+
const entry = {
|
|
12
|
+
reconcile() {
|
|
13
|
+
const enabled = method === "addHeaderButton" ? enableHeader : enableComposer;
|
|
14
|
+
if (enabled && !registration) registration = (client[method] as (value: typeof input) => PluginButtonRegistration)({ ...input, button: descriptor });
|
|
15
|
+
if (!enabled && registration) { registration.remove(); registration = undefined; }
|
|
16
|
+
},
|
|
17
|
+
remove() { registration?.remove(); registration = undefined; entries.delete(entry); },
|
|
18
|
+
};
|
|
19
|
+
entries.add(entry);
|
|
20
|
+
entry.reconcile();
|
|
21
|
+
return { remove: entry.remove, update(patch) { descriptor = { ...descriptor, ...patch }; registration?.update(patch); } };
|
|
22
|
+
}
|
|
23
|
+
const buttons = Object.create(client) as PluginClientContext;
|
|
24
|
+
buttons.addHeaderButton = input => gated("addHeaderButton", input);
|
|
25
|
+
buttons.addComposerPill = input => gated("addComposerPill", input);
|
|
26
|
+
const unsubscribe = subscribeDisplay(values => {
|
|
27
|
+
enableHeader = values.enable_header_button;
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) entry.reconcile();
|
|
30
|
+
});
|
|
31
|
+
return { client: buttons, dispose() { unsubscribe(); for (const entry of [...entries]) entry.remove(); } };
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
5
|
+
import { graphitePrUrl, compactPrStatus, settledForDisplay } from "../shared/presentation";
|
|
6
|
+
import { summary, type Workspace, type Pr, type Status } from "../shared/model";
|
|
7
|
+
import { PrControls } from "./pr-controls";
|
|
8
|
+
export type Theme = PluginHostProps["theme"];
|
|
9
|
+
export const colorFor = (theme: Theme, state: Status) => state === "done" ? theme.colors.statusSuccess : state === "needs-you" ? theme.colors.statusDanger : state === "waiting" ? theme.colors.statusWarning : theme.colors.accent;
|
|
10
|
+
export function mergedColor(theme: Theme): string {
|
|
11
|
+
const provided = (theme.colors as Theme["colors"] & { statusMerged?: string }).statusMerged;
|
|
12
|
+
if (provided) return provided;
|
|
13
|
+
// The plugin SDK omits Paseo's merged token; use the host's light/dark tones.
|
|
14
|
+
const raw = theme.colors.surface0.replace(/^#/, "");
|
|
15
|
+
const hex = raw.length === 3 ? raw.split("").map(c => c + c).join("") : raw;
|
|
16
|
+
const brightness = [0, 2, 4].reduce((sum, offset) => sum + parseInt(hex.slice(offset, offset + 2), 16), 0) / 3;
|
|
17
|
+
return brightness < 128 ? "#a890d5" : "#7347af";
|
|
18
|
+
}
|
|
19
|
+
export function Button({ title, onPress, theme, primary = false, disabled = false, icon, compact = false, accessibilityLabel }: { title: string; onPress: () => void; theme: Theme; primary?: boolean; disabled?: boolean; icon?: string; compact?: boolean; accessibilityLabel?: string }) {
|
|
20
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? title} disabled={disabled} onPress={onPress} style={({ pressed }) => ({ minHeight: compact ? 44 : 36, paddingHorizontal: 10, paddingVertical: 8, borderRadius: 6, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, backgroundColor: primary ? theme.colors.accent : pressed ? theme.colors.surface2 : theme.colors.surface1, borderWidth: primary ? 0 : 1, borderColor: theme.colors.border, opacity: disabled ? 0.5 : pressed ? 0.8 : 1 })}>
|
|
21
|
+
{icon && <Icon name={icon} size={14} color={primary ? theme.colors.accentForeground : theme.colors.foreground} />}
|
|
22
|
+
<Text style={{ fontSize: 12, fontWeight: "600", color: primary ? theme.colors.accentForeground : theme.colors.foreground }}>{title}</Text>
|
|
23
|
+
</Pressable>;
|
|
24
|
+
}
|
|
25
|
+
export function Section({ title, count, theme, children, compact = false, initiallyExpanded = true }: { title: string; count?: number; theme: Theme; children: ReactNode; compact?: boolean; initiallyExpanded?: boolean }) {
|
|
26
|
+
const [expanded, setExpanded] = useState(initiallyExpanded);
|
|
27
|
+
return <View style={{ gap: expanded ? 8 : 0, marginTop: 16 }}><Pressable accessibilityRole="button" accessibilityLabel={`${expanded ? "Collapse" : "Expand"} ${title}`} accessibilityState={{ expanded }} aria-expanded={expanded} onPress={() => setExpanded(!expanded)} style={{ minHeight: compact ? 44 : 36, flexDirection: "row", gap: 6, alignItems: "center" }}><Icon name={expanded ? "ChevronDown" : "ChevronRight"} size={14} color={theme.colors.foregroundMuted} /><Text style={{ fontSize: 13, fontWeight: "600", color: theme.colors.foreground, flex: 1 }}>{title}</Text>{count !== undefined && <Text style={{ fontSize: 12, color: theme.colors.foregroundMuted }}>{count}</Text>}</Pressable><View accessibilityElementsHidden={!expanded} importantForAccessibility={expanded ? "auto" : "no-hide-descendants"} style={{ display: expanded ? "flex" : "none", gap: 8 }}>{children}</View></View>;
|
|
28
|
+
}
|
|
29
|
+
export function StatusLabel({ workspace, theme }: { workspace: Workspace; theme: Theme }) {
|
|
30
|
+
const state = summary(workspace);
|
|
31
|
+
return <View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}><Icon name={state.status === "done" ? "CircleCheck" : state.status === "needs-you" ? "CircleAlert" : state.status === "waiting" ? "Clock" : "Activity"} size={14} color={colorFor(theme, state.status)} /><Text style={{ color: colorFor(theme, state.status), fontSize: 12, fontWeight: "600" }}>{state.label}</Text></View>;
|
|
32
|
+
}
|
|
33
|
+
export function PrRow({ pr, theme, onOpen, compact, workspace, browserReady }: { browserReady: boolean; pr: Pr; theme: Theme; onOpen: (url: string) => void; compact: boolean; workspace: Workspace }) {
|
|
34
|
+
const s = pr.snapshot;
|
|
35
|
+
const [details, setDetails] = useState(false);
|
|
36
|
+
const { label: status, tone } = compactPrStatus(pr);
|
|
37
|
+
const color = tone === "merged" ? mergedColor(theme) : tone === "approved" ? theme.colors.statusSuccess : tone === "warning" ? theme.colors.statusWarning : theme.colors.foregroundMuted;
|
|
38
|
+
return <View style={{ paddingVertical: compact ? 6 : 5, gap: 3, borderTopWidth: 1, borderColor: theme.colors.border }}><View style={{ flexDirection: "row", alignItems: "center" }}><Pressable accessibilityRole="link" accessibilityLabel={`Open ${pr.repo} pull request ${pr.number} in Graphite: ${s?.title ?? pr.stack}, ${status}`} onPress={() => onOpen(graphitePrUrl(pr))} style={({ pressed }) => ({ flex: 1, backgroundColor: pressed ? theme.colors.surface1 : "transparent", minHeight: compact ? 44 : 36, justifyContent: "center" })}>
|
|
39
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}><View style={{ width: 88, gap: 2 }}><View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}><Icon name={s?.state === "MERGED" ? "GitMerge" : "GitPullRequest"} size={14} color={color} /><Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 16 }}>#{pr.number}</Text></View><Text style={{ fontSize: 11, lineHeight: 16, color }}>{status}</Text></View><Text numberOfLines={details ? undefined : 2} style={{ color: theme.colors.foreground, fontSize: 13, lineHeight: 18, flex: 1 }}>{s?.title ?? `${pr.repo} pull request`}</Text></View>
|
|
40
|
+
</Pressable><Pressable accessibilityRole="button" accessibilityLabel={`${details ? "Hide" : "Show"} details for PR ${pr.number}`} aria-expanded={details} accessibilityState={{ expanded: details }} onPress={() => setDetails(!details)} style={{ minHeight: compact ? 44 : 36, width: compact ? 44 : 36, justifyContent: "center", alignItems: "center" }}><Icon name={details ? "ChevronUp" : "ChevronDown"} size={12} color={theme.colors.foregroundMuted} /></Pressable></View>
|
|
41
|
+
{details && <View style={{ gap: 4 }}><Text style={{ fontSize: 12, color: theme.colors.foregroundMuted }}>{pr.repo} · {s?.state === "MERGED" ? "Merged" : status}</Text>{pr.error && <Text style={{ fontSize: 12, color: theme.colors.statusWarning }}>{pr.error}</Text>}{s?.reviewers?.complete && [...s.reviewers.requested, ...s.reviewers.teams, ...s.reviewers.reviewed].length > 0 && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.foregroundMuted }}>Reviewers: {[...new Set([...s.reviewers.requested, ...s.reviewers.teams.map(team => `team/${team}`), ...s.reviewers.reviewed])].join(", ")}</Text>}{s?.deployment && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.foregroundMuted }}>{s.deployment.evidence}</Text>}</View>}
|
|
42
|
+
{s?.state === "OPEN" && <PrControls browserReady={browserReady} pr={pr} workspace={workspace} theme={theme} compact={compact} />}
|
|
43
|
+
</View>;
|
|
44
|
+
}
|
|
45
|
+
export function StackGroup({ name, prs, theme, onOpen, compact, workspace, browserReady }: { browserReady: boolean; name: string; prs: Pr[]; theme: Theme; onOpen: (url: string) => void; compact: boolean; workspace: Workspace }) {
|
|
46
|
+
const [expanded, setExpanded] = useState(true);
|
|
47
|
+
const [history, setHistory] = useState(false);
|
|
48
|
+
const open = prs.filter(p => !settledForDisplay(p));
|
|
49
|
+
const past = prs.filter(settledForDisplay);
|
|
50
|
+
const fullySettled = open.length === 0;
|
|
51
|
+
const sort = (rows: Pr[]) => {
|
|
52
|
+
const depth = (p: Pr, seen = new Set<string>()): number => { if (seen.has(p.key)) return 0; seen.add(p.key); const parent = prs.find(x => x.key === p.parent); return parent ? depth(parent, seen) + 1 : 0; };
|
|
53
|
+
return [...rows].sort((a, b) => depth(a) - depth(b) || a.number - b.number);
|
|
54
|
+
};
|
|
55
|
+
return <View style={{ marginTop: 3 }}><Pressable accessibilityRole="button" accessibilityLabel={`${expanded ? "Collapse" : "Expand"} ${name} stack`} accessibilityState={{ expanded }} aria-expanded={expanded} onPress={() => setExpanded(!expanded)} style={{ minHeight: compact ? 44 : 36, flexDirection: "row", alignItems: "center", gap: 6 }}><Icon name={expanded ? "ChevronDown" : "ChevronRight"} size={14} color={theme.colors.foregroundMuted} /><Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600", flex: 1 }}>{name}</Text><Text style={{ fontSize: 12, color: theme.colors.foregroundMuted }}>{fullySettled ? past.length : open.length}</Text></Pressable>
|
|
56
|
+
{expanded && <View>{sort(open).map(pr => <PrRow browserReady={browserReady} key={pr.key} pr={pr} workspace={workspace} theme={theme} onOpen={onOpen} compact={compact} />)}{past.length > 0 && !fullySettled && <Pressable accessibilityRole="button" accessibilityState={{ expanded: history }} aria-expanded={history} accessibilityLabel={`${history ? "Hide" : "Show"} settled PRs in ${name}`} onPress={() => setHistory(!history)} style={{ paddingVertical: 10, minHeight: compact ? 44 : 36, flexDirection: "row", alignItems: "center", gap: 6 }}><Icon name={history ? "ChevronDown" : "ChevronRight"} size={12} color={theme.colors.foregroundMuted} /><Text style={{ fontSize: 12, color: theme.colors.foregroundMuted }}>{history ? "Hide" : "Show"} {past.length} settled PR{past.length === 1 ? "" : "s"}</Text></Pressable>}{(fullySettled || history) && sort(past).map(pr => <PrRow browserReady={browserReady} key={pr.key} pr={pr} workspace={workspace} theme={theme} onOpen={onOpen} compact={compact} />)}</View>}
|
|
57
|
+
</View>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginSidebarContribution, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useSettings } from "@getpaseo/plugin/client";
|
|
3
|
+
import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
|
6
|
+
import { displayPreferences, displayRpc } from "../shared/display";
|
|
7
|
+
import { withNavigation } from "./navigation";
|
|
8
|
+
import { sidebarVisibility } from "./sidebar-visibility";
|
|
9
|
+
|
|
10
|
+
type Display = import("zod").infer<typeof displayPreferences.schema>;
|
|
11
|
+
const listeners = new Set<(values: Display) => void>();
|
|
12
|
+
export function subscribeDisplay(listener: (values: Display) => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
|
|
13
|
+
|
|
14
|
+
function DisplaySettings({ theme }: PluginSurfaceProps) {
|
|
15
|
+
const settings = useSettings(displayPreferences);
|
|
16
|
+
const values = settings.status === "ready" ? settings.values : undefined;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (values !== undefined) for (const listener of listeners) listener(values);
|
|
19
|
+
}, [values]);
|
|
20
|
+
|
|
21
|
+
if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading display settings" color={theme.colors.foregroundMuted} />;
|
|
22
|
+
if (settings.status !== "ready") return (
|
|
23
|
+
<View style={{ gap: 12 }}>
|
|
24
|
+
<Text style={{ color: theme.colors.statusDanger }}>{settings.error}</Text>
|
|
25
|
+
<SettingsCard>
|
|
26
|
+
<SettingsAction label="Try loading the saved settings again" actionLabel="Retry" onPress={() => void settings.reload()} />
|
|
27
|
+
{settings.status === "invalid" && <SettingsAction label="Replace invalid display settings with defaults" actionLabel="Reset" disabled={settings.saving} onPress={() => void settings.reset()} />}
|
|
28
|
+
|
|
29
|
+
</SettingsCard>
|
|
30
|
+
{settings.saveError && <Text style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
|
|
36
|
+
<SettingsSection title="Navigation">
|
|
37
|
+
<SettingsCard>
|
|
38
|
+
<SettingsSwitch
|
|
39
|
+
label="Show in sidebar"
|
|
40
|
+
hint="Open this plugin from Command-K (Ctrl-K on Windows and Linux) while viewing a workspace or plugin on this host."
|
|
41
|
+
value={settings.values.showSidebar}
|
|
42
|
+
disabled={settings.saving}
|
|
43
|
+
onValueChange={(showSidebar) => void settings.save({ ...settings.values, showSidebar }, settings.revision)}
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<SettingsSwitch label="Show workspace header button" hint="Command-K and the sidebar remain available when this button is hidden." value={settings.values.enable_header_button} disabled={settings.saving} onValueChange={(enable_header_button) => void settings.save({ ...settings.values, enable_header_button }, settings.revision)} />
|
|
47
|
+
</SettingsCard>
|
|
48
|
+
{settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
|
|
49
|
+
</SettingsSection>
|
|
50
|
+
</ScrollView>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Settings notifications reach mounted screens; poll for changes made on other clients. */
|
|
55
|
+
export function contributeDisplay(client: PluginClientContext, item: PluginSidebarContribution) {
|
|
56
|
+
const sidebar = sidebarVisibility(() => client.addSidebarItem(item));
|
|
57
|
+
// Paseo 0.10.1 memoizes sidebar route resolution when an installation mounts.
|
|
58
|
+
// Register synchronously, then reconcile the saved preference, so that an
|
|
59
|
+
// already-open sidebar route can resolve its surface during that first render.
|
|
60
|
+
sidebar.set(true);
|
|
61
|
+
const Settings = withNavigation(DisplaySettings, client, item.surface, "display");
|
|
62
|
+
const removeSurface = client.addSurface("display-settings", Settings);
|
|
63
|
+
const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
|
|
64
|
+
const removeCommand = client.addCommandCenterItem({
|
|
65
|
+
id: "display-settings",
|
|
66
|
+
title: `${item.title}: display settings`,
|
|
67
|
+
icon: "PanelLeft",
|
|
68
|
+
keywords: [item.title, "sidebar", "show", "hide", "display", "settings"],
|
|
69
|
+
context: "global",
|
|
70
|
+
onSelect({ openSurface }) { openSurface("display-settings"); },
|
|
71
|
+
});
|
|
72
|
+
let disposed = false;
|
|
73
|
+
let pending = false;
|
|
74
|
+
let generation = 0;
|
|
75
|
+
const update = (values: Display) => { generation += 1; sidebar.set(values.showSidebar); };
|
|
76
|
+
listeners.add(update);
|
|
77
|
+
const refresh = async () => {
|
|
78
|
+
if (disposed || pending) return;
|
|
79
|
+
pending = true;
|
|
80
|
+
const currentGeneration = generation;
|
|
81
|
+
try {
|
|
82
|
+
const result = await client.rpc(displayRpc.read, {});
|
|
83
|
+
if (!disposed && generation === currentGeneration && result.status === "ready") {
|
|
84
|
+
const parsed = displayPreferences.schema.safeParse(result.values);
|
|
85
|
+
if (parsed.success) { sidebar.set(parsed.data.showSidebar); for (const listener of listeners) listener(parsed.data); }
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Retain the last valid preference through temporary disconnects.
|
|
89
|
+
} finally { pending = false; }
|
|
90
|
+
};
|
|
91
|
+
void refresh();
|
|
92
|
+
const timer = setInterval(() => void refresh(), 5000);
|
|
93
|
+
return () => {
|
|
94
|
+
disposed = true;
|
|
95
|
+
clearInterval(timer);
|
|
96
|
+
listeners.delete(update);
|
|
97
|
+
sidebar.dispose();
|
|
98
|
+
removeSurface();
|
|
99
|
+
removeSettings();
|
|
100
|
+
removeCommand();
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
|
|
5
|
+
export function withNavigation<P extends PluginSurfaceProps>(Component: ComponentType<P>, client: PluginClientContext, home: string, active = "overview") {
|
|
6
|
+
return function PluginView(props: P) {
|
|
7
|
+
return <View style={{ flex: 1, backgroundColor: props.theme.colors.surface0 }}>
|
|
8
|
+
<View accessibilityRole="toolbar" style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, padding: 8, borderBottomWidth: 1, borderColor: props.theme.colors.border }}>
|
|
9
|
+
{[{ id: "overview", title: "Overview", target: home }, { id: "display", title: "Display settings", target: "display-settings" }, { id: "projects", title: "Project settings", target: "project-settings" }].map(item => <Pressable key={item.id} accessibilityRole="button" accessibilityState={{ selected: active === item.id }} onPress={() => client.openSurface(item.target)} style={({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, justifyContent: "center", borderRadius: 6, backgroundColor: pressed || active === item.id ? props.theme.colors.surface2 : "transparent" })}><Text style={{ color: active === item.id ? props.theme.colors.foreground : props.theme.colors.foregroundMuted, fontSize: 13, fontWeight: active === item.id ? "600" : "400" }}>{item.title}</Text></Pressable>)}
|
|
10
|
+
</View>
|
|
11
|
+
<Component {...props} />
|
|
12
|
+
</View>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
|
3
|
+
import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
6
|
+
import { listRpc } from "../shared/contracts";
|
|
7
|
+
import { summary, type Status } from "../shared/model";
|
|
8
|
+
import { Button, colorFor } from "./components";
|
|
9
|
+
import { overviewKey } from "./panel";
|
|
10
|
+
|
|
11
|
+
export function FollowOverview({ theme, layout, navigation }: PluginSurfaceProps) {
|
|
12
|
+
const list = useRpc(listRpc);
|
|
13
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
14
|
+
const query = useQuery({ queryKey: overviewKey, queryFn: () => list({}), refetchInterval: 5000 });
|
|
15
|
+
const groups: { status: Status; title: string }[] = [{ status: "needs-you", title: "Needs you" }, { status: "handling", title: "Agent handling" }, { status: "waiting", title: "Waiting" }, { status: "active", title: "Active" }, { status: "done", title: "Done · ready to archive" }];
|
|
16
|
+
const workspaces = (query.data?.workspaces ?? []).filter(w => !w.archived || showArchived);
|
|
17
|
+
return <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface0 }} contentContainerStyle={{ padding: layout.compact ? 16 : 24, gap: 8, maxWidth: 960, width: "100%", alignSelf: "center", paddingBottom: 40 }}>
|
|
18
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}><View style={{ flex: 1, gap: 7 }}><Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "600" }}>Workspace follow-up</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13 }}>What needs you, what the agents are handling, and what is finished.</Text></View><Button title="Refresh" icon="RefreshCw" theme={theme} compact={layout.compact} onPress={() => void query.refetch()} /></View>
|
|
19
|
+
{query.isPending && <ActivityIndicator color={theme.colors.accent} />}{(query.error || query.data?.error) && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>Could not refresh all workspace state. Try Refresh.</Text>}
|
|
20
|
+
{!query.isPending && !query.error && !workspaces.length && <View style={{ marginTop: 28, gap: 9 }}><Text style={{ color: theme.colors.foreground, fontSize: 16 }}>No workspaces tracked yet</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 }}>Open Follow-up in a workspace and click Track this workspace. Tracking and messages stay scoped to workspaces you choose.</Text></View>}
|
|
21
|
+
{groups.map(group => {
|
|
22
|
+
const rows = workspaces.filter(w => summary(w).status === group.status);
|
|
23
|
+
if (!rows.length) return null;
|
|
24
|
+
return <View key={group.status} style={{ marginTop: 24, gap: 9 }}><View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}><Text style={{ color: colorFor(theme, group.status), fontSize: 14, fontWeight: "600" }}>{group.title}</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{rows.length}</Text></View>{rows.map(w => <Pressable key={w.id} accessibilityRole="button" accessibilityLabel={`Open ${w.title}`} disabled={!navigation} onPress={() => navigation?.openWorkspace({ workspaceId: w.id })} style={({ pressed }) => ({ paddingVertical: 14, borderTopWidth: 1, borderColor: theme.colors.border, backgroundColor: pressed ? theme.colors.surface1 : "transparent", flexDirection: "row", alignItems: "flex-start", gap: 12 })}><Icon name={group.status === "done" ? "CircleCheck" : group.status === "needs-you" ? "CircleAlert" : "GitPullRequest"} size={17} color={colorFor(theme, group.status)} /><View style={{ flex: 1, gap: 6 }}><Text style={{ color: theme.colors.foreground, fontSize: 14, fontWeight: "600" }}>{w.title}{w.archived ? " · archived" : ""}</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{summary(w).reason}</Text><Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{new Set(w.prs.map(p => p.stack)).size} stacks · {w.prs.length} PRs · {w.obligations.filter(o => !["done", "waived"].includes(o.status)).length} remaining tasks</Text></View>{navigation && <Icon name="ArrowUpRight" size={14} color={theme.colors.foregroundMuted} />}</Pressable>)}</View>;
|
|
25
|
+
})}
|
|
26
|
+
{query.data?.workspaces.some(w => w.archived) && <Button title={showArchived ? "Hide archived workspaces" : "Show archived history"} theme={theme} compact={layout.compact} onPress={() => setShowArchived(!showArchived)} />}
|
|
27
|
+
</ScrollView>;
|
|
28
|
+
}
|
package/client/panel.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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";
|
|
4
|
+
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";
|
|
7
|
+
import { summary, type Workspace } from "../shared/model";
|
|
8
|
+
import { followUpObligations, recoveryBatch } from "../shared/follow-up";
|
|
9
|
+
import { FollowTask } from "./tasks";
|
|
10
|
+
import { Button, Section, StackGroup, StatusLabel, colorFor } from "./components";
|
|
11
|
+
|
|
12
|
+
export const workspaceKey = (id: string) => ["follow-through", "workspace", id];
|
|
13
|
+
export const overviewKey = ["follow-through", "overview"];
|
|
14
|
+
export function FollowPanel(props: PluginWorkspacePanelProps) {
|
|
15
|
+
const read = useRpc(getRpc);
|
|
16
|
+
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>;
|
|
20
|
+
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} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
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>;
|
|
28
|
+
}
|
|
29
|
+
|
|
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;
|
|
48
|
+
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 }]}>Requires the paired Send to Paseo extension and an open Graphite tab.</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>
|
|
81
|
+
</ScrollView>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { confirmPrActionRpc, preparePrActionRpc, prControlStatusRpc, requestReviewersRpc } from "../shared/contracts";
|
|
6
|
+
import type { ActionPlan, PrActionKind } from "../shared/pr-actions";
|
|
7
|
+
import type { Pr, Workspace } from "../shared/model";
|
|
8
|
+
import { Button, type Theme } from "./components";
|
|
9
|
+
import { defaultReviewers, noReviewers } from "../shared/reviewers";
|
|
10
|
+
|
|
11
|
+
export function PrControls({ pr, workspace, theme, compact, browserReady }: { browserReady: boolean; pr: Pr; workspace: Workspace; theme: Theme; compact: boolean }) {
|
|
12
|
+
const read = useRpc(prControlStatusRpc); const prepare = useRpc(preparePrActionRpc); const confirm = useRpc(confirmPrActionRpc); const cache = useQueryClient();
|
|
13
|
+
const requestReviewers = useRpc(requestReviewersRpc);
|
|
14
|
+
const [plan, setPlan] = useState<ActionPlan | null>(null); const [notice, setNotice] = useState<string | null>(null);
|
|
15
|
+
const statusKey = ["follow-through", "pr-controls", workspace.id, pr.key, pr.snapshot?.head];
|
|
16
|
+
const status = useQuery({ queryKey: statusKey, queryFn: () => read({ workspaceId: workspace.id, prKey: pr.key }), refetchInterval: 5000, enabled: browserReady });
|
|
17
|
+
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 });
|
|
20
|
+
const checking = useMutation({ mutationFn: (kind: PrActionKind) => prepare({ workspaceId: workspace.id, prKey: pr.key, kind }), onSuccess: result => {
|
|
21
|
+
if (result.kind !== "merge" && result.prs.length === 1 && !result.blockers.length && !result.prs[0].blockers.length) submit.mutate({ planId: result.id, scope: "target" });
|
|
22
|
+
else setPlan(result);
|
|
23
|
+
} });
|
|
24
|
+
const pendingAction = workspace.prActions.some(a => ["sending", "uncertain"].includes(a.state));
|
|
25
|
+
const busy = checking.isPending || submit.isPending;
|
|
26
|
+
const actual = browserReady ? status.data?.pr : null;
|
|
27
|
+
const known = !!actual && !status.error && !status.data?.error;
|
|
28
|
+
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 ?? "");
|
|
30
|
+
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;
|
|
32
|
+
const disabled = busy || reviewRequest.isPending || pendingAction || workspace.archived;
|
|
33
|
+
const missingReviewers = noReviewers(pr);
|
|
34
|
+
const reviewers = defaultReviewers(pr.repo).filter(r => r.login.toLowerCase() !== pr.snapshot?.reviewers?.author?.toLowerCase());
|
|
35
|
+
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;
|
|
38
|
+
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
|
+
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(); }} />}
|
|
45
|
+
</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")} />
|
|
62
|
+
{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(); }} />
|
|
64
|
+
</View>
|
|
65
|
+
</View>}
|
|
66
|
+
</View>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
|
2
|
+
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { projectSettingsRpc, setProjectTrackingRpc } from "../shared/contracts";
|
|
6
|
+
import { Button } from "./components";
|
|
7
|
+
|
|
8
|
+
export function FollowSettings({ theme, layout }: PluginSurfaceProps) {
|
|
9
|
+
const read = useRpc(projectSettingsRpc); const write = useRpc(setProjectTrackingRpc); const cache = useQueryClient();
|
|
10
|
+
const query = useQuery({ queryKey: ["follow-through", "project-settings"], queryFn: () => read({}), refetchInterval: 15000 });
|
|
11
|
+
const change = useMutation({ mutationFn: (input: Parameters<typeof write>[0]) => write(input), onSuccess: value => { cache.setQueryData(["follow-through", "project-settings"], value); void cache.invalidateQueries({ queryKey: ["follow-through", "overview"] }); void cache.invalidateQueries({ queryKey: ["follow-through", "workspace"] }); } });
|
|
12
|
+
const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 20 };
|
|
13
|
+
return <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface0 }} contentContainerStyle={{ padding: layout.compact ? 16 : 24, gap: 14, maxWidth: 820, width: "100%", alignSelf: "center", paddingBottom: 40 }}>
|
|
14
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}><Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "600", flex: 1 }}>Follow-up settings</Text><Button title="Refresh" icon="RefreshCw" theme={theme} compact={layout.compact} onPress={() => void query.refetch()} /></View>
|
|
15
|
+
<Text style={muted}>Choose which projects track workspaces by default. Turning a project on adds its existing and new workspaces when a main agent is available. Turning it off keeps workspaces already tracked.</Text>
|
|
16
|
+
<Text style={muted}>Auto sends stay off for newly tracked workspaces. Recovery and follow-up wait for your click unless you turn on Auto in that workspace.</Text>
|
|
17
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 15, fontWeight: "600", marginTop: 12 }}>Default tracking by project</Text>
|
|
18
|
+
{query.isPending && <ActivityIndicator color={theme.colors.accent} />}
|
|
19
|
+
{(query.error || change.error) && <Text accessibilityRole="alert" style={{ ...muted, color: theme.colors.statusDanger }}>{change.error?.message ?? query.error?.message}</Text>}
|
|
20
|
+
{query.data?.projects.length === 0 && <Text style={muted}>Projects appear here after you add them to Paseo.</Text>}
|
|
21
|
+
{query.data?.projects.map(project => <Pressable key={project.id} accessibilityRole="switch" accessibilityLabel={`Default tracking for ${project.name}, ${project.key}`} aria-checked={project.enable_default_tracking} accessibilityState={{ checked: project.enable_default_tracking, disabled: change.isPending }} disabled={change.isPending} onPress={() => change.mutate({ projectId: project.id, enable_default_tracking: !project.enable_default_tracking })} style={({ pressed }) => ({ minHeight: layout.compact ? 64 : 58, paddingVertical: 12, borderTopWidth: 1, borderColor: theme.colors.border, flexDirection: "row", alignItems: "center", gap: 16, backgroundColor: pressed ? theme.colors.surface1 : "transparent", opacity: change.isPending && change.variables?.projectId === project.id ? 0.5 : 1 })}>
|
|
22
|
+
<View style={{ flex: 1, gap: 4 }}><Text style={{ color: theme.colors.foreground, fontSize: 14, fontWeight: "600" }}>{project.name}</Text><Text style={{ ...muted, fontSize: 12 }}>{project.key.startsWith("remote:") ? project.key.slice(7) : project.path}</Text></View>
|
|
23
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Text style={muted}>{project.enable_default_tracking ? "On" : "Off"}</Text><View style={{ width: 30, height: 18, borderRadius: 9, backgroundColor: project.enable_default_tracking ? theme.colors.accent : theme.colors.surface2, borderWidth: 1, borderColor: project.enable_default_tracking ? theme.colors.accent : theme.colors.border }}><View style={{ width: 12, height: 12, borderRadius: 6, position: "absolute", top: 2, left: project.enable_default_tracking ? 14 : 2, backgroundColor: project.enable_default_tracking ? theme.colors.accentForeground : theme.colors.foregroundMuted }} /></View></View>
|
|
24
|
+
</Pressable>)}
|
|
25
|
+
{change.isSuccess && <Text accessibilityLiveRegion="polite" style={muted}>Project default saved.</Text>}
|
|
26
|
+
</ScrollView>;
|
|
27
|
+
}
|