Skip to content
This repository was archived by the owner on Mar 9, 2026. It is now read-only.

Commit 92c5eae

Browse files
author
Evie Gauthier
committed
Merge branch 'feat/in-app-bug-report' (PR 7w1#249)
2 parents db5eaa1 + 138e48f commit 92c5eae

8 files changed

Lines changed: 446 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
sable: minor
3+
---
4+
5+
feat: in-app bug report and feature request modal
6+
7+
Adds a `/bugreport` slash command and a "Report an Issue" button on the About settings page. Both open a modal where you fill out fields that mirror the repo's GitHub issue templates:
8+
9+
- **Bug Report**: description (required), steps to reproduce, expected behavior, platform/version info (auto-populated), and additional context
10+
- **Feature Request**: problem description (required), desired solution (required), alternatives considered, and additional context
11+
12+
The title field searches for duplicate open issues as you type. Submitting opens the pre-filled GitHub new issue form in a new tab — no authentication required.
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
import { useState, useEffect } from 'react';
2+
import FocusTrap from 'focus-trap-react';
3+
import {
4+
Box,
5+
Button,
6+
Chip,
7+
config,
8+
Header,
9+
Icon,
10+
IconButton,
11+
Icons,
12+
Input,
13+
Modal,
14+
Overlay,
15+
OverlayBackdrop,
16+
OverlayCenter,
17+
Scroll,
18+
Spinner,
19+
Text,
20+
TextArea,
21+
} from 'folds';
22+
import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal';
23+
import { stopPropagation } from '$utils/keyboard';
24+
25+
type ReportType = 'bug' | 'feature';
26+
27+
type SimilarIssue = {
28+
number: number;
29+
title: string;
30+
html_url: string;
31+
};
32+
33+
const GITHUB_REPO = '7w1/sable';
34+
35+
async function searchSimilarIssues(query: string, signal: AbortSignal): Promise<SimilarIssue[]> {
36+
// Split into individual words, drop very short ones, and join with OR so that
37+
// partial / stemmed titles (e.g. "reporting" still matches "report") surface results.
38+
const words = query
39+
.split(/[\s\-_/]+/)
40+
.map((w) => w.replace(/[^\w]/g, ''))
41+
.filter((w) => w.length >= 3);
42+
43+
if (words.length === 0) return [];
44+
45+
const q = `${words.join(' OR ')} repo:${GITHUB_REPO} is:issue is:open`;
46+
const params = new URLSearchParams({ q, per_page: '5' });
47+
const res = await fetch(`https://api.github.com/search/issues?${params}`, { signal });
48+
if (!res.ok) return [];
49+
const data = (await res.json()) as { items?: SimilarIssue[] };
50+
return data.items ?? [];
51+
}
52+
53+
// Field IDs match the ids defined in .github/ISSUE_TEMPLATE/bug_report.yml
54+
// and feature_request.yml so GitHub pre-fills each form field directly.
55+
function buildGitHubUrl(type: ReportType, title: string, fields: Record<string, string>): string {
56+
const devLabel = IS_RELEASE_TAG ? '' : '-dev';
57+
const buildLabel = BUILD_HASH ? ` (${BUILD_HASH})` : '';
58+
const version = `v${APP_VERSION}${devLabel}${buildLabel}`;
59+
60+
const params: Record<string, string> = { title };
61+
62+
if (type === 'bug') {
63+
params.template = 'bug_report.yml';
64+
if (fields.description) params.description = fields.description;
65+
if (fields.reproduction) params.reproduction = fields.reproduction;
66+
if (fields['expected-behavior']) params['expected-behavior'] = fields['expected-behavior'];
67+
// Auto-populate the platform/versions field
68+
params.info = `- OS: ${navigator.platform || 'unknown'}\n- Browser: ${navigator.userAgent}\n- Sable: ${version}`;
69+
if (fields.context) params.context = fields.context;
70+
} else {
71+
params.template = 'feature_request.yml';
72+
if (fields.problem) params.problem = fields.problem;
73+
if (fields.solution) params.solution = fields.solution;
74+
if (fields.alternatives) params.alternatives = fields.alternatives;
75+
if (fields.context) params.context = fields.context;
76+
}
77+
78+
return `https://github.com/${GITHUB_REPO}/issues/new?${new URLSearchParams(params)}`;
79+
}
80+
81+
function BugReportModal() {
82+
const close = useCloseBugReportModal();
83+
const [type, setType] = useState<ReportType>('bug');
84+
const [title, setTitle] = useState('');
85+
86+
// Bug fields (match bug_report.yml ids)
87+
const [description, setDescription] = useState('');
88+
const [reproduction, setReproduction] = useState('');
89+
const [expectedBehavior, setExpectedBehavior] = useState('');
90+
91+
// Feature fields (match feature_request.yml ids)
92+
const [problem, setProblem] = useState('');
93+
const [solution, setSolution] = useState('');
94+
const [alternatives, setAlternatives] = useState('');
95+
96+
// Shared optional field
97+
const [context, setContext] = useState('');
98+
99+
const [similarIssues, setSimilarIssues] = useState<SimilarIssue[]>([]);
100+
const [searching, setSearching] = useState(false);
101+
102+
useEffect(() => {
103+
const trimmed = title.trim();
104+
const controller = new AbortController();
105+
let cancelled = false;
106+
let timer: ReturnType<typeof setTimeout> | undefined;
107+
108+
if (trimmed.length >= 3) {
109+
timer = setTimeout(async () => {
110+
setSearching(true);
111+
try {
112+
const issues = await searchSimilarIssues(trimmed, controller.signal);
113+
if (!cancelled) setSimilarIssues(issues);
114+
} catch {
115+
// silently ignore network errors / rate limits
116+
} finally {
117+
if (!cancelled) setSearching(false);
118+
}
119+
}, 600);
120+
} else {
121+
setSimilarIssues([]);
122+
setSearching(false);
123+
}
124+
125+
return () => {
126+
cancelled = true;
127+
if (timer !== undefined) clearTimeout(timer);
128+
controller.abort();
129+
};
130+
}, [title]);
131+
132+
const canSubmit =
133+
title.trim().length > 0 &&
134+
(type === 'bug'
135+
? description.trim().length > 0
136+
: problem.trim().length > 0 && solution.trim().length > 0);
137+
138+
const handleSubmit = () => {
139+
if (!canSubmit) return;
140+
const fields: Record<string, string> =
141+
type === 'bug'
142+
? { description, reproduction, 'expected-behavior': expectedBehavior, context }
143+
: { problem, solution, alternatives, context };
144+
const url = buildGitHubUrl(type, title.trim(), fields);
145+
window.open(url, '_blank', 'noopener,noreferrer');
146+
close();
147+
};
148+
149+
return (
150+
<Overlay open backdrop={<OverlayBackdrop />}>
151+
<OverlayCenter>
152+
<FocusTrap
153+
focusTrapOptions={{
154+
initialFocus: false,
155+
clickOutsideDeactivates: true,
156+
onDeactivate: close,
157+
escapeDeactivates: stopPropagation,
158+
}}
159+
>
160+
<Modal size="500" flexHeight variant="Surface">
161+
<Box direction="Column">
162+
<Header
163+
size="500"
164+
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
165+
>
166+
<Box grow="Yes">
167+
<Text size="H4">Report an Issue</Text>
168+
</Box>
169+
<IconButton size="300" radii="300" onClick={close}>
170+
<Icon src={Icons.Cross} />
171+
</IconButton>
172+
</Header>
173+
<Scroll size="300" hideTrack>
174+
<Box
175+
style={{ padding: config.space.S400, paddingRight: config.space.S200 }}
176+
direction="Column"
177+
gap="500"
178+
>
179+
{/* Type */}
180+
<Box direction="Column" gap="100">
181+
<Text size="L400">Type</Text>
182+
<Box gap="200">
183+
<Chip
184+
radii="Pill"
185+
variant={type === 'bug' ? 'Primary' : 'SurfaceVariant'}
186+
aria-pressed={type === 'bug'}
187+
onClick={() => setType('bug')}
188+
>
189+
<Text size="T300">Bug Report</Text>
190+
</Chip>
191+
<Chip
192+
radii="Pill"
193+
variant={type === 'feature' ? 'Primary' : 'SurfaceVariant'}
194+
aria-pressed={type === 'feature'}
195+
onClick={() => setType('feature')}
196+
>
197+
<Text size="T300">Feature Request</Text>
198+
</Chip>
199+
</Box>
200+
</Box>
201+
202+
{/* Title + duplicate check */}
203+
<Box direction="Column" gap="100">
204+
<Text size="L400">Title *</Text>
205+
<Input
206+
size="500"
207+
variant="SurfaceVariant"
208+
radii="400"
209+
autoFocus
210+
placeholder="Brief description"
211+
value={title}
212+
onChange={(e) => setTitle((e.target as HTMLInputElement).value)}
213+
/>
214+
{searching && (
215+
<Box gap="200" alignItems="Center">
216+
<Spinner size="100" variant="Secondary" />
217+
<Text size="T200">Searching for similar issues…</Text>
218+
</Box>
219+
)}
220+
{!searching && similarIssues.length > 0 && (
221+
<Box direction="Column" gap="100">
222+
<Text size="T200">
223+
Similar open issues — please check before submitting:
224+
</Text>
225+
{similarIssues.map((issue) => (
226+
<Text key={issue.number} size="T200">
227+
{'→ '}
228+
<a href={issue.html_url} target="_blank" rel="noopener noreferrer">
229+
#{issue.number}: {issue.title}
230+
</a>
231+
</Text>
232+
))}
233+
</Box>
234+
)}
235+
</Box>
236+
237+
{/* Description */}
238+
<Box direction="Column" gap="100">
239+
<Text size="L400">
240+
{type === 'bug' ? 'Describe the bug *' : 'Describe the problem *'}
241+
</Text>
242+
<TextArea
243+
size="500"
244+
variant="SurfaceVariant"
245+
radii="400"
246+
rows={4}
247+
placeholder={
248+
type === 'bug'
249+
? 'A clear description of what the bug is.'
250+
: 'A clear description of the problem this feature would solve.'
251+
}
252+
value={type === 'bug' ? description : problem}
253+
onChange={(e) =>
254+
type === 'bug'
255+
? setDescription((e.target as HTMLTextAreaElement).value)
256+
: setProblem((e.target as HTMLTextAreaElement).value)
257+
}
258+
/>
259+
</Box>
260+
261+
{/* Bug: steps to reproduce */}
262+
{type === 'bug' && (
263+
<Box direction="Column" gap="100">
264+
<Text size="L400">Steps to reproduce (optional)</Text>
265+
<TextArea
266+
size="500"
267+
variant="SurfaceVariant"
268+
radii="400"
269+
rows={3}
270+
placeholder={'1. Go to…\n2. Click on…\n3. See error'}
271+
value={reproduction}
272+
onChange={(e) => setReproduction((e.target as HTMLTextAreaElement).value)}
273+
/>
274+
</Box>
275+
)}
276+
277+
{/* Bug: expected behavior */}
278+
{type === 'bug' && (
279+
<Box direction="Column" gap="100">
280+
<Text size="L400">Expected behavior (optional)</Text>
281+
<TextArea
282+
size="500"
283+
variant="SurfaceVariant"
284+
radii="400"
285+
rows={2}
286+
placeholder="A clear description of what you expected to happen."
287+
value={expectedBehavior}
288+
onChange={(e) =>
289+
setExpectedBehavior((e.target as HTMLTextAreaElement).value)
290+
}
291+
/>
292+
</Box>
293+
)}
294+
295+
{/* Feature: solution */}
296+
{type === 'feature' && (
297+
<Box direction="Column" gap="100">
298+
<Text size="L400">Describe the solution you&apos;d like *</Text>
299+
<TextArea
300+
size="500"
301+
variant="SurfaceVariant"
302+
radii="400"
303+
rows={3}
304+
placeholder="I would like to…"
305+
value={solution}
306+
onChange={(e) => setSolution((e.target as HTMLTextAreaElement).value)}
307+
/>
308+
</Box>
309+
)}
310+
311+
{/* Feature: alternatives */}
312+
{type === 'feature' && (
313+
<Box direction="Column" gap="100">
314+
<Text size="L400">Alternatives considered (optional)</Text>
315+
<TextArea
316+
size="500"
317+
variant="SurfaceVariant"
318+
radii="400"
319+
rows={2}
320+
placeholder="Any alternative solutions or features you've considered."
321+
value={alternatives}
322+
onChange={(e) => setAlternatives((e.target as HTMLTextAreaElement).value)}
323+
/>
324+
</Box>
325+
)}
326+
327+
{/* Platform info for bugs */}
328+
{type === 'bug' && (
329+
<Box direction="Column" gap="100">
330+
<Text size="L400">Platform info (auto-included)</Text>
331+
<Text size="T200" style={{ opacity: 0.7, wordBreak: 'break-all' }}>
332+
{`Sable v${APP_VERSION}${IS_RELEASE_TAG ? '' : '-dev'}${navigator.userAgent}`}
333+
</Text>
334+
</Box>
335+
)}
336+
337+
{/* Additional context — shared */}
338+
<Box direction="Column" gap="100">
339+
<Text size="L400">Additional context (optional)</Text>
340+
<TextArea
341+
size="500"
342+
variant="SurfaceVariant"
343+
radii="400"
344+
rows={2}
345+
placeholder="Any other context or screenshots."
346+
value={context}
347+
onChange={(e) => setContext((e.target as HTMLTextAreaElement).value)}
348+
/>
349+
</Box>
350+
351+
{/* Actions */}
352+
<Box gap="300" justifyContent="End">
353+
<Button size="400" variant="Secondary" fill="None" radii="400" onClick={close}>
354+
<Text size="B400">Cancel</Text>
355+
</Button>
356+
<Button
357+
size="400"
358+
variant="Primary"
359+
radii="400"
360+
disabled={!canSubmit}
361+
onClick={handleSubmit}
362+
after={<Icon src={Icons.ArrowRight} size="100" />}
363+
>
364+
<Text size="B400">Open on GitHub</Text>
365+
</Button>
366+
</Box>
367+
</Box>
368+
</Scroll>
369+
</Box>
370+
</Modal>
371+
</FocusTrap>
372+
</OverlayCenter>
373+
</Overlay>
374+
);
375+
}
376+
377+
export function BugReportModalRenderer() {
378+
const open = useBugReportModalOpen();
379+
380+
if (!open) return null;
381+
return <BugReportModal />;
382+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BugReportModalRenderer } from './BugReportModal';

0 commit comments

Comments
 (0)