@andrioid/paseo-attention-span 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/OVERVIEW.md +63 -0
- package/README.md +33 -2
- package/assets/favicon.svg +7 -0
- package/assets/icon.png +0 -0
- package/assets/logo.svg +7 -0
- package/client/attention-screen.tsx +948 -0
- package/client/coverage-settings.tsx +145 -0
- package/client/data.ts +22 -0
- package/client/icons.ts +45 -0
- package/client/next-up.tsx +161 -0
- package/client/sessions.ts +202 -0
- package/client/setup.tsx +134 -0
- package/client/sheet.tsx +87 -0
- package/client/ui.tsx +173 -0
- package/docs/screenshots/desktop-dark-doing.png +0 -0
- package/docs/screenshots/desktop-dark-next-up.png +0 -0
- package/docs/screenshots/phone-dark-doing.png +0 -0
- package/index.client.tsx +36 -0
- package/index.server.ts +49 -0
- package/package.json +45 -4
- package/paseo-plugin.json +14 -0
- package/server/actions.ts +90 -0
- package/server/classify.ts +448 -0
- package/server/context.ts +87 -0
- package/server/details.ts +76 -0
- package/server/fixture.ts +220 -0
- package/server/gh.ts +36 -0
- package/server/github-types.ts +114 -0
- package/server/markdown.ts +73 -0
- package/server/queries.ts +152 -0
- package/server/service.ts +248 -0
- package/server/setup.ts +47 -0
- package/server/store.ts +99 -0
- package/server/view.ts +37 -0
- package/shared/attention.ts +208 -0
- package/shared/config.ts +62 -0
- package/shared/format.ts +16 -0
- package/shared/prompts.ts +89 -0
- package/shared/rpc.ts +56 -0
- package/shared/setup.ts +27 -0
package/client/setup.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { openExternalUrl, usePaseo } from "@getpaseo/plugin/client";
|
|
2
|
+
import { copyText, useToast } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import { AttentionConfig } from "../shared/config";
|
|
6
|
+
import type { SetupStatus } from "../shared/setup";
|
|
7
|
+
import { githubProjects, listProfiles } from "./sessions";
|
|
8
|
+
import { Button, useStyles } from "./ui";
|
|
9
|
+
|
|
10
|
+
type State = "done" | "missing" | "optional";
|
|
11
|
+
|
|
12
|
+
interface Line {
|
|
13
|
+
key: string;
|
|
14
|
+
state: State;
|
|
15
|
+
title: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
action?: { label: string; onPress: () => void };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// What's needed and how to fix it, in place of the tabs while nothing can load [SET-01].
|
|
21
|
+
// The last three lines don't block; they say what Attention Span will use.
|
|
22
|
+
export function SetupChecklist({
|
|
23
|
+
status,
|
|
24
|
+
config: loaded,
|
|
25
|
+
onCheck,
|
|
26
|
+
checking,
|
|
27
|
+
openSettings,
|
|
28
|
+
}: {
|
|
29
|
+
status: SetupStatus;
|
|
30
|
+
// The last config that loaded, for the profile names; the defaults otherwise.
|
|
31
|
+
config?: AttentionConfig;
|
|
32
|
+
onCheck: () => void;
|
|
33
|
+
checking: boolean;
|
|
34
|
+
openSettings?: () => void;
|
|
35
|
+
}) {
|
|
36
|
+
const styles = useStyles();
|
|
37
|
+
const paseo = usePaseo();
|
|
38
|
+
const toast = useToast();
|
|
39
|
+
const projects = useQuery({ queryKey: ["attention-span", "github-projects"], queryFn: () => githubProjects(paseo) });
|
|
40
|
+
const profiles = useQuery({ queryKey: ["attention-span", "profiles"], queryFn: () => listProfiles(paseo) });
|
|
41
|
+
const { gh, config } = status;
|
|
42
|
+
|
|
43
|
+
const copy = (command: string) =>
|
|
44
|
+
void copyText(command).then(
|
|
45
|
+
() => toast.show(`Copied ${command}`, { variant: "success" }),
|
|
46
|
+
() => toast.error("Couldn't copy"),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const lines: Line[] = [];
|
|
50
|
+
lines.push(
|
|
51
|
+
gh.found
|
|
52
|
+
? { key: "gh", state: "done", title: "GitHub CLI installed" }
|
|
53
|
+
: {
|
|
54
|
+
key: "gh",
|
|
55
|
+
state: "missing",
|
|
56
|
+
title: "Install the GitHub CLI",
|
|
57
|
+
detail: "On the machine that runs Paseo. Attention Span reads GitHub through it.",
|
|
58
|
+
action: { label: "cli.github.com ↗", onPress: () => void openExternalUrl("https://cli.github.com") },
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
if (gh.found)
|
|
62
|
+
lines.push(
|
|
63
|
+
gh.login
|
|
64
|
+
? { key: "login", state: "done", title: `Signed in to GitHub as ${gh.login}` }
|
|
65
|
+
: {
|
|
66
|
+
key: "login",
|
|
67
|
+
state: "missing",
|
|
68
|
+
title: "Sign in to GitHub",
|
|
69
|
+
detail: `Run gh auth login on the machine that runs Paseo. gh says: ${gh.error}`,
|
|
70
|
+
action: { label: "Copy command", onPress: () => copy("gh auth login") },
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
lines.push(
|
|
74
|
+
config.error
|
|
75
|
+
? { key: "config", state: "missing", title: "Fix config.json", detail: config.error }
|
|
76
|
+
: {
|
|
77
|
+
key: "coverage",
|
|
78
|
+
state: config.orgs.length || config.repos.length ? "done" : "optional",
|
|
79
|
+
title: config.orgs.length || config.repos.length ? `Covering ${[...config.orgs, ...config.repos].join(", ")}` : "Covering everything on GitHub that involves you",
|
|
80
|
+
action: openSettings ? { label: "Choose orgs and repos", onPress: openSettings } : undefined,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
if (projects.data)
|
|
84
|
+
lines.push(
|
|
85
|
+
projects.data.length
|
|
86
|
+
? { key: "projects", state: "done", title: `${projects.data.length} Paseo ${projects.data.length === 1 ? "project" : "projects"} with a GitHub remote` }
|
|
87
|
+
: { key: "projects", state: "optional", title: "No Paseo projects with a GitHub remote yet", detail: "Add the repos you work in as projects, so items can start sessions and Next up has issues to suggest." },
|
|
88
|
+
);
|
|
89
|
+
if (profiles.data) lines.push(agentLine(profiles.data.map((profile) => profile.name), (loaded ?? AttentionConfig.parse({})).profiles));
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View style={{ gap: 16 }}>
|
|
93
|
+
<View style={{ gap: 4 }}>
|
|
94
|
+
<Text style={styles.title}>Set up Attention Span</Text>
|
|
95
|
+
<Text style={styles.muted}>A few things are needed before it can show what's waiting on you.</Text>
|
|
96
|
+
</View>
|
|
97
|
+
<View style={styles.card}>
|
|
98
|
+
{lines.map((line, index) => (
|
|
99
|
+
<View key={line.key} style={[styles.item, index > 0 && styles.divider]}>
|
|
100
|
+
<Mark state={line.state} />
|
|
101
|
+
<View style={styles.body}>
|
|
102
|
+
<Text style={styles.text}>{line.title}</Text>
|
|
103
|
+
{line.detail ? <Text style={styles.muted}>{line.detail}</Text> : null}
|
|
104
|
+
</View>
|
|
105
|
+
{line.action ? <Button label={line.action.label} variant="calm" onPress={line.action.onPress} /> : null}
|
|
106
|
+
</View>
|
|
107
|
+
))}
|
|
108
|
+
</View>
|
|
109
|
+
<View style={styles.row}>
|
|
110
|
+
<Button label={checking ? "Checking…" : "Check again"} icon="reload" variant="solid" disabled={checking} onPress={onCheck} />
|
|
111
|
+
</View>
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function Mark({ state }: { state: State }) {
|
|
117
|
+
const styles = useStyles();
|
|
118
|
+
const { glyph, color } = {
|
|
119
|
+
done: { glyph: "✓", color: styles.c.statusSuccess },
|
|
120
|
+
missing: { glyph: "✗", color: styles.c.statusDanger },
|
|
121
|
+
optional: { glyph: "○", color: styles.c.foregroundMuted },
|
|
122
|
+
}[state];
|
|
123
|
+
return <Text style={{ color, fontSize: 15, fontWeight: "700", width: 16, textAlign: "center" }}>{glyph}</Text>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Which agent sessions will use: the profile named for the session type, then "default", then Paseo's defaults [SES-06].
|
|
127
|
+
function agentLine(names: string[], profiles: AttentionConfig["profiles"]): Line {
|
|
128
|
+
const has = (name: string) => names.some((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
129
|
+
const fallback = has(profiles.default) ? `the ${profiles.default} profile` : "Paseo's default agent";
|
|
130
|
+
const review = has(profiles.review ?? "") ? `the ${profiles.review} profile` : fallback;
|
|
131
|
+
if (!has(profiles.default) && !has(profiles.review ?? ""))
|
|
132
|
+
return { key: "agent", state: "optional", title: "Sessions use Paseo's default agent", detail: `Add an agent profile named ${profiles.review} for reviews, or ${profiles.default} for every session.` };
|
|
133
|
+
return { key: "agent", state: "done", title: `Reviews use ${review}; other sessions use ${fallback}` };
|
|
134
|
+
}
|
package/client/sheet.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { openExternalUrl } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Modal } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Pressable, Text, View } from "react-native";
|
|
5
|
+
import type { ItemDetails } from "../shared/attention";
|
|
6
|
+
import type { IconName } from "./icons";
|
|
7
|
+
import { KeepStyles, TablerIcon, useHover, useStyles } from "./ui";
|
|
8
|
+
|
|
9
|
+
// Tapping a card opens its sheet: what the card knows, every action saying what it will do, then what's fetched [ATT-06].
|
|
10
|
+
// Fetched details go last, so the actions don't move when they arrive. A bottom sheet on phones, a dialog on desktop.
|
|
11
|
+
|
|
12
|
+
export interface SheetEntry {
|
|
13
|
+
key: string;
|
|
14
|
+
icon: IconName;
|
|
15
|
+
label: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
onPress: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ActionSheet({ title, top, entries, bottom, onClose }: { title: string; top: ReactNode; entries: SheetEntry[]; bottom: ReactNode; onClose: () => void }) {
|
|
22
|
+
const styles = useStyles();
|
|
23
|
+
return (
|
|
24
|
+
<Modal title={title} open onOpenChange={(open) => (open ? null : onClose())}>
|
|
25
|
+
<Modal.Content>
|
|
26
|
+
<KeepStyles styles={styles}>
|
|
27
|
+
<View style={{ gap: 10 }}>{top}</View>
|
|
28
|
+
<View style={[styles.card, { backgroundColor: styles.c.surface2 }]}>
|
|
29
|
+
{entries.map((entry, index) => (
|
|
30
|
+
<Entry key={entry.key} entry={entry} first={index === 0} />
|
|
31
|
+
))}
|
|
32
|
+
</View>
|
|
33
|
+
<View style={{ gap: 10 }}>{bottom}</View>
|
|
34
|
+
</KeepStyles>
|
|
35
|
+
</Modal.Content>
|
|
36
|
+
</Modal>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Entry({ entry, first }: { entry: SheetEntry; first: boolean }) {
|
|
41
|
+
const styles = useStyles();
|
|
42
|
+
const { hovered, hover } = useHover();
|
|
43
|
+
const muted = styles.c.foregroundMuted;
|
|
44
|
+
return (
|
|
45
|
+
<Pressable
|
|
46
|
+
accessibilityRole="button"
|
|
47
|
+
accessibilityLabel={entry.label}
|
|
48
|
+
disabled={entry.disabled}
|
|
49
|
+
onPress={entry.onPress}
|
|
50
|
+
{...hover}
|
|
51
|
+
style={({ pressed }) => [
|
|
52
|
+
styles.row,
|
|
53
|
+
{ alignItems: "flex-start", paddingVertical: 10, paddingHorizontal: 12, backgroundColor: pressed || hovered ? styles.c.border : "transparent" },
|
|
54
|
+
first ? null : { borderTopWidth: 1, borderTopColor: styles.c.surface1 },
|
|
55
|
+
]}
|
|
56
|
+
>
|
|
57
|
+
<View style={{ paddingTop: 1 }}>
|
|
58
|
+
<TablerIcon name={entry.icon} size={16} color={entry.disabled ? muted : styles.action} />
|
|
59
|
+
</View>
|
|
60
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
61
|
+
<Text style={{ color: entry.disabled ? muted : styles.c.foreground, fontSize: 14, fontWeight: "600" }}>{entry.label}</Text>
|
|
62
|
+
{entry.detail ? <Text style={styles.muted}>{entry.detail}</Text> : null}
|
|
63
|
+
</View>
|
|
64
|
+
</Pressable>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The PR's size and the linked issues or PRs, once fetched.
|
|
69
|
+
export function DetailLines({ details, loading, error, linkedLabel }: { details?: ItemDetails; loading: boolean; error: unknown; linkedLabel: string }) {
|
|
70
|
+
const styles = useStyles();
|
|
71
|
+
if (loading) return <Text style={styles.muted}>Loading details…</Text>;
|
|
72
|
+
if (!details) return error ? <Text style={styles.muted}>Couldn't load details: {(error as Error).message}</Text> : null;
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{details.size ? <Text style={styles.muted}>{sizeText(details.size)}</Text> : null}
|
|
76
|
+
{details.linked.map((link) => (
|
|
77
|
+
<Pressable key={link.url} accessibilityRole="link" onPress={() => void openExternalUrl(link.url)}>
|
|
78
|
+
<Text style={styles.muted} numberOfLines={1}>
|
|
79
|
+
{linkedLabel} #{link.number} {link.title} · {link.state.toLowerCase()}
|
|
80
|
+
</Text>
|
|
81
|
+
</Pressable>
|
|
82
|
+
))}
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sizeText = (size: ItemDetails["size"]) => (size ? `+${size.additions} −${size.deletions} · ${size.files} file${size.files === 1 ? "" : "s"}` : null);
|
package/client/ui.tsx
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { PluginScreenProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
|
4
|
+
import { Image, Pressable, Text, View } from "react-native";
|
|
5
|
+
import type { Chip } from "../shared/attention";
|
|
6
|
+
import { ICONS, type IconName } from "./icons";
|
|
7
|
+
|
|
8
|
+
// Shared look: theme colors only, tighter on compact layouts [REACH-01].
|
|
9
|
+
// Color is for signals; facts stay quiet.
|
|
10
|
+
|
|
11
|
+
type Theme = PluginScreenProps["theme"];
|
|
12
|
+
|
|
13
|
+
export function makeStyles(theme: Theme, compact: boolean) {
|
|
14
|
+
const c = theme.colors;
|
|
15
|
+
return {
|
|
16
|
+
c,
|
|
17
|
+
compact,
|
|
18
|
+
// Action text and icons. The accent is a fill: as text on a dark theme it's 2.5:1; this green is 5–6:1 on every surface.
|
|
19
|
+
action: c.statusSuccess,
|
|
20
|
+
screen: { flex: 1, backgroundColor: c.surface0 },
|
|
21
|
+
content: { padding: compact ? 12 : 24, gap: compact ? 20 : 28, paddingBottom: 48, maxWidth: 860, width: "100%" as const, alignSelf: "center" as const },
|
|
22
|
+
row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
|
23
|
+
wrap: { flexDirection: "row" as const, flexWrap: "wrap" as const, alignItems: "center" as const, gap: 6 },
|
|
24
|
+
text: { color: c.foreground, fontSize: 14 },
|
|
25
|
+
muted: { color: c.foregroundMuted, fontSize: 12 },
|
|
26
|
+
danger: { color: c.statusDanger, fontSize: 13 },
|
|
27
|
+
title: { color: c.foreground, fontSize: 16, fontWeight: "600" as const, flexShrink: 1 },
|
|
28
|
+
card: { backgroundColor: c.surface1, borderRadius: 12, borderWidth: 1, borderColor: c.border, overflow: "hidden" as const },
|
|
29
|
+
item: { padding: compact ? 12 : 16, gap: 10, flexDirection: "row" as const, alignItems: "flex-start" as const },
|
|
30
|
+
divider: { borderTopWidth: 1, borderTopColor: c.border },
|
|
31
|
+
body: { flex: 1, gap: 6, minWidth: 0 },
|
|
32
|
+
quote: { backgroundColor: c.surface2, borderRadius: 8, paddingVertical: 8, paddingHorizontal: 10, gap: 4 },
|
|
33
|
+
panel: { gap: 10, padding: 12, borderRadius: 10, backgroundColor: c.surface2 },
|
|
34
|
+
input: { color: c.foreground, backgroundColor: c.surface1, borderColor: c.border, borderWidth: 1, borderRadius: 8, padding: 10, fontSize: 13 },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export type Styles = ReturnType<typeof makeStyles>;
|
|
38
|
+
|
|
39
|
+
const StylesContext = createContext<Styles | null>(null);
|
|
40
|
+
export const useStyles = () => useContext(StylesContext)!;
|
|
41
|
+
|
|
42
|
+
export function StylesProvider({ theme, compact, children }: { theme: Theme; compact: boolean; children: ReactNode }) {
|
|
43
|
+
const styles = useMemo(() => makeStyles(theme, compact), [theme, compact]);
|
|
44
|
+
return <StylesContext.Provider value={styles}>{children}</StylesContext.Provider>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Modals can render outside this tree (a bottom sheet on phones), so they take the styles along.
|
|
48
|
+
export function KeepStyles({ styles, children }: { styles: Styles; children: ReactNode }) {
|
|
49
|
+
return <StylesContext.Provider value={styles}>{children}</StylesContext.Provider>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TablerIcon({ name, size = 16, color }: { name: IconName; size?: number; color?: string }) {
|
|
53
|
+
const { c } = useStyles();
|
|
54
|
+
return <Icon icon={ICONS[name]} size={size} color={color ?? c.foregroundMuted} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toneColor(styles: Styles, tone: Chip["tone"]): string {
|
|
58
|
+
return { good: styles.c.statusSuccess, bad: styles.c.statusDanger, warn: styles.c.statusWarning, neutral: styles.c.foregroundMuted }[tone];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A state that matters, as a colored dot and text.
|
|
62
|
+
export function Signal({ text, tone }: Chip) {
|
|
63
|
+
const styles = useStyles();
|
|
64
|
+
const color = toneColor(styles, tone);
|
|
65
|
+
return (
|
|
66
|
+
<View style={[styles.row, { gap: 5 }]}>
|
|
67
|
+
<View style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: color }} />
|
|
68
|
+
<Text style={{ color, fontSize: 12, fontWeight: "500" }}>{text}</Text>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The line under a title: facts as quiet text joined by "·", then signals [ATT-05].
|
|
74
|
+
export function Meta({ facts, chips }: { facts: string[]; chips: Chip[] }) {
|
|
75
|
+
const styles = useStyles();
|
|
76
|
+
const text = [...facts, ...chips.filter((chip) => chip.tone === "neutral").map((chip) => chip.text)];
|
|
77
|
+
const signals = chips.filter((chip) => chip.tone !== "neutral");
|
|
78
|
+
return (
|
|
79
|
+
<View style={[styles.wrap, { columnGap: 10, rowGap: 4 }]}>
|
|
80
|
+
{text.length ? <Text style={styles.muted}>{text.join(" · ")}</Text> : null}
|
|
81
|
+
{signals.map((chip) => (
|
|
82
|
+
<Signal key={chip.text} {...chip} />
|
|
83
|
+
))}
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function Avatar({ url, bot, size = 18 }: { url?: string; bot?: boolean; size?: number }) {
|
|
89
|
+
const styles = useStyles();
|
|
90
|
+
if (bot || !url) return <TablerIcon name={bot ? "bot" : "issue"} size={size} />;
|
|
91
|
+
return <Image source={{ uri: url }} style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: styles.c.surface2 }} />;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ghost: no background until hovered or pressed, for secondary icons on every row.
|
|
95
|
+
type Variant = "calm" | "solid" | "plain" | "ghost";
|
|
96
|
+
|
|
97
|
+
export function useHover() {
|
|
98
|
+
const [hovered, setHovered] = useState(false);
|
|
99
|
+
return { hovered, hover: { onHoverIn: () => setHovered(true), onHoverOut: () => setHovered(false) } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Calm by default: accent text on a subtle surface. Solid accent is for confirmations.
|
|
103
|
+
export function Button({
|
|
104
|
+
label,
|
|
105
|
+
icon,
|
|
106
|
+
onPress,
|
|
107
|
+
variant = "plain",
|
|
108
|
+
disabled,
|
|
109
|
+
accessibilityLabel,
|
|
110
|
+
}: {
|
|
111
|
+
label?: string;
|
|
112
|
+
icon?: IconName;
|
|
113
|
+
onPress: () => void;
|
|
114
|
+
variant?: Variant;
|
|
115
|
+
disabled?: boolean;
|
|
116
|
+
accessibilityLabel?: string;
|
|
117
|
+
}) {
|
|
118
|
+
const styles = useStyles();
|
|
119
|
+
const { hovered, hover } = useHover();
|
|
120
|
+
const { c } = styles;
|
|
121
|
+
const color = variant === "solid" ? c.accentForeground : variant === "calm" ? styles.action : variant === "ghost" ? c.foregroundMuted : c.foreground;
|
|
122
|
+
return (
|
|
123
|
+
<Pressable
|
|
124
|
+
accessibilityRole="button"
|
|
125
|
+
accessibilityLabel={accessibilityLabel ?? label ?? icon}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
onPress={onPress}
|
|
128
|
+
{...hover}
|
|
129
|
+
style={({ pressed }) => ({
|
|
130
|
+
flexDirection: "row",
|
|
131
|
+
alignItems: "center",
|
|
132
|
+
gap: 6,
|
|
133
|
+
height: 30,
|
|
134
|
+
paddingHorizontal: label ? 11 : 8,
|
|
135
|
+
borderRadius: 8,
|
|
136
|
+
backgroundColor: variant === "solid" ? c.accent : hovered || (pressed && variant === "ghost") ? c.border : variant === "ghost" ? "transparent" : c.surface2,
|
|
137
|
+
opacity: disabled ? 0.5 : pressed ? 0.8 : 1,
|
|
138
|
+
})}
|
|
139
|
+
>
|
|
140
|
+
{icon ? <TablerIcon name={icon} size={15} color={color} /> : null}
|
|
141
|
+
{label ? <Text style={{ color, fontSize: 13, fontWeight: "600" }}>{label}</Text> : null}
|
|
142
|
+
</Pressable>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Sentence case, with the count as quiet text.
|
|
147
|
+
export function SectionHeader({ icon, title, count }: { icon: IconName; title: string; count: number }) {
|
|
148
|
+
const styles = useStyles();
|
|
149
|
+
return (
|
|
150
|
+
<View style={[styles.row, { marginBottom: 10 }]}>
|
|
151
|
+
<TablerIcon name={icon} size={15} />
|
|
152
|
+
<Text style={{ color: styles.c.foreground, fontSize: 13, fontWeight: "600" }}>
|
|
153
|
+
{title}
|
|
154
|
+
<Text style={{ color: styles.c.foregroundMuted, fontWeight: "400" }}> · {count}</Text>
|
|
155
|
+
</Text>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Choice chips for options and filters: the only pills, because they're tappable.
|
|
161
|
+
export function Toggle({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
162
|
+
const { c } = useStyles();
|
|
163
|
+
return (
|
|
164
|
+
<Pressable
|
|
165
|
+
accessibilityRole="button"
|
|
166
|
+
accessibilityState={{ selected: active }}
|
|
167
|
+
onPress={onPress}
|
|
168
|
+
style={{ paddingVertical: 4, paddingHorizontal: 10, borderRadius: 14, borderWidth: 1, borderColor: active ? c.accent : c.border, backgroundColor: active ? c.accent : "transparent" }}
|
|
169
|
+
>
|
|
170
|
+
<Text style={{ color: active ? c.accentForeground : c.foreground, fontSize: 12 }}>{label}</Text>
|
|
171
|
+
</Pressable>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginScreenProps, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { Text } from "react-native";
|
|
4
|
+
import { AttentionScreen } from "./client/attention-screen";
|
|
5
|
+
import { CoverageSettings } from "./client/coverage-settings";
|
|
6
|
+
import { useAttention } from "./client/data";
|
|
7
|
+
import { ICONS } from "./client/icons";
|
|
8
|
+
|
|
9
|
+
// The count, visible from anywhere in Paseo [ATT-09].
|
|
10
|
+
function SidebarItem({ theme, currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
11
|
+
const { data } = useAttention();
|
|
12
|
+
const count = data?.count ?? 0;
|
|
13
|
+
return (
|
|
14
|
+
<SidebarRow
|
|
15
|
+
icon={ICONS.logo}
|
|
16
|
+
label="Attention"
|
|
17
|
+
active={currentScreen?.screenId === "attention"}
|
|
18
|
+
onPress={() => openScreen({ screenId: "attention" })}
|
|
19
|
+
trailing={
|
|
20
|
+
count > 0 ? (
|
|
21
|
+
<Text style={{ color: data?.feeds.live.error ? theme.colors.statusDanger : theme.colors.foregroundMuted, fontSize: 12 }}>{count}</Text>
|
|
22
|
+
) : undefined
|
|
23
|
+
}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One screen for everything that needs me [ATT-01].
|
|
29
|
+
export default function contribute(client: PluginClientContext) {
|
|
30
|
+
// Settings → Plugins → Attention Span, also opened from the screen's gear and the setup checklist [SRC-02].
|
|
31
|
+
client.addSettingsScreen({ id: "coverage", title: "Attention Span", icon: ICONS.logo, Component: CoverageSettings });
|
|
32
|
+
const Screen = (props: PluginScreenProps) => <AttentionScreen {...props} openSettings={() => client.openSettings("coverage")} />;
|
|
33
|
+
client.addScreen({ id: "attention", title: "Attention Span", Component: Screen });
|
|
34
|
+
client.addSidebarHeaderItem({ id: "attention", title: "Attention Span", Component: SidebarItem });
|
|
35
|
+
return () => {};
|
|
36
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { claimIssue, closePullRequest, mergePullRequest, react, rerunFailed, setAutoMerge, setReady, unclaimIssue, updateBranch } from "./server/actions";
|
|
3
|
+
import { defaultBranch, sessionContext } from "./server/context";
|
|
4
|
+
import { itemDetails } from "./server/details";
|
|
5
|
+
import { FIXTURE_CONTEXT, fixtureDetails } from "./server/fixture";
|
|
6
|
+
import { FIXTURE } from "./server/gh";
|
|
7
|
+
import { checkCoverage, configStatus, setupStatus } from "./server/setup";
|
|
8
|
+
import { AttentionService } from "./server/service";
|
|
9
|
+
import type { FeedId } from "./shared/attention";
|
|
10
|
+
import * as rpc from "./shared/rpc";
|
|
11
|
+
|
|
12
|
+
export default function contribute(server: PluginServerContext) {
|
|
13
|
+
const attention = new AttentionService();
|
|
14
|
+
const started = attention.start().catch((error) => console.error("Attention Span failed to start:", error));
|
|
15
|
+
const ready = <T>(work: () => Promise<T>) => started.then(work);
|
|
16
|
+
|
|
17
|
+
// After a change on GitHub, refresh in the background so the list catches up. Only a merge changes History.
|
|
18
|
+
const thenRefresh = async (work: Promise<void>, feeds: FeedId[] = ["live"]) => {
|
|
19
|
+
await work;
|
|
20
|
+
for (const feed of feeds) void attention.refresh(feed).catch(() => {});
|
|
21
|
+
return { ok: true as const };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The app asks for the list on load and every few minutes; its Paseo connection lets refreshes see my projects [NXT-08].
|
|
25
|
+
server.handle(rpc.getAttention, ({ refresh }, { paseo }) => ready(() => (attention.connect(paseo), attention.get(refresh))));
|
|
26
|
+
server.handle(rpc.dismissItem, ({ key }) => ready(() => attention.dismiss(key)));
|
|
27
|
+
server.handle(rpc.undismissItem, ({ key }) => ready(() => attention.undismiss(key)));
|
|
28
|
+
server.handle(rpc.restoreDismissed, () => ready(() => attention.restoreDismissed()));
|
|
29
|
+
server.handle(rpc.setCodeRepo, ({ issueRepo, codeRepo }) => ready(() => attention.setCodeRepo(issueRepo, codeRepo)));
|
|
30
|
+
server.handle(rpc.setupStatus, () => setupStatus());
|
|
31
|
+
server.handle(rpc.getCoverage, () => configStatus());
|
|
32
|
+
server.handle(rpc.checkCoverage, ({ kind, name }) => checkCoverage(kind, name));
|
|
33
|
+
server.handle(rpc.setCoverage, ({ orgs, repos }) => ready(() => attention.setCoverage(orgs, repos)).then(() => ({ ok: true as const })));
|
|
34
|
+
server.handle(rpc.setScopes, ({ scopes }) => ready(() => attention.setScopes(scopes)).then(() => ({ ok: true as const })));
|
|
35
|
+
server.handle(rpc.updateBranch, ({ target }) => thenRefresh(updateBranch(target)));
|
|
36
|
+
server.handle(rpc.mergePullRequest, ({ target }) => thenRefresh(mergePullRequest(target), ["live", "history"]));
|
|
37
|
+
server.handle(rpc.closePullRequest, ({ target, comment }) => thenRefresh(closePullRequest(target, comment)));
|
|
38
|
+
server.handle(rpc.claimIssue, ({ target }) => thenRefresh(claimIssue(target)));
|
|
39
|
+
server.handle(rpc.unclaimIssue, ({ target }) => thenRefresh(unclaimIssue(target)));
|
|
40
|
+
server.handle(rpc.setReady, ({ target, ready }) => thenRefresh(setReady(target, ready)));
|
|
41
|
+
server.handle(rpc.setAutoMerge, ({ target, on }) => thenRefresh(setAutoMerge(target, on)));
|
|
42
|
+
server.handle(rpc.react, ({ subjectIds, on }) => thenRefresh(react(subjectIds, on)));
|
|
43
|
+
server.handle(rpc.rerunFailed, ({ repo, runIds }) => thenRefresh(rerunFailed(repo, runIds)));
|
|
44
|
+
server.handle(rpc.itemDetails, ({ subject, target, checks }) => (FIXTURE ? Promise.resolve(fixtureDetails(subject, target, checks)) : itemDetails(subject, target, checks)));
|
|
45
|
+
server.handle(rpc.defaultBranch, async ({ repo }) => ({ name: FIXTURE ? "main" : await defaultBranch(repo) }));
|
|
46
|
+
server.handle(rpc.sessionContext, async ({ kind, target }) => ({ context: FIXTURE ? FIXTURE_CONTEXT : await sessionContext(kind, target) }));
|
|
47
|
+
|
|
48
|
+
return () => attention.stop();
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrioid/paseo-attention-span",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "For when your attention is spread thin: the GitHub issues and pull requests waiting on you, grouped by why, with one-tap Paseo sessions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/andrioid/paseo-attention-span.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"provenance": true
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"files": [
|
|
16
|
+
"paseo-plugin.json",
|
|
17
|
+
"OVERVIEW.md",
|
|
18
|
+
"index.client.tsx",
|
|
19
|
+
"index.server.ts",
|
|
20
|
+
"client/",
|
|
21
|
+
"server/",
|
|
22
|
+
"shared/",
|
|
23
|
+
"!**/*.test.ts",
|
|
24
|
+
"assets/",
|
|
25
|
+
"docs/screenshots/desktop-dark-doing.png",
|
|
26
|
+
"docs/screenshots/phone-dark-doing.png",
|
|
27
|
+
"docs/screenshots/desktop-dark-next-up.png"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"check": "npm run typecheck && npm test && node .claude/skills/requirements-yaml/scripts/check.mjs",
|
|
33
|
+
"preview": "sh preview/run.sh",
|
|
34
|
+
"screenshots": "sh preview/screenshots.sh"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@getpaseo/plugin": "0.13.0",
|
|
38
|
+
"@tanstack/react-query": "^5.90.11",
|
|
39
|
+
"@types/node": "^24.0.0",
|
|
40
|
+
"@types/react": "~19.2.0",
|
|
41
|
+
"react": "19.1.0",
|
|
42
|
+
"react-native": "0.81.5",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"vitest": "^3.2.4",
|
|
45
|
+
"zod": "^4.4.3"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "attention-span",
|
|
3
|
+
"name": "Attention Span",
|
|
4
|
+
"description": "For when your attention is spread thin: the GitHub issues and pull requests waiting on you, grouped by why, with one-tap Paseo sessions.",
|
|
5
|
+
"icon": "assets/icon.png",
|
|
6
|
+
"media": [
|
|
7
|
+
"docs/screenshots/desktop-dark-doing.png",
|
|
8
|
+
"docs/screenshots/phone-dark-doing.png",
|
|
9
|
+
"docs/screenshots/desktop-dark-next-up.png"
|
|
10
|
+
],
|
|
11
|
+
"requirements": {
|
|
12
|
+
"paseo": ">=0.13.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Target } from "../shared/attention";
|
|
2
|
+
import { graphql, runGh } from "./gh";
|
|
3
|
+
|
|
4
|
+
// Each action runs only from an explicit tap in Attention Span [SAFE-01].
|
|
5
|
+
|
|
6
|
+
function pullRequestId(target: Target): string {
|
|
7
|
+
if (target.kind !== "pr" || !target.id) throw new Error(`${target.repo}#${target.number} is not a pull request.`);
|
|
8
|
+
return target.id;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// GitHub's "Update branch", rebasing onto the base [UPS-03].
|
|
12
|
+
export async function updateBranch(target: Target): Promise<void> {
|
|
13
|
+
await graphql(
|
|
14
|
+
`mutation($id: ID!, $oid: GitObjectID) {
|
|
15
|
+
updatePullRequestBranch(input: { pullRequestId: $id, expectedHeadOid: $oid, updateMethod: REBASE }) { clientMutationId }
|
|
16
|
+
}`,
|
|
17
|
+
{ id: pullRequestId(target), oid: target.headRefOid ?? null },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The expected head guards against merging commits Attention Span hasn't seen [PRS-04].
|
|
22
|
+
export async function mergePullRequest(target: Target): Promise<void> {
|
|
23
|
+
await graphql(
|
|
24
|
+
`mutation($id: ID!, $method: PullRequestMergeMethod!, $oid: GitObjectID) {
|
|
25
|
+
mergePullRequest(input: { pullRequestId: $id, mergeMethod: $method, expectedHeadOid: $oid }) { clientMutationId }
|
|
26
|
+
}`,
|
|
27
|
+
{ id: pullRequestId(target), method: (target.mergeMethod ?? "squash").toUpperCase(), oid: target.headRefOid ?? null },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Close a stale draft; comment only when I wrote one [PRS-07].
|
|
32
|
+
export async function closePullRequest(target: Target, comment?: string): Promise<void> {
|
|
33
|
+
const id = pullRequestId(target);
|
|
34
|
+
if (comment?.trim()) {
|
|
35
|
+
await graphql(`mutation($id: ID!, $body: String!) { addComment(input: { subjectId: $id, body: $body }) { clientMutationId } }`, {
|
|
36
|
+
id,
|
|
37
|
+
body: comment.trim(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
await graphql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { clientMutationId } }`, { id });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Assign me, so teammates see the issue is taken [NXT-05].
|
|
44
|
+
export async function claimIssue(target: Target): Promise<void> {
|
|
45
|
+
const { code, stderr } = await runGh(["issue", "edit", String(target.number), "--repo", target.repo, "--add-assignee", "@me"]);
|
|
46
|
+
if (code !== 0) throw new Error(stderr.trim() || "Claiming the issue failed");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Hand an assigned issue back; also undoes a claim [NXT-07].
|
|
50
|
+
export async function unclaimIssue(target: Target): Promise<void> {
|
|
51
|
+
const { code, stderr } = await runGh(["issue", "edit", String(target.number), "--repo", target.repo, "--remove-assignee", "@me"]);
|
|
52
|
+
if (code !== 0) throw new Error(stderr.trim() || "Unclaiming the issue failed");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Ready for review, or back to draft for Undo [PRS-11].
|
|
56
|
+
export async function setReady(target: Target, ready: boolean): Promise<void> {
|
|
57
|
+
const mutation = ready ? "markPullRequestReadyForReview" : "convertPullRequestToDraft";
|
|
58
|
+
await graphql(`mutation($id: ID!) { ${mutation}(input: { pullRequestId: $id }) { clientMutationId } }`, { id: pullRequestId(target) });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// GitHub merges once checks pass; the expected head guards like Merge does [PRS-10].
|
|
62
|
+
export async function setAutoMerge(target: Target, on: boolean): Promise<void> {
|
|
63
|
+
if (!on) {
|
|
64
|
+
await graphql(`mutation($id: ID!) { disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId } }`, { id: pullRequestId(target) });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await graphql(
|
|
68
|
+
`mutation($id: ID!, $method: PullRequestMergeMethod!, $oid: GitObjectID) {
|
|
69
|
+
enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: $method, expectedHeadOid: $oid }) { clientMutationId }
|
|
70
|
+
}`,
|
|
71
|
+
{ id: pullRequestId(target), method: (target.mergeMethod ?? "squash").toUpperCase(), oid: target.headRefOid ?? null },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 👍 the comments I picked, which clears them as mentions; or take it back for Undo [MEN-07].
|
|
76
|
+
export async function react(subjectIds: string[], on: boolean): Promise<void> {
|
|
77
|
+
if (subjectIds.length === 0) return;
|
|
78
|
+
const mutation = on ? "addReaction" : "removeReaction";
|
|
79
|
+
const fields = subjectIds.map((_, index) => `r${index}: ${mutation}(input: { subjectId: $s${index}, content: THUMBS_UP }) { clientMutationId }`);
|
|
80
|
+
const variables = subjectIds.map((_, index) => `$s${index}: ID!`).join(", ");
|
|
81
|
+
await graphql(`mutation(${variables}) {\n${fields.join("\n")}\n}`, Object.fromEntries(subjectIds.map((id, index) => [`s${index}`, id])));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Rerun only the failed jobs of each run [PRS-09].
|
|
85
|
+
export async function rerunFailed(repo: string, runIds: number[]): Promise<void> {
|
|
86
|
+
for (const id of runIds) {
|
|
87
|
+
const { code, stderr } = await runGh(["run", "rerun", String(id), "--failed", "--repo", repo]);
|
|
88
|
+
if (code !== 0) throw new Error(stderr.trim() || `Rerunning run ${id} failed`);
|
|
89
|
+
}
|
|
90
|
+
}
|