@xpufx/paseo-forges 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/README.md +109 -0
- package/client/active-forge.ts +47 -0
- package/client/board-alert.tsx +236 -0
- package/client/foreign-link.tsx +33 -0
- package/client/hook-queue-panel.tsx +412 -0
- package/client/issues-pill.tsx +1976 -0
- package/client/label-chip.tsx +116 -0
- package/client/linkifier.tsx +280 -0
- package/client/pill-label.ts +183 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/webhook-card.tsx +198 -0
- package/docs/specs/forge-workflow-gui.md +820 -0
- package/docs/workflow.md +339 -0
- package/examples/README.md +108 -0
- package/examples/hook-service/README.md +66 -0
- package/examples/hook-service/forge-hook.service +28 -0
- package/examples/hook-service/hook-server.mjs +265 -0
- package/examples/hook-service/hook.env.example +21 -0
- package/examples/labels/README.md +64 -0
- package/examples/labels/label-base.yaml +117 -0
- package/examples/skills/coding-agent/SKILL.md +262 -0
- package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
- package/examples/skills/orchestrator/SKILL.md +133 -0
- package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
- package/examples/tools/README.md +68 -0
- package/examples/tools/fgjx +464 -0
- package/package.json +38 -0
- package/paseo-plugin.json +4 -0
- package/server/forge-client.ts +484 -0
- package/server/forge-guard.ts +70 -0
- package/server/git-origin.ts +70 -0
- package/server/hook-queue.ts +127 -0
- package/server/issues.ts +542 -0
- package/server/settings.ts +52 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/hook-queue.ts +140 -0
- package/shared/issues.ts +1750 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
- package/shared/webhook.ts +223 -0
|
@@ -0,0 +1,1976 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Linking, Pressable, StyleSheet, Text, View, type ScrollView as ScrollViewInstance } from "react-native";
|
|
3
|
+
import { useWorkspace } from "@getpaseo/plugin/client";
|
|
4
|
+
import { useToast } from "@getpaseo/plugin/client/react-native";
|
|
5
|
+
import { SettingsSelect } from "@getpaseo/plugin/client/ui";
|
|
6
|
+
import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
|
7
|
+
import {
|
|
8
|
+
ModalBody,
|
|
9
|
+
Card,
|
|
10
|
+
Button,
|
|
11
|
+
Badge,
|
|
12
|
+
EmptyState,
|
|
13
|
+
FormRow,
|
|
14
|
+
SearchInput,
|
|
15
|
+
Tabs,
|
|
16
|
+
Toggle,
|
|
17
|
+
CodeBlock,
|
|
18
|
+
CommandBox,
|
|
19
|
+
KeyValue,
|
|
20
|
+
KeyValueGroup,
|
|
21
|
+
TextInput,
|
|
22
|
+
Collapsible,
|
|
23
|
+
ActionBar,
|
|
24
|
+
ForgeIcon,
|
|
25
|
+
HighlightedText,
|
|
26
|
+
useRpcQuery,
|
|
27
|
+
useRpcMutation,
|
|
28
|
+
usePluginSettings,
|
|
29
|
+
usePluginTheme,
|
|
30
|
+
copyToClipboard,
|
|
31
|
+
getClientHost,
|
|
32
|
+
type RenderModalProps,
|
|
33
|
+
type RenderPillProps,
|
|
34
|
+
} from "paseo-plugin-helper/client";
|
|
35
|
+
import { hasFuzzyHighlight, normalizeSearchQuery } from "paseo-plugin-helper/shared";
|
|
36
|
+
import { HookQueueView } from "./hook-queue-panel.js";
|
|
37
|
+
import {
|
|
38
|
+
ATTENTION_LABELS,
|
|
39
|
+
PRIORITY_ORDER,
|
|
40
|
+
SPEC_LABELS,
|
|
41
|
+
STATE_ORDER,
|
|
42
|
+
activeForgeForDirectory,
|
|
43
|
+
addCommentContract,
|
|
44
|
+
classifyForgeUrl,
|
|
45
|
+
createIssueContract,
|
|
46
|
+
currentPriorityLabel,
|
|
47
|
+
currentStateLabel,
|
|
48
|
+
createRemoteSearchGate,
|
|
49
|
+
displayNameForDirectory,
|
|
50
|
+
displayRemoteForApi,
|
|
51
|
+
deriveForgeAccess,
|
|
52
|
+
effectiveForgeHost,
|
|
53
|
+
forgeContextContract,
|
|
54
|
+
forgeTargetsForWorkspace,
|
|
55
|
+
isValidForgeTarget,
|
|
56
|
+
forgeSettingsContract,
|
|
57
|
+
installLabelsContract,
|
|
58
|
+
parseLabelList,
|
|
59
|
+
type ForgeAccessInput,
|
|
60
|
+
type ForgeAccessState,
|
|
61
|
+
type ForgeSettings,
|
|
62
|
+
type InstallLabelMode,
|
|
63
|
+
issueDetailContract,
|
|
64
|
+
issueMatchesQuery,
|
|
65
|
+
nextStateLabel,
|
|
66
|
+
openIssuesContract,
|
|
67
|
+
parseMarkdownLite,
|
|
68
|
+
resolveIssueSearchLayer,
|
|
69
|
+
searchIssuesContract,
|
|
70
|
+
setLabelContract,
|
|
71
|
+
shortLabelName,
|
|
72
|
+
stripAgentEnvelopeFooter,
|
|
73
|
+
writeGateNotice,
|
|
74
|
+
type AgentEnvelope,
|
|
75
|
+
type ForgeRepoIdentity,
|
|
76
|
+
type ForgeIssue,
|
|
77
|
+
type ForgeLabel,
|
|
78
|
+
type IssueComment,
|
|
79
|
+
type MarkdownLiteSpan,
|
|
80
|
+
workspaceNameKey,
|
|
81
|
+
} from "../shared/issues.js";
|
|
82
|
+
import { useActiveForgeIdentity } from "./active-forge.js";
|
|
83
|
+
import { forgePillLabel } from "./pill-label.js";
|
|
84
|
+
import { ForeignInlineMark } from "./foreign-link.js";
|
|
85
|
+
import { LabelChip, LabelChipList } from "./label-chip.js";
|
|
86
|
+
|
|
87
|
+
export const ISSUES_PILL_ID = "forges-issues";
|
|
88
|
+
|
|
89
|
+
function useDirectory(workspaceId: string): string | undefined {
|
|
90
|
+
return useWorkspace(
|
|
91
|
+
workspaceId,
|
|
92
|
+
(w: PluginWorkspaceSnapshot) => w?.directory,
|
|
93
|
+
) as string | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Main-repo root backing a workspace (shared by its worktrees), if known. */
|
|
97
|
+
function useWorkspaceRoot(workspaceId: string): string | undefined {
|
|
98
|
+
return useWorkspace(
|
|
99
|
+
workspaceId,
|
|
100
|
+
(w: PluginWorkspaceSnapshot) => w?.projectRootPath,
|
|
101
|
+
) as string | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Settings key for the workspace display name (worktrees share the main root). */
|
|
105
|
+
function useWorkspaceNameKey(workspaceId: string): string {
|
|
106
|
+
return workspaceNameKey(useDirectory(workspaceId), useWorkspaceRoot(workspaceId));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Workspace display name everywhere: explicit label, else resolved repo. */
|
|
110
|
+
function useDisplayName(workspaceId: string, inferredRepo: string | null | undefined): string | null {
|
|
111
|
+
const nameKey = useWorkspaceNameKey(workspaceId);
|
|
112
|
+
const { settings } = usePluginSettings(forgeSettingsContract);
|
|
113
|
+
return displayNameForDirectory(settings, nameKey, inferredRepo);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function useOpenIssues(workspaceId: string) {
|
|
117
|
+
const directory = useDirectory(workspaceId);
|
|
118
|
+
const { settings } = usePluginSettings(forgeSettingsContract);
|
|
119
|
+
const forgeTarget = activeForgeForDirectory(settings, directory) ?? "";
|
|
120
|
+
const query = useRpcQuery(
|
|
121
|
+
openIssuesContract,
|
|
122
|
+
{ directory: directory ?? undefined, remoteUrl: forgeTarget || undefined },
|
|
123
|
+
{ refetchInterval: 30000 },
|
|
124
|
+
);
|
|
125
|
+
return { directory, ...query };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function ForgePill({ workspaceId, isOpen }: RenderPillProps) {
|
|
129
|
+
const { colors } = usePluginTheme();
|
|
130
|
+
const { data, isLoading, directory } = useOpenIssues(workspaceId);
|
|
131
|
+
const activeForge = useActiveForgeIdentity(directory);
|
|
132
|
+
const forgeHost = activeForge?.host ?? data?.host ?? null;
|
|
133
|
+
const count = data && !data.error ? (data.openIssueCount ?? data.issues.length) : null;
|
|
134
|
+
const displayName = useDisplayName(workspaceId, data?.repo);
|
|
135
|
+
const label = forgePillLabel({ displayName, count, loading: isLoading && !data });
|
|
136
|
+
return (
|
|
137
|
+
<View
|
|
138
|
+
accessibilityLabel={`Forge ${label}`}
|
|
139
|
+
style={styles.pillContainer}
|
|
140
|
+
>
|
|
141
|
+
<ForgeIcon host={forgeHost} size={13} color={colors.foreground} />
|
|
142
|
+
<Text
|
|
143
|
+
numberOfLines={1}
|
|
144
|
+
ellipsizeMode="clip"
|
|
145
|
+
style={[styles.title, { color: colors.foreground }, isOpen && styles.titleActive]}
|
|
146
|
+
>
|
|
147
|
+
{label}
|
|
148
|
+
</Text>
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Trailing-edge debounce: emits `value` only after it stops changing. */
|
|
154
|
+
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
155
|
+
const [debounced, setDebounced] = useState(value);
|
|
156
|
+
React.useEffect(() => {
|
|
157
|
+
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
158
|
+
return () => clearTimeout(timer);
|
|
159
|
+
}, [value, delayMs]);
|
|
160
|
+
return debounced;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const REMOTE_SEARCH_DEBOUNCE_MS = 300;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Scrolls the helper-owned `ModalBody` scroller to a measured match target the
|
|
167
|
+
* moment a search trigger changes. `resolveY` is polled briefly so a target
|
|
168
|
+
* that lays out after the keystroke is still reached. Best-effort: when the
|
|
169
|
+
* host owns the scroll (`scrollRef` null — desktop dialogs and popovers) there
|
|
170
|
+
* is no scroller to move and this is a no-op.
|
|
171
|
+
*/
|
|
172
|
+
function useMatchScrollTarget(
|
|
173
|
+
scrollRef: React.RefObject<ScrollViewInstance | null>,
|
|
174
|
+
trigger: string,
|
|
175
|
+
resolveY: () => number | null,
|
|
176
|
+
): void {
|
|
177
|
+
const scrollTo = React.useCallback(
|
|
178
|
+
(y: number) => {
|
|
179
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, y - 8), animated: true });
|
|
180
|
+
},
|
|
181
|
+
[scrollRef],
|
|
182
|
+
);
|
|
183
|
+
const resolveRef = React.useRef(resolveY);
|
|
184
|
+
resolveRef.current = resolveY;
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
if (!trigger.trim()) return;
|
|
187
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
188
|
+
let tries = 0;
|
|
189
|
+
const attempt = () => {
|
|
190
|
+
const y = resolveRef.current();
|
|
191
|
+
if (y != null) {
|
|
192
|
+
scrollTo(y);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (tries++ < 8) timer = setTimeout(attempt, 25);
|
|
196
|
+
};
|
|
197
|
+
timer = setTimeout(attempt, 0);
|
|
198
|
+
return () => {
|
|
199
|
+
if (timer) clearTimeout(timer);
|
|
200
|
+
};
|
|
201
|
+
}, [trigger, scrollTo]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Operator-only surface: the optional label-set install stays wired for our own
|
|
205
|
+
// board but never renders in the release (issue #163). The matching RPC is not
|
|
206
|
+
// registered either, so there is no end-user path to it.
|
|
207
|
+
const LABEL_SET_INSTALL_VISIBLE = false;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Markdown reference for pasting into chat, e.g. `[#30 Turn count](url)`.
|
|
211
|
+
* The repo web URL is derived from the RPC repo when available.
|
|
212
|
+
*/
|
|
213
|
+
function issueMarkdownRef(
|
|
214
|
+
issue: Pick<ForgeIssue, "number" | "title">,
|
|
215
|
+
repo: string | null,
|
|
216
|
+
host: string | null,
|
|
217
|
+
): string {
|
|
218
|
+
const ref = `#${issue.number}: ${issue.title}`;
|
|
219
|
+
if (!repo || !host) return ref;
|
|
220
|
+
return `[${ref}](https://${host}/${repo}/issues/${issue.number})`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatTimestamp(value: string | undefined | null): string {
|
|
224
|
+
if (!value) return "unknown";
|
|
225
|
+
const parsed = new Date(value);
|
|
226
|
+
if (Number.isNaN(parsed.getTime())) return value;
|
|
227
|
+
return parsed.toLocaleString();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Host-resolved forge mark for headers and rows. The helper's `ForgeIcon`
|
|
232
|
+
* resolves the host to its brand mark (generic for unknown hosts), so the
|
|
233
|
+
* plugin carries no host table of its own.
|
|
234
|
+
*/
|
|
235
|
+
function ForgeMark({ host, size = 14 }: { host?: string | null; size?: number }) {
|
|
236
|
+
const { colors } = usePluginTheme();
|
|
237
|
+
return <ForgeIcon host={host} size={size} color={colors.foregroundMuted} />;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Combined visibility + auth chips, derived from the same access state for
|
|
242
|
+
* both the issues and settings pages (issue #152). The auth chip is what
|
|
243
|
+
* tells the user whether edits are enabled on a public repo.
|
|
244
|
+
*/
|
|
245
|
+
function RepoAccessChips({ access }: { access: ForgeAccessState }) {
|
|
246
|
+
return (
|
|
247
|
+
<View style={styles.accessChips}>
|
|
248
|
+
{access.visibilityLabel ? (
|
|
249
|
+
<Badge
|
|
250
|
+
variant={access.visibility === "public" ? "success" : "neutral"}
|
|
251
|
+
label={access.visibilityLabel}
|
|
252
|
+
icon={access.visibility === "public" ? "Globe" : "Lock"}
|
|
253
|
+
/>
|
|
254
|
+
) : null}
|
|
255
|
+
<Badge
|
|
256
|
+
variant={access.authVariant}
|
|
257
|
+
label={access.authLabel}
|
|
258
|
+
icon={access.authIcon}
|
|
259
|
+
/>
|
|
260
|
+
</View>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Card-header identity cluster: the forge mark beside the access chips. */
|
|
265
|
+
function ForgeCardChips({ host, access }: { host?: string | null; access: ForgeAccessState }) {
|
|
266
|
+
return (
|
|
267
|
+
<View style={styles.accessChips}>
|
|
268
|
+
<ForgeMark host={host} />
|
|
269
|
+
<RepoAccessChips access={access} />
|
|
270
|
+
</View>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Single derivation both pages render from: visibility × token state. */
|
|
275
|
+
function useRepoAccess(input: ForgeAccessInput): ForgeAccessState {
|
|
276
|
+
return useMemo(
|
|
277
|
+
() => deriveForgeAccess(input),
|
|
278
|
+
[input.repoPublic, input.tokenPresent, input.tokenValid, input.repoWritePermission],
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function issueLabelChips(issue: Pick<ForgeIssue, "labels" | "labelDetails">): ForgeLabel[] {
|
|
283
|
+
// Prefer the color-bearing API view; fall back to names for older payloads.
|
|
284
|
+
if (issue.labelDetails.length > 0) return issue.labelDetails;
|
|
285
|
+
return issue.labels.map((name) => ({ name }));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Color map for labels seen on the loaded board, keyed by name. The editor's
|
|
290
|
+
* candidate vocabulary is static, so this is how a picker chip learns the
|
|
291
|
+
* forge's color for a label the issue itself does not carry.
|
|
292
|
+
*/
|
|
293
|
+
function boardLabelMap(issues: readonly ForgeIssue[]): Map<string, ForgeLabel> {
|
|
294
|
+
const map = new Map<string, ForgeLabel>();
|
|
295
|
+
for (const issue of issues) {
|
|
296
|
+
for (const label of issue.labelDetails) {
|
|
297
|
+
if (!map.has(label.name)) map.set(label.name, label);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return map;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function editorLabelChips(
|
|
304
|
+
names: readonly string[],
|
|
305
|
+
known: Map<string, ForgeLabel>,
|
|
306
|
+
): ForgeLabel[] {
|
|
307
|
+
return names.map((name) => known.get(name) ?? { name });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderInlineSpans(
|
|
311
|
+
spans: MarkdownLiteSpan[],
|
|
312
|
+
colors: { foreground: string; accent: string; statusWarning: string },
|
|
313
|
+
keyPrefix: string,
|
|
314
|
+
activeForge: ForgeRepoIdentity | null,
|
|
315
|
+
query: string,
|
|
316
|
+
) {
|
|
317
|
+
return spans.map((span, index) => {
|
|
318
|
+
const key = `${keyPrefix}-${index}`;
|
|
319
|
+
if (span.kind === "bold") {
|
|
320
|
+
return (
|
|
321
|
+
<Text key={key} style={styles.bold}>
|
|
322
|
+
<HighlightedText text={span.text} query={query} />
|
|
323
|
+
</Text>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (span.kind === "italic") {
|
|
327
|
+
return (
|
|
328
|
+
<Text key={key} style={styles.italic}>
|
|
329
|
+
<HighlightedText text={span.text} query={query} />
|
|
330
|
+
</Text>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (span.kind === "code") {
|
|
334
|
+
return (
|
|
335
|
+
<Text key={key} style={styles.inlineCode}>
|
|
336
|
+
<HighlightedText text={span.text} query={query} />
|
|
337
|
+
</Text>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (span.kind === "link") {
|
|
341
|
+
const foreign = classifyForgeUrl(span.url, activeForge) === "foreign";
|
|
342
|
+
return (
|
|
343
|
+
<Text
|
|
344
|
+
key={key}
|
|
345
|
+
style={[styles.link, { color: colors.accent }]}
|
|
346
|
+
onPress={() => {
|
|
347
|
+
Linking.openURL(span.url).catch(() => {
|
|
348
|
+
copyToClipboard(span.url).catch(() => {});
|
|
349
|
+
});
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
<HighlightedText text={span.text} query={query} />
|
|
353
|
+
{foreign ? <ForeignInlineMark color={colors.statusWarning} /> : null}
|
|
354
|
+
</Text>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return <HighlightedText key={key} text={span.text} query={query} />;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Markdown-lite for forge bodies: headings, paragraphs, lists, links,
|
|
363
|
+
* inline code/emphasis, and fenced code blocks. Outer Text stays
|
|
364
|
+
* selectable; links open on tap with copy fallback. Forge issue links that
|
|
365
|
+
* point outside the active repo get an inline `foreign` marker.
|
|
366
|
+
*/
|
|
367
|
+
function MarkdownLite({
|
|
368
|
+
body,
|
|
369
|
+
activeForge,
|
|
370
|
+
query = "",
|
|
371
|
+
}: {
|
|
372
|
+
body: string;
|
|
373
|
+
activeForge: ForgeRepoIdentity | null;
|
|
374
|
+
/** Active search query; matches inside inline spans are highlighted. */
|
|
375
|
+
query?: string;
|
|
376
|
+
}) {
|
|
377
|
+
const { colors } = usePluginTheme();
|
|
378
|
+
const blocks = useMemo(() => parseMarkdownLite(body), [body]);
|
|
379
|
+
return (
|
|
380
|
+
<View style={styles.markdown}>
|
|
381
|
+
{blocks.map((block, index) => {
|
|
382
|
+
if (block.kind === "code") {
|
|
383
|
+
return <CodeBlock key={index} code={block.text} language={block.language} />;
|
|
384
|
+
}
|
|
385
|
+
if (block.kind === "heading") {
|
|
386
|
+
return (
|
|
387
|
+
<Text
|
|
388
|
+
key={index}
|
|
389
|
+
selectable
|
|
390
|
+
style={[
|
|
391
|
+
styles.bodyText,
|
|
392
|
+
block.level === 1 ? styles.heading1 : block.level === 2 ? styles.heading2 : styles.heading3,
|
|
393
|
+
{ color: colors.foreground },
|
|
394
|
+
]}
|
|
395
|
+
>
|
|
396
|
+
{renderInlineSpans(block.spans, colors, `h${index}`, activeForge, query)}
|
|
397
|
+
</Text>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (block.kind === "list") {
|
|
401
|
+
return (
|
|
402
|
+
<View key={index} style={styles.list}>
|
|
403
|
+
{block.items.map((item, itemIndex) => (
|
|
404
|
+
<View key={itemIndex} style={styles.listRow}>
|
|
405
|
+
<Text selectable style={[styles.bullet, { color: colors.foreground }]}>
|
|
406
|
+
{block.ordered ? `${itemIndex + 1}.` : "•"}
|
|
407
|
+
</Text>
|
|
408
|
+
<Text selectable style={[styles.listText, { color: colors.foreground }]}>
|
|
409
|
+
{renderInlineSpans(item, colors, `li${index}-${itemIndex}`, activeForge, query)}
|
|
410
|
+
</Text>
|
|
411
|
+
</View>
|
|
412
|
+
))}
|
|
413
|
+
</View>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return (
|
|
417
|
+
<Text
|
|
418
|
+
key={index}
|
|
419
|
+
selectable
|
|
420
|
+
style={[styles.bodyText, { color: colors.foreground }]}
|
|
421
|
+
>
|
|
422
|
+
{renderInlineSpans(block.spans, colors, `p${index}`, activeForge, query)}
|
|
423
|
+
</Text>
|
|
424
|
+
);
|
|
425
|
+
})}
|
|
426
|
+
</View>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function IssueRow({
|
|
431
|
+
number,
|
|
432
|
+
title,
|
|
433
|
+
state,
|
|
434
|
+
labels,
|
|
435
|
+
repo,
|
|
436
|
+
host,
|
|
437
|
+
onSelect,
|
|
438
|
+
query,
|
|
439
|
+
onLayoutY,
|
|
440
|
+
}: {
|
|
441
|
+
number: number;
|
|
442
|
+
title: string;
|
|
443
|
+
state: string;
|
|
444
|
+
labels: ForgeLabel[];
|
|
445
|
+
repo: string | null;
|
|
446
|
+
host: string | null;
|
|
447
|
+
onSelect: (issueNumber: number) => void;
|
|
448
|
+
/** Active search query; title and labels highlight their matches. */
|
|
449
|
+
query?: string;
|
|
450
|
+
onLayoutY?: (y: number) => void;
|
|
451
|
+
}) {
|
|
452
|
+
const { colors } = usePluginTheme();
|
|
453
|
+
const [copied, setCopied] = useState(false);
|
|
454
|
+
const copy = () => {
|
|
455
|
+
copyToClipboard(issueMarkdownRef({ number, title }, repo, host))
|
|
456
|
+
.then(() => {
|
|
457
|
+
setCopied(true);
|
|
458
|
+
setTimeout(() => setCopied(false), 2000);
|
|
459
|
+
})
|
|
460
|
+
.catch(() => {});
|
|
461
|
+
};
|
|
462
|
+
return (
|
|
463
|
+
<View style={styles.row} onLayout={(e) => onLayoutY?.(e.nativeEvent.layout.y)}>
|
|
464
|
+
<ForgeMark host={host} />
|
|
465
|
+
<Badge
|
|
466
|
+
variant={state === "open" ? "success" : "neutral"}
|
|
467
|
+
label={`#${number}`}
|
|
468
|
+
/>
|
|
469
|
+
<Badge
|
|
470
|
+
variant={state === "open" ? "success" : "neutral"}
|
|
471
|
+
label={state === "closed" ? "Closed" : "Open"}
|
|
472
|
+
/>
|
|
473
|
+
<Pressable style={styles.rowBody} onPress={() => onSelect(number)} hitSlop={4}>
|
|
474
|
+
{/*
|
|
475
|
+
* The row title must not opt into the whole-field fallback: it is one of
|
|
476
|
+
* several fields, and a query that matches only a label must not light
|
|
477
|
+
* the whole title. Literal/token hits still paint; label chips carry the
|
|
478
|
+
* match for everything else.
|
|
479
|
+
*/}
|
|
480
|
+
<HighlightedText
|
|
481
|
+
text={title}
|
|
482
|
+
query={query ?? ""}
|
|
483
|
+
style={[styles.rowTitle, { color: colors.foreground }]}
|
|
484
|
+
/>
|
|
485
|
+
<LabelChipList labels={labels} style={styles.rowLabels} query={query} />
|
|
486
|
+
</Pressable>
|
|
487
|
+
<Button
|
|
488
|
+
size="sm"
|
|
489
|
+
variant={copied ? "primary" : "ghost"}
|
|
490
|
+
icon={copied ? "Check" : "Copy"}
|
|
491
|
+
label={copied ? "Copied" : "Copy"}
|
|
492
|
+
onPress={copy}
|
|
493
|
+
/>
|
|
494
|
+
</View>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function AgentEnvelopeCard({ envelope }: { envelope: AgentEnvelope }) {
|
|
499
|
+
const openSession = () => {
|
|
500
|
+
const link = envelope.paseoLinks[0];
|
|
501
|
+
if (!link) return;
|
|
502
|
+
Linking.openURL(link).catch(() => {
|
|
503
|
+
copyToClipboard(link).catch(() => {});
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
return (
|
|
507
|
+
<Card variant="tinted" style={styles.envelopeCard}>
|
|
508
|
+
<Card.Header
|
|
509
|
+
title={envelope.sessionTitle}
|
|
510
|
+
subtitle={envelope.postedAt ?? undefined}
|
|
511
|
+
badge={<Badge variant="neutral" label={envelope.agentShortId} />}
|
|
512
|
+
icon="Bot"
|
|
513
|
+
/>
|
|
514
|
+
<KeyValueGroup columns={2}>
|
|
515
|
+
<KeyValue label="Model" value={envelope.model ?? "-"} mono />
|
|
516
|
+
<KeyValue
|
|
517
|
+
label="Branch"
|
|
518
|
+
value={envelope.branch ? `${envelope.repo ?? ""}:${envelope.branch}` : "-"}
|
|
519
|
+
mono
|
|
520
|
+
copyable={Boolean(envelope.branch)}
|
|
521
|
+
/>
|
|
522
|
+
</KeyValueGroup>
|
|
523
|
+
{envelope.commitShas.length > 0 ? (
|
|
524
|
+
<View style={styles.shaList}>
|
|
525
|
+
{envelope.commitShas.map((sha) => (
|
|
526
|
+
<CommandBox key={sha} command={sha} copyLabel={`Copy commit ${sha}`} />
|
|
527
|
+
))}
|
|
528
|
+
</View>
|
|
529
|
+
) : null}
|
|
530
|
+
{envelope.paseoLinks.length > 0 ? (
|
|
531
|
+
<Button
|
|
532
|
+
size="sm"
|
|
533
|
+
variant="ghost"
|
|
534
|
+
icon="ExternalLink"
|
|
535
|
+
label="Open agent session"
|
|
536
|
+
onPress={openSession}
|
|
537
|
+
/>
|
|
538
|
+
) : null}
|
|
539
|
+
</Card>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function CommentCard({
|
|
544
|
+
comment,
|
|
545
|
+
issueUrl,
|
|
546
|
+
activeForge,
|
|
547
|
+
query,
|
|
548
|
+
}: {
|
|
549
|
+
comment: IssueComment;
|
|
550
|
+
issueUrl: string;
|
|
551
|
+
activeForge: ForgeRepoIdentity | null;
|
|
552
|
+
query?: string;
|
|
553
|
+
}) {
|
|
554
|
+
const { colors } = usePluginTheme();
|
|
555
|
+
const { Icon } = getClientHost();
|
|
556
|
+
const body = stripAgentEnvelopeFooter(comment.body) || comment.body;
|
|
557
|
+
const target = comment.url || issueUrl;
|
|
558
|
+
const open = () => {
|
|
559
|
+
Linking.openURL(target).catch(() => {});
|
|
560
|
+
};
|
|
561
|
+
const copy = () => {
|
|
562
|
+
copyToClipboard(target).catch(() => {});
|
|
563
|
+
};
|
|
564
|
+
return (
|
|
565
|
+
<Card style={styles.commentCard}>
|
|
566
|
+
<Card.Header
|
|
567
|
+
title={comment.author}
|
|
568
|
+
subtitle={formatTimestamp(comment.createdAt)}
|
|
569
|
+
icon="MessageSquare"
|
|
570
|
+
/>
|
|
571
|
+
<View style={styles.commentRow}>
|
|
572
|
+
<Icon name="ExternalLink" size={13} color={colors.accent} />
|
|
573
|
+
<Pressable
|
|
574
|
+
accessibilityRole="link"
|
|
575
|
+
accessibilityLabel={`Open comment by ${comment.author}`}
|
|
576
|
+
style={styles.commentBody}
|
|
577
|
+
onPress={open}
|
|
578
|
+
hitSlop={8}
|
|
579
|
+
>
|
|
580
|
+
<MarkdownLite body={body} activeForge={activeForge} query={query} />
|
|
581
|
+
</Pressable>
|
|
582
|
+
<Pressable
|
|
583
|
+
accessibilityRole="button"
|
|
584
|
+
accessibilityLabel="Copy comment link"
|
|
585
|
+
onPress={copy}
|
|
586
|
+
hitSlop={10}
|
|
587
|
+
>
|
|
588
|
+
<Text style={[styles.copyText, { color: colors.foregroundMuted }]}>⧉</Text>
|
|
589
|
+
</Pressable>
|
|
590
|
+
</View>
|
|
591
|
+
{comment.envelope ? <AgentEnvelopeCard envelope={comment.envelope} /> : null}
|
|
592
|
+
</Card>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Gated edit notice with a working route to fix the credential state. */
|
|
597
|
+
function ReadOnlyNotice({
|
|
598
|
+
access,
|
|
599
|
+
capability,
|
|
600
|
+
onAction,
|
|
601
|
+
}: {
|
|
602
|
+
access: ForgeAccessState;
|
|
603
|
+
capability: string;
|
|
604
|
+
onAction: () => void;
|
|
605
|
+
}) {
|
|
606
|
+
const { colors } = usePluginTheme();
|
|
607
|
+
return (
|
|
608
|
+
<View style={styles.readOnlyNotice}>
|
|
609
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
610
|
+
{writeGateNotice(access, capability)}
|
|
611
|
+
</Text>
|
|
612
|
+
<Button
|
|
613
|
+
size="sm"
|
|
614
|
+
variant="secondary"
|
|
615
|
+
icon="Settings"
|
|
616
|
+
label="Add a token"
|
|
617
|
+
onPress={onAction}
|
|
618
|
+
/>
|
|
619
|
+
</View>
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function ScopedLabelGroup({
|
|
624
|
+
title,
|
|
625
|
+
labels,
|
|
626
|
+
active,
|
|
627
|
+
pending,
|
|
628
|
+
onSelect,
|
|
629
|
+
}: {
|
|
630
|
+
title: string;
|
|
631
|
+
labels: readonly ForgeLabel[];
|
|
632
|
+
active: string | null;
|
|
633
|
+
pending: boolean;
|
|
634
|
+
onSelect: (label: string) => void;
|
|
635
|
+
}) {
|
|
636
|
+
const { colors, touchTargetMin } = usePluginTheme();
|
|
637
|
+
const hitSlop = Math.max(0, (touchTargetMin - 32) / 2);
|
|
638
|
+
return (
|
|
639
|
+
<View style={styles.labelGroup}>
|
|
640
|
+
<Text style={[styles.sectionTitle, { color: colors.foregroundMuted }]}>{title}</Text>
|
|
641
|
+
<View style={styles.labelRow}>
|
|
642
|
+
{labels.map((label) => (
|
|
643
|
+
<Pressable
|
|
644
|
+
key={label.name}
|
|
645
|
+
onPress={() => onSelect(label.name)}
|
|
646
|
+
disabled={pending}
|
|
647
|
+
accessibilityRole="button"
|
|
648
|
+
accessibilityLabel={shortLabelName(label.name)}
|
|
649
|
+
hitSlop={hitSlop}
|
|
650
|
+
style={pending && active === label.name ? styles.labelChipPending : undefined}
|
|
651
|
+
>
|
|
652
|
+
<LabelChip label={label} selected={active === label.name} />
|
|
653
|
+
</Pressable>
|
|
654
|
+
))}
|
|
655
|
+
</View>
|
|
656
|
+
</View>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Compose surface for opening a ticket on the active forge (issue #200).
|
|
662
|
+
* Gated on the shared access state: hidden when anonymous (no path to a write),
|
|
663
|
+
* the #193 scope hint when the token is accepted but under-scoped, and the
|
|
664
|
+
* enabled form otherwise. The list refreshes on success.
|
|
665
|
+
*/
|
|
666
|
+
function NewIssueComposer({
|
|
667
|
+
directory,
|
|
668
|
+
forgeTarget,
|
|
669
|
+
access,
|
|
670
|
+
onCreated,
|
|
671
|
+
onOpenSettings,
|
|
672
|
+
}: {
|
|
673
|
+
directory?: string;
|
|
674
|
+
forgeTarget: string;
|
|
675
|
+
access: ForgeAccessState;
|
|
676
|
+
onCreated: () => void;
|
|
677
|
+
onOpenSettings: () => void;
|
|
678
|
+
}) {
|
|
679
|
+
const toast = useToast();
|
|
680
|
+
const [expanded, setExpanded] = useState(false);
|
|
681
|
+
const [title, setTitle] = useState("");
|
|
682
|
+
const [body, setBody] = useState("");
|
|
683
|
+
const [labelsText, setLabelsText] = useState("");
|
|
684
|
+
const create = useRpcMutation(createIssueContract, {
|
|
685
|
+
onSuccess: (result) => {
|
|
686
|
+
if (result.error) {
|
|
687
|
+
toast.error(result.error);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
setTitle("");
|
|
691
|
+
setBody("");
|
|
692
|
+
setLabelsText("");
|
|
693
|
+
setExpanded(false);
|
|
694
|
+
onCreated();
|
|
695
|
+
toast.show(result.number ? `Created issue #${result.number}` : "Issue created", {
|
|
696
|
+
variant: "success",
|
|
697
|
+
});
|
|
698
|
+
},
|
|
699
|
+
onError: (error) => {
|
|
700
|
+
toast.error(error instanceof Error ? error.message : "Could not create issue");
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
// Anonymous hosts have no path to a write, so the surface is hidden entirely;
|
|
704
|
+
// every other non-editable state keeps the gate visible with its explanation.
|
|
705
|
+
if (access.auth === "anonymous") return null;
|
|
706
|
+
if (!access.canEdit) {
|
|
707
|
+
return (
|
|
708
|
+
<ReadOnlyNotice access={access} capability="creating issues" onAction={onOpenSettings} />
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
const pending = create.isPending;
|
|
712
|
+
const canSubmit = title.trim().length > 0 && !pending;
|
|
713
|
+
const submit = () => {
|
|
714
|
+
const nextTitle = title.trim();
|
|
715
|
+
if (!nextTitle) return;
|
|
716
|
+
create.mutate({
|
|
717
|
+
directory: directory ?? undefined,
|
|
718
|
+
remoteUrl: forgeTarget || undefined,
|
|
719
|
+
title: nextTitle,
|
|
720
|
+
body: body.trim(),
|
|
721
|
+
labels: parseLabelList(labelsText),
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
return (
|
|
725
|
+
<Collapsible
|
|
726
|
+
title="New issue"
|
|
727
|
+
subtitle="Open a ticket on the active forge"
|
|
728
|
+
icon="Plus"
|
|
729
|
+
isExpanded={expanded}
|
|
730
|
+
onToggle={setExpanded}
|
|
731
|
+
>
|
|
732
|
+
<View style={styles.newIssueForm}>
|
|
733
|
+
<FormRow label="Title">
|
|
734
|
+
<TextInput
|
|
735
|
+
value={title}
|
|
736
|
+
onChangeText={setTitle}
|
|
737
|
+
placeholder="Short, specific title"
|
|
738
|
+
autoCapitalize="sentences"
|
|
739
|
+
/>
|
|
740
|
+
</FormRow>
|
|
741
|
+
<FormRow label="Description" description="Markdown supported.">
|
|
742
|
+
<TextInput
|
|
743
|
+
value={body}
|
|
744
|
+
onChangeText={setBody}
|
|
745
|
+
placeholder="What needs to happen?"
|
|
746
|
+
multiline
|
|
747
|
+
numberOfLines={4}
|
|
748
|
+
/>
|
|
749
|
+
</FormRow>
|
|
750
|
+
<Collapsible title="Labels" subtitle="Optional — comma-separated names" icon="Tags">
|
|
751
|
+
<FormRow label="Label names">
|
|
752
|
+
<TextInput
|
|
753
|
+
value={labelsText}
|
|
754
|
+
onChangeText={setLabelsText}
|
|
755
|
+
placeholder="state/1-wip, priority/1-high"
|
|
756
|
+
/>
|
|
757
|
+
</FormRow>
|
|
758
|
+
</Collapsible>
|
|
759
|
+
<ActionBar>
|
|
760
|
+
<Button
|
|
761
|
+
label="Cancel"
|
|
762
|
+
variant="ghost"
|
|
763
|
+
disabled={pending}
|
|
764
|
+
onPress={() => setExpanded(false)}
|
|
765
|
+
/>
|
|
766
|
+
<Button
|
|
767
|
+
label={pending ? "Creating…" : "Create issue"}
|
|
768
|
+
variant="primary"
|
|
769
|
+
icon="Plus"
|
|
770
|
+
disabled={!canSubmit}
|
|
771
|
+
loading={pending}
|
|
772
|
+
onPress={submit}
|
|
773
|
+
/>
|
|
774
|
+
</ActionBar>
|
|
775
|
+
</View>
|
|
776
|
+
</Collapsible>
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function IssueDetailView({
|
|
781
|
+
workspaceId,
|
|
782
|
+
issueNumber,
|
|
783
|
+
forgeTarget,
|
|
784
|
+
activeForge,
|
|
785
|
+
boardLabels,
|
|
786
|
+
query,
|
|
787
|
+
scrollRef,
|
|
788
|
+
onBack,
|
|
789
|
+
onBoardRefresh,
|
|
790
|
+
onOpenSettings,
|
|
791
|
+
}: {
|
|
792
|
+
workspaceId: string;
|
|
793
|
+
issueNumber: number;
|
|
794
|
+
forgeTarget: string;
|
|
795
|
+
activeForge: ForgeRepoIdentity | null;
|
|
796
|
+
boardLabels: Map<string, ForgeLabel>;
|
|
797
|
+
/** Active search query; matched title/label/body/comment text is highlighted. */
|
|
798
|
+
query: string;
|
|
799
|
+
/** Helper-owned ModalBody scroller, used for best-effort go-to-match. */
|
|
800
|
+
scrollRef: React.RefObject<ScrollViewInstance | null>;
|
|
801
|
+
onBack: () => void;
|
|
802
|
+
onBoardRefresh: () => void;
|
|
803
|
+
onOpenSettings: () => void;
|
|
804
|
+
}) {
|
|
805
|
+
const { colors } = usePluginTheme();
|
|
806
|
+
const toast = useToast();
|
|
807
|
+
const directory = useDirectory(workspaceId);
|
|
808
|
+
const baseInput = {
|
|
809
|
+
issueNumber,
|
|
810
|
+
directory: directory ?? undefined,
|
|
811
|
+
remoteUrl: forgeTarget || undefined,
|
|
812
|
+
};
|
|
813
|
+
const detail = useRpcQuery(
|
|
814
|
+
issueDetailContract,
|
|
815
|
+
baseInput,
|
|
816
|
+
{ refetchInterval: 30000 },
|
|
817
|
+
);
|
|
818
|
+
const [draft, setDraft] = useState("");
|
|
819
|
+
const displayName = useDisplayName(workspaceId, detail.data?.repo);
|
|
820
|
+
const issue = detail.data && !detail.data.error ? detail.data.issue : null;
|
|
821
|
+
const failed = Boolean(detail.data?.error) || detail.isError;
|
|
822
|
+
|
|
823
|
+
const setLabel = useRpcMutation(setLabelContract, {
|
|
824
|
+
onSuccess: (result) => {
|
|
825
|
+
if (result.error) {
|
|
826
|
+
toast.error(result.error);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
detail.refetch();
|
|
830
|
+
onBoardRefresh();
|
|
831
|
+
},
|
|
832
|
+
onError: (error) => {
|
|
833
|
+
toast.error(error instanceof Error ? error.message : "Could not update labels");
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const addComment = useRpcMutation(addCommentContract, {
|
|
838
|
+
onSuccess: (result) => {
|
|
839
|
+
if (result.error) {
|
|
840
|
+
toast.error(result.error);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
setDraft("");
|
|
844
|
+
detail.refetch();
|
|
845
|
+
onBoardRefresh();
|
|
846
|
+
toast.show("Comment posted", { variant: "success" });
|
|
847
|
+
},
|
|
848
|
+
onError: (error) => {
|
|
849
|
+
toast.error(error instanceof Error ? error.message : "Could not post comment");
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const labels = issue?.labels ?? [];
|
|
854
|
+
const state = currentStateLabel(labels);
|
|
855
|
+
const priority = issue ? currentPriorityLabel(labels) : null;
|
|
856
|
+
const next = issue ? nextStateLabel(labels) : null;
|
|
857
|
+
const labelPending = setLabel.isPending;
|
|
858
|
+
const commentPending = addComment.isPending;
|
|
859
|
+
const candidates = useMemo(() => {
|
|
860
|
+
const known = new Map(boardLabels);
|
|
861
|
+
for (const label of issue?.labelDetails ?? []) known.set(label.name, label);
|
|
862
|
+
return {
|
|
863
|
+
state: editorLabelChips(STATE_ORDER, known),
|
|
864
|
+
priority: editorLabelChips(PRIORITY_ORDER, known),
|
|
865
|
+
attention: editorLabelChips(ATTENTION_LABELS, known),
|
|
866
|
+
spec: editorLabelChips(SPEC_LABELS, known),
|
|
867
|
+
};
|
|
868
|
+
}, [boardLabels, issue?.labelDetails]);
|
|
869
|
+
const access = useRepoAccess({
|
|
870
|
+
repoPublic: detail.data?.repoPublic,
|
|
871
|
+
tokenPresent: detail.data?.tokenPresent,
|
|
872
|
+
tokenValid: detail.data?.tokenValid,
|
|
873
|
+
repoWritePermission: detail.data?.repoWritePermission,
|
|
874
|
+
});
|
|
875
|
+
// Go-to-match (issue #190): section offsets reported relative to the detail
|
|
876
|
+
// root, then resolved to the first section that contains the query. The
|
|
877
|
+
// predicate is fuzzy-tolerant (literal or whole-token), and a fuzzy result
|
|
878
|
+
// with no pinpointable section still lands on the detail root, so open/scroll
|
|
879
|
+
// never silently fails. Only the helper-owned scroller can move; when the host
|
|
880
|
+
// owns it this is a no-op.
|
|
881
|
+
const detailTop = React.useRef(0);
|
|
882
|
+
const titleY = React.useRef<number | null>(null);
|
|
883
|
+
const bodyY = React.useRef<number | null>(null);
|
|
884
|
+
const commentsY = React.useRef<number | null>(null);
|
|
885
|
+
const commentYs = React.useRef(new Map<number, number>());
|
|
886
|
+
const firstCommentMatch = issue
|
|
887
|
+
? issue.comments.findIndex((comment) => hasFuzzyHighlight(comment.body, query))
|
|
888
|
+
: -1;
|
|
889
|
+
useMatchScrollTarget(
|
|
890
|
+
scrollRef,
|
|
891
|
+
query.trim() && issue ? `${query.trim()}|${issue.number}|${issue.comments.length}` : "",
|
|
892
|
+
() => {
|
|
893
|
+
if (!issue) return null;
|
|
894
|
+
const top = detailTop.current;
|
|
895
|
+
if (
|
|
896
|
+
hasFuzzyHighlight(issue.title, query) ||
|
|
897
|
+
issue.labels.some((label) => hasFuzzyHighlight(label, query))
|
|
898
|
+
) {
|
|
899
|
+
return top + (titleY.current ?? 0);
|
|
900
|
+
}
|
|
901
|
+
if (hasFuzzyHighlight(issue.body, query)) {
|
|
902
|
+
return top + (bodyY.current ?? 0);
|
|
903
|
+
}
|
|
904
|
+
if (firstCommentMatch >= 0) {
|
|
905
|
+
const commentY = commentYs.current.get(issue.comments[firstCommentMatch].id);
|
|
906
|
+
if (commentY == null) return null;
|
|
907
|
+
return top + (commentsY.current ?? 0) + commentY;
|
|
908
|
+
}
|
|
909
|
+
return top;
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
return (
|
|
914
|
+
<View
|
|
915
|
+
style={styles.detail}
|
|
916
|
+
onLayout={(e) => {
|
|
917
|
+
detailTop.current = e.nativeEvent.layout.y;
|
|
918
|
+
}}
|
|
919
|
+
>
|
|
920
|
+
<Button
|
|
921
|
+
size="sm"
|
|
922
|
+
variant="ghost"
|
|
923
|
+
icon="ArrowLeft"
|
|
924
|
+
label="Back to issues"
|
|
925
|
+
onPress={onBack}
|
|
926
|
+
/>
|
|
927
|
+
{detail.isLoading && !detail.data ? (
|
|
928
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
929
|
+
Loading issue #{issueNumber}…
|
|
930
|
+
</Text>
|
|
931
|
+
) : null}
|
|
932
|
+
{failed || (!detail.isLoading && !issue) ? (
|
|
933
|
+
<EmptyState
|
|
934
|
+
icon="AlertCircle"
|
|
935
|
+
title={`Issue #${issueNumber} unavailable`}
|
|
936
|
+
description={detail.data?.error ?? "Could not reach the forge."}
|
|
937
|
+
actionLabel="Retry"
|
|
938
|
+
onAction={() => detail.refetch()}
|
|
939
|
+
/>
|
|
940
|
+
) : null}
|
|
941
|
+
{issue ? (
|
|
942
|
+
<>
|
|
943
|
+
<View
|
|
944
|
+
onLayout={(e) => {
|
|
945
|
+
titleY.current = e.nativeEvent.layout.y;
|
|
946
|
+
}}
|
|
947
|
+
>
|
|
948
|
+
<Card variant="elevated">
|
|
949
|
+
<Card.Header
|
|
950
|
+
title={`#${issue.number} ${issue.title}`}
|
|
951
|
+
highlightQuery={query}
|
|
952
|
+
subtitle={`${displayName ? `${displayName} · ` : ""}by ${issue.author} · ${formatTimestamp(issue.updatedAt)}`}
|
|
953
|
+
badge={<ForgeCardChips host={activeForge?.host} access={access} />}
|
|
954
|
+
/>
|
|
955
|
+
<LabelChipList
|
|
956
|
+
labels={issueLabelChips(issue)}
|
|
957
|
+
style={styles.badgeRow}
|
|
958
|
+
query={query}
|
|
959
|
+
/>
|
|
960
|
+
</Card>
|
|
961
|
+
</View>
|
|
962
|
+
|
|
963
|
+
<Card>
|
|
964
|
+
<Card.Header title="Labels" subtitle="One tap applies; scope evicts the rest" icon="Tags" />
|
|
965
|
+
{!access.canEdit ? (
|
|
966
|
+
<ReadOnlyNotice access={access} capability="labeling" onAction={onOpenSettings} />
|
|
967
|
+
) : (
|
|
968
|
+
<>
|
|
969
|
+
{next ? (
|
|
970
|
+
<Button
|
|
971
|
+
variant="primary"
|
|
972
|
+
icon="ArrowRight"
|
|
973
|
+
label={state ? `Move to ${shortLabelName(next)}` : `Start ${shortLabelName(next)}`}
|
|
974
|
+
disabled={labelPending}
|
|
975
|
+
loading={labelPending}
|
|
976
|
+
onPress={() => setLabel.mutate({ ...baseInput, label: next })}
|
|
977
|
+
/>
|
|
978
|
+
) : null}
|
|
979
|
+
<ScopedLabelGroup
|
|
980
|
+
title="State"
|
|
981
|
+
labels={candidates.state}
|
|
982
|
+
active={state}
|
|
983
|
+
pending={labelPending}
|
|
984
|
+
onSelect={(label) => setLabel.mutate({ ...baseInput, label })}
|
|
985
|
+
/>
|
|
986
|
+
<ScopedLabelGroup
|
|
987
|
+
title="Priority"
|
|
988
|
+
labels={candidates.priority}
|
|
989
|
+
active={priority}
|
|
990
|
+
pending={labelPending}
|
|
991
|
+
onSelect={(label) => setLabel.mutate({ ...baseInput, label })}
|
|
992
|
+
/>
|
|
993
|
+
<ScopedLabelGroup
|
|
994
|
+
title="Attention"
|
|
995
|
+
labels={candidates.attention}
|
|
996
|
+
active={labels.find((label) => (ATTENTION_LABELS as readonly string[]).includes(label)) ?? null}
|
|
997
|
+
pending={labelPending}
|
|
998
|
+
onSelect={(label) => setLabel.mutate({ ...baseInput, label })}
|
|
999
|
+
/>
|
|
1000
|
+
<ScopedLabelGroup
|
|
1001
|
+
title="Spec"
|
|
1002
|
+
labels={candidates.spec}
|
|
1003
|
+
active={labels.find((label) => (SPEC_LABELS as readonly string[]).includes(label)) ?? null}
|
|
1004
|
+
pending={labelPending}
|
|
1005
|
+
onSelect={(label) => setLabel.mutate({ ...baseInput, label })}
|
|
1006
|
+
/>
|
|
1007
|
+
</>
|
|
1008
|
+
)}
|
|
1009
|
+
</Card>
|
|
1010
|
+
|
|
1011
|
+
<View
|
|
1012
|
+
onLayout={(e) => {
|
|
1013
|
+
bodyY.current = e.nativeEvent.layout.y;
|
|
1014
|
+
}}
|
|
1015
|
+
>
|
|
1016
|
+
<Card>
|
|
1017
|
+
<Card.Header title="Description" icon="FileText" />
|
|
1018
|
+
{issue.body.trim() ? (
|
|
1019
|
+
<MarkdownLite body={issue.body} activeForge={activeForge} query={query} />
|
|
1020
|
+
) : (
|
|
1021
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>No description.</Text>
|
|
1022
|
+
)}
|
|
1023
|
+
</Card>
|
|
1024
|
+
</View>
|
|
1025
|
+
|
|
1026
|
+
<View
|
|
1027
|
+
onLayout={(e) => {
|
|
1028
|
+
commentsY.current = e.nativeEvent.layout.y;
|
|
1029
|
+
}}
|
|
1030
|
+
>
|
|
1031
|
+
<Card>
|
|
1032
|
+
<Card.Header
|
|
1033
|
+
title={`Comments (${issue.comments.length})`}
|
|
1034
|
+
icon="MessagesSquare"
|
|
1035
|
+
/>
|
|
1036
|
+
{issue.comments.length === 0 ? (
|
|
1037
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>No comments yet.</Text>
|
|
1038
|
+
) : (
|
|
1039
|
+
issue.comments.map((comment) => (
|
|
1040
|
+
<View
|
|
1041
|
+
key={comment.id}
|
|
1042
|
+
onLayout={(e) => {
|
|
1043
|
+
commentYs.current.set(comment.id, e.nativeEvent.layout.y);
|
|
1044
|
+
}}
|
|
1045
|
+
>
|
|
1046
|
+
<CommentCard
|
|
1047
|
+
comment={comment}
|
|
1048
|
+
issueUrl={issue.webUrl}
|
|
1049
|
+
activeForge={activeForge}
|
|
1050
|
+
query={query}
|
|
1051
|
+
/>
|
|
1052
|
+
</View>
|
|
1053
|
+
))
|
|
1054
|
+
)}
|
|
1055
|
+
{!access.canEdit ? (
|
|
1056
|
+
<ReadOnlyNotice access={access} capability="commenting" onAction={onOpenSettings} />
|
|
1057
|
+
) : (
|
|
1058
|
+
<View style={styles.composer}>
|
|
1059
|
+
<TextInput
|
|
1060
|
+
value={draft}
|
|
1061
|
+
onChangeText={setDraft}
|
|
1062
|
+
placeholder="Quick comment…"
|
|
1063
|
+
multiline
|
|
1064
|
+
numberOfLines={3}
|
|
1065
|
+
/>
|
|
1066
|
+
<Button
|
|
1067
|
+
variant="primary"
|
|
1068
|
+
icon="Send"
|
|
1069
|
+
label={commentPending ? "Posting…" : "Post"}
|
|
1070
|
+
disabled={!draft.trim() || commentPending}
|
|
1071
|
+
loading={commentPending}
|
|
1072
|
+
onPress={() => {
|
|
1073
|
+
const body = draft.trim();
|
|
1074
|
+
if (!body) return;
|
|
1075
|
+
addComment.mutate({ ...baseInput, body });
|
|
1076
|
+
}}
|
|
1077
|
+
/>
|
|
1078
|
+
</View>
|
|
1079
|
+
)}
|
|
1080
|
+
</Card>
|
|
1081
|
+
</View>
|
|
1082
|
+
</>
|
|
1083
|
+
) : null}
|
|
1084
|
+
</View>
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export function ForgeIssuesView({
|
|
1089
|
+
workspaceId,
|
|
1090
|
+
onClose,
|
|
1091
|
+
}: {
|
|
1092
|
+
agentId?: string;
|
|
1093
|
+
workspaceId: string;
|
|
1094
|
+
onClose?: () => void;
|
|
1095
|
+
}) {
|
|
1096
|
+
const { colors } = usePluginTheme();
|
|
1097
|
+
const toast = useToast();
|
|
1098
|
+
const { data, isLoading, isError, refetch, isRefetching } = useOpenIssues(workspaceId);
|
|
1099
|
+
const access = useRepoAccess({
|
|
1100
|
+
repoPublic: data?.repoPublic,
|
|
1101
|
+
tokenPresent: data?.tokenPresent,
|
|
1102
|
+
tokenValid: data?.tokenValid,
|
|
1103
|
+
repoWritePermission: data?.repoWritePermission,
|
|
1104
|
+
});
|
|
1105
|
+
const directory = useDirectory(workspaceId);
|
|
1106
|
+
const projectRootPath = useWorkspaceRoot(workspaceId);
|
|
1107
|
+
const nameKey = workspaceNameKey(directory, projectRootPath);
|
|
1108
|
+
const activeForge = useActiveForgeIdentity(directory);
|
|
1109
|
+
const { settings, updateSettings, updateSettingsAsync } = usePluginSettings(forgeSettingsContract);
|
|
1110
|
+
// Git-origin context queried separately so the token/name fields render from
|
|
1111
|
+
// persisted settings even while the issues query is loading or unavailable.
|
|
1112
|
+
const forgeContext = useRpcQuery(forgeContextContract, { directory: directory ?? undefined });
|
|
1113
|
+
const [forgeDraft, setForgeDraft] = useState<string | null>(null);
|
|
1114
|
+
const [tokenDraft, setTokenDraft] = useState<string | null>(null);
|
|
1115
|
+
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
|
1116
|
+
const [forgeBusy, setForgeBusy] = useState(false);
|
|
1117
|
+
const [forgeError, setForgeError] = useState<string | null>(null);
|
|
1118
|
+
const [formSaving, setFormSaving] = useState(false);
|
|
1119
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
1120
|
+
const forgeTargets = forgeTargetsForWorkspace(settings, directory);
|
|
1121
|
+
const activeForgeValue = activeForgeForDirectory(settings, directory) ?? "";
|
|
1122
|
+
const forgeTarget = activeForgeValue;
|
|
1123
|
+
const derivedRemote =
|
|
1124
|
+
forgeContext.data?.derivedRemote ?? data?.derivedRemote ?? null;
|
|
1125
|
+
const effectiveHost = effectiveForgeHost(activeForgeValue, derivedRemote);
|
|
1126
|
+
const activeForgeInvalid = Boolean(activeForgeValue.trim()) && !isValidForgeTarget(activeForgeValue);
|
|
1127
|
+
const forgeDraftInvalid = Boolean(forgeDraft?.trim()) && !isValidForgeTarget(forgeDraft);
|
|
1128
|
+
const forgeOptions: Array<{ label: string; value: string }> = [
|
|
1129
|
+
{ label: "Auto — derive from the git origin remote", value: "" },
|
|
1130
|
+
...forgeTargets.map((target) => ({ label: target, value: target })),
|
|
1131
|
+
];
|
|
1132
|
+
if (activeForgeValue && !forgeTargets.includes(activeForgeValue)) {
|
|
1133
|
+
forgeOptions.push({ label: activeForgeValue, value: activeForgeValue });
|
|
1134
|
+
}
|
|
1135
|
+
const storedName = nameKey ? (settings.namesByDirectory?.[nameKey] ?? "") : "";
|
|
1136
|
+
const nameValue = nameDraft ?? storedName;
|
|
1137
|
+
const storedToken = effectiveHost ? (settings.tokensByHost?.[effectiveHost] ?? "") : "";
|
|
1138
|
+
const tokenValue = tokenDraft ?? storedToken;
|
|
1139
|
+
const selectForge = (value: string) => {
|
|
1140
|
+
if (!directory) return;
|
|
1141
|
+
updateSettings({
|
|
1142
|
+
activeForgeByDirectory: { ...(settings.activeForgeByDirectory ?? {}), [directory]: value },
|
|
1143
|
+
});
|
|
1144
|
+
};
|
|
1145
|
+
const addForge = async () => {
|
|
1146
|
+
const target = (forgeDraft ?? "").trim();
|
|
1147
|
+
if (!target) {
|
|
1148
|
+
setForgeError("Enter a remote URL or owner/repo to add.");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (!isValidForgeTarget(target)) {
|
|
1152
|
+
setForgeError("Forge target must be a remote URL or owner/repo, e.g. https://codeberg.org/owner/repo.");
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (!directory) {
|
|
1156
|
+
setForgeError("Workspace directory is still loading — try again in a moment.");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
setForgeBusy(true);
|
|
1160
|
+
setForgeError(null);
|
|
1161
|
+
try {
|
|
1162
|
+
const alreadyWatched = forgeTargets.includes(target);
|
|
1163
|
+
const list = alreadyWatched ? forgeTargets : [...forgeTargets, target];
|
|
1164
|
+
// Editing the list supersedes the legacy single remote: fold it into the
|
|
1165
|
+
// list and clear the legacy key so the two sources cannot disagree.
|
|
1166
|
+
const nextRemotes = { ...(settings.remotesByDirectory ?? {}) };
|
|
1167
|
+
delete nextRemotes[directory];
|
|
1168
|
+
await updateSettingsAsync({
|
|
1169
|
+
forgesByDirectory: { ...(settings.forgesByDirectory ?? {}), [directory]: list },
|
|
1170
|
+
remotesByDirectory: nextRemotes,
|
|
1171
|
+
activeForgeByDirectory: { ...(settings.activeForgeByDirectory ?? {}), [directory]: target },
|
|
1172
|
+
});
|
|
1173
|
+
setForgeDraft(null);
|
|
1174
|
+
toast.show(alreadyWatched ? `Already watching ${target}` : `Added ${target}`, {
|
|
1175
|
+
variant: "success",
|
|
1176
|
+
});
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
setForgeError(error instanceof Error ? error.message : "Could not add forge");
|
|
1179
|
+
} finally {
|
|
1180
|
+
setForgeBusy(false);
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
const removeForge = (target: string) => {
|
|
1184
|
+
if (!directory) return;
|
|
1185
|
+
const list = forgeTargets.filter((entry) => entry !== target);
|
|
1186
|
+
const nextForges = { ...(settings.forgesByDirectory ?? {}) };
|
|
1187
|
+
if (list.length) nextForges[directory] = list;
|
|
1188
|
+
else delete nextForges[directory];
|
|
1189
|
+
const nextRemotes = { ...(settings.remotesByDirectory ?? {}) };
|
|
1190
|
+
delete nextRemotes[directory];
|
|
1191
|
+
const currentActive = activeForgeForDirectory(settings, directory) ?? "";
|
|
1192
|
+
updateSettings({
|
|
1193
|
+
forgesByDirectory: nextForges,
|
|
1194
|
+
remotesByDirectory: nextRemotes,
|
|
1195
|
+
activeForgeByDirectory: {
|
|
1196
|
+
...(settings.activeForgeByDirectory ?? {}),
|
|
1197
|
+
[directory]: currentActive === target ? (list[0] ?? "") : currentActive,
|
|
1198
|
+
},
|
|
1199
|
+
});
|
|
1200
|
+
};
|
|
1201
|
+
const saveSettings = async () => {
|
|
1202
|
+
if (!directory) return;
|
|
1203
|
+
setFormSaving(true);
|
|
1204
|
+
setFormError(null);
|
|
1205
|
+
try {
|
|
1206
|
+
const updates: Partial<ForgeSettings> = {};
|
|
1207
|
+
if (tokenDraft != null && effectiveHost) {
|
|
1208
|
+
const next = { ...(settings.tokensByHost ?? {}) };
|
|
1209
|
+
if (tokenDraft.trim()) next[effectiveHost] = tokenDraft.trim();
|
|
1210
|
+
else delete next[effectiveHost];
|
|
1211
|
+
updates.tokensByHost = next;
|
|
1212
|
+
}
|
|
1213
|
+
if (nameValue !== storedName) {
|
|
1214
|
+
const next = { ...(settings.namesByDirectory ?? {}) };
|
|
1215
|
+
if (nameValue.trim()) next[nameKey] = nameValue.trim();
|
|
1216
|
+
else delete next[nameKey];
|
|
1217
|
+
updates.namesByDirectory = next;
|
|
1218
|
+
}
|
|
1219
|
+
await updateSettingsAsync(updates);
|
|
1220
|
+
setTokenDraft(null);
|
|
1221
|
+
setNameDraft(null);
|
|
1222
|
+
refetch();
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
setFormError(error instanceof Error ? error.message : "Could not save settings");
|
|
1225
|
+
} finally {
|
|
1226
|
+
setFormSaving(false);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
const repo = data?.repo ?? null;
|
|
1230
|
+
const displayName = useDisplayName(workspaceId, repo);
|
|
1231
|
+
const [installChoiceOpen, setInstallChoiceOpen] = useState(false);
|
|
1232
|
+
const [installSummary, setInstallSummary] = useState<string | null>(null);
|
|
1233
|
+
const installLabels = useRpcMutation(installLabelsContract, {
|
|
1234
|
+
onSuccess: (result) => {
|
|
1235
|
+
if (result.error) {
|
|
1236
|
+
setInstallSummary(result.error);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
setInstallChoiceOpen(false);
|
|
1240
|
+
setInstallSummary(
|
|
1241
|
+
`Installed: ${result.created.length} created, ${result.removed.length} removed, ${result.skipped.length} already present.`,
|
|
1242
|
+
);
|
|
1243
|
+
refetch();
|
|
1244
|
+
},
|
|
1245
|
+
onError: (error) => {
|
|
1246
|
+
setInstallSummary(error instanceof Error ? error.message : "Could not install labels");
|
|
1247
|
+
},
|
|
1248
|
+
});
|
|
1249
|
+
const runInstall = (mode: InstallLabelMode) => {
|
|
1250
|
+
setInstallSummary(null);
|
|
1251
|
+
installLabels.mutate({
|
|
1252
|
+
directory: directory ?? undefined,
|
|
1253
|
+
remoteUrl: forgeTarget || undefined,
|
|
1254
|
+
mode,
|
|
1255
|
+
});
|
|
1256
|
+
};
|
|
1257
|
+
const [activeTab, setActiveTab] = useState("issues");
|
|
1258
|
+
const [query, setQuery] = useState("");
|
|
1259
|
+
const [selected, setSelected] = useState<number | null>(null);
|
|
1260
|
+
const [extraIssues, setExtraIssues] = useState<ForgeIssue[]>([]);
|
|
1261
|
+
const [nextPage, setNextPage] = useState(2);
|
|
1262
|
+
const [extraHasMore, setExtraHasMore] = useState(false);
|
|
1263
|
+
const [moreError, setMoreError] = useState<string | null>(null);
|
|
1264
|
+
// Live remote search (issue #139). Off by default: the tab filters the loaded
|
|
1265
|
+
// snapshot instantly. On, the debounced query goes to the selected forge and
|
|
1266
|
+
// the instant client filter keeps rendering until a current result lands.
|
|
1267
|
+
const [remoteSearch, setRemoteSearch] = useState(false);
|
|
1268
|
+
const [remoteIssues, setRemoteIssues] = useState<ForgeIssue[] | null>(null);
|
|
1269
|
+
const [remoteResultQuery, setRemoteResultQuery] = useState<string | null>(null);
|
|
1270
|
+
const [remoteError, setRemoteError] = useState<string | null>(null);
|
|
1271
|
+
const [remotePending, setRemotePending] = useState(false);
|
|
1272
|
+
const searchGate = React.useRef(createRemoteSearchGate());
|
|
1273
|
+
const searchIssuesMutation = useRpcMutation(searchIssuesContract);
|
|
1274
|
+
const debouncedQuery = useDebouncedValue(query, REMOTE_SEARCH_DEBOUNCE_MS);
|
|
1275
|
+
const remoteQuery = remoteSearch ? debouncedQuery.trim() : "";
|
|
1276
|
+
React.useEffect(() => {
|
|
1277
|
+
const generation = searchGate.current.begin();
|
|
1278
|
+
if (!remoteQuery) {
|
|
1279
|
+
setRemoteIssues(null);
|
|
1280
|
+
setRemoteResultQuery(null);
|
|
1281
|
+
setRemoteError(null);
|
|
1282
|
+
setRemotePending(false);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
let cancelled = false;
|
|
1286
|
+
setRemotePending(true);
|
|
1287
|
+
void searchIssuesMutation
|
|
1288
|
+
.mutateAsync({
|
|
1289
|
+
directory: directory ?? undefined,
|
|
1290
|
+
remoteUrl: forgeTarget || undefined,
|
|
1291
|
+
query: remoteQuery,
|
|
1292
|
+
page: 1,
|
|
1293
|
+
})
|
|
1294
|
+
.then((result) => {
|
|
1295
|
+
// Out-of-order guard: only the newest dispatch may publish, and an
|
|
1296
|
+
// unmounted surface never sets state.
|
|
1297
|
+
if (cancelled || !searchGate.current.accept(generation)) return;
|
|
1298
|
+
setRemotePending(false);
|
|
1299
|
+
if (result.error) {
|
|
1300
|
+
setRemoteIssues(null);
|
|
1301
|
+
setRemoteResultQuery(null);
|
|
1302
|
+
setRemoteError(result.error);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
setRemoteError(null);
|
|
1306
|
+
setRemoteIssues(result.issues);
|
|
1307
|
+
setRemoteResultQuery(remoteQuery);
|
|
1308
|
+
})
|
|
1309
|
+
.catch(() => {
|
|
1310
|
+
if (cancelled || !searchGate.current.accept(generation)) return;
|
|
1311
|
+
setRemotePending(false);
|
|
1312
|
+
setRemoteIssues(null);
|
|
1313
|
+
setRemoteResultQuery(null);
|
|
1314
|
+
setRemoteError("Remote search failed");
|
|
1315
|
+
});
|
|
1316
|
+
return () => {
|
|
1317
|
+
cancelled = true;
|
|
1318
|
+
};
|
|
1319
|
+
}, [remoteQuery, directory, forgeTarget, searchIssuesMutation.mutateAsync]);
|
|
1320
|
+
const resetPages = () => {
|
|
1321
|
+
setExtraIssues([]);
|
|
1322
|
+
setNextPage(2);
|
|
1323
|
+
setExtraHasMore(false);
|
|
1324
|
+
setMoreError(null);
|
|
1325
|
+
};
|
|
1326
|
+
React.useEffect(() => { resetPages(); }, [data?.repo, forgeTarget]);
|
|
1327
|
+
const loadMore = useRpcMutation(openIssuesContract, {
|
|
1328
|
+
onSuccess: (result) => {
|
|
1329
|
+
if (result.error) {
|
|
1330
|
+
setMoreError(result.error);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
setExtraIssues((prev) => [...prev, ...result.issues.filter((issue: ForgeIssue) => !prev.some((p) => p.number === issue.number))]);
|
|
1334
|
+
setNextPage(result.page + 1);
|
|
1335
|
+
setExtraHasMore(result.hasMore);
|
|
1336
|
+
setMoreError(null);
|
|
1337
|
+
},
|
|
1338
|
+
onError: (error) => {
|
|
1339
|
+
setMoreError(error instanceof Error ? error.message : "Could not load more issues");
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
const pool = useMemo(
|
|
1343
|
+
() => (data && !data.error ? [...data.issues, ...extraIssues] : [...extraIssues]),
|
|
1344
|
+
[data, extraIssues],
|
|
1345
|
+
);
|
|
1346
|
+
const boardLabels = useMemo(() => boardLabelMap(pool), [pool]);
|
|
1347
|
+
const clientIssues = useMemo(
|
|
1348
|
+
() => pool.filter((issue: ForgeIssue) => issueMatchesQuery(issue, query)),
|
|
1349
|
+
[pool, query],
|
|
1350
|
+
);
|
|
1351
|
+
const searchLayer = useMemo(
|
|
1352
|
+
() =>
|
|
1353
|
+
resolveIssueSearchLayer({
|
|
1354
|
+
query,
|
|
1355
|
+
remoteEnabled: remoteSearch && remoteQuery.length > 0,
|
|
1356
|
+
remoteQuery: remoteResultQuery,
|
|
1357
|
+
remoteIssues,
|
|
1358
|
+
remoteError,
|
|
1359
|
+
clientIssues,
|
|
1360
|
+
}),
|
|
1361
|
+
[query, remoteSearch, remoteQuery, remoteResultQuery, remoteIssues, remoteError, clientIssues],
|
|
1362
|
+
);
|
|
1363
|
+
const issues = searchLayer.issues;
|
|
1364
|
+
// Local filter/highlight use the normalized (quote-stripped) query so `meta`
|
|
1365
|
+
// and `"meta"` behave identically; the remote search keeps the raw query.
|
|
1366
|
+
const activeQuery = normalizeSearchQuery(debouncedQuery);
|
|
1367
|
+
const scrollRef = React.useRef<ScrollViewInstance | null>(null);
|
|
1368
|
+
const listTop = React.useRef(0);
|
|
1369
|
+
const rowTops = React.useRef(new Map<number, number>());
|
|
1370
|
+
const firstMatchNumber = activeQuery && issues.length > 0 ? issues[0].number : null;
|
|
1371
|
+
// Go-to-match (issue #190): scroll the results container to the first match
|
|
1372
|
+
// when the debounced query settles. Disabled while a detail view is open (the
|
|
1373
|
+
// detail runs its own target) and when the helper does not own the scroller.
|
|
1374
|
+
useMatchScrollTarget(
|
|
1375
|
+
scrollRef,
|
|
1376
|
+
selected == null && activeQuery
|
|
1377
|
+
? `${activeQuery}|${firstMatchNumber ?? ""}|${issues.length}`
|
|
1378
|
+
: "",
|
|
1379
|
+
() => {
|
|
1380
|
+
if (firstMatchNumber == null) return null;
|
|
1381
|
+
const y = rowTops.current.get(firstMatchNumber);
|
|
1382
|
+
return y == null ? null : listTop.current + y;
|
|
1383
|
+
},
|
|
1384
|
+
);
|
|
1385
|
+
const hasMore = extraHasMore || (data && !data.error ? data.hasMore : false);
|
|
1386
|
+
const failed = Boolean(data?.error) || isError;
|
|
1387
|
+
return (
|
|
1388
|
+
<ModalBody
|
|
1389
|
+
header={
|
|
1390
|
+
<Tabs
|
|
1391
|
+
tabs={[
|
|
1392
|
+
{ id: "issues", label: "Open Issues", shortLabel: "Issues" },
|
|
1393
|
+
{ id: "queues", label: "Hook Queues", shortLabel: "Queues" },
|
|
1394
|
+
{ id: "settings", label: "Settings", shortLabel: "Settings" },
|
|
1395
|
+
]}
|
|
1396
|
+
activeTab={activeTab}
|
|
1397
|
+
onTabChange={setActiveTab}
|
|
1398
|
+
/>
|
|
1399
|
+
}
|
|
1400
|
+
headerMode="pinned"
|
|
1401
|
+
headerStyle={{
|
|
1402
|
+
backgroundColor: colors.surface0,
|
|
1403
|
+
paddingHorizontal: 12,
|
|
1404
|
+
paddingTop: 12,
|
|
1405
|
+
paddingBottom: 6,
|
|
1406
|
+
}}
|
|
1407
|
+
refreshing={isRefetching}
|
|
1408
|
+
onRefresh={() => { refetch(); }}
|
|
1409
|
+
scrollRef={scrollRef}
|
|
1410
|
+
>
|
|
1411
|
+
{activeTab === "queues" ? (
|
|
1412
|
+
<HookQueueView onClose={onClose} embedded />
|
|
1413
|
+
) : selected != null ? (
|
|
1414
|
+
<>
|
|
1415
|
+
<IssueDetailView
|
|
1416
|
+
workspaceId={workspaceId}
|
|
1417
|
+
issueNumber={selected}
|
|
1418
|
+
forgeTarget={forgeTarget}
|
|
1419
|
+
activeForge={activeForge}
|
|
1420
|
+
boardLabels={boardLabels}
|
|
1421
|
+
query={activeQuery}
|
|
1422
|
+
scrollRef={scrollRef}
|
|
1423
|
+
onBack={() => setSelected(null)}
|
|
1424
|
+
onBoardRefresh={() => refetch()}
|
|
1425
|
+
onOpenSettings={() => {
|
|
1426
|
+
setSelected(null);
|
|
1427
|
+
setActiveTab("settings");
|
|
1428
|
+
}}
|
|
1429
|
+
/>
|
|
1430
|
+
<View style={styles.actions}>
|
|
1431
|
+
<Button label="Refresh" variant="secondary" onPress={() => { refetch(); }} />
|
|
1432
|
+
{onClose ? <Button label="Close" variant="ghost" onPress={onClose} /> : null}
|
|
1433
|
+
</View>
|
|
1434
|
+
</>
|
|
1435
|
+
) : (
|
|
1436
|
+
<>
|
|
1437
|
+
{activeTab === "settings" ? (
|
|
1438
|
+
<>
|
|
1439
|
+
<Card variant="elevated">
|
|
1440
|
+
<Card.Header
|
|
1441
|
+
title={displayName ? `Remotes & watch targets · ${displayName}` : "Remotes & watch targets"}
|
|
1442
|
+
subtitle="What this workspace watches — active forge and known remotes"
|
|
1443
|
+
badge={<ForgeCardChips host={activeForge?.host ?? effectiveHost} access={access} />}
|
|
1444
|
+
icon="GitBranch"
|
|
1445
|
+
/>
|
|
1446
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1447
|
+
Pick which forge this workspace watches. Issues and search follow the
|
|
1448
|
+
active forge; Auto derives from the git origin remote.
|
|
1449
|
+
</Text>
|
|
1450
|
+
<SettingsSelect
|
|
1451
|
+
label="Active forge"
|
|
1452
|
+
hint="Explicit selection wins; an unreachable or invalid forge fails instead of deriving."
|
|
1453
|
+
value={activeForgeValue}
|
|
1454
|
+
options={forgeOptions}
|
|
1455
|
+
onValueChange={selectForge}
|
|
1456
|
+
disabled={!directory}
|
|
1457
|
+
/>
|
|
1458
|
+
{activeForgeInvalid ? (
|
|
1459
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1460
|
+
Selected forge is not a valid remote URL or owner/repo.
|
|
1461
|
+
</Text>
|
|
1462
|
+
) : null}
|
|
1463
|
+
<FormRow
|
|
1464
|
+
label="Add a remote"
|
|
1465
|
+
description="Remote URL or owner/repo for another forge, e.g. https://codeberg.org/owner/repo."
|
|
1466
|
+
>
|
|
1467
|
+
<TextInput
|
|
1468
|
+
value={forgeDraft ?? ""}
|
|
1469
|
+
onChangeText={(value) => {
|
|
1470
|
+
setForgeDraft(value);
|
|
1471
|
+
if (forgeError) setForgeError(null);
|
|
1472
|
+
}}
|
|
1473
|
+
placeholder="https://codeberg.org/owner/repo"
|
|
1474
|
+
autoCapitalize="none"
|
|
1475
|
+
autoCorrect={false}
|
|
1476
|
+
/>
|
|
1477
|
+
<View style={styles.actions}>
|
|
1478
|
+
<Button
|
|
1479
|
+
size="sm"
|
|
1480
|
+
variant="secondary"
|
|
1481
|
+
icon="Plus"
|
|
1482
|
+
label={forgeBusy ? "Adding…" : "Add remote"}
|
|
1483
|
+
disabled={!(forgeDraft ?? "").trim() || forgeBusy}
|
|
1484
|
+
loading={forgeBusy}
|
|
1485
|
+
onPress={() => { void addForge(); }}
|
|
1486
|
+
/>
|
|
1487
|
+
</View>
|
|
1488
|
+
</FormRow>
|
|
1489
|
+
{forgeDraftInvalid ? (
|
|
1490
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1491
|
+
Forge target must be a remote URL or owner/repo.
|
|
1492
|
+
</Text>
|
|
1493
|
+
) : null}
|
|
1494
|
+
{forgeError ? (
|
|
1495
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1496
|
+
{forgeError}
|
|
1497
|
+
</Text>
|
|
1498
|
+
) : null}
|
|
1499
|
+
{forgeTargets.length > 0 ? (
|
|
1500
|
+
<View style={styles.forgeList}>
|
|
1501
|
+
{forgeTargets.map((target) => (
|
|
1502
|
+
<View key={target} style={styles.forgeRow}>
|
|
1503
|
+
<ForgeMark host={target} />
|
|
1504
|
+
<Text
|
|
1505
|
+
numberOfLines={1}
|
|
1506
|
+
style={[styles.forgeTarget, { color: colors.foreground }]}
|
|
1507
|
+
>
|
|
1508
|
+
{target}
|
|
1509
|
+
</Text>
|
|
1510
|
+
<Button
|
|
1511
|
+
size="sm"
|
|
1512
|
+
variant="ghost"
|
|
1513
|
+
icon="Trash2"
|
|
1514
|
+
label="Remove"
|
|
1515
|
+
onPress={() => removeForge(target)}
|
|
1516
|
+
/>
|
|
1517
|
+
</View>
|
|
1518
|
+
))}
|
|
1519
|
+
</View>
|
|
1520
|
+
) : (
|
|
1521
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1522
|
+
No remotes added yet — Auto uses the git origin remote.
|
|
1523
|
+
</Text>
|
|
1524
|
+
)}
|
|
1525
|
+
{derivedRemote && !activeForgeValue ? (
|
|
1526
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1527
|
+
Derived: {displayRemoteForApi(derivedRemote)}
|
|
1528
|
+
</Text>
|
|
1529
|
+
) : null}
|
|
1530
|
+
</Card>
|
|
1531
|
+
<Card>
|
|
1532
|
+
<Card.Header
|
|
1533
|
+
title="Authentication & workspace metadata"
|
|
1534
|
+
subtitle="Saved together by the button below — not by adding a remote"
|
|
1535
|
+
icon="ShieldCheck"
|
|
1536
|
+
/>
|
|
1537
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1538
|
+
These apply to the active forge above and are saved separately from
|
|
1539
|
+
the remote list.
|
|
1540
|
+
</Text>
|
|
1541
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1542
|
+
{effectiveHost
|
|
1543
|
+
? `API token for ${effectiveHost}. Leave empty to remove it.`
|
|
1544
|
+
: "API token — needs a resolvable host from the forge above."}
|
|
1545
|
+
</Text>
|
|
1546
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1547
|
+
Minimum scopes: {access.requiredScopes}.
|
|
1548
|
+
</Text>
|
|
1549
|
+
<TextInput
|
|
1550
|
+
value={tokenValue}
|
|
1551
|
+
onChangeText={setTokenDraft}
|
|
1552
|
+
placeholder="Forge API token"
|
|
1553
|
+
secureTextEntry
|
|
1554
|
+
autoCapitalize="none"
|
|
1555
|
+
autoCorrect={false}
|
|
1556
|
+
/>
|
|
1557
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1558
|
+
Name for this workspace on the pill. Leave empty to use the repo.
|
|
1559
|
+
</Text>
|
|
1560
|
+
<TextInput
|
|
1561
|
+
value={nameValue}
|
|
1562
|
+
onChangeText={setNameDraft}
|
|
1563
|
+
placeholder={data?.repo ?? "e.g. tea"}
|
|
1564
|
+
autoCapitalize="none"
|
|
1565
|
+
autoCorrect={false}
|
|
1566
|
+
/>
|
|
1567
|
+
{failed && data?.error ? (
|
|
1568
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1569
|
+
Forge unavailable: {data.error}
|
|
1570
|
+
</Text>
|
|
1571
|
+
) : null}
|
|
1572
|
+
{data && !isLoading && !data.error ? (
|
|
1573
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1574
|
+
{access.summary}
|
|
1575
|
+
</Text>
|
|
1576
|
+
) : null}
|
|
1577
|
+
{formError ? (
|
|
1578
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1579
|
+
{formError}
|
|
1580
|
+
</Text>
|
|
1581
|
+
) : null}
|
|
1582
|
+
<View style={styles.actions}>
|
|
1583
|
+
<Button
|
|
1584
|
+
label={formSaving ? "Saving…" : "Save token & settings"}
|
|
1585
|
+
variant="primary"
|
|
1586
|
+
disabled={formSaving || !directory || activeForgeInvalid || (tokenDraft == null && nameValue === storedName) || (tokenDraft != null && !effectiveHost)}
|
|
1587
|
+
loading={formSaving}
|
|
1588
|
+
onPress={() => { void saveSettings(); }}
|
|
1589
|
+
/>
|
|
1590
|
+
</View>
|
|
1591
|
+
</Card>
|
|
1592
|
+
{LABEL_SET_INSTALL_VISIBLE ? (
|
|
1593
|
+
<Card>
|
|
1594
|
+
<Card.Header
|
|
1595
|
+
title="Paseo label set"
|
|
1596
|
+
subtitle="Optional — copy our workflow taxonomy onto this repo"
|
|
1597
|
+
icon="Tags"
|
|
1598
|
+
/>
|
|
1599
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
1600
|
+
Installs our state/, priority/, attention/ and spec/ labels.
|
|
1601
|
+
Nothing is written until you pick an option below. Labels in
|
|
1602
|
+
other scopes — yours or a foreign board's — are never touched.
|
|
1603
|
+
</Text>
|
|
1604
|
+
{!access.canEdit ? (
|
|
1605
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1606
|
+
A valid API token is required to install labels. Add one above and save.
|
|
1607
|
+
</Text>
|
|
1608
|
+
) : null}
|
|
1609
|
+
{installChoiceOpen ? (
|
|
1610
|
+
<>
|
|
1611
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>
|
|
1612
|
+
Keep existing labels, or replace the labels in our scopes —
|
|
1613
|
+
removes any conflicting state/, priority/, attention/ or
|
|
1614
|
+
spec/ label, then installs ours.
|
|
1615
|
+
</Text>
|
|
1616
|
+
<View style={styles.actions}>
|
|
1617
|
+
<Button
|
|
1618
|
+
size="sm"
|
|
1619
|
+
variant="secondary"
|
|
1620
|
+
icon="Plus"
|
|
1621
|
+
label="Keep existing"
|
|
1622
|
+
disabled={!directory || installLabels.isPending}
|
|
1623
|
+
loading={installLabels.isPending}
|
|
1624
|
+
onPress={() => runInstall("merge")}
|
|
1625
|
+
/>
|
|
1626
|
+
<Button
|
|
1627
|
+
size="sm"
|
|
1628
|
+
variant="danger"
|
|
1629
|
+
icon="Trash2"
|
|
1630
|
+
label="Replace our scopes"
|
|
1631
|
+
disabled={!directory || installLabels.isPending}
|
|
1632
|
+
onPress={() => runInstall("replace")}
|
|
1633
|
+
/>
|
|
1634
|
+
<Button
|
|
1635
|
+
size="sm"
|
|
1636
|
+
variant="ghost"
|
|
1637
|
+
label="Cancel"
|
|
1638
|
+
disabled={installLabels.isPending}
|
|
1639
|
+
onPress={() => {
|
|
1640
|
+
setInstallChoiceOpen(false);
|
|
1641
|
+
setInstallSummary(null);
|
|
1642
|
+
}}
|
|
1643
|
+
/>
|
|
1644
|
+
</View>
|
|
1645
|
+
</>
|
|
1646
|
+
) : (
|
|
1647
|
+
<View style={styles.actions}>
|
|
1648
|
+
<Button
|
|
1649
|
+
size="sm"
|
|
1650
|
+
variant="secondary"
|
|
1651
|
+
icon="Tags"
|
|
1652
|
+
label="Install label set…"
|
|
1653
|
+
disabled={!directory || !access.canEdit || installLabels.isPending}
|
|
1654
|
+
onPress={() => {
|
|
1655
|
+
setInstallChoiceOpen(true);
|
|
1656
|
+
setInstallSummary(null);
|
|
1657
|
+
}}
|
|
1658
|
+
/>
|
|
1659
|
+
</View>
|
|
1660
|
+
)}
|
|
1661
|
+
{installSummary ? (
|
|
1662
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>{installSummary}</Text>
|
|
1663
|
+
) : null}
|
|
1664
|
+
</Card>
|
|
1665
|
+
) : null}
|
|
1666
|
+
</>
|
|
1667
|
+
) : (
|
|
1668
|
+
<>
|
|
1669
|
+
<SearchInput
|
|
1670
|
+
value={query}
|
|
1671
|
+
onChangeText={setQuery}
|
|
1672
|
+
placeholder="Filter by keyword or #number…"
|
|
1673
|
+
/>
|
|
1674
|
+
<FormRow
|
|
1675
|
+
label="Search the forge"
|
|
1676
|
+
description="Query the selected forge for open and closed issues. Off filters the loaded snapshot instantly."
|
|
1677
|
+
>
|
|
1678
|
+
<Toggle value={remoteSearch} onValueChange={setRemoteSearch} />
|
|
1679
|
+
</FormRow>
|
|
1680
|
+
<NewIssueComposer
|
|
1681
|
+
directory={directory}
|
|
1682
|
+
forgeTarget={forgeTarget}
|
|
1683
|
+
access={access}
|
|
1684
|
+
onCreated={() => {
|
|
1685
|
+
resetPages();
|
|
1686
|
+
refetch();
|
|
1687
|
+
}}
|
|
1688
|
+
onOpenSettings={() => setActiveTab("settings")}
|
|
1689
|
+
/>
|
|
1690
|
+
<View
|
|
1691
|
+
onLayout={(e) => {
|
|
1692
|
+
listTop.current = e.nativeEvent.layout.y;
|
|
1693
|
+
}}
|
|
1694
|
+
>
|
|
1695
|
+
<Card variant="elevated">
|
|
1696
|
+
<Card.Header
|
|
1697
|
+
title={displayName ? `Issues · ${displayName}` : "Forge Issues"}
|
|
1698
|
+
badge={<ForgeCardChips host={activeForge?.host ?? data?.host} access={access} />}
|
|
1699
|
+
subtitle={
|
|
1700
|
+
data && !data.error
|
|
1701
|
+
? query.trim()
|
|
1702
|
+
? searchLayer.source === "remote"
|
|
1703
|
+
? `${issues.length} match${issues.length === 1 ? "" : "es"} (open and closed)`
|
|
1704
|
+
: `${issues.length} of ${data.openIssueCount ?? pool.length} match`
|
|
1705
|
+
: `${data.openIssueCount ?? pool.length} open`
|
|
1706
|
+
: "Open issues for this workspace repo"
|
|
1707
|
+
}
|
|
1708
|
+
/>
|
|
1709
|
+
{isLoading && !data ? (
|
|
1710
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>Loading issues…</Text>
|
|
1711
|
+
) : null}
|
|
1712
|
+
{remotePending ? (
|
|
1713
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>Searching the forge…</Text>
|
|
1714
|
+
) : null}
|
|
1715
|
+
{remoteError ? (
|
|
1716
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>{remoteError}</Text>
|
|
1717
|
+
) : null}
|
|
1718
|
+
{failed ? (
|
|
1719
|
+
<EmptyState
|
|
1720
|
+
icon="AlertCircle"
|
|
1721
|
+
title="Issues unavailable"
|
|
1722
|
+
description={data?.error ?? "Could not reach the forge."}
|
|
1723
|
+
actionLabel="Retry"
|
|
1724
|
+
onAction={() => refetch()}
|
|
1725
|
+
/>
|
|
1726
|
+
) : null}
|
|
1727
|
+
{data && !data.error && issues.length === 0 ? (
|
|
1728
|
+
<EmptyState
|
|
1729
|
+
icon={query.trim() ? "Search" : "CheckCircle2"}
|
|
1730
|
+
title={query.trim() ? "No matches" : "No open issues"}
|
|
1731
|
+
description={
|
|
1732
|
+
query.trim()
|
|
1733
|
+
? remoteSearch
|
|
1734
|
+
? "No open or closed issues matched on this forge."
|
|
1735
|
+
: "Try a different keyword or issue number."
|
|
1736
|
+
: "Nothing open on this repo right now."
|
|
1737
|
+
}
|
|
1738
|
+
/>
|
|
1739
|
+
) : null}
|
|
1740
|
+
{!failed
|
|
1741
|
+
? issues.map((issue: ForgeIssue) => (
|
|
1742
|
+
<IssueRow
|
|
1743
|
+
key={issue.number}
|
|
1744
|
+
number={issue.number}
|
|
1745
|
+
title={issue.title}
|
|
1746
|
+
state={issue.state}
|
|
1747
|
+
labels={issueLabelChips(issue)}
|
|
1748
|
+
repo={repo}
|
|
1749
|
+
host={data?.host ?? null}
|
|
1750
|
+
onSelect={setSelected}
|
|
1751
|
+
query={activeQuery}
|
|
1752
|
+
onLayoutY={(y) => rowTops.current.set(issue.number, y)}
|
|
1753
|
+
/>
|
|
1754
|
+
))
|
|
1755
|
+
: null}
|
|
1756
|
+
{!failed && hasMore && !query.trim() ? (
|
|
1757
|
+
<View style={styles.actions}>
|
|
1758
|
+
<Button
|
|
1759
|
+
label={loadMore.isPending ? "Loading…" : "Load more"}
|
|
1760
|
+
variant="secondary"
|
|
1761
|
+
disabled={loadMore.isPending}
|
|
1762
|
+
loading={loadMore.isPending}
|
|
1763
|
+
onPress={() => { loadMore.mutate({ directory: directory ?? undefined, remoteUrl: forgeTarget || undefined, page: nextPage }); }}
|
|
1764
|
+
/>
|
|
1765
|
+
</View>
|
|
1766
|
+
) : null}
|
|
1767
|
+
{moreError ? (
|
|
1768
|
+
<Text style={[styles.hint, { color: colors.foreground }]}>{moreError}</Text>
|
|
1769
|
+
) : null}
|
|
1770
|
+
</Card>
|
|
1771
|
+
</View>
|
|
1772
|
+
<Text style={[styles.note, { color: colors.foregroundMuted }]}>
|
|
1773
|
+
Tap an issue to open its detail view. Push-to-composer is
|
|
1774
|
+
unavailable: the v8 SDK exposes no composer-insert API, so copy
|
|
1775
|
+
the reference and paste it into chat.
|
|
1776
|
+
</Text>
|
|
1777
|
+
<View style={styles.actions}>
|
|
1778
|
+
<Button label="Refresh" variant="secondary" onPress={() => { resetPages(); refetch(); }} />
|
|
1779
|
+
{onClose ? <Button label="Close" variant="ghost" onPress={onClose} /> : null}
|
|
1780
|
+
</View>
|
|
1781
|
+
</>
|
|
1782
|
+
)}
|
|
1783
|
+
</>
|
|
1784
|
+
)}
|
|
1785
|
+
</ModalBody>
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
export function ForgeIssuesModal({ agentId, workspaceId, close }: RenderModalProps) {
|
|
1790
|
+
return <ForgeIssuesView agentId={agentId} workspaceId={workspaceId} onClose={close} />;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
export function ForgeIssuesPanel({ workspaceId }: { workspaceId: string }) {
|
|
1794
|
+
return <ForgeIssuesView workspaceId={workspaceId} />;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const styles = StyleSheet.create({
|
|
1798
|
+
pillContainer: {
|
|
1799
|
+
flexDirection: "row",
|
|
1800
|
+
alignItems: "center",
|
|
1801
|
+
flexWrap: "nowrap",
|
|
1802
|
+
gap: 6,
|
|
1803
|
+
paddingHorizontal: 6,
|
|
1804
|
+
minWidth: 44,
|
|
1805
|
+
minHeight: 22,
|
|
1806
|
+
overflow: "hidden",
|
|
1807
|
+
flexShrink: 1,
|
|
1808
|
+
},
|
|
1809
|
+
title: {
|
|
1810
|
+
fontSize: 11,
|
|
1811
|
+
fontWeight: "500",
|
|
1812
|
+
flexShrink: 1,
|
|
1813
|
+
},
|
|
1814
|
+
titleActive: {
|
|
1815
|
+
fontWeight: "700",
|
|
1816
|
+
},
|
|
1817
|
+
row: {
|
|
1818
|
+
flexDirection: "row",
|
|
1819
|
+
alignItems: "flex-start",
|
|
1820
|
+
gap: 8,
|
|
1821
|
+
paddingVertical: 6,
|
|
1822
|
+
},
|
|
1823
|
+
rowBody: {
|
|
1824
|
+
flex: 1,
|
|
1825
|
+
gap: 2,
|
|
1826
|
+
},
|
|
1827
|
+
rowTitle: {
|
|
1828
|
+
fontSize: 13,
|
|
1829
|
+
fontWeight: "600",
|
|
1830
|
+
},
|
|
1831
|
+
rowLabels: {
|
|
1832
|
+
flexDirection: "row",
|
|
1833
|
+
flexWrap: "wrap",
|
|
1834
|
+
gap: 4,
|
|
1835
|
+
},
|
|
1836
|
+
note: {
|
|
1837
|
+
fontSize: 11,
|
|
1838
|
+
fontStyle: "italic",
|
|
1839
|
+
},
|
|
1840
|
+
hint: {
|
|
1841
|
+
fontSize: 12,
|
|
1842
|
+
paddingVertical: 8,
|
|
1843
|
+
},
|
|
1844
|
+
accessChips: {
|
|
1845
|
+
flexDirection: "row",
|
|
1846
|
+
alignItems: "center",
|
|
1847
|
+
gap: 6,
|
|
1848
|
+
},
|
|
1849
|
+
forgeList: {
|
|
1850
|
+
gap: 4,
|
|
1851
|
+
paddingVertical: 4,
|
|
1852
|
+
},
|
|
1853
|
+
forgeRow: {
|
|
1854
|
+
flexDirection: "row",
|
|
1855
|
+
alignItems: "center",
|
|
1856
|
+
gap: 8,
|
|
1857
|
+
},
|
|
1858
|
+
forgeTarget: {
|
|
1859
|
+
fontSize: 12,
|
|
1860
|
+
flex: 1,
|
|
1861
|
+
},
|
|
1862
|
+
readOnlyNotice: {
|
|
1863
|
+
gap: 4,
|
|
1864
|
+
alignItems: "flex-start",
|
|
1865
|
+
},
|
|
1866
|
+
actions: {
|
|
1867
|
+
flexDirection: "row",
|
|
1868
|
+
gap: 8,
|
|
1869
|
+
justifyContent: "flex-end",
|
|
1870
|
+
},
|
|
1871
|
+
detail: {
|
|
1872
|
+
gap: 12,
|
|
1873
|
+
},
|
|
1874
|
+
badgeRow: {
|
|
1875
|
+
flexDirection: "row",
|
|
1876
|
+
flexWrap: "wrap",
|
|
1877
|
+
gap: 6,
|
|
1878
|
+
},
|
|
1879
|
+
labelGroup: {
|
|
1880
|
+
gap: 6,
|
|
1881
|
+
paddingTop: 4,
|
|
1882
|
+
},
|
|
1883
|
+
labelRow: {
|
|
1884
|
+
flexDirection: "row",
|
|
1885
|
+
flexWrap: "wrap",
|
|
1886
|
+
gap: 6,
|
|
1887
|
+
},
|
|
1888
|
+
labelChipPending: {
|
|
1889
|
+
opacity: 0.5,
|
|
1890
|
+
},
|
|
1891
|
+
sectionTitle: {
|
|
1892
|
+
fontSize: 11,
|
|
1893
|
+
fontWeight: "600",
|
|
1894
|
+
},
|
|
1895
|
+
markdown: {
|
|
1896
|
+
gap: 8,
|
|
1897
|
+
},
|
|
1898
|
+
bodyText: {
|
|
1899
|
+
fontSize: 13,
|
|
1900
|
+
lineHeight: 19,
|
|
1901
|
+
},
|
|
1902
|
+
bold: {
|
|
1903
|
+
fontWeight: "700",
|
|
1904
|
+
},
|
|
1905
|
+
italic: {
|
|
1906
|
+
fontStyle: "italic",
|
|
1907
|
+
},
|
|
1908
|
+
inlineCode: {
|
|
1909
|
+
fontFamily: "monospace",
|
|
1910
|
+
fontSize: 12,
|
|
1911
|
+
},
|
|
1912
|
+
link: {
|
|
1913
|
+
fontSize: 13,
|
|
1914
|
+
textDecorationLine: "underline",
|
|
1915
|
+
},
|
|
1916
|
+
heading1: {
|
|
1917
|
+
fontSize: 16,
|
|
1918
|
+
fontWeight: "700",
|
|
1919
|
+
lineHeight: 22,
|
|
1920
|
+
},
|
|
1921
|
+
heading2: {
|
|
1922
|
+
fontSize: 14,
|
|
1923
|
+
fontWeight: "700",
|
|
1924
|
+
lineHeight: 20,
|
|
1925
|
+
},
|
|
1926
|
+
heading3: {
|
|
1927
|
+
fontSize: 13,
|
|
1928
|
+
fontWeight: "700",
|
|
1929
|
+
lineHeight: 19,
|
|
1930
|
+
},
|
|
1931
|
+
list: {
|
|
1932
|
+
gap: 4,
|
|
1933
|
+
},
|
|
1934
|
+
listRow: {
|
|
1935
|
+
flexDirection: "row",
|
|
1936
|
+
gap: 6,
|
|
1937
|
+
alignItems: "flex-start",
|
|
1938
|
+
},
|
|
1939
|
+
bullet: {
|
|
1940
|
+
fontSize: 13,
|
|
1941
|
+
lineHeight: 19,
|
|
1942
|
+
minWidth: 14,
|
|
1943
|
+
},
|
|
1944
|
+
listText: {
|
|
1945
|
+
fontSize: 13,
|
|
1946
|
+
lineHeight: 19,
|
|
1947
|
+
flex: 1,
|
|
1948
|
+
},
|
|
1949
|
+
envelopeCard: {
|
|
1950
|
+
gap: 8,
|
|
1951
|
+
},
|
|
1952
|
+
shaList: {
|
|
1953
|
+
gap: 6,
|
|
1954
|
+
},
|
|
1955
|
+
commentCard: {
|
|
1956
|
+
gap: 8,
|
|
1957
|
+
},
|
|
1958
|
+
commentRow: {
|
|
1959
|
+
flexDirection: "row",
|
|
1960
|
+
alignItems: "flex-start",
|
|
1961
|
+
gap: 8,
|
|
1962
|
+
},
|
|
1963
|
+
commentBody: {
|
|
1964
|
+
flex: 1,
|
|
1965
|
+
},
|
|
1966
|
+
copyText: {
|
|
1967
|
+
fontSize: 14,
|
|
1968
|
+
},
|
|
1969
|
+
composer: {
|
|
1970
|
+
gap: 8,
|
|
1971
|
+
paddingTop: 4,
|
|
1972
|
+
},
|
|
1973
|
+
newIssueForm: {
|
|
1974
|
+
gap: 8,
|
|
1975
|
+
},
|
|
1976
|
+
});
|