paseo-req-kit 1.8.0 → 1.9.0-beta.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +2 -0
  3. package/bin/req-plugin.mjs +226 -0
  4. package/bin/req.mjs +27 -0
  5. package/contracts/README.md +2 -0
  6. package/contracts/cli-json.md +66 -0
  7. package/contracts/usage-record.md +48 -0
  8. package/docs/adr/0026-machine-records-and-api.md +23 -0
  9. package/docs/adr/0027-paseo-plugin.md +23 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  12. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +26 -0
  13. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +3 -1
  14. package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +10 -0
  15. package/engine/src/api.ts +397 -0
  16. package/engine/src/control.ts +2 -2
  17. package/engine/src/eval.ts +2 -0
  18. package/engine/src/index-cmd.ts +33 -0
  19. package/engine/src/main.ts +26 -0
  20. package/engine/src/records.ts +271 -0
  21. package/engine/src/state.ts +3 -0
  22. package/engine/src/stats.ts +479 -0
  23. package/install.mjs +3 -0
  24. package/package.json +3 -2
  25. package/paseo-plugin/OVERVIEW.md +7 -0
  26. package/paseo-plugin/client/data.ts +55 -0
  27. package/paseo-plugin/client/detail.tsx +277 -0
  28. package/paseo-plugin/client/format.ts +101 -0
  29. package/paseo-plugin/client/live.ts +161 -0
  30. package/paseo-plugin/client/overview.tsx +89 -0
  31. package/paseo-plugin/client/panel.tsx +56 -0
  32. package/paseo-plugin/client/row.tsx +40 -0
  33. package/paseo-plugin/client/settings.tsx +90 -0
  34. package/paseo-plugin/client/sidebar.tsx +27 -0
  35. package/paseo-plugin/client/stats.tsx +80 -0
  36. package/paseo-plugin/client/ui.tsx +111 -0
  37. package/paseo-plugin/index.client.tsx +52 -0
  38. package/paseo-plugin/index.server.ts +41 -0
  39. package/paseo-plugin/package.json +22 -0
  40. package/paseo-plugin/paseo-plugin.json +6 -0
  41. package/paseo-plugin/server/handlers.ts +45 -0
  42. package/paseo-plugin/server/req.ts +100 -0
  43. package/paseo-plugin/shared/contracts.ts +165 -0
  44. package/paseo-plugin/tsconfig.json +16 -0
@@ -0,0 +1,277 @@
1
+ // One requirement: where it stands, its steps, the quality gate, its documents, and the
2
+ // management actions (each one is `req <command>` on the daemon machine, after confirming).
3
+ import type { PluginHostProps, PluginScreenProps, PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Modal, ScrollView, useToast } from "@getpaseo/plugin/client/react-native";
5
+ import { useState } from "react";
6
+ import { Pressable, Text, View } from "react-native";
7
+ import type { Action, RequirementDetail, Step } from "../shared/contracts";
8
+ import { useAction, useDocument, usePreferences, useRequirement, useRequirements } from "./data";
9
+ import { ACTION_TEXT, actionsFor, ago, ATTENTION_LABELS, facts, FILE_LABELS, minutes, tokens } from "./format";
10
+ import { Badge, Button, Chip, Status, useStyles } from "./ui";
11
+
12
+ type Theme = PluginHostProps["theme"];
13
+ type Layout = PluginHostProps["layout"];
14
+ type Navigation = PluginSurfaceProps["navigation"];
15
+
16
+ const OUTCOME_LABELS: Record<Step["outcome"], string> = { running: "运行中", completed: "完成", failed: "失败" };
17
+
18
+ function StepRow({ theme, layout, step, navigation }: { theme: Theme; layout: Layout; step: Step; navigation: Navigation }) {
19
+ const s = useStyles(theme, layout.compact);
20
+ const tone = step.outcome === "failed" ? "danger" : step.outcome === "running" ? "accent" : "muted";
21
+ const body = (
22
+ <View style={{ gap: 2, paddingVertical: 6 }}>
23
+ <View style={s.row}>
24
+ <Text style={[s.text, { flexShrink: 1 }]}>{step.title}</Text>
25
+ <Badge theme={theme} tone={tone}>
26
+ {step.outcome === "completed" && step.result ? step.result : OUTCOME_LABELS[step.outcome]}
27
+ </Badge>
28
+ </View>
29
+ <Text style={s.muted}>
30
+ 第 {step.attempt} 次尝试 · {minutes(step.minutes)} · {ago(step.startedAt)}开始
31
+ </Text>
32
+ {step.error ? <Text style={s.danger}>{step.error}</Text> : null}
33
+ </View>
34
+ );
35
+ // A step agent opens in Paseo, where its whole conversation is.
36
+ if (!navigation || !step.agentId) return body;
37
+ return (
38
+ <Pressable accessibilityRole="button" accessibilityLabel={`打开 ${step.title} 的 agent`} onPress={() => navigation.openAgent({ agentId: step.agentId })}>
39
+ {body}
40
+ </Pressable>
41
+ );
42
+ }
43
+
44
+ function DocumentModal({
45
+ theme,
46
+ layout,
47
+ repo,
48
+ featureDir,
49
+ file,
50
+ onClose,
51
+ }: {
52
+ theme: Theme;
53
+ layout: Layout;
54
+ repo: string;
55
+ featureDir: string;
56
+ file: string | null;
57
+ onClose(): void;
58
+ }) {
59
+ const s = useStyles(theme, layout.compact);
60
+ const doc = useDocument(repo, featureDir, file);
61
+ return (
62
+ <Modal title={file ? `${FILE_LABELS[file] ?? file}(${file})` : ""} open={file !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
63
+ <Modal.Content>
64
+ <Status theme={theme} loading={doc.isPending} error={doc.error} />
65
+ {doc.data ? (
66
+ <>
67
+ <Text selectable style={[s.text, { lineHeight: 21 }]}>
68
+ {doc.data.content}
69
+ </Text>
70
+ {doc.data.truncated ? <Text style={s.muted}>文件较大,只显示了开头的 512 KB。</Text> : null}
71
+ </>
72
+ ) : null}
73
+ </Modal.Content>
74
+ </Modal>
75
+ );
76
+ }
77
+
78
+ function ActionModal({
79
+ theme,
80
+ layout,
81
+ detail,
82
+ action,
83
+ onClose,
84
+ }: {
85
+ theme: Theme;
86
+ layout: Layout;
87
+ detail: RequirementDetail;
88
+ action: Action | null;
89
+ onClose(): void;
90
+ }) {
91
+ const s = useStyles(theme, layout.compact);
92
+ const run = useAction();
93
+ const toast = useToast();
94
+ const go = async (verifyEnv?: "local" | "deployed") => {
95
+ if (!action) return;
96
+ try {
97
+ const result = await run.mutateAsync({ action, repo: detail.sourceRepo ?? detail.repo, featureDir: detail.featureDir, verifyEnv });
98
+ const first = result.message.split("\n").find((l) => l.trim()) ?? "";
99
+ if (result.ok) toast.show(first || "已完成", { variant: "success", durationMs: 4000 });
100
+ else toast.error(first || "没有完成");
101
+ onClose();
102
+ } catch (error) {
103
+ toast.error((error as Error).message);
104
+ }
105
+ };
106
+ const text = action ? ACTION_TEXT[action] : null;
107
+ return (
108
+ <Modal title={text ? `${text.label}:${detail.title}` : ""} open={action !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
109
+ <Modal.Content>
110
+ <Text style={s.text}>{text?.confirm}</Text>
111
+ {action === "approve" && detail.files.includes("preview.md") ? <Text style={s.muted}>确认预览在本页的"文档"中:确认预览(preview.md)。</Text> : null}
112
+ <View style={s.row}>
113
+ {action === "verify" ? (
114
+ <>
115
+ <Button theme={theme} primary label="在本地验证" disabled={run.isPending} onPress={() => void go("local")} />
116
+ <Button theme={theme} label="在已部署的测试环境验证" disabled={run.isPending} onPress={() => void go("deployed")} />
117
+ </>
118
+ ) : (
119
+ <Button theme={theme} primary label={run.isPending ? "正在执行…" : (text?.label ?? "")} disabled={run.isPending} onPress={() => void go()} />
120
+ )}
121
+ <Button theme={theme} label="取消" disabled={run.isPending} onPress={onClose} />
122
+ </View>
123
+ </Modal.Content>
124
+ </Modal>
125
+ );
126
+ }
127
+
128
+ function GateSummary({ theme, layout, detail }: { theme: Theme; layout: Layout; detail: RequirementDetail }) {
129
+ const s = useStyles(theme, layout.compact);
130
+ const runs = [...(detail.gate?.runs ?? []), ...detail.joint.flatMap((j) => j.gate?.runs ?? [])];
131
+ if (runs.length === 0) return null;
132
+ const last = runs.at(-1)!;
133
+ const failed = runs.filter((r) => !r.passed).length;
134
+ return (
135
+ <View style={s.card}>
136
+ <Text style={s.heading}>质量门</Text>
137
+ <Text style={s.text}>
138
+ 运行 {runs.length} 次{failed > 0 ? `,其中 ${failed} 次发现改动带来的新失败` : ",都没有新失败"}
139
+ </Text>
140
+ <Text style={s.muted}>
141
+ 最近一次({last.key}):{last.passed ? "通过" : `新失败:${last.newFailures.join("、")}`}
142
+ </Text>
143
+ </View>
144
+ );
145
+ }
146
+
147
+ export function RequirementView({
148
+ theme,
149
+ layout,
150
+ navigation,
151
+ repo,
152
+ featureDir,
153
+ }: {
154
+ theme: Theme;
155
+ layout: Layout;
156
+ navigation: Navigation;
157
+ repo: string;
158
+ featureDir: string;
159
+ }) {
160
+ const s = useStyles(theme, layout.compact);
161
+ const query = useRequirement(repo, featureDir);
162
+ const { readOnly } = usePreferences();
163
+ const [doc, setDoc] = useState<string | null>(null);
164
+ const [action, setAction] = useState<Action | null>(null);
165
+ const [allSteps, setAllSteps] = useState(false);
166
+ const d = query.data;
167
+ if (!d) return <Status theme={theme} loading={query.isPending} error={query.error} />;
168
+ const steps = allSteps ? d.steps : d.steps.slice(-12);
169
+ const actions = readOnly ? [] : actionsFor(d);
170
+ return (
171
+ <View style={{ gap: 16 }}>
172
+ <View style={{ gap: 4 }}>
173
+ <View style={s.row}>
174
+ <Text style={s.title}>{d.title}</Text>
175
+ {d.attention ? (
176
+ <Badge theme={theme} tone={d.attention === "approval" || d.attention === "business" ? "warning" : "danger"}>
177
+ {ATTENTION_LABELS[d.attention]}
178
+ </Badge>
179
+ ) : null}
180
+ </View>
181
+ <Text style={s.muted}>{facts(d)}</Text>
182
+ <Text style={s.text}>{d.stage}</Text>
183
+ {d.run?.stopReason && !d.running ? <Text style={s.danger}>{d.run.stopReason}</Text> : null}
184
+ {d.attention === "business" ? <Text style={s.muted}>需求方的决定请在发起需求的对话中回答,回答后再继续。</Text> : null}
185
+ </View>
186
+
187
+ {actions.length > 0 ? (
188
+ <View style={s.row}>
189
+ {actions.map((a) => (
190
+ <Button key={a} theme={theme} primary={a !== "stop"} label={ACTION_TEXT[a].label} onPress={() => setAction(a)} />
191
+ ))}
192
+ </View>
193
+ ) : null}
194
+
195
+ {d.files.length > 0 ? (
196
+ <View style={{ gap: 6 }}>
197
+ <Text style={s.heading}>文档</Text>
198
+ <View style={s.row}>
199
+ {d.files.map((f) => (
200
+ <Chip key={f} theme={theme} label={FILE_LABELS[f] ?? f} active={false} onPress={() => setDoc(f)} />
201
+ ))}
202
+ </View>
203
+ </View>
204
+ ) : null}
205
+
206
+ <View style={s.card}>
207
+ <Text style={s.heading}>位置</Text>
208
+ <Text selectable style={s.text}>
209
+ {d.repo}/{d.featureDir}
210
+ </Text>
211
+ {d.branch ? <Text style={s.muted}>分支 {d.branch}</Text> : null}
212
+ {d.workspaceId && navigation ? (
213
+ <Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: d.workspaceId! })}>
214
+ <Text style={{ color: theme.colors.accent, fontSize: 13 }}>打开这个需求的工作区</Text>
215
+ </Pressable>
216
+ ) : null}
217
+ {d.joint.map((j) => (
218
+ <Text key={j.name} style={s.muted}>
219
+ 联合开发「{j.name}」:{j.repo}(分支 {j.branch})
220
+ </Text>
221
+ ))}
222
+ </View>
223
+
224
+ {d.usage ? (
225
+ <View style={s.card}>
226
+ <Text style={s.heading}>用时与用量</Text>
227
+ <Text style={s.text}>
228
+ {d.usage.steps} 个步骤,agent 合计 {minutes(d.usage.agentMinutes)}
229
+ </Text>
230
+ <Text style={s.muted}>
231
+ 输入 {tokens(d.usage.inputTokens)} tokens,输出 {tokens(d.usage.outputTokens)} tokens
232
+ {d.usage.costUsd !== null ? `,约 $${d.usage.costUsd.toFixed(2)}` : ""}
233
+ </Text>
234
+ </View>
235
+ ) : null}
236
+
237
+ <GateSummary theme={theme} layout={layout} detail={d} />
238
+
239
+ {d.steps.length > 0 ? (
240
+ <View style={s.card}>
241
+ <View style={s.row}>
242
+ <Text style={s.heading}>步骤({d.steps.length})</Text>
243
+ {d.steps.length > 12 ? <Button theme={theme} label={allSteps ? "只看最近的" : "显示全部"} onPress={() => setAllSteps((v) => !v)} /> : null}
244
+ </View>
245
+ {steps.map((step) => (
246
+ <StepRow key={`${step.key}-${step.attempt}-${step.startedAt}`} theme={theme} layout={layout} step={step} navigation={navigation} />
247
+ ))}
248
+ </View>
249
+ ) : null}
250
+
251
+ <DocumentModal theme={theme} layout={layout} repo={d.repo} featureDir={d.featureDir} file={doc} onClose={() => setDoc(null)} />
252
+ <ActionModal theme={theme} layout={layout} detail={d} action={action} onClose={() => setAction(null)} />
253
+ </View>
254
+ );
255
+ }
256
+
257
+ export function RequirementScreen({ theme, layout, navigation, params }: PluginScreenProps) {
258
+ const s = useStyles(theme, layout.compact);
259
+ // Opened from a step agent's pill: only the requirement's name is known; find it in the list.
260
+ const list = useRequirements();
261
+ const byName = !params.repo && params.name ? (list.data ?? []).find((r) => r.name === params.name) : null;
262
+ const repo = params.repo ?? byName?.repo;
263
+ const featureDir = params.featureDir ?? byName?.featureDir;
264
+ return (
265
+ <View style={s.screen}>
266
+ <ScrollView contentContainerStyle={s.body}>
267
+ {repo && featureDir ? (
268
+ <RequirementView theme={theme} layout={layout} navigation={navigation} repo={repo} featureDir={featureDir} />
269
+ ) : params.name ? (
270
+ <Status theme={theme} loading={list.isPending} error={list.error} empty={list.data ? `本机记录中没有需求 ${params.name}。` : null} />
271
+ ) : (
272
+ <Text style={s.muted}>没有指定需求。</Text>
273
+ )}
274
+ </ScrollView>
275
+ </View>
276
+ );
277
+ }
@@ -0,0 +1,101 @@
1
+ // Plain-language labels, shared by every screen.
2
+ import type { Attention, Requirement } from "../shared/contracts";
3
+
4
+ export const ATTENTION_LABELS: Record<Exclude<Attention, null>, string> = {
5
+ approval: "等待确认方案",
6
+ business: "等待需求方决定",
7
+ interrupted: "已中断,可以继续",
8
+ not_completed: "结束但未完成",
9
+ };
10
+
11
+ export const TYPE_LABELS: Record<string, string> = { defect: "缺陷", refactor: "重构", assessment: "评估" };
12
+
13
+ export const PROFILE_LABELS: Record<string, string> = { quick: "快速档", standard: "标准档" };
14
+
15
+ export const FILE_LABELS: Record<string, string> = {
16
+ "spec.md": "需求说明",
17
+ "alignment.md": "访谈记录",
18
+ "spec-review.md": "需求说明审阅",
19
+ "changes.md": "需求变更",
20
+ "decisions.tsv": "决策记录",
21
+ "design.md": "方案",
22
+ "design-review.md": "方案审核",
23
+ "preview.md": "确认预览",
24
+ "contract.md": "接口约定",
25
+ "diagnosis.md": "复现与定位",
26
+ "assessment.md": "评估",
27
+ "implementation.md": "实现记录",
28
+ "progress.md": "进展",
29
+ "code-review.md": "代码评审",
30
+ "handoff.md": "提测说明",
31
+ "service-start.md": "服务启动",
32
+ "verification.md": "功能验证",
33
+ "report.md": "完成报告",
34
+ "acceptance.md": "验收记录",
35
+ "lessons.tsv": "可以变成规则的问题",
36
+ };
37
+
38
+ export type Group = "attention" | "running" | "verify" | "done" | "other";
39
+
40
+ export const GROUP_LABELS: Record<Group, string> = {
41
+ attention: "需要你处理",
42
+ running: "进行中",
43
+ verify: "研发完成,待测试验证",
44
+ done: "最近完成",
45
+ other: "其他",
46
+ };
47
+
48
+ const RECENT_MS = 14 * 24 * 3600_000;
49
+
50
+ export function groupOf(r: Requirement, now = Date.now()): Group {
51
+ if (r.attention) return "attention";
52
+ if (r.running) return "running";
53
+ if (r.status === "phase_done") return "verify";
54
+ if (r.status === "completed" && now - Date.parse(r.updatedAt) < RECENT_MS) return "done";
55
+ return "other";
56
+ }
57
+
58
+ export function minutes(m: number): string {
59
+ if (m < 1) return "不到 1 分钟";
60
+ if (m < 60) return `${Math.round(m)} 分钟`;
61
+ const h = Math.floor(m / 60);
62
+ const rest = Math.round(m % 60);
63
+ return rest === 0 ? `${h} 小时` : `${h} 小时 ${rest} 分`;
64
+ }
65
+
66
+ export function ago(iso: string, now = Date.now()): string {
67
+ const m = (now - Date.parse(iso)) / 60_000;
68
+ if (!Number.isFinite(m)) return "";
69
+ if (m < 1) return "刚刚";
70
+ if (m < 60) return `${Math.round(m)} 分钟前`;
71
+ if (m < 24 * 60) return `${Math.round(m / 60)} 小时前`;
72
+ return `${Math.round(m / 60 / 24)} 天前`;
73
+ }
74
+
75
+ export function tokens(n: number | null): string {
76
+ if (n === null) return "未报告";
77
+ return n >= 10_000 ? `${Math.round(n / 1_000) / 10} 万` : String(n);
78
+ }
79
+
80
+ /** One line under a requirement's title: project, type, stage. */
81
+ export function facts(r: Requirement): string {
82
+ return [r.project, r.type ? TYPE_LABELS[r.type] ?? r.type : null, r.profile ? PROFILE_LABELS[r.profile] ?? r.profile : null]
83
+ .filter(Boolean)
84
+ .join(" · ");
85
+ }
86
+
87
+ /** What a person can do from here, by the requirement's state. */
88
+ export function actionsFor(r: Pick<Requirement, "attention" | "running" | "status">): Array<"approve" | "stop" | "resume" | "verify"> {
89
+ if (r.running) return ["stop"];
90
+ if (r.attention === "approval") return ["approve"];
91
+ if (r.attention === "interrupted" || r.attention === "business") return ["resume"];
92
+ if (r.status === "phase_done") return ["verify"];
93
+ return [];
94
+ }
95
+
96
+ export const ACTION_TEXT = {
97
+ approve: { label: "确认方案", confirm: "确认 preview.md 中的方案,开始写代码?请先看过确认预览和原型。" },
98
+ stop: { label: "停止", confirm: "停止这个需求的流水线?正在运行的步骤会被中断,之后可以继续。" },
99
+ resume: { label: "继续", confirm: "从中断处继续?需求说明改过的话,只处理变化的部分。" },
100
+ verify: { label: "开始测试验证", confirm: "开始测试验证?选择在哪里验证。" },
101
+ } as const;
@@ -0,0 +1,161 @@
1
+ // Buttons that follow Paseo's agents and workspaces (registered outside React):
2
+ // - a pill on every step agent of a requirement: which requirement, opens it;
3
+ // - a header button on every workspace that has requirements: how many need a person.
4
+ import type { PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
5
+ import { listRpc, type Requirement } from "../shared/contracts";
6
+ import { inWorkspace } from "./panel";
7
+
8
+ /** The step agents' labels (req-kit's runtime sets them when it starts a step). */
9
+ const PIPELINE_LABEL = "requirement-pipeline";
10
+
11
+ interface AgentLike {
12
+ id: string;
13
+ workspaceId?: string | null;
14
+ labels?: Record<string, string> | null;
15
+ }
16
+
17
+ interface WorkspaceLike {
18
+ id: string;
19
+ workspaceDirectory?: string;
20
+ projectRootPath?: string;
21
+ }
22
+
23
+ export function contributeLive(client: PluginClientContext): () => void {
24
+ const lifetime = new AbortController();
25
+ const pills = new Map<string, PluginButtonRegistration>();
26
+ const headers = new Map<string, PluginButtonRegistration>();
27
+ const workspaces = new Map<string, WorkspaceLike>();
28
+ let requirements: Requirement[] = [];
29
+
30
+ // ----- pills on step agents
31
+ const registerAgent = (agent: AgentLike) => {
32
+ pills.get(agent.id)?.remove();
33
+ pills.delete(agent.id);
34
+ const name = agent.labels?.[PIPELINE_LABEL];
35
+ if (lifetime.signal.aborted || !name || !agent.workspaceId) return;
36
+ pills.set(
37
+ agent.id,
38
+ client.addComposerPill({
39
+ id: "requirement",
40
+ workspaceId: agent.workspaceId,
41
+ agentId: agent.id,
42
+ button: {
43
+ title: `打开需求 ${name}`,
44
+ icon: "ClipboardList",
45
+ label: "需求",
46
+ behavior: { kind: "action", onPress: () => client.openScreen({ screenId: "requirement", params: { name } }) },
47
+ },
48
+ }),
49
+ );
50
+ };
51
+ void client.paseo.agents
52
+ .list({ subscribe: {}, signal: lifetime.signal })
53
+ .then(({ subscription }) => {
54
+ subscription.subscribe({
55
+ snapshot: ({ entries }) => {
56
+ for (const pill of pills.values()) pill.remove();
57
+ pills.clear();
58
+ for (const { agent } of entries) registerAgent(agent as AgentLike);
59
+ },
60
+ update: (message) => {
61
+ if (message.type !== "agent_update") return;
62
+ const update = message.payload;
63
+ if (update.kind === "remove") {
64
+ pills.get(update.agentId)?.remove();
65
+ pills.delete(update.agentId);
66
+ } else registerAgent(update.agent as AgentLike);
67
+ },
68
+ });
69
+ return undefined;
70
+ })
71
+ .catch((error: Error) => {
72
+ if (!lifetime.signal.aborted) console.error("[req-kit] 读取 agent 列表失败", error);
73
+ });
74
+
75
+ // ----- header buttons on workspaces with requirements
76
+ const refreshHeaders = () => {
77
+ for (const [id, ws] of workspaces) {
78
+ const mine = inWorkspace(requirements, ws.workspaceDirectory ?? ws.projectRootPath, id);
79
+ const existing = headers.get(id);
80
+ if (mine.length === 0) {
81
+ existing?.remove();
82
+ headers.delete(id);
83
+ continue;
84
+ }
85
+ const waiting = mine.filter((r) => r.attention).length;
86
+ const label = waiting > 0 ? `需求 · 待处理 ${waiting}` : `需求 ${mine.length}`;
87
+ if (existing) existing.update({ label });
88
+ else
89
+ headers.set(
90
+ id,
91
+ client.addHeaderButton({
92
+ id: "requirements",
93
+ workspaceId: id,
94
+ button: {
95
+ title: "这个工作区的需求",
96
+ icon: "ClipboardList",
97
+ label,
98
+ behavior: { kind: "action", onPress: () => client.openPanel("requirements", { workspaceId: id }) },
99
+ },
100
+ }),
101
+ );
102
+ }
103
+ for (const [id, button] of headers) {
104
+ if (!workspaces.has(id)) {
105
+ button.remove();
106
+ headers.delete(id);
107
+ }
108
+ }
109
+ };
110
+ const poll = async () => {
111
+ try {
112
+ requirements = await client.rpc(listRpc, {});
113
+ refreshHeaders();
114
+ } catch {
115
+ // req-kit missing or too old: the overview explains; no header buttons meanwhile.
116
+ }
117
+ };
118
+ let releaseWorkspaces: (() => void) | null = null;
119
+ void client.paseo.workspaces
120
+ .list({ subscribe: {} })
121
+ .then(({ subscription }) => {
122
+ // The workspace list takes no abort signal: release it here or at cleanup.
123
+ releaseWorkspaces = () => void subscription.release().catch(() => undefined);
124
+ if (lifetime.signal.aborted) {
125
+ releaseWorkspaces();
126
+ return undefined;
127
+ }
128
+ subscription.subscribe({
129
+ snapshot: ({ entries }) => {
130
+ workspaces.clear();
131
+ for (const ws of entries) workspaces.set(ws.id, ws as WorkspaceLike);
132
+ refreshHeaders();
133
+ },
134
+ update: (message) => {
135
+ if (message.type !== "workspace_update") return;
136
+ const update = message.payload as { kind: string; workspace?: WorkspaceLike; workspaceId?: string; id?: string };
137
+ if (update.kind === "upsert" && update.workspace) workspaces.set(update.workspace.id, update.workspace);
138
+ else {
139
+ const id = update.workspaceId ?? update.id;
140
+ if (id) workspaces.delete(id);
141
+ }
142
+ refreshHeaders();
143
+ },
144
+ });
145
+ return undefined;
146
+ })
147
+ .catch((error: Error) => {
148
+ if (!lifetime.signal.aborted) console.error("[req-kit] 读取工作区列表失败", error);
149
+ });
150
+ void poll();
151
+ const timer = setInterval(() => void poll(), 30_000);
152
+
153
+ return () => {
154
+ lifetime.abort();
155
+ releaseWorkspaces?.();
156
+ clearInterval(timer);
157
+ for (const b of [...pills.values(), ...headers.values()]) b.remove();
158
+ pills.clear();
159
+ headers.clear();
160
+ };
161
+ }
@@ -0,0 +1,89 @@
1
+ // The overview: every requirement on this machine, grouped by what it needs.
2
+ import type { PluginOpenScreenInput, PluginScreenProps } from "@getpaseo/plugin/client";
3
+ import { ScrollView } from "@getpaseo/plugin/client/react-native";
4
+ import { useMemo, useState } from "react";
5
+ import { Text, View } from "react-native";
6
+ import type { Requirement } from "../shared/contracts";
7
+ import { useRequirements } from "./data";
8
+ import { type Group, GROUP_LABELS, groupOf } from "./format";
9
+ import { RequirementRow } from "./row";
10
+ import { Button, Chip, Status, useStyles } from "./ui";
11
+
12
+ export const ORDER: Group[] = ["attention", "running", "verify", "done", "other"];
13
+
14
+ /** Opens a requirement's detail screen. */
15
+ export type OpenRequirement = (item: Pick<Requirement, "repo" | "featureDir" | "title">) => void;
16
+
17
+ export function RequirementGroups({
18
+ theme,
19
+ layout,
20
+ items,
21
+ open,
22
+ }: Pick<PluginScreenProps, "theme" | "layout"> & { items: Requirement[]; open: OpenRequirement }) {
23
+ const s = useStyles(theme, layout.compact);
24
+ const [showOther, setShowOther] = useState(false);
25
+ const groups = useMemo(() => {
26
+ const map = new Map<Group, Requirement[]>();
27
+ for (const item of items) map.set(groupOf(item), [...(map.get(groupOf(item)) ?? []), item]);
28
+ return map;
29
+ }, [items]);
30
+ return (
31
+ <View style={{ gap: 20 }}>
32
+ {ORDER.filter((g) => groups.has(g)).map((g) => (
33
+ <View key={g} style={{ gap: 8 }}>
34
+ <View style={s.row}>
35
+ <Text style={s.heading}>
36
+ {GROUP_LABELS[g]}({groups.get(g)!.length})
37
+ </Text>
38
+ {g === "other" ? <Button theme={theme} label={showOther ? "收起" : "展开"} onPress={() => setShowOther((v) => !v)} /> : null}
39
+ </View>
40
+ {g === "other" && !showOther
41
+ ? null
42
+ : groups.get(g)!.map((item) => <RequirementRow key={item.key} theme={theme} layout={layout} item={item} onPress={() => open(item)} />)}
43
+ </View>
44
+ ))}
45
+ </View>
46
+ );
47
+ }
48
+
49
+ /** Screens open other screens through the client context; index.client.tsx passes it in. */
50
+ export type ScreenOpener = (input: PluginOpenScreenInput) => void;
51
+
52
+ export function requirementOpener(openScreen: ScreenOpener): OpenRequirement {
53
+ return (item) => openScreen({ screenId: "requirement", params: { repo: item.repo, featureDir: item.featureDir, title: item.title } });
54
+ }
55
+
56
+ export function OverviewScreen(props: PluginScreenProps & { openScreen: ScreenOpener }) {
57
+ const { theme, layout } = props;
58
+ const s = useStyles(theme, layout.compact);
59
+ const query = useRequirements();
60
+ const [project, setProject] = useState<string | null>(null);
61
+ const items = query.data ?? [];
62
+ const projects = useMemo(() => [...new Set(items.map((i) => i.project))].sort(), [items]);
63
+ const shown = project ? items.filter((i) => i.project === project) : items;
64
+ const open = requirementOpener(props.openScreen);
65
+ return (
66
+ <View style={s.screen}>
67
+ <ScrollView contentContainerStyle={s.body}>
68
+ <View style={s.row}>
69
+ <Button theme={theme} label="使用统计" onPress={() => props.openScreen({ screenId: "stats" })} />
70
+ </View>
71
+ {projects.length > 1 ? (
72
+ <View style={s.row}>
73
+ <Chip theme={theme} label="全部项目" active={project === null} onPress={() => setProject(null)} />
74
+ {projects.map((p) => (
75
+ <Chip key={p} theme={theme} label={p} active={project === p} onPress={() => setProject(p)} />
76
+ ))}
77
+ </View>
78
+ ) : null}
79
+ <Status
80
+ theme={theme}
81
+ loading={query.isPending}
82
+ error={query.error}
83
+ empty={query.data && items.length === 0 ? "这台机器上还没有记录任何需求。在 Paseo 对话中用 /req 开始一个;1.9 之前运行过的需求,在项目目录运行 req index --rebuild。" : null}
84
+ />
85
+ <RequirementGroups theme={theme} layout={layout} items={shown} open={open} />
86
+ </ScrollView>
87
+ </View>
88
+ );
89
+ }
@@ -0,0 +1,56 @@
1
+ // The panel beside a workspace: the requirements developed in it or started from it.
2
+ import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin/client";
3
+ import { ScrollView } from "@getpaseo/plugin/client/react-native";
4
+ import { useState } from "react";
5
+ import { Text, View } from "react-native";
6
+ import type { Requirement } from "../shared/contracts";
7
+ import { useRequirements } from "./data";
8
+ import { RequirementView } from "./detail";
9
+ import { RequirementGroups } from "./overview";
10
+ import { Button, Status, useStyles } from "./ui";
11
+
12
+ /** Same directory, ignoring a trailing separator and (on Windows) case. */
13
+ export function sameDir(a: string | null | undefined, b: string | null | undefined): boolean {
14
+ if (!a || !b) return false;
15
+ const norm = (p: string) => p.replace(/[\\/]+$/, "").replace(/\\/g, "/");
16
+ const x = norm(a);
17
+ const y = norm(b);
18
+ return /^[a-z]:\//i.test(x) ? x.toLowerCase() === y.toLowerCase() : x === y;
19
+ }
20
+
21
+ export function inWorkspace(items: Requirement[], directory: string | null | undefined, workspaceId?: string | null): Requirement[] {
22
+ return items.filter((r) => (workspaceId && r.workspaceId === workspaceId) || sameDir(r.repo, directory) || sameDir(r.sourceRepo, directory));
23
+ }
24
+
25
+ export function WorkspacePanel({ theme, layout, navigation, workspaceId }: PluginWorkspacePanelProps) {
26
+ const s = useStyles(theme, layout.compact);
27
+ const workspace = useWorkspace(workspaceId, (w) => ({ directory: w.directory, name: w.name }));
28
+ const query = useRequirements();
29
+ const [picked, setPicked] = useState<Pick<Requirement, "repo" | "featureDir"> | null>(null);
30
+ const items = inWorkspace(query.data ?? [], workspace?.directory, workspaceId);
31
+ const only = items.length === 1 ? items[0] : null;
32
+ const shown = picked ?? only;
33
+ return (
34
+ <View style={s.screen}>
35
+ <ScrollView contentContainerStyle={s.body}>
36
+ <Status
37
+ theme={theme}
38
+ loading={query.isPending}
39
+ error={query.error}
40
+ empty={query.data && items.length === 0 ? `${workspace?.name ?? "这个工作区"}还没有需求。在对话中用 /req 开始一个。` : null}
41
+ />
42
+ {shown ? (
43
+ <>
44
+ {picked && !only ? <Button theme={theme} label="返回列表" onPress={() => setPicked(null)} /> : null}
45
+ <RequirementView theme={theme} layout={layout} navigation={navigation} repo={shown.repo} featureDir={shown.featureDir} />
46
+ </>
47
+ ) : items.length > 1 ? (
48
+ <>
49
+ <Text style={s.muted}>这个工作区有 {items.length} 个需求</Text>
50
+ <RequirementGroups theme={theme} layout={layout} items={items} open={(item) => setPicked(item)} />
51
+ </>
52
+ ) : null}
53
+ </ScrollView>
54
+ </View>
55
+ );
56
+ }