@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
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
2
|
+
import { openExternalUrl, type PluginScreenProps, usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Markdown, Modal, TextInput, useToast } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { type ReactNode, useState } from "react";
|
|
6
|
+
import { Pressable, ScrollView, Text, View } from "react-native";
|
|
7
|
+
import { type ActionId, type AgentSummary, type Chip, type FailingChecks, type Item, type NextUpIssue, type Section, type SectionId, type SubRow, TAB_FEED, type TabId, type Target } from "../shared/attention";
|
|
8
|
+
import type { AttentionConfig, SessionKind } from "../shared/config";
|
|
9
|
+
import { ago } from "../shared/format";
|
|
10
|
+
import { ACTION_SESSION, renderPrompt } from "../shared/prompts";
|
|
11
|
+
import * as rpc from "../shared/rpc";
|
|
12
|
+
import { blocks } from "../shared/setup";
|
|
13
|
+
import { useAttention } from "./data";
|
|
14
|
+
import type { IconName } from "./icons";
|
|
15
|
+
import { NextUpList } from "./next-up";
|
|
16
|
+
import { ActionSheet, DetailLines, type SheetEntry } from "./sheet";
|
|
17
|
+
import { branchWorkspaces, findCheckout, githubProjects, itemAgents, listProfiles, noProjectMessage, resolveAgentConfig, startSession } from "./sessions";
|
|
18
|
+
import { SetupChecklist } from "./setup";
|
|
19
|
+
import { Avatar, Button, KeepStyles, Meta, SectionHeader, StylesProvider, TablerIcon, Toggle, useHover, useStyles } from "./ui";
|
|
20
|
+
|
|
21
|
+
// The agent icon marks every action that starts an agent session; the rest act on GitHub right away [ATT-13].
|
|
22
|
+
const ACTION_ICONS: Record<ActionId, IconName> = {
|
|
23
|
+
reply: "replyArrow",
|
|
24
|
+
acknowledge: "thumbUp",
|
|
25
|
+
review: "agent",
|
|
26
|
+
fix: "agent",
|
|
27
|
+
rerun: "reload",
|
|
28
|
+
address: "agent",
|
|
29
|
+
update: "update",
|
|
30
|
+
rebase: "agent",
|
|
31
|
+
refresh: "agent",
|
|
32
|
+
ready: "pr",
|
|
33
|
+
automerge: "clock",
|
|
34
|
+
merge: "merge",
|
|
35
|
+
close: "close",
|
|
36
|
+
start: "agent",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function actionLabel(action: ActionId, item: Item): string {
|
|
40
|
+
const stacked = (item.target.stack?.length ?? 0) > 1;
|
|
41
|
+
return {
|
|
42
|
+
reply: "Reply ↗",
|
|
43
|
+
acknowledge: "Acknowledge",
|
|
44
|
+
review: "Review",
|
|
45
|
+
fix: "Fix CI",
|
|
46
|
+
rerun: "Rerun failed jobs",
|
|
47
|
+
address: "Address comments",
|
|
48
|
+
update: "Update branch",
|
|
49
|
+
rebase: stacked ? "Rebase stack" : "Rebase",
|
|
50
|
+
refresh: "Refresh draft",
|
|
51
|
+
ready: "Ready for review",
|
|
52
|
+
automerge: "Auto-merge",
|
|
53
|
+
merge: "Merge",
|
|
54
|
+
close: "Close draft",
|
|
55
|
+
start: "Start",
|
|
56
|
+
}[action];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// What an action will do, in the sheet, so pressing it makes sense [ATT-06]. Rerun's comes from the fetched checks.
|
|
60
|
+
function actionDetail(action: Exclude<ActionId, "rerun">, item: Item): string {
|
|
61
|
+
const { target } = item;
|
|
62
|
+
const pr = `#${target.number}`;
|
|
63
|
+
const base = target.baseRefName ?? target.defaultBranch ?? "its base";
|
|
64
|
+
const stacked = (target.stack?.length ?? 0) > 1;
|
|
65
|
+
const comments = item.person?.comments ?? [];
|
|
66
|
+
return {
|
|
67
|
+
reply: item.person ? `Opens ${item.person.login}'s comment on GitHub` : "Opens it on GitHub",
|
|
68
|
+
acknowledge:
|
|
69
|
+
comments.length === 1 ? `Reacts 👍 to ${comments[0].author}'s comment, which clears it` : `Pick which of the ${comments.length} waiting comments get a 👍`,
|
|
70
|
+
review: "An agent reviews the PR in a checkout and reports back. Nothing is posted",
|
|
71
|
+
fix: "An agent reads the failing logs and commits a fix, without pushing",
|
|
72
|
+
address: "An agent works through the unresolved comments and applies the trivial ones, without pushing",
|
|
73
|
+
update: `GitHub rebases ${pr} onto ${base}`,
|
|
74
|
+
rebase: `An agent rebases ${stacked ? "the stack" : pr} onto ${base}. It pushes only if there were no conflicts`,
|
|
75
|
+
refresh: `An agent rebases the draft onto ${base} and says what it still needs`,
|
|
76
|
+
ready: `Takes ${pr} out of draft, so reviewers are notified`,
|
|
77
|
+
automerge: `GitHub merges ${pr} into ${base} (${target.mergeMethod}) once checks pass`,
|
|
78
|
+
merge: `Merges ${pr} into ${base} (${target.mergeMethod}) now`,
|
|
79
|
+
close: `Closes ${pr}. A comment is posted only if you write one`,
|
|
80
|
+
start: "An agent reads the issue and proposes a plan",
|
|
81
|
+
}[action];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Which checks failed, and whether they were rerun before: flaky or real? [PRS-09]
|
|
85
|
+
function rerunEntry(failing: FailingChecks[] | undefined, loading: boolean): { detail: string; runIds: number[] } {
|
|
86
|
+
if (loading) return { detail: "Checking which checks failed…", runIds: [] };
|
|
87
|
+
if (!failing) return { detail: "Couldn't find the failing checks", runIds: [] };
|
|
88
|
+
if (failing.length === 0) return { detail: "No failed checks on the latest commit", runIds: [] };
|
|
89
|
+
const names = failing
|
|
90
|
+
.map((f) => `${failing.length > 1 ? `#${f.pr}: ` : ""}${f.checks.map((check) => `${check.name}${check.rerunnable ? "" : " (not Actions, can't rerun)"}`).join(", ")}`)
|
|
91
|
+
.join(" · ");
|
|
92
|
+
const runs = failing.flatMap((f) => f.runs);
|
|
93
|
+
const reruns = Math.max(0, ...runs.map((run) => run.attempt - 1));
|
|
94
|
+
return { detail: `${names} · ${reruns ? `rerun ${reruns === 1 ? "once" : `${reruns} times`} already` : "not rerun yet"}`, runIds: runs.map((run) => run.id) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// "Continue review" keeps what the session is for [SES-03].
|
|
98
|
+
const CONTINUE_LABELS: Record<SessionKind, string> = {
|
|
99
|
+
review: "Continue review",
|
|
100
|
+
fix: "Continue fix",
|
|
101
|
+
address: "Continue comments",
|
|
102
|
+
rebase: "Continue rebase",
|
|
103
|
+
refresh: "Continue refresh",
|
|
104
|
+
start: "Continue",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const SECTION_ICONS: Record<SectionId, IconName> = {
|
|
108
|
+
reply: "reply",
|
|
109
|
+
review: "review",
|
|
110
|
+
ci: "ci",
|
|
111
|
+
changes: "changes",
|
|
112
|
+
ready: "merge",
|
|
113
|
+
upstream: "branch",
|
|
114
|
+
too_long: "hourglass",
|
|
115
|
+
drafts: "pencil",
|
|
116
|
+
wait_review: "review",
|
|
117
|
+
wait_reviewer: "replyArrow",
|
|
118
|
+
wait_ci: "clock",
|
|
119
|
+
wait_answer: "reply",
|
|
120
|
+
done: "merge",
|
|
121
|
+
reviewed: "review",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const TABS: { id: TabId; label: string; icon: IconName }[] = [
|
|
125
|
+
{ id: "history", label: "History", icon: "history" },
|
|
126
|
+
{ id: "doing", label: "Doing", icon: "tray" },
|
|
127
|
+
{ id: "waiting", label: "Waiting", icon: "hourglass" },
|
|
128
|
+
{ id: "next", label: "Next up", icon: "nextUp" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const AGENTS_QUERY_KEY = ["attention-span", "agents"] as const;
|
|
132
|
+
type FetchContext = (input: RpcInput<typeof rpc.sessionContext>) => Promise<RpcOutput<typeof rpc.sessionContext>>;
|
|
133
|
+
export type FetchDetails = (input: RpcInput<typeof rpc.itemDetails>) => Promise<RpcOutput<typeof rpc.itemDetails>>;
|
|
134
|
+
type DefaultBranch = (repo: string) => Promise<string | null>;
|
|
135
|
+
|
|
136
|
+
// A start held back until I say where the issue's code lives [SES-07].
|
|
137
|
+
interface Placing {
|
|
138
|
+
itemKey: string;
|
|
139
|
+
kind: SessionKind;
|
|
140
|
+
target: Target;
|
|
141
|
+
missing: string;
|
|
142
|
+
before?: () => Promise<unknown>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Something I just did, offered for undo until the tab changes [ATT-08, ATT-14].
|
|
146
|
+
interface Undoable {
|
|
147
|
+
id: number;
|
|
148
|
+
text: string;
|
|
149
|
+
dismissal?: boolean;
|
|
150
|
+
undo: () => Promise<unknown>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A session started from the item, as a signal [ATT-07].
|
|
154
|
+
function agentSignal(agent: AgentSummary): Chip {
|
|
155
|
+
if (agent.requiresAttention) return { text: "agent needs you", tone: "warn" };
|
|
156
|
+
if (agent.status === "running" || agent.status === "initializing") return { text: "agent working", tone: "good" };
|
|
157
|
+
if (agent.status === "error") return { text: "agent failed", tone: "bad" };
|
|
158
|
+
return { text: "agent idle", tone: "neutral" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// `openSettings` opens the orgs and repos settings [SRC-02].
|
|
162
|
+
export function AttentionScreen({ theme, layout, navigation, openSettings }: PluginScreenProps & { openSettings?: () => void }) {
|
|
163
|
+
return (
|
|
164
|
+
<StylesProvider theme={theme} compact={layout.compact}>
|
|
165
|
+
<AttentionList navigation={navigation} openSettings={openSettings} />
|
|
166
|
+
</StylesProvider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function AttentionList({ navigation, openSettings }: Pick<PluginScreenProps, "navigation"> & { openSettings?: () => void }) {
|
|
171
|
+
const styles = useStyles();
|
|
172
|
+
const paseo = usePaseo();
|
|
173
|
+
const toast = useToast();
|
|
174
|
+
const queryClient = useQueryClient();
|
|
175
|
+
const attention = useAttention();
|
|
176
|
+
const agents = useQuery({ queryKey: AGENTS_QUERY_KEY, queryFn: () => itemAgents(paseo), refetchInterval: 15_000 });
|
|
177
|
+
const fetchContext = useRpc(rpc.sessionContext);
|
|
178
|
+
const dismiss = useRpc(rpc.dismissItem);
|
|
179
|
+
const undismiss = useRpc(rpc.undismissItem);
|
|
180
|
+
const restore = useRpc(rpc.restoreDismissed);
|
|
181
|
+
const saveScopes = useRpc(rpc.setScopes);
|
|
182
|
+
const update = useRpc(rpc.updateBranch);
|
|
183
|
+
const merge = useRpc(rpc.mergePullRequest);
|
|
184
|
+
const close = useRpc(rpc.closePullRequest);
|
|
185
|
+
const claim = useRpc(rpc.claimIssue);
|
|
186
|
+
const unclaim = useRpc(rpc.unclaimIssue);
|
|
187
|
+
const setReady = useRpc(rpc.setReady);
|
|
188
|
+
const setAutoMerge = useRpc(rpc.setAutoMerge);
|
|
189
|
+
const react = useRpc(rpc.react);
|
|
190
|
+
const rerunFailed = useRpc(rpc.rerunFailed);
|
|
191
|
+
const fetchDetails = useRpc(rpc.itemDetails);
|
|
192
|
+
const setCodeRepo = useRpc(rpc.setCodeRepo);
|
|
193
|
+
const fetchDefaultBranch = useRpc(rpc.defaultBranch);
|
|
194
|
+
const defaultBranch: DefaultBranch = async (repo) => (await fetchDefaultBranch({ repo })).name;
|
|
195
|
+
const [tab, setTab] = useState<TabId>("doing");
|
|
196
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
197
|
+
const [undoable, setUndoable] = useState<Undoable[]>([]);
|
|
198
|
+
const [placing, setPlacing] = useState<Placing | null>(null);
|
|
199
|
+
const view = attention.data;
|
|
200
|
+
// Only when something fails: is it setup, and what's missing [SET-01]?
|
|
201
|
+
const fetchSetup = useRpc(rpc.setupStatus);
|
|
202
|
+
const failing = !!attention.error || !!view?.feeds.live.error;
|
|
203
|
+
const setup = useQuery({ queryKey: ["attention-span", "setup"], queryFn: () => fetchSetup({}), enabled: failing });
|
|
204
|
+
const [checking, setChecking] = useState(false);
|
|
205
|
+
|
|
206
|
+
async function run(key: string, label: string, work: () => Promise<unknown>) {
|
|
207
|
+
setBusy(key);
|
|
208
|
+
try {
|
|
209
|
+
await work();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
toast.error(`${label} failed: ${(error as Error).message}`);
|
|
212
|
+
} finally {
|
|
213
|
+
setBusy(null);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function offerUndo(text: string, undo: () => Promise<unknown>, dismissal?: boolean) {
|
|
218
|
+
setUndoable((rows) => [...rows, { id: Date.now() + Math.random(), text, undo, dismissal }]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A change on GitHub from one tap, without confirming: Undo instead, where GitHub can take it back [SAFE-01, ATT-14].
|
|
222
|
+
function onGitHub(key: string, label: string, work: () => Promise<unknown>, done: string, undo?: () => Promise<unknown>) {
|
|
223
|
+
void run(key, label, async () => {
|
|
224
|
+
await work();
|
|
225
|
+
if (undo) offerUndo(done, undo);
|
|
226
|
+
else toast.show(done, { variant: "success" });
|
|
227
|
+
void attention.refreshNow("live").catch(() => {}); // the server is already refreshing; this waits for it
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function openAgent(agentId: string) {
|
|
232
|
+
if (navigation?.openAgent) navigation.openAgent({ agentId });
|
|
233
|
+
else toast.show("Session started. Open it from the sidebar.", { variant: "success" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function started(agentId: string) {
|
|
237
|
+
await queryClient.invalidateQueries({ queryKey: AGENTS_QUERY_KEY });
|
|
238
|
+
openAgent(agentId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// One tap: continue the item's session, or start one with defaults [SES-01, SES-03].
|
|
242
|
+
async function startWithDefaults(itemKey: string, kind: SessionKind, target: Target, before?: () => Promise<unknown>) {
|
|
243
|
+
const existing = (view?.fixtureAgents ?? agents.data)?.[itemKey];
|
|
244
|
+
if (existing) return openAgent(existing.id);
|
|
245
|
+
if (!view) return;
|
|
246
|
+
await run(itemKey, "Starting the session", () => startNow(itemKey, kind, target, view.config, before));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function startNow(itemKey: string, kind: SessionKind, target: Target, config: AttentionConfig, before?: () => Promise<unknown>) {
|
|
250
|
+
const checkout = await findCheckout(paseo, config, target, defaultBranch);
|
|
251
|
+
// Ask where an issue's code lives before claiming it, so a failed start doesn't leave it taken [SES-07].
|
|
252
|
+
if (!checkout.project && target.kind === "issue") return setPlacing({ itemKey, kind, target, missing: checkout.repo, before });
|
|
253
|
+
await before?.();
|
|
254
|
+
const { context } = await fetchContext({ kind, target });
|
|
255
|
+
const prompt = renderPrompt(kind, target, context, config);
|
|
256
|
+
await started(await startSession(paseo, { itemKey, kind, target, prompt, config, checkout }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function place(codeRepo: string) {
|
|
260
|
+
if (!placing) return;
|
|
261
|
+
const { itemKey, kind, target, before } = placing;
|
|
262
|
+
setPlacing(null);
|
|
263
|
+
void run(itemKey, "Starting the session", async () => {
|
|
264
|
+
const next = await setCodeRepo({ issueRepo: target.repo, codeRepo });
|
|
265
|
+
attention.setView(next);
|
|
266
|
+
await startNow(itemKey, kind, target, next.config, before);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (failing && setup.data && blocks(setup.data)) {
|
|
271
|
+
const check = async () => {
|
|
272
|
+
setChecking(true);
|
|
273
|
+
await attention.refreshNow("live").catch(() => {});
|
|
274
|
+
await setup.refetch();
|
|
275
|
+
setChecking(false);
|
|
276
|
+
};
|
|
277
|
+
return (
|
|
278
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
279
|
+
<SetupChecklist status={setup.data} config={view?.config} onCheck={() => void check()} checking={checking} openSettings={openSettings} />
|
|
280
|
+
</ScrollView>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!view) {
|
|
285
|
+
return (
|
|
286
|
+
<View style={[styles.screen, styles.content]}>
|
|
287
|
+
<Text style={attention.error ? styles.danger : styles.muted}>{attention.error ? `Couldn't load Attention Span: ${(attention.error as Error).message}` : "Loading…"}</Text>
|
|
288
|
+
</View>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Each tab shows how fresh its own feed is.
|
|
293
|
+
const feed = view.feeds[TAB_FEED[tab]];
|
|
294
|
+
const tooLong = view.doing.find((section) => section.id === "too_long")?.items.length ?? 0;
|
|
295
|
+
const counts: Record<TabId, number> = {
|
|
296
|
+
history: view.history.filter((item) => item.section === "done").length,
|
|
297
|
+
doing: view.count,
|
|
298
|
+
waiting: view.waiting.reduce((sum, section) => sum + section.items.length, 0),
|
|
299
|
+
next: view.assigned.length + view.unclaimed.length,
|
|
300
|
+
};
|
|
301
|
+
// The repo is worth showing only when the list spans several.
|
|
302
|
+
const repos = new Set([...view.doing, ...view.waiting].flatMap((section) => section.items.map((item) => item.repo)));
|
|
303
|
+
|
|
304
|
+
const actions: Actions = {
|
|
305
|
+
agents: view.fixtureAgents ?? agents.data ?? {},
|
|
306
|
+
busy,
|
|
307
|
+
config: view.config,
|
|
308
|
+
showRepo: repos.size > 1,
|
|
309
|
+
fetchContext,
|
|
310
|
+
fetchDetails,
|
|
311
|
+
defaultBranch,
|
|
312
|
+
openAgent,
|
|
313
|
+
started,
|
|
314
|
+
act: (item, action) => {
|
|
315
|
+
const session = ACTION_SESSION[action];
|
|
316
|
+
const { target } = item;
|
|
317
|
+
if (action === "reply") return void openExternalUrl(item.person?.url ?? item.url);
|
|
318
|
+
if (session) return void startWithDefaults(item.key, session, target);
|
|
319
|
+
if (action === "update")
|
|
320
|
+
void run(item.key, "Update", async () => {
|
|
321
|
+
await update({ target });
|
|
322
|
+
toast.show(`Updating PR ${target.number} with ${target.baseRefName}`, { variant: "success" });
|
|
323
|
+
});
|
|
324
|
+
// [PRS-11]
|
|
325
|
+
if (action === "ready")
|
|
326
|
+
onGitHub(item.key, "Ready for review", () => setReady({ target, ready: true }), `PR ${target.number} is ready for review`, () => setReady({ target, ready: false }));
|
|
327
|
+
// [PRS-10]
|
|
328
|
+
if (action === "automerge")
|
|
329
|
+
onGitHub(item.key, "Auto-merge", () => setAutoMerge({ target, on: true }), `PR ${target.number} merges once checks pass`, () => setAutoMerge({ target, on: false }));
|
|
330
|
+
},
|
|
331
|
+
// [PRS-09]
|
|
332
|
+
rerun: (item, runIds) =>
|
|
333
|
+
onGitHub(item.key, "Rerun", () => rerunFailed({ repo: item.target.repo, runIds }), `Rerunning the failed jobs of PR ${item.target.number}`),
|
|
334
|
+
// [MEN-07]
|
|
335
|
+
acknowledge: (item, ids) =>
|
|
336
|
+
onGitHub(item.key, "👍", () => react({ subjectIds: ids, on: true }), `Reacted 👍 to ${ids.length === 1 ? "a comment" : `${ids.length} comments`} on #${item.number}`, () =>
|
|
337
|
+
react({ subjectIds: ids, on: false }),
|
|
338
|
+
),
|
|
339
|
+
confirm: (item, action, comment) =>
|
|
340
|
+
void run(item.key, action === "merge" ? "Merge" : "Close", async () => {
|
|
341
|
+
if (action === "merge") await merge({ target: item.target });
|
|
342
|
+
else await close({ target: item.target, comment });
|
|
343
|
+
toast.show(`PR ${item.target.number} ${action === "merge" ? "merged" : "closed"}`, { variant: "success" });
|
|
344
|
+
await attention.refreshNow("live");
|
|
345
|
+
}),
|
|
346
|
+
dismiss: (item) =>
|
|
347
|
+
void run(item.key, "Dismiss", async () => {
|
|
348
|
+
attention.setView(await dismiss({ key: item.key }));
|
|
349
|
+
offerUndo(`Dismissed “${item.title}”`, async () => attention.setView(await undismiss({ key: item.key })), true);
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const switchTab = (id: TabId) => {
|
|
354
|
+
setTab(id);
|
|
355
|
+
setUndoable([]);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
360
|
+
<View style={[styles.row, { justifyContent: "space-between", flexWrap: "wrap" }]}>
|
|
361
|
+
<View style={styles.row}>
|
|
362
|
+
{TABS.map(({ id, label, icon }) => (
|
|
363
|
+
<Tab key={id} icon={icon} label={label} count={counts[id]} active={tab === id} onPress={() => switchTab(id)} />
|
|
364
|
+
))}
|
|
365
|
+
</View>
|
|
366
|
+
<View style={[styles.row, { gap: 16 }]}>
|
|
367
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Refresh now" style={styles.row} onPress={() => void attention.refreshNow(TAB_FEED[tab]).catch(() => {})}>
|
|
368
|
+
<TablerIcon name="update" size={14} />
|
|
369
|
+
<Text style={styles.muted}>{feed.refreshing || attention.isFetching ? "refreshing…" : feed.fetchedAt ? `${ago(feed.fetchedAt)} ago` : "never"}</Text>
|
|
370
|
+
</Pressable>
|
|
371
|
+
{openSettings ? (
|
|
372
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Attention Span settings" onPress={openSettings} hitSlop={8}>
|
|
373
|
+
<TablerIcon name="settings" size={16} />
|
|
374
|
+
</Pressable>
|
|
375
|
+
) : null}
|
|
376
|
+
</View>
|
|
377
|
+
</View>
|
|
378
|
+
{feed.error ? <Text style={styles.danger}>{feed.error}</Text> : null}
|
|
379
|
+
|
|
380
|
+
{undoable.map((row) => (
|
|
381
|
+
<View key={row.id} style={[styles.panel, styles.row, { justifyContent: "space-between" }]}>
|
|
382
|
+
<Text style={[styles.muted, { flexShrink: 1 }]} numberOfLines={1}>
|
|
383
|
+
{row.text}
|
|
384
|
+
</Text>
|
|
385
|
+
<Button
|
|
386
|
+
label="Undo"
|
|
387
|
+
variant="calm"
|
|
388
|
+
onPress={() =>
|
|
389
|
+
void run(String(row.id), "Undo", async () => {
|
|
390
|
+
await row.undo();
|
|
391
|
+
setUndoable((rows) => rows.filter((other) => other.id !== row.id));
|
|
392
|
+
if (!row.dismissal) void attention.refreshNow("live").catch(() => {});
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
/>
|
|
396
|
+
</View>
|
|
397
|
+
))}
|
|
398
|
+
{tab === "history" ? <History items={view.history} actions={actions} /> : null}
|
|
399
|
+
{tab === "doing" ? (
|
|
400
|
+
<>
|
|
401
|
+
<Sections sections={view.doing} actions={actions} empty="Nothing needs you right now." />
|
|
402
|
+
{view.dismissedCount > undoable.filter((row) => row.dismissal).length ? (
|
|
403
|
+
<Pressable accessibilityRole="button" onPress={() => void run("restore", "Restore", async () => (attention.setView(await restore({})), setUndoable([])))}>
|
|
404
|
+
<Text style={styles.muted}>{view.dismissedCount} dismissed · show them again</Text>
|
|
405
|
+
</Pressable>
|
|
406
|
+
) : null}
|
|
407
|
+
</>
|
|
408
|
+
) : null}
|
|
409
|
+
{tab === "waiting" ? (
|
|
410
|
+
<>
|
|
411
|
+
<Sections sections={view.waiting} actions={actions} empty="Nothing is waiting on others." />
|
|
412
|
+
{tooLong > 0 ? (
|
|
413
|
+
<Pressable accessibilityRole="button" onPress={() => switchTab("doing")}>
|
|
414
|
+
<Text style={styles.muted}>{tooLong} more waited too long and moved to Doing →</Text>
|
|
415
|
+
</Pressable>
|
|
416
|
+
) : null}
|
|
417
|
+
</>
|
|
418
|
+
) : null}
|
|
419
|
+
{tab === "next" ? (
|
|
420
|
+
<NextUpList
|
|
421
|
+
assigned={view.assigned}
|
|
422
|
+
unclaimed={view.unclaimed}
|
|
423
|
+
unclaimedFrom={view.unclaimedFrom}
|
|
424
|
+
selected={view.scopes}
|
|
425
|
+
onSelect={(scopes) => {
|
|
426
|
+
attention.setView({ ...view, scopes });
|
|
427
|
+
saveScopes({ scopes }).catch((error) => toast.error(`Saving the filter failed: ${(error as Error).message}`));
|
|
428
|
+
}}
|
|
429
|
+
agents={actions.agents}
|
|
430
|
+
busy={busy}
|
|
431
|
+
fetchDetails={fetchDetails}
|
|
432
|
+
onOpenAgent={openAgent}
|
|
433
|
+
onStart={(issue, claimFirst) => {
|
|
434
|
+
const target = issueTarget(issue);
|
|
435
|
+
void startWithDefaults(issue.key, "start", target, claimFirst ? () => claim({ target }) : undefined);
|
|
436
|
+
}}
|
|
437
|
+
// Claim without starting, or give it back; each undoes the other [NXT-07].
|
|
438
|
+
onClaim={(issue, on) => {
|
|
439
|
+
const target = issueTarget(issue);
|
|
440
|
+
const [take, giveBack] = [() => claim({ target }), () => unclaim({ target })];
|
|
441
|
+
onGitHub(issue.key, on ? "Claim" : "Unclaim", on ? take : giveBack, `${on ? "Claimed" : "Unclaimed"} #${issue.number}`, on ? giveBack : take);
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
) : null}
|
|
445
|
+
{placing ? <CodeRepoPicker issueRepo={placing.target.repo} missing={placing.missing} onPick={place} onClose={() => setPlacing(null)} /> : null}
|
|
446
|
+
</ScrollView>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function Tab({ icon, label, count, active, onPress }: { icon: IconName; label: string; count: number; active: boolean; onPress: () => void }) {
|
|
451
|
+
const styles = useStyles();
|
|
452
|
+
const color = active ? styles.c.foreground : styles.c.foregroundMuted;
|
|
453
|
+
return (
|
|
454
|
+
<Pressable
|
|
455
|
+
accessibilityRole="tab"
|
|
456
|
+
accessibilityLabel={`${label}, ${count}`}
|
|
457
|
+
accessibilityState={{ selected: active }}
|
|
458
|
+
onPress={onPress}
|
|
459
|
+
style={[styles.row, { gap: 6, paddingVertical: 7, paddingHorizontal: styles.compact ? 9 : 12, borderRadius: 9, backgroundColor: active ? styles.c.surface2 : "transparent" }]}
|
|
460
|
+
>
|
|
461
|
+
<TablerIcon name={icon} size={16} color={color} />
|
|
462
|
+
{/* On phones only the active tab is labelled, so four tabs fit. */}
|
|
463
|
+
{active || !styles.compact ? <Text style={{ color, fontSize: 14, fontWeight: active ? "600" : "400" }}>{label}</Text> : null}
|
|
464
|
+
<Text style={[styles.muted, { fontSize: 12 }]}>{count}</Text>
|
|
465
|
+
</Pressable>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
interface Actions {
|
|
470
|
+
agents: Record<string, AgentSummary>;
|
|
471
|
+
busy: string | null;
|
|
472
|
+
config: AttentionConfig;
|
|
473
|
+
showRepo: boolean;
|
|
474
|
+
fetchContext: FetchContext;
|
|
475
|
+
fetchDetails: FetchDetails;
|
|
476
|
+
defaultBranch: DefaultBranch;
|
|
477
|
+
act: (item: Item, action: ActionId) => void;
|
|
478
|
+
rerun: (item: Item, runIds: number[]) => void;
|
|
479
|
+
acknowledge: (item: Item, commentIds: string[]) => void;
|
|
480
|
+
confirm: (item: Item, action: "merge" | "close", comment?: string) => void;
|
|
481
|
+
dismiss: (item: Item) => void;
|
|
482
|
+
openAgent: (agentId: string) => void;
|
|
483
|
+
started: (agentId: string) => Promise<void>;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function stateIcon(item: Item, c: ReturnType<typeof useStyles>["c"]): { name: IconName; color: string } {
|
|
487
|
+
if (item.closedAt) return item.kind === "pr" ? { name: "merge", color: c.accent } : { name: "issueClosed", color: item.chips.length ? c.foregroundMuted : c.accent };
|
|
488
|
+
if (item.kind === "issue") return { name: "issue", color: c.statusSuccess };
|
|
489
|
+
return item.draft ? { name: "draft", color: c.foregroundMuted } : { name: "pr", color: c.statusSuccess };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// A card that works collapsed: its main action and ⋯. Tapping it opens the sheet with everything else [ATT-05, ATT-06].
|
|
493
|
+
const isRed = (chips: Chip[]) => chips.some((chip) => chip.tone === "bad");
|
|
494
|
+
|
|
495
|
+
type Sheet = "item" | "acknowledge" | { options: SessionKind } | null;
|
|
496
|
+
|
|
497
|
+
function ItemRow({ item, first, actions }: { item: Item; first: boolean; actions: Actions }) {
|
|
498
|
+
const styles = useStyles();
|
|
499
|
+
const agent = actions.agents[item.key];
|
|
500
|
+
const chips = agent ? [...item.chips, agentSignal(agent)] : item.chips;
|
|
501
|
+
const [sheet, setSheet] = useState<Sheet>(null);
|
|
502
|
+
const [confirm, setConfirm] = useState<"merge" | "close" | null>(null);
|
|
503
|
+
const [comment, setComment] = useState("");
|
|
504
|
+
const sessionAction = item.actions.find((action) => ACTION_SESSION[action]);
|
|
505
|
+
const sessionKind = sessionAction ? ACTION_SESSION[sessionAction] : undefined;
|
|
506
|
+
const main = item.actions[0];
|
|
507
|
+
const facts = [`#${item.number}`, ...(actions.showRepo ? [item.repo.split("/")[1]] : [])];
|
|
508
|
+
// The full stack is in the sheet; the card shows the PRs that need attention [ATT-04].
|
|
509
|
+
const children = item.children.filter((child) => child.attention || isRed(child.chips));
|
|
510
|
+
const icon = stateIcon(item, styles.c);
|
|
511
|
+
const open = () => setSheet("item");
|
|
512
|
+
|
|
513
|
+
function perform(action: ActionId) {
|
|
514
|
+
setSheet(null);
|
|
515
|
+
if (action === "merge" || action === "close") setConfirm(action);
|
|
516
|
+
else if (action === "acknowledge") setSheet("acknowledge");
|
|
517
|
+
else actions.act(item, action);
|
|
518
|
+
}
|
|
519
|
+
const handlers: SheetHandlers = { onPerform: perform, onOptions: (kind) => setSheet({ options: kind }), onClose: () => setSheet(null) };
|
|
520
|
+
// A menu with one action is a wasted tap: show that action instead of ⋯. The card still opens the sheet [ATT-06].
|
|
521
|
+
const entries = sheetEntries(item, agent, actions, handlers);
|
|
522
|
+
const only = entries.length === 1 ? entries[0] : undefined;
|
|
523
|
+
|
|
524
|
+
const buttons = (
|
|
525
|
+
<View style={styles.wrap}>
|
|
526
|
+
{actions.busy === item.key ? (
|
|
527
|
+
<Text style={styles.muted}>working…</Text>
|
|
528
|
+
) : agent && sessionKind ? (
|
|
529
|
+
<Button label={CONTINUE_LABELS[sessionKind]} icon="agent" variant="calm" onPress={() => actions.openAgent(agent.id)} />
|
|
530
|
+
) : main ? (
|
|
531
|
+
<Button label={actionLabel(main, item)} icon={ACTION_ICONS[main]} variant="calm" onPress={() => perform(main)} />
|
|
532
|
+
) : null}
|
|
533
|
+
{only ? (
|
|
534
|
+
<Button icon={only.icon} variant="ghost" accessibilityLabel={only.label} onPress={only.onPress} />
|
|
535
|
+
) : (
|
|
536
|
+
<Button icon="dots" variant="ghost" accessibilityLabel="More actions" onPress={open} />
|
|
537
|
+
)}
|
|
538
|
+
</View>
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<View style={first ? null : styles.divider}>
|
|
543
|
+
<View style={styles.item}>
|
|
544
|
+
<View style={{ paddingTop: 3 }}>
|
|
545
|
+
<TablerIcon name={icon.name} color={icon.color} size={18} />
|
|
546
|
+
</View>
|
|
547
|
+
<View style={styles.body}>
|
|
548
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`${item.title}, more`} onPress={open} style={{ gap: 6 }}>
|
|
549
|
+
<Text style={styles.title} numberOfLines={styles.compact ? 2 : 1}>
|
|
550
|
+
{item.title}
|
|
551
|
+
</Text>
|
|
552
|
+
<Meta facts={facts} chips={chips} />
|
|
553
|
+
{item.person ? <PersonQuote person={item.person} expanded={false} /> : null}
|
|
554
|
+
{children.length > 0 ? <StackLine rows={children} merged={!!item.closedAt} /> : null}
|
|
555
|
+
</Pressable>
|
|
556
|
+
{styles.compact ? buttons : null}
|
|
557
|
+
{confirm ? (
|
|
558
|
+
<View style={styles.panel}>
|
|
559
|
+
<Text style={styles.text}>
|
|
560
|
+
{confirm === "merge"
|
|
561
|
+
? `Merge PR ${item.target.number} into ${item.target.baseRefName} (${item.target.mergeMethod})?`
|
|
562
|
+
: `Close PR ${item.target.number}? A comment is only posted if you write one.`}
|
|
563
|
+
</Text>
|
|
564
|
+
{confirm === "close" ? (
|
|
565
|
+
<TextInput style={styles.input} placeholder="Closing comment (optional)" placeholderTextColor={styles.c.foregroundMuted} value={comment} onChangeText={setComment} multiline />
|
|
566
|
+
) : null}
|
|
567
|
+
<View style={styles.wrap}>
|
|
568
|
+
<Button label={confirm === "merge" ? "Merge" : "Close PR"} icon={confirm} variant="solid" onPress={() => (setConfirm(null), actions.confirm(item, confirm, comment))} />
|
|
569
|
+
<Button label="Cancel" onPress={() => setConfirm(null)} />
|
|
570
|
+
</View>
|
|
571
|
+
</View>
|
|
572
|
+
) : null}
|
|
573
|
+
</View>
|
|
574
|
+
{styles.compact ? null : buttons}
|
|
575
|
+
</View>
|
|
576
|
+
{sheet === "item" ? (
|
|
577
|
+
<ItemSheet item={item} agent={agent} actions={actions} {...handlers} />
|
|
578
|
+
) : null}
|
|
579
|
+
{sheet === "acknowledge" ? <AcknowledgeSheet item={item} onReact={(ids) => (setSheet(null), actions.acknowledge(item, ids))} onClose={() => setSheet(null)} /> : null}
|
|
580
|
+
{sheet && typeof sheet === "object" ? <SessionOptions item={item} kind={sheet.options} actions={actions} onClose={() => setSheet(null)} /> : null}
|
|
581
|
+
</View>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
interface SheetHandlers {
|
|
586
|
+
onPerform: (action: ActionId) => void;
|
|
587
|
+
onOptions: (kind: SessionKind) => void;
|
|
588
|
+
onClose: () => void;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// What the card already knows on top, then every action, then what's fetched when it opens, so nothing jumps as it loads [ATT-06, GH-01].
|
|
592
|
+
function ItemSheet({ item, agent, actions, ...handlers }: { item: Item; agent?: AgentSummary; actions: Actions } & SheetHandlers) {
|
|
593
|
+
const checks = item.actions.includes("rerun");
|
|
594
|
+
const details = useQuery({
|
|
595
|
+
queryKey: ["attention-span", "details", item.key, checks],
|
|
596
|
+
queryFn: () => actions.fetchDetails({ subject: { kind: item.kind, repo: item.repo, number: item.number }, target: item.target, checks }),
|
|
597
|
+
staleTime: 60_000,
|
|
598
|
+
});
|
|
599
|
+
const meta = [`${item.repo}#${item.number}`, ...(item.target.number !== item.number ? [`actions apply to PR #${item.target.number}`] : [])];
|
|
600
|
+
// PRs already in the stack line link from there; list only the rest, like someone else's PR closing the same issue.
|
|
601
|
+
const shown = new Set(item.children.map((child) => child.url));
|
|
602
|
+
const extra = details.data && { ...details.data, linked: details.data.linked.filter((link) => !shown.has(link.url)) };
|
|
603
|
+
return (
|
|
604
|
+
<ActionSheet
|
|
605
|
+
title={item.title}
|
|
606
|
+
onClose={handlers.onClose}
|
|
607
|
+
top={
|
|
608
|
+
<>
|
|
609
|
+
<Meta facts={meta} chips={item.chips} />
|
|
610
|
+
{item.person ? <PersonQuote person={item.person} expanded /> : null}
|
|
611
|
+
{item.children.length > 0 ? <StackLine rows={item.children} merged={!!item.closedAt} links /> : null}
|
|
612
|
+
</>
|
|
613
|
+
}
|
|
614
|
+
entries={sheetEntries(item, agent, actions, handlers, details)}
|
|
615
|
+
bottom={<DetailLines details={extra} loading={details.isLoading} error={details.error} linkedLabel={item.kind === "issue" ? "PR" : "Closes"} />}
|
|
616
|
+
/>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Everything an item's sheet offers, each saying what it will do [ATT-06]. Rerun's line needs the fetched checks.
|
|
621
|
+
function sheetEntries(
|
|
622
|
+
item: Item,
|
|
623
|
+
agent: AgentSummary | undefined,
|
|
624
|
+
actions: Actions,
|
|
625
|
+
{ onPerform, onOptions, onClose }: SheetHandlers,
|
|
626
|
+
details?: { data?: { failing: FailingChecks[] }; isLoading: boolean },
|
|
627
|
+
): SheetEntry[] {
|
|
628
|
+
const inDoing = !item.section.startsWith("wait_") && item.section !== "done" && item.section !== "reviewed";
|
|
629
|
+
const close = (then: () => void) => () => (onClose(), then());
|
|
630
|
+
|
|
631
|
+
const entries: SheetEntry[] = [];
|
|
632
|
+
let continued = false;
|
|
633
|
+
for (const action of item.actions) {
|
|
634
|
+
const kind = ACTION_SESSION[action];
|
|
635
|
+
const label = actionLabel(action, item);
|
|
636
|
+
if (kind && agent) {
|
|
637
|
+
// One session per item: continue it instead of starting another [SES-03].
|
|
638
|
+
if (!continued) entries.push({ key: action, icon: "agent", label: CONTINUE_LABELS[kind], detail: `Opens the session (${agentSignal(agent).text})`, onPress: close(() => actions.openAgent(agent.id)) });
|
|
639
|
+
continued = true;
|
|
640
|
+
} else if (kind) {
|
|
641
|
+
entries.push({ key: action, icon: "agent", label, detail: actionDetail(action as Exclude<ActionId, "rerun">, item), onPress: () => onPerform(action) });
|
|
642
|
+
entries.push({ key: `${action}-options`, icon: "agent", label: `${label} with options…`, detail: "Choose the workspace, agent profile and prompt first", onPress: () => onOptions(kind) });
|
|
643
|
+
} else if (action === "rerun") {
|
|
644
|
+
const { detail, runIds } = rerunEntry(details?.data?.failing, details?.isLoading ?? true);
|
|
645
|
+
entries.push({ key: action, icon: ACTION_ICONS.rerun, label, detail, disabled: runIds.length === 0, onPress: close(() => actions.rerun(item, runIds)) });
|
|
646
|
+
} else {
|
|
647
|
+
entries.push({ key: action, icon: ACTION_ICONS[action], label, detail: actionDetail(action, item), onPress: () => onPerform(action) });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Anything Attention Span doesn't cover [ATT-12].
|
|
651
|
+
entries.push({ key: "github", icon: "github", label: `Open ${item.kind === "issue" ? "issue" : "PR"} on GitHub`, detail: `${item.repo}#${item.number}`, onPress: close(() => void openExternalUrl(item.url)) });
|
|
652
|
+
if (inDoing) entries.push({ key: "dismiss", icon: "dismiss", label: "Dismiss", detail: "Hides it until something new happens on it", onPress: close(() => actions.dismiss(item)) });
|
|
653
|
+
return entries;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Pick which waiting comments get a 👍, after reading them in full; all are picked to start [MEN-07].
|
|
657
|
+
function AcknowledgeSheet({ item, onReact, onClose }: { item: Item; onReact: (ids: string[]) => void; onClose: () => void }) {
|
|
658
|
+
const styles = useStyles();
|
|
659
|
+
const comments = item.person?.comments ?? [];
|
|
660
|
+
const [picked, setPicked] = useState(() => new Set(comments.map((c) => c.id)));
|
|
661
|
+
const toggle = (id: string) => setPicked((now) => (now.has(id) ? new Set([...now].filter((other) => other !== id)) : new Set([...now, id])));
|
|
662
|
+
return (
|
|
663
|
+
<Modal title={`Acknowledge: ${item.title}`} open onOpenChange={(open) => (open ? null : onClose())}>
|
|
664
|
+
<Modal.Content>
|
|
665
|
+
<KeepStyles styles={styles}>
|
|
666
|
+
<Text style={styles.muted}>A 👍 tells them you've seen it, and clears it from your list.</Text>
|
|
667
|
+
{comments.map((c) => (
|
|
668
|
+
<Pressable
|
|
669
|
+
key={c.id}
|
|
670
|
+
accessibilityRole="checkbox"
|
|
671
|
+
accessibilityState={{ checked: picked.has(c.id) }}
|
|
672
|
+
accessibilityLabel={`Comment from ${c.author}`}
|
|
673
|
+
onPress={() => toggle(c.id)}
|
|
674
|
+
style={[styles.quote, styles.row, { alignItems: "flex-start", gap: 10 }]}
|
|
675
|
+
>
|
|
676
|
+
<TablerIcon name={picked.has(c.id) ? "squareCheck" : "square"} size={18} color={picked.has(c.id) ? styles.action : styles.c.foregroundMuted} />
|
|
677
|
+
<View style={{ flex: 1, gap: 4 }}>
|
|
678
|
+
<Text style={{ color: styles.c.foreground, fontSize: 12, fontWeight: "600" }}>
|
|
679
|
+
{c.author} <Text style={[styles.muted, { fontWeight: "400" }]}>· {ago(c.createdAt)}</Text>
|
|
680
|
+
</Text>
|
|
681
|
+
<Markdown>{c.body}</Markdown>
|
|
682
|
+
</View>
|
|
683
|
+
</Pressable>
|
|
684
|
+
))}
|
|
685
|
+
<View style={styles.wrap}>
|
|
686
|
+
<Button
|
|
687
|
+
label={`👍 ${picked.size} comment${picked.size === 1 ? "" : "s"}`}
|
|
688
|
+
variant="solid"
|
|
689
|
+
disabled={picked.size === 0}
|
|
690
|
+
onPress={() => onReact(comments.filter((c) => picked.has(c.id)).map((c) => c.id))}
|
|
691
|
+
/>
|
|
692
|
+
<Button label="Cancel" onPress={onClose} />
|
|
693
|
+
</View>
|
|
694
|
+
</KeepStyles>
|
|
695
|
+
</Modal.Content>
|
|
696
|
+
</Modal>
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Who is waiting on me, or who I'm waiting on.
|
|
701
|
+
function PersonQuote({ person, expanded }: { person: NonNullable<Item["person"]>; expanded: boolean }) {
|
|
702
|
+
const styles = useStyles();
|
|
703
|
+
const line = (
|
|
704
|
+
<View style={styles.row}>
|
|
705
|
+
<Avatar url={person.avatarUrl} bot={person.bot} />
|
|
706
|
+
<Text style={{ color: styles.c.foreground, fontSize: 12, fontWeight: "600" }}>{person.login}</Text>
|
|
707
|
+
<Text style={styles.muted}>· {ago(person.since)}</Text>
|
|
708
|
+
</View>
|
|
709
|
+
);
|
|
710
|
+
if (!person.snippet) return line;
|
|
711
|
+
return (
|
|
712
|
+
<View style={styles.quote}>
|
|
713
|
+
{line}
|
|
714
|
+
<Text style={{ color: styles.c.foreground, fontSize: 13, lineHeight: 18 }} numberOfLines={expanded ? undefined : 2}>
|
|
715
|
+
{person.snippet}
|
|
716
|
+
</Text>
|
|
717
|
+
</View>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function Sections({ sections, actions, empty }: { sections: Section[]; actions: Actions; empty: string }) {
|
|
722
|
+
const styles = useStyles();
|
|
723
|
+
if (sections.length === 0) return <Text style={styles.text}>{empty}</Text>;
|
|
724
|
+
return sections.map((section) => (
|
|
725
|
+
<View key={section.id}>
|
|
726
|
+
<SectionHeader icon={SECTION_ICONS[section.id]} title={section.title} count={section.items.length} />
|
|
727
|
+
<Card items={section.items} actions={actions} />
|
|
728
|
+
</View>
|
|
729
|
+
));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function Card({ items, actions }: { items: Item[]; actions: Actions }) {
|
|
733
|
+
const styles = useStyles();
|
|
734
|
+
return (
|
|
735
|
+
<View style={styles.card}>
|
|
736
|
+
{items.map((item, index) => (
|
|
737
|
+
<ItemRow key={item.key} item={item} first={index === 0} actions={actions} />
|
|
738
|
+
))}
|
|
739
|
+
</View>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Monday-start weeks in the device's time zone.
|
|
744
|
+
function weekOf(iso: string): Date {
|
|
745
|
+
const day = new Date(iso);
|
|
746
|
+
day.setHours(0, 0, 0, 0);
|
|
747
|
+
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
|
|
748
|
+
return day;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function weekLabel(monday: Date): string {
|
|
752
|
+
const weeks = Math.round((weekOf(new Date().toISOString()).getTime() - monday.getTime()) / (7 * 86_400_000));
|
|
753
|
+
if (weeks === 0) return "This week";
|
|
754
|
+
if (weeks === 1) return "Last week";
|
|
755
|
+
const sunday = new Date(monday.getTime() + 6 * 86_400_000);
|
|
756
|
+
const format = (date: Date) => date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
757
|
+
return `${format(monday)} – ${format(sunday)}`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// What I finished, by week, with reviews tucked away per week [HIS-01, HIS-02].
|
|
761
|
+
function History({ items, actions }: { items: Item[]; actions: Actions }) {
|
|
762
|
+
const styles = useStyles();
|
|
763
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
764
|
+
if (items.length === 0) return <Text style={styles.text}>Nothing finished in this period.</Text>;
|
|
765
|
+
const weeks = new Map<number, Item[]>();
|
|
766
|
+
for (const item of items) {
|
|
767
|
+
const week = weekOf(item.closedAt!).getTime();
|
|
768
|
+
weeks.set(week, [...(weeks.get(week) ?? []), item]);
|
|
769
|
+
}
|
|
770
|
+
const blocks: ReactNode[] = [];
|
|
771
|
+
for (const [week, all] of [...weeks].sort((a, b) => b[0] - a[0])) {
|
|
772
|
+
const done = all.filter((item) => item.section === "done").sort((a, b) => b.closedAt!.localeCompare(a.closedAt!));
|
|
773
|
+
const reviewed = all.filter((item) => item.section === "reviewed");
|
|
774
|
+
const label = weekLabel(new Date(week));
|
|
775
|
+
blocks.push(
|
|
776
|
+
<View key={week} style={{ gap: 8 }}>
|
|
777
|
+
<SectionHeader icon="clock" title={label} count={done.length} />
|
|
778
|
+
{done.length > 0 ? <Card items={done} actions={actions} /> : null}
|
|
779
|
+
{reviewed.length > 0 ? (
|
|
780
|
+
<View style={{ gap: 8 }}>
|
|
781
|
+
<View style={styles.wrap}>
|
|
782
|
+
<Toggle label={`Reviewed (${reviewed.length})`} active={open === label} onPress={() => setOpen(open === label ? null : label)} />
|
|
783
|
+
</View>
|
|
784
|
+
{open === label ? <Card items={reviewed} actions={actions} /> : null}
|
|
785
|
+
</View>
|
|
786
|
+
) : null}
|
|
787
|
+
</View>,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
return blocks;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Stacked or linked PRs as a connected line; collapsed, only those needing attention [ATT-03, ATT-04].
|
|
794
|
+
// In the sheet each row opens its PR on GitHub [ATT-12].
|
|
795
|
+
function StackLine({ rows, merged, links = false }: { rows: SubRow[]; merged: boolean; links?: boolean }) {
|
|
796
|
+
const styles = useStyles();
|
|
797
|
+
return (
|
|
798
|
+
<View>
|
|
799
|
+
{rows.map((row, index) => (
|
|
800
|
+
<StackRow key={row.key} url={row.url} number={row.number} link={links}>
|
|
801
|
+
<View style={{ width: 16, alignItems: "center" }}>
|
|
802
|
+
<View style={{ width: 2, flex: 1, backgroundColor: index === 0 ? "transparent" : styles.c.border }} />
|
|
803
|
+
<TablerIcon name={merged ? "merge" : row.draft ? "draft" : "pr"} size={14} color={merged ? styles.c.accent : row.draft ? styles.c.foregroundMuted : styles.c.statusSuccess} />
|
|
804
|
+
<View style={{ width: 2, flex: 1, backgroundColor: index === rows.length - 1 ? "transparent" : styles.c.border }} />
|
|
805
|
+
</View>
|
|
806
|
+
<View style={{ flex: 1, paddingVertical: 3, gap: 2 }}>
|
|
807
|
+
<Text style={{ color: styles.c.foreground, fontSize: 13 }} numberOfLines={1}>
|
|
808
|
+
{row.title}
|
|
809
|
+
</Text>
|
|
810
|
+
<Meta facts={[`#${row.number}`]} chips={row.chips} />
|
|
811
|
+
</View>
|
|
812
|
+
{links ? (
|
|
813
|
+
<View style={{ justifyContent: "center", paddingHorizontal: 4 }}>
|
|
814
|
+
<TablerIcon name="github" size={14} color={styles.c.foregroundMuted} />
|
|
815
|
+
</View>
|
|
816
|
+
) : null}
|
|
817
|
+
</StackRow>
|
|
818
|
+
))}
|
|
819
|
+
</View>
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function StackRow({ url, number, link, children }: { url: string; number: number; link: boolean; children: ReactNode }) {
|
|
824
|
+
const styles = useStyles();
|
|
825
|
+
const { hovered, hover } = useHover();
|
|
826
|
+
if (!link) return <View style={[styles.row, { alignItems: "stretch" }]}>{children}</View>;
|
|
827
|
+
return (
|
|
828
|
+
<Pressable
|
|
829
|
+
accessibilityRole="link"
|
|
830
|
+
accessibilityLabel={`Open PR #${number} on GitHub`}
|
|
831
|
+
onPress={() => void openExternalUrl(url)}
|
|
832
|
+
{...hover}
|
|
833
|
+
style={({ pressed }) => [styles.row, { alignItems: "stretch", borderRadius: 6, backgroundColor: pressed || hovered ? styles.c.surface2 : "transparent" }]}
|
|
834
|
+
>
|
|
835
|
+
{children}
|
|
836
|
+
</Pressable>
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Session options in a modal: a bottom sheet on phones [SES-02].
|
|
841
|
+
function SessionOptions({ item, kind, actions, onClose }: { item: Item; kind: SessionKind; actions: Actions; onClose: () => void }) {
|
|
842
|
+
const styles = useStyles();
|
|
843
|
+
const paseo = usePaseo();
|
|
844
|
+
const toast = useToast();
|
|
845
|
+
const { target } = item;
|
|
846
|
+
const options = useQuery({
|
|
847
|
+
queryKey: ["attention-span", "options", item.key, kind],
|
|
848
|
+
queryFn: async () => {
|
|
849
|
+
const [{ context }, profiles, checkout, defaults] = await Promise.all([
|
|
850
|
+
actions.fetchContext({ kind, target }),
|
|
851
|
+
listProfiles(paseo),
|
|
852
|
+
findCheckout(paseo, actions.config, target, actions.defaultBranch),
|
|
853
|
+
// Without a ready provider, still show the options; starting reports the error.
|
|
854
|
+
resolveAgentConfig(paseo, kind, actions.config).catch(() => ({ label: "No agent provider is ready" })),
|
|
855
|
+
]);
|
|
856
|
+
const workspaces = checkout.project ? await branchWorkspaces(paseo, checkout.project.projectId, target.headRefName) : [];
|
|
857
|
+
return { prompt: renderPrompt(kind, target, context, actions.config), profiles, checkout, workspaces, defaultLabel: defaults.label };
|
|
858
|
+
},
|
|
859
|
+
staleTime: 60_000,
|
|
860
|
+
});
|
|
861
|
+
const [workspaceId, setWorkspaceId] = useState<string>();
|
|
862
|
+
const [profileId, setProfileId] = useState<string>();
|
|
863
|
+
const [prompt, setPrompt] = useState<string | null>(null);
|
|
864
|
+
const [starting, setStarting] = useState(false);
|
|
865
|
+
const data = options.data;
|
|
866
|
+
|
|
867
|
+
async function start() {
|
|
868
|
+
if (!data) return;
|
|
869
|
+
setStarting(true);
|
|
870
|
+
try {
|
|
871
|
+
await actions.started(await startSession(paseo, { itemKey: item.key, kind, target, prompt: prompt ?? data.prompt, config: actions.config, checkout: data.checkout, profileId, workspaceId }));
|
|
872
|
+
onClose();
|
|
873
|
+
} catch (error) {
|
|
874
|
+
toast.error(`Starting the session failed: ${(error as Error).message}`);
|
|
875
|
+
} finally {
|
|
876
|
+
setStarting(false);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return (
|
|
881
|
+
<Modal title={`${actionLabel(item.actions.find((a) => ACTION_SESSION[a] === kind) ?? "start", item)}: ${item.title}`} open onOpenChange={(open) => (open ? null : onClose())}>
|
|
882
|
+
<Modal.Content>
|
|
883
|
+
<KeepStyles styles={styles}>
|
|
884
|
+
{options.isLoading ? <Text style={styles.muted}>Loading context…</Text> : null}
|
|
885
|
+
{!options.isLoading && !data ? <Text style={styles.danger}>{(options.error as Error)?.message ?? "No options"}</Text> : null}
|
|
886
|
+
{data && !data.checkout.project ? <Text style={styles.danger}>{noProjectMessage(data.checkout, target)}</Text> : null}
|
|
887
|
+
{data?.checkout.project ? (
|
|
888
|
+
<View style={{ gap: 10 }}>
|
|
889
|
+
<Text style={styles.muted}>Workspace</Text>
|
|
890
|
+
<View style={styles.wrap}>
|
|
891
|
+
<Toggle label="New worktree" active={!workspaceId} onPress={() => setWorkspaceId(undefined)} />
|
|
892
|
+
{data.workspaces.map((w) => (
|
|
893
|
+
<Toggle key={w.id} label={w.name} active={workspaceId === w.id} onPress={() => setWorkspaceId(w.id)} />
|
|
894
|
+
))}
|
|
895
|
+
</View>
|
|
896
|
+
<Text style={styles.muted}>Agent profile</Text>
|
|
897
|
+
<View style={styles.wrap}>
|
|
898
|
+
<Toggle label={data.defaultLabel} active={!profileId} onPress={() => setProfileId(undefined)} />
|
|
899
|
+
{data.profiles.map((p) => (
|
|
900
|
+
<Toggle key={p.id} label={p.name} active={profileId === p.id} onPress={() => setProfileId(p.id)} />
|
|
901
|
+
))}
|
|
902
|
+
</View>
|
|
903
|
+
<Text style={styles.muted}>Prompt</Text>
|
|
904
|
+
<TextInput style={[styles.input, { minHeight: 180 }]} value={prompt ?? data.prompt} onChangeText={setPrompt} multiline textAlignVertical="top" />
|
|
905
|
+
<View style={styles.wrap}>
|
|
906
|
+
<Button label={starting ? "Starting…" : "Start session"} icon="agent" variant="solid" disabled={starting} onPress={() => void start()} />
|
|
907
|
+
<Button label="Cancel" onPress={onClose} />
|
|
908
|
+
</View>
|
|
909
|
+
</View>
|
|
910
|
+
) : null}
|
|
911
|
+
</KeepStyles>
|
|
912
|
+
</Modal.Content>
|
|
913
|
+
</Modal>
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Where an issue repo's code lives, picked from my Paseo projects and saved to config.json [SES-07].
|
|
918
|
+
function CodeRepoPicker({ issueRepo, missing, onPick, onClose }: { issueRepo: string; missing: string; onPick: (repo: string) => void; onClose: () => void }) {
|
|
919
|
+
const styles = useStyles();
|
|
920
|
+
const paseo = usePaseo();
|
|
921
|
+
const projects = useQuery({ queryKey: ["attention-span", "github-projects"], queryFn: () => githubProjects(paseo) });
|
|
922
|
+
return (
|
|
923
|
+
<Modal title={`Where does the code for ${issueRepo} live?`} open onOpenChange={(open) => (open ? null : onClose())}>
|
|
924
|
+
<Modal.Content>
|
|
925
|
+
<KeepStyles styles={styles}>
|
|
926
|
+
<View style={{ gap: 10 }}>
|
|
927
|
+
<Text style={styles.text}>No Paseo project has {missing} as its GitHub remote. Pick the project that holds the code for issues in {issueRepo}.</Text>
|
|
928
|
+
{projects.isLoading ? <Text style={styles.muted}>Loading projects…</Text> : null}
|
|
929
|
+
{projects.data?.length === 0 ? <Text style={styles.danger}>No Paseo project has a GitHub remote. Add the code repository as a project first.</Text> : null}
|
|
930
|
+
<View style={styles.wrap}>
|
|
931
|
+
{projects.data?.map((project) => (
|
|
932
|
+
<Button key={project.projectId} label={project.repo} icon="play" variant="calm" onPress={() => onPick(project.repo)} />
|
|
933
|
+
))}
|
|
934
|
+
</View>
|
|
935
|
+
<Text style={styles.muted}>Saved to codeRepos in config.json, so the next start goes straight there.</Text>
|
|
936
|
+
<View style={styles.wrap}>
|
|
937
|
+
<Button label="Cancel" onPress={onClose} />
|
|
938
|
+
</View>
|
|
939
|
+
</View>
|
|
940
|
+
</KeepStyles>
|
|
941
|
+
</Modal.Content>
|
|
942
|
+
</Modal>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function issueTarget(issue: NextUpIssue): Target {
|
|
947
|
+
return { kind: "issue", repo: issue.repo, number: issue.number, title: issue.title, url: issue.url, defaultBranch: issue.defaultBranch };
|
|
948
|
+
}
|