@toolr/ui-design 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/README.md +63 -0
- package/components/content/info-panel-primitives.tsx +297 -0
- package/components/diagrams/diagram-utils.tsx +908 -0
- package/components/hooks/use-click-outside.ts +27 -0
- package/components/hooks/use-dropdown-max-height.ts +20 -0
- package/components/hooks/use-navigation-history.ts +94 -0
- package/components/lib/ai-tools.tsx +44 -0
- package/components/lib/cn.ts +6 -0
- package/components/lib/form-colors.ts +32 -0
- package/components/lib/theme-engine.ts +97 -0
- package/components/lib/toolr-brand.tsx +31 -0
- package/components/sections/ai-tools-paths/index.ts +37 -0
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
- package/components/sections/ai-tools-paths/types.ts +111 -0
- package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
- package/components/sections/captured-issues/index.ts +38 -0
- package/components/sections/captured-issues/types.ts +113 -0
- package/components/sections/captured-issues/use-captured-issues.ts +111 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
- package/components/sections/golden-snapshots/index.ts +145 -0
- package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
- package/components/sections/golden-snapshots/status-overview.tsx +305 -0
- package/components/sections/golden-snapshots/types.ts +288 -0
- package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
- package/components/sections/golden-snapshots/version-manager.tsx +186 -0
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
- package/components/sections/prompt-editor/index.ts +121 -0
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
- package/components/sections/prompt-editor/types.ts +101 -0
- package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
- package/components/sections/report-bug/error-logger.ts +392 -0
- package/components/sections/report-bug/index.ts +59 -0
- package/components/sections/report-bug/issue-reporter-api.ts +83 -0
- package/components/sections/report-bug/report-bug-form.tsx +282 -0
- package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
- package/components/sections/report-bug/use-report-bug.ts +170 -0
- package/components/sections/snapshot-browser/index.ts +53 -0
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
- package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
- package/components/sections/snapshot-browser/types.ts +106 -0
- package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
- package/components/sections/snippets-editor/index.ts +31 -0
- package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
- package/components/sections/snippets-editor/types.ts +48 -0
- package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
- package/components/ui/action-dialog.tsx +309 -0
- package/components/ui/ai-action-button.tsx +137 -0
- package/components/ui/ai-execution-action-buttons.tsx +106 -0
- package/components/ui/badge.tsx +67 -0
- package/components/ui/bottom-panel-header.tsx +240 -0
- package/components/ui/breadcrumb.tsx +168 -0
- package/components/ui/checkbox.tsx +102 -0
- package/components/ui/collapsible-section.tsx +100 -0
- package/components/ui/confirm-badge.tsx +71 -0
- package/components/ui/detail-section.tsx +67 -0
- package/components/ui/detail-view-wrapper.tsx +55 -0
- package/components/ui/editor-placeholder-card.tsx +197 -0
- package/components/ui/editor-toolbar.tsx +123 -0
- package/components/ui/execution-details-panel.tsx +93 -0
- package/components/ui/extension-list-card.tsx +105 -0
- package/components/ui/file-structure-section.tsx +373 -0
- package/components/ui/file-tree.tsx +171 -0
- package/components/ui/files-panel.tsx +251 -0
- package/components/ui/filter-dropdown.tsx +173 -0
- package/components/ui/form-actions.tsx +127 -0
- package/components/ui/frontmatter-form-header.tsx +80 -0
- package/components/ui/icon-button.tsx +388 -0
- package/components/ui/input.tsx +211 -0
- package/components/ui/label.tsx +159 -0
- package/components/ui/layout-tab-bar.tsx +289 -0
- package/components/ui/modal.tsx +194 -0
- package/components/ui/nav-card.tsx +81 -0
- package/components/ui/navigation-bar.tsx +285 -0
- package/components/ui/number-input.tsx +165 -0
- package/components/ui/registry-browser.tsx +261 -0
- package/components/ui/registry-card.tsx +710 -0
- package/components/ui/registry-detail.tsx +224 -0
- package/components/ui/resizable-textarea.tsx +290 -0
- package/components/ui/scope-badge.tsx +67 -0
- package/components/ui/segmented-toggle.tsx +133 -0
- package/components/ui/select.tsx +172 -0
- package/components/ui/selection-grid.tsx +313 -0
- package/components/ui/setting-row.tsx +97 -0
- package/components/ui/snapshot-card.tsx +107 -0
- package/components/ui/snippets-panel.tsx +161 -0
- package/components/ui/sort-dropdown.tsx +109 -0
- package/components/ui/status-card.tsx +96 -0
- package/components/ui/tab-bar.tsx +340 -0
- package/components/ui/toggle.tsx +142 -0
- package/components/ui/tooltip.tsx +326 -0
- package/dist/content.d.ts +110 -0
- package/dist/content.js +195 -0
- package/dist/diagrams.d.ts +371 -0
- package/dist/diagrams.js +702 -0
- package/dist/index.d.ts +2714 -0
- package/dist/index.js +11220 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +17 -0
- package/dist/tokens/tokens/primitives.css +45 -0
- package/dist/tokens/tokens/semantic.css +46 -0
- package/dist/tokens/tokens/theme.css +11 -0
- package/dist/tokens/tokens/tokens.json +65 -0
- package/index.ts +123 -0
- package/package.json +63 -0
- package/tailwind-preset.ts +22 -0
- package/tokens/primitives.css +45 -0
- package/tokens/semantic.css +46 -0
- package/tokens/theme.css +11 -0
- package/tokens/tokens.json +65 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePromptEditor — Hook managing prompt editor state
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Prompt Editor
|
|
5
|
+
*
|
|
6
|
+
* Handles:
|
|
7
|
+
* - Local content state (separate from saved content for dirty detection)
|
|
8
|
+
* - Dirty state detection
|
|
9
|
+
* - Save with Ctrl/Cmd+S keyboard shortcut
|
|
10
|
+
* - Variable search/filter
|
|
11
|
+
* - Active tab state
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
15
|
+
import type { ToolTab, PromptPlaceholder } from './types.ts'
|
|
16
|
+
|
|
17
|
+
export interface UsePromptEditorOptions {
|
|
18
|
+
/** Prompt content keyed by tool id */
|
|
19
|
+
prompts: Record<string, string>
|
|
20
|
+
/** Called when saving (all modified prompts at once) */
|
|
21
|
+
onPromptChange: (tool: string, value: string) => void
|
|
22
|
+
/** Available tool tabs */
|
|
23
|
+
tools: ToolTab[]
|
|
24
|
+
/** Available template variables */
|
|
25
|
+
variables?: PromptPlaceholder[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UsePromptEditorReturn {
|
|
29
|
+
/** Currently active tool tab id */
|
|
30
|
+
activeTab: string
|
|
31
|
+
setActiveTab: (tab: string) => void
|
|
32
|
+
/** Local editor content (may differ from saved prompts) */
|
|
33
|
+
localContent: Record<string, string>
|
|
34
|
+
/** Current prompt for the active tab */
|
|
35
|
+
currentPrompt: string
|
|
36
|
+
/** Whether any tab has unsaved changes */
|
|
37
|
+
isDirty: boolean
|
|
38
|
+
/** Handle editor content change */
|
|
39
|
+
handleEditorChange: (value: string | undefined) => void
|
|
40
|
+
/** Save all modified prompts */
|
|
41
|
+
handleSave: () => void
|
|
42
|
+
/** Variable search state */
|
|
43
|
+
variableSearch: string
|
|
44
|
+
setVariableSearch: (search: string) => void
|
|
45
|
+
/** Filtered variables based on search */
|
|
46
|
+
filteredVariables: PromptPlaceholder[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function usePromptEditor({
|
|
50
|
+
prompts,
|
|
51
|
+
onPromptChange,
|
|
52
|
+
tools,
|
|
53
|
+
variables,
|
|
54
|
+
}: UsePromptEditorOptions): UsePromptEditorReturn {
|
|
55
|
+
const [activeTab, setActiveTab] = useState(tools[0]?.id ?? '')
|
|
56
|
+
const [localContent, setLocalContent] = useState<Record<string, string>>(prompts)
|
|
57
|
+
const [isDirty, setIsDirty] = useState(false)
|
|
58
|
+
const [variableSearch, setVariableSearch] = useState('')
|
|
59
|
+
|
|
60
|
+
// Sync local content when prompts change externally
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
setLocalContent(prompts)
|
|
63
|
+
setIsDirty(false)
|
|
64
|
+
}, [prompts])
|
|
65
|
+
|
|
66
|
+
const handleEditorChange = useCallback(
|
|
67
|
+
(value: string | undefined) => {
|
|
68
|
+
const newValue = value ?? ''
|
|
69
|
+
setLocalContent((prev) => {
|
|
70
|
+
const updated = { ...prev, [activeTab]: newValue }
|
|
71
|
+
const hasDiff = Object.keys(prompts).some(
|
|
72
|
+
(tool) => updated[tool] !== prompts[tool],
|
|
73
|
+
)
|
|
74
|
+
setIsDirty(hasDiff)
|
|
75
|
+
return updated
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
[activeTab, prompts],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const handleSave = useCallback(() => {
|
|
82
|
+
for (const tool of Object.keys(prompts)) {
|
|
83
|
+
if (localContent[tool] !== prompts[tool]) {
|
|
84
|
+
onPromptChange(tool, localContent[tool])
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
setIsDirty(false)
|
|
88
|
+
}, [localContent, prompts, onPromptChange])
|
|
89
|
+
|
|
90
|
+
// Keyboard shortcut: Cmd/Ctrl + S
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
93
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
if (isDirty) {
|
|
96
|
+
handleSave()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
101
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
102
|
+
}, [isDirty, handleSave])
|
|
103
|
+
|
|
104
|
+
const currentPrompt = localContent[activeTab] ?? ''
|
|
105
|
+
|
|
106
|
+
// Filter variables based on search
|
|
107
|
+
const filteredVariables = useMemo(() => {
|
|
108
|
+
if (!variables) return []
|
|
109
|
+
if (!variableSearch.trim()) return variables
|
|
110
|
+
const search = variableSearch.toLowerCase()
|
|
111
|
+
return variables.filter(
|
|
112
|
+
(v) =>
|
|
113
|
+
v.name.toLowerCase().includes(search) ||
|
|
114
|
+
v.description.toLowerCase().includes(search) ||
|
|
115
|
+
(v.example && v.example.toLowerCase().includes(search)),
|
|
116
|
+
)
|
|
117
|
+
}, [variables, variableSearch])
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
activeTab,
|
|
121
|
+
setActiveTab,
|
|
122
|
+
localContent,
|
|
123
|
+
currentPrompt,
|
|
124
|
+
isDirty,
|
|
125
|
+
handleEditorChange,
|
|
126
|
+
handleSave,
|
|
127
|
+
variableSearch,
|
|
128
|
+
setVariableSearch,
|
|
129
|
+
filteredVariables,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Logger — Automatic console error capture for issue reporting
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Report a Bug
|
|
5
|
+
*
|
|
6
|
+
* Provides a factory function to create app-specific error loggers.
|
|
7
|
+
* Each logger instance has its own localStorage namespace and in-memory state.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const logger = createErrorLogger('myapp')
|
|
11
|
+
* logger.init() // Call early in app startup to begin capturing
|
|
12
|
+
* // Later, pass captured data to ReportBugForm:
|
|
13
|
+
* <ReportBugForm
|
|
14
|
+
* errorReport={logger.getFormattedReport()}
|
|
15
|
+
* errorCount={logger.getErrorCount()}
|
|
16
|
+
* warnCount={logger.getWarnCount()}
|
|
17
|
+
* onErrorsSubmitted={() => { logger.markAsSubmitted(); logger.clearLogs() }}
|
|
18
|
+
* />
|
|
19
|
+
*
|
|
20
|
+
* AI agent notes:
|
|
21
|
+
* - createErrorLogger returns an isolated instance with its own storage prefix
|
|
22
|
+
* - Three-list architecture: Current (active) -> Submitted (reported) | Dismissed (ignored)
|
|
23
|
+
* - Errors are deduplicated by fingerprint (djb2 hash of first error line)
|
|
24
|
+
* - init() intercepts console.error, console.warn, and window error events
|
|
25
|
+
* - subscribe/getSnapshot are for React's useSyncExternalStore
|
|
26
|
+
* - The form component (ReportBugForm) does NOT depend on this directly;
|
|
27
|
+
* it accepts errorReport/errorCount as props, keeping the two decoupled
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface TrackedError {
|
|
31
|
+
fingerprint: string
|
|
32
|
+
firstMessage: string
|
|
33
|
+
count: number
|
|
34
|
+
firstSeen: string
|
|
35
|
+
lastSeen: string
|
|
36
|
+
stack?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ErrorLogger {
|
|
40
|
+
/** Start capturing console.error, console.warn, and unhandled errors */
|
|
41
|
+
init(): void
|
|
42
|
+
/** Number of unique current (unreported) errors */
|
|
43
|
+
getErrorCount(): number
|
|
44
|
+
/** Number of warnings in the log buffer */
|
|
45
|
+
getWarnCount(): number
|
|
46
|
+
/** Current errors not yet submitted or dismissed */
|
|
47
|
+
getCurrentErrors(): TrackedError[]
|
|
48
|
+
/** Previously submitted errors */
|
|
49
|
+
getSubmittedErrors(): TrackedError[]
|
|
50
|
+
/** Markdown-formatted error report for Linear issue submission */
|
|
51
|
+
getFormattedReport(): string
|
|
52
|
+
/** Combined fingerprint of all current errors (for duplicate detection) */
|
|
53
|
+
getErrorFingerprint(): string
|
|
54
|
+
/** Raw log output as a string */
|
|
55
|
+
getLogs(): string
|
|
56
|
+
/** Move all current errors to the submitted list */
|
|
57
|
+
markAsSubmitted(): void
|
|
58
|
+
/** Move all current errors to the dismissed list */
|
|
59
|
+
markAsDismissed(): void
|
|
60
|
+
/** Clear the in-memory log buffer (does not affect tracked errors) */
|
|
61
|
+
clearLogs(): void
|
|
62
|
+
/** For React useSyncExternalStore */
|
|
63
|
+
subscribe(listener: () => void): () => void
|
|
64
|
+
/** For React useSyncExternalStore */
|
|
65
|
+
getSnapshot(): number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface LogEntry {
|
|
69
|
+
timestamp: string
|
|
70
|
+
level: 'error' | 'warn' | 'info'
|
|
71
|
+
message: string
|
|
72
|
+
stack?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function generateFingerprint(message: string): string {
|
|
76
|
+
const normalized = message.split('\n')[0].trim()
|
|
77
|
+
let hash = 5381
|
|
78
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
79
|
+
hash = ((hash << 5) + hash) ^ normalized.charCodeAt(i)
|
|
80
|
+
}
|
|
81
|
+
return (hash >>> 0).toString(16).padStart(8, '0')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanStackTrace(stack: string): string {
|
|
85
|
+
return stack
|
|
86
|
+
.replace(/https?:\/\/localhost:\d+\//g, '')
|
|
87
|
+
.replace(/https?:\/\/127\.0\.0\.1:\d+\//g, '')
|
|
88
|
+
.replace(/node_modules\/\.vite\/deps\//g, '')
|
|
89
|
+
.split('\n')
|
|
90
|
+
.map((line) => line.trim())
|
|
91
|
+
.filter((line) => line.length > 0)
|
|
92
|
+
.join('\n')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatTimestamp(isoString: string): string {
|
|
96
|
+
try {
|
|
97
|
+
return new Date(isoString).toLocaleString('en-US', {
|
|
98
|
+
year: 'numeric',
|
|
99
|
+
month: 'short',
|
|
100
|
+
day: 'numeric',
|
|
101
|
+
hour: '2-digit',
|
|
102
|
+
minute: '2-digit',
|
|
103
|
+
second: '2-digit',
|
|
104
|
+
hour12: false,
|
|
105
|
+
})
|
|
106
|
+
} catch {
|
|
107
|
+
return isoString
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const MAX_LOGS = 100
|
|
112
|
+
const MAX_TRACKED = 100
|
|
113
|
+
|
|
114
|
+
export function createErrorLogger(storagePrefix: string): ErrorLogger {
|
|
115
|
+
const CURRENT_KEY = `${storagePrefix}_current_errors`
|
|
116
|
+
const SUBMITTED_KEY = `${storagePrefix}_submitted_errors`
|
|
117
|
+
const DISMISSED_KEY = `${storagePrefix}_dismissed_errors`
|
|
118
|
+
|
|
119
|
+
const logs: LogEntry[] = []
|
|
120
|
+
let currentErrors: Map<string, TrackedError> = new Map()
|
|
121
|
+
const listeners = new Set<() => void>()
|
|
122
|
+
let snapshotVersion = 0
|
|
123
|
+
|
|
124
|
+
function notifyListeners() {
|
|
125
|
+
snapshotVersion++
|
|
126
|
+
listeners.forEach((l) => l())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getTrackedErrors(key: string): Map<string, TrackedError> {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof window === 'undefined') return new Map()
|
|
132
|
+
const stored = localStorage.getItem(key)
|
|
133
|
+
if (!stored) return new Map()
|
|
134
|
+
const arr: TrackedError[] = JSON.parse(stored)
|
|
135
|
+
return new Map(arr.map((e) => [e.fingerprint, e]))
|
|
136
|
+
} catch {
|
|
137
|
+
return new Map()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function saveTrackedErrors(key: string, errors: Map<string, TrackedError>): void {
|
|
142
|
+
try {
|
|
143
|
+
if (typeof window === 'undefined') return
|
|
144
|
+
const arr = Array.from(errors.values()).slice(-MAX_TRACKED)
|
|
145
|
+
localStorage.setItem(key, JSON.stringify(arr))
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore storage errors
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function loadCurrentErrors(): void {
|
|
152
|
+
currentErrors = getTrackedErrors(CURRENT_KEY)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function saveCurrentErrors(): void {
|
|
156
|
+
saveTrackedErrors(CURRENT_KEY, currentErrors)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tryUpdateTrackedError(storageKey: string, fingerprint: string, now: string, entry: LogEntry): boolean {
|
|
160
|
+
const errors = getTrackedErrors(storageKey)
|
|
161
|
+
if (!errors.has(fingerprint)) return false
|
|
162
|
+
const existing = errors.get(fingerprint)!
|
|
163
|
+
existing.count++
|
|
164
|
+
existing.lastSeen = now
|
|
165
|
+
saveTrackedErrors(storageKey, errors)
|
|
166
|
+
logs.push(entry)
|
|
167
|
+
if (logs.length > MAX_LOGS) logs.shift()
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function addLog(level: LogEntry['level'], args: unknown[]): void {
|
|
172
|
+
const message = args
|
|
173
|
+
.map((arg) => {
|
|
174
|
+
if (arg instanceof Error) return `${arg.message}\n${arg.stack || ''}`
|
|
175
|
+
if (typeof arg === 'object') {
|
|
176
|
+
try {
|
|
177
|
+
return JSON.stringify(arg, null, 2)
|
|
178
|
+
} catch {
|
|
179
|
+
return String(arg)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return String(arg)
|
|
183
|
+
})
|
|
184
|
+
.join(' ')
|
|
185
|
+
|
|
186
|
+
const entry: LogEntry = { timestamp: new Date().toISOString(), level, message }
|
|
187
|
+
|
|
188
|
+
const errorArg = args.find((arg) => arg instanceof Error) as Error | undefined
|
|
189
|
+
if (errorArg?.stack) entry.stack = errorArg.stack
|
|
190
|
+
|
|
191
|
+
if (level !== 'error') {
|
|
192
|
+
logs.push(entry)
|
|
193
|
+
if (logs.length > MAX_LOGS) logs.shift()
|
|
194
|
+
if (level === 'warn') notifyListeners()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fingerprint = generateFingerprint(message)
|
|
199
|
+
const now = new Date().toISOString()
|
|
200
|
+
const firstLine = message.split('\n')[0]
|
|
201
|
+
const truncatedMessage = firstLine.length > 300 ? firstLine.slice(0, 300) + '...' : firstLine
|
|
202
|
+
|
|
203
|
+
if (tryUpdateTrackedError(DISMISSED_KEY, fingerprint, now, entry)) return
|
|
204
|
+
if (tryUpdateTrackedError(SUBMITTED_KEY, fingerprint, now, entry)) return
|
|
205
|
+
|
|
206
|
+
if (currentErrors.has(fingerprint)) {
|
|
207
|
+
const existing = currentErrors.get(fingerprint)!
|
|
208
|
+
existing.count++
|
|
209
|
+
existing.lastSeen = now
|
|
210
|
+
saveCurrentErrors()
|
|
211
|
+
logs.push(entry)
|
|
212
|
+
if (logs.length > MAX_LOGS) logs.shift()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
currentErrors.set(fingerprint, {
|
|
217
|
+
fingerprint,
|
|
218
|
+
firstMessage: truncatedMessage,
|
|
219
|
+
count: 1,
|
|
220
|
+
firstSeen: now,
|
|
221
|
+
lastSeen: now,
|
|
222
|
+
stack: entry.stack,
|
|
223
|
+
})
|
|
224
|
+
saveCurrentErrors()
|
|
225
|
+
notifyListeners()
|
|
226
|
+
|
|
227
|
+
logs.push(entry)
|
|
228
|
+
if (logs.length > MAX_LOGS) logs.shift()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
init() {
|
|
233
|
+
loadCurrentErrors()
|
|
234
|
+
|
|
235
|
+
const originalError = console.error
|
|
236
|
+
const originalWarn = console.warn
|
|
237
|
+
|
|
238
|
+
console.error = (...args: unknown[]) => {
|
|
239
|
+
addLog('error', args)
|
|
240
|
+
originalError.apply(console, args)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.warn = (...args: unknown[]) => {
|
|
244
|
+
addLog('warn', args)
|
|
245
|
+
originalWarn.apply(console, args)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (typeof window !== 'undefined') {
|
|
249
|
+
window.addEventListener('error', (event) => {
|
|
250
|
+
addLog('error', [
|
|
251
|
+
`Unhandled error: ${event.message}`,
|
|
252
|
+
event.error || { filename: event.filename, lineno: event.lineno, colno: event.colno },
|
|
253
|
+
])
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
257
|
+
addLog('error', [`Unhandled promise rejection: ${event.reason}`])
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
getErrorCount() {
|
|
263
|
+
return currentErrors.size
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
getWarnCount() {
|
|
267
|
+
return logs.filter((e) => e.level === 'warn').length
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
getCurrentErrors() {
|
|
271
|
+
return Array.from(currentErrors.values())
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
getSubmittedErrors() {
|
|
275
|
+
return Array.from(getTrackedErrors(SUBMITTED_KEY).values())
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
getFormattedReport() {
|
|
279
|
+
const errors = Array.from(currentErrors.values())
|
|
280
|
+
if (errors.length === 0) return 'No errors captured.'
|
|
281
|
+
|
|
282
|
+
const parts: string[] = []
|
|
283
|
+
parts.push('## Error Report')
|
|
284
|
+
parts.push('')
|
|
285
|
+
parts.push(`**${errors.length} unique error${errors.length > 1 ? 's' : ''} captured**`)
|
|
286
|
+
parts.push('')
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < errors.length; i++) {
|
|
289
|
+
const error = errors[i]
|
|
290
|
+
const countInfo = error.count > 1 ? ` (x${error.count})` : ''
|
|
291
|
+
parts.push(`### ${i + 1}. ${error.firstMessage}${countInfo}`)
|
|
292
|
+
parts.push('')
|
|
293
|
+
parts.push('| Field | Value |')
|
|
294
|
+
parts.push('|-------|-------|')
|
|
295
|
+
parts.push(`| Fingerprint | \`${error.fingerprint}\` |`)
|
|
296
|
+
parts.push(`| First seen | ${formatTimestamp(error.firstSeen)} |`)
|
|
297
|
+
if (error.count > 1) {
|
|
298
|
+
parts.push(`| Last seen | ${formatTimestamp(error.lastSeen)} |`)
|
|
299
|
+
parts.push(`| Occurrences | ${error.count} |`)
|
|
300
|
+
}
|
|
301
|
+
parts.push('')
|
|
302
|
+
|
|
303
|
+
if (error.stack) {
|
|
304
|
+
parts.push('**Stack trace:**')
|
|
305
|
+
parts.push('```')
|
|
306
|
+
parts.push(cleanStackTrace(error.stack))
|
|
307
|
+
parts.push('```')
|
|
308
|
+
parts.push('')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (i < errors.length - 1) {
|
|
312
|
+
parts.push('---')
|
|
313
|
+
parts.push('')
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return parts.join('\n')
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
getErrorFingerprint() {
|
|
321
|
+
if (currentErrors.size === 0) return ''
|
|
322
|
+
const fingerprints = Array.from(currentErrors.keys()).sort().join('|')
|
|
323
|
+
let hash = 5381
|
|
324
|
+
for (let i = 0; i < fingerprints.length; i++) {
|
|
325
|
+
hash = ((hash << 5) + hash) ^ fingerprints.charCodeAt(i)
|
|
326
|
+
}
|
|
327
|
+
return (hash >>> 0).toString(16).padStart(8, '0')
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
getLogs() {
|
|
331
|
+
if (logs.length === 0) return 'No console logs captured.'
|
|
332
|
+
return logs
|
|
333
|
+
.map((log) => {
|
|
334
|
+
const parts = [`[${log.timestamp}] [${log.level.toUpperCase()}] ${log.message}`]
|
|
335
|
+
if (log.stack) parts.push(`Stack: ${log.stack}`)
|
|
336
|
+
return parts.join('\n')
|
|
337
|
+
})
|
|
338
|
+
.join('\n\n')
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
markAsSubmitted() {
|
|
342
|
+
if (currentErrors.size === 0) return
|
|
343
|
+
const submittedErrors = getTrackedErrors(SUBMITTED_KEY)
|
|
344
|
+
const now = new Date().toISOString()
|
|
345
|
+
for (const [fp, error] of currentErrors) {
|
|
346
|
+
const existing = submittedErrors.get(fp)
|
|
347
|
+
if (existing) {
|
|
348
|
+
existing.count += error.count
|
|
349
|
+
existing.lastSeen = now
|
|
350
|
+
} else {
|
|
351
|
+
submittedErrors.set(fp, { ...error, lastSeen: now })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
saveTrackedErrors(SUBMITTED_KEY, submittedErrors)
|
|
355
|
+
currentErrors.clear()
|
|
356
|
+
saveCurrentErrors()
|
|
357
|
+
notifyListeners()
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
markAsDismissed() {
|
|
361
|
+
if (currentErrors.size === 0) return
|
|
362
|
+
const dismissedErrors = getTrackedErrors(DISMISSED_KEY)
|
|
363
|
+
const now = new Date().toISOString()
|
|
364
|
+
for (const [fp, error] of currentErrors) {
|
|
365
|
+
const existing = dismissedErrors.get(fp)
|
|
366
|
+
if (existing) {
|
|
367
|
+
existing.count += error.count
|
|
368
|
+
existing.lastSeen = now
|
|
369
|
+
} else {
|
|
370
|
+
dismissedErrors.set(fp, { ...error, lastSeen: now })
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
saveTrackedErrors(DISMISSED_KEY, dismissedErrors)
|
|
374
|
+
currentErrors.clear()
|
|
375
|
+
saveCurrentErrors()
|
|
376
|
+
notifyListeners()
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
clearLogs() {
|
|
380
|
+
logs.length = 0
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
subscribe(listener: () => void) {
|
|
384
|
+
listeners.add(listener)
|
|
385
|
+
return () => listeners.delete(listener)
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
getSnapshot() {
|
|
389
|
+
return snapshotVersion
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report a Bug — Section barrel export
|
|
3
|
+
*
|
|
4
|
+
* This section provides a complete, reusable bug reporting feature that
|
|
5
|
+
* submits issues to Linear via a Cloudflare Worker.
|
|
6
|
+
*
|
|
7
|
+
* File structure:
|
|
8
|
+
* - report-bug-form.tsx — Main form component (drop-in usage)
|
|
9
|
+
* - screenshot-uploader.tsx — Drag & drop image upload (used by form, also standalone)
|
|
10
|
+
* - use-report-bug.ts — Form state & submission hook (used by form, also standalone)
|
|
11
|
+
* - issue-reporter-api.ts — Types and HTTP client for the worker endpoint
|
|
12
|
+
* - error-logger.ts — Optional error capture utility (decoupled from form)
|
|
13
|
+
*
|
|
14
|
+
* Quick start for consuming apps:
|
|
15
|
+
* import { ReportBugForm } from '@toolr/ui-design'
|
|
16
|
+
*
|
|
17
|
+
* <ReportBugForm
|
|
18
|
+
* workerUrl="https://your-issue-reporter.workers.dev"
|
|
19
|
+
* getSystemInfo={yourGetSystemInfoFn}
|
|
20
|
+
* linearTeamId="your-linear-team-uuid"
|
|
21
|
+
* onSuccess={(r) => toast(`Created ${r.issueId}`)}
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* For error log integration:
|
|
25
|
+
* import { createErrorLogger, ReportBugForm } from '@toolr/ui-design'
|
|
26
|
+
*
|
|
27
|
+
* const logger = createErrorLogger('yourapp')
|
|
28
|
+
* logger.init()
|
|
29
|
+
*
|
|
30
|
+
* <ReportBugForm
|
|
31
|
+
* {...config}
|
|
32
|
+
* errorReport={logger.getFormattedReport()}
|
|
33
|
+
* errorCount={logger.getErrorCount()}
|
|
34
|
+
* onErrorsSubmitted={() => { logger.markAsSubmitted(); logger.clearLogs() }}
|
|
35
|
+
* />
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// Main form component
|
|
39
|
+
export { ReportBugForm, type ReportBugFormProps } from './report-bug-form.tsx'
|
|
40
|
+
|
|
41
|
+
// Screenshot uploader (standalone usage)
|
|
42
|
+
export { ScreenshotUploader, type ScreenshotUploaderProps, type Screenshot } from './screenshot-uploader.tsx'
|
|
43
|
+
|
|
44
|
+
// Hook for custom form UIs
|
|
45
|
+
export { useReportBug, type UseReportBugOptions, type UseReportBugReturn } from './use-report-bug.ts'
|
|
46
|
+
|
|
47
|
+
// API types and client
|
|
48
|
+
export {
|
|
49
|
+
submitIssueReport,
|
|
50
|
+
ISSUE_TYPES,
|
|
51
|
+
type IssueType,
|
|
52
|
+
type IssueReport,
|
|
53
|
+
type IssueReportResult,
|
|
54
|
+
type SystemInfo,
|
|
55
|
+
type ScreenshotAttachment,
|
|
56
|
+
} from './issue-reporter-api.ts'
|
|
57
|
+
|
|
58
|
+
// Error logger utility
|
|
59
|
+
export { createErrorLogger, type ErrorLogger, type TrackedError } from './error-logger.ts'
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Reporter API — Types and submission client
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Report a Bug
|
|
5
|
+
*
|
|
6
|
+
* This module defines the shared types for issue reporting and provides
|
|
7
|
+
* the HTTP client that submits reports to a Cloudflare Worker endpoint.
|
|
8
|
+
* The worker creates Linear issues with optional screenshot uploads and
|
|
9
|
+
* duplicate detection.
|
|
10
|
+
*
|
|
11
|
+
* AI agent notes:
|
|
12
|
+
* - workerUrl is passed per-call so different apps can use different endpoints
|
|
13
|
+
* - linearTeamId/linearProjectId are sent in the request body, allowing one
|
|
14
|
+
* worker to serve multiple apps (the worker must read these from the body)
|
|
15
|
+
* - The worker source lives at /reviewr/workers/issue-reporter/
|
|
16
|
+
* - ScreenshotAttachment.data is base64-encoded (no data URL prefix)
|
|
17
|
+
* - SystemInfo must be provided by the consuming app (e.g. via Tauri invoke,
|
|
18
|
+
* navigator.userAgent, or a custom endpoint)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type IssueType = 'bug' | 'feature' | 'question' | 'improvement' | 'documentation'
|
|
22
|
+
|
|
23
|
+
export const ISSUE_TYPES: { value: IssueType; label: string }[] = [
|
|
24
|
+
{ value: 'bug', label: 'Bug' },
|
|
25
|
+
{ value: 'feature', label: 'Feature' },
|
|
26
|
+
{ value: 'question', label: 'Question' },
|
|
27
|
+
{ value: 'improvement', label: 'Improvement' },
|
|
28
|
+
{ value: 'documentation', label: 'Documentation' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export interface SystemInfo {
|
|
32
|
+
os: string
|
|
33
|
+
osVersion: string
|
|
34
|
+
appVersion: string
|
|
35
|
+
arch: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ScreenshotAttachment {
|
|
39
|
+
filename: string
|
|
40
|
+
data: string
|
|
41
|
+
contentType: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IssueReport {
|
|
45
|
+
title: string
|
|
46
|
+
description: string
|
|
47
|
+
type: IssueType
|
|
48
|
+
stackTrace?: string
|
|
49
|
+
errorFingerprint?: string
|
|
50
|
+
systemInfo: SystemInfo
|
|
51
|
+
userEmail?: string
|
|
52
|
+
screenshots?: ScreenshotAttachment[]
|
|
53
|
+
/** Target Linear team — allows one worker to serve multiple apps */
|
|
54
|
+
linearTeamId?: string
|
|
55
|
+
/** Target Linear project (optional) */
|
|
56
|
+
linearProjectId?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface IssueReportResult {
|
|
60
|
+
success: boolean
|
|
61
|
+
issueId?: string
|
|
62
|
+
message: string
|
|
63
|
+
isDuplicate?: boolean
|
|
64
|
+
url?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function submitIssueReport(
|
|
68
|
+
workerUrl: string,
|
|
69
|
+
report: IssueReport,
|
|
70
|
+
): Promise<IssueReportResult> {
|
|
71
|
+
const response = await fetch(workerUrl, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify(report),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
79
|
+
return { success: false, message: error.error || 'Failed to submit issue' }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return await response.json()
|
|
83
|
+
}
|