feat(stage-ui): new BugReporterDialog component

This commit is contained in:
Neko Ayaka
2026-04-10 03:40:10 +08:00
parent 90294ce15c
commit b1a4eac605
11 changed files with 739 additions and 0 deletions
@@ -68,6 +68,23 @@ dialogs:
stateRequesting: Requesting...
stateGranted: Granted
stateNotGranted: Not granted
bug-report:
title: Bug report (´;ω;`)ヾ(・∀・`)
subtitle: Oops, sorry we made something wrong. Would you mind telling us what happened?
trigger-label: Report a Bug
submit-label: Send Bug Report
triage-description: Include screenshot and page context to help us triage this issue.
description-placeholder: Steps to reproduce, expected behavior, and actual behavior
include-current-page-screenshot: Include current page screenshot
manual-media:
label: Manually choose screenshots / recordings
optional: Optional.
media-files:
label: Media files
description: 'Optional: manually select.'
placeholder: Choose screenshot or video file
selected-count: '{count} file(s) selected'
last-submitted-preview: Last Submitted Payload Preview
language:
title: Language
description: |
@@ -45,3 +45,49 @@ notice:
preparing: Preparing…
read-more: Read more
preview-title: What is it?
about:
subtitle: Desktop ver.
current-version: Current Version
common:
cancel: Cancel
links:
title: Links
home: Home
documentation: Documentations
github: GitHub
update:
lane:
label: Update lane
description: Choose which release lane updater checks against. Auto follows the current app prerelease lane.
placeholder: Choose update lane
channels:
auto: Auto
stable: Stable
alpha: Alpha
beta: Beta
nightly: Nightly
canary: Canary
status:
downloading: Downloading update...
error-prefix: Error
latest: Up to date (v{version}).
downloaded:
windows: Update ready to install silently (v{version}).
restart: Update ready to install on restart (v{version}).
actions:
download: Download Update
download-short: Download
confirm-download: Confirm Download
check-for-updates: Check for updates
checking: Checking...
latest-version: Latest version
disabled-dev: Updates disabled in Dev
retry-check: Retry Check
restart-silent: Restart to update silently
restart-install: Restart to install update
confirm-restart: Confirm Restart
dialog:
title: Update Available
description: A new version (v{version}) is available.
no-release-notes-markdown: _No release notes provided._
@@ -0,0 +1,214 @@
<script setup lang="ts">
import type { BugReportPageContext } from './bug-report-payload'
import type { BugReportDialogSubmitPayload } from './types'
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, VisuallyHidden } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import BugReportForm from './bug-report-form.vue'
import { useBreakpoints } from '../../../../composables/use-breakpoints'
import { buildBugReportPayload, createBugReportPageContext } from './bug-report-payload'
const props = withDefaults(defineProps<{
sending?: boolean
submitError?: unknown
title?: string
subtitle?: string
submitLabel?: string
triageContextSummary?: string
pageContext?: BugReportPageContext | null
screenshotAttached?: boolean
}>(), {
sending: false,
submitError: undefined,
triageContextSummary: '',
pageContext: null,
screenshotAttached: false,
})
const emit = defineEmits<{
(e: 'submit', payload: BugReportDialogSubmitPayload): void
(e: 'requestTriageContext'): void
(e: 'feedback'): void
}>()
const showDialog = defineModel<boolean>({ default: false, required: false })
const description = defineModel<string>('description', { default: '' })
const includeTriageContext = defineModel<boolean>('includeTriageContext', { default: false })
const uploadMediaFromLibrary = defineModel<boolean>('uploadMediaFromLibrary', { default: false })
const screenshotFiles = defineModel<File[] | undefined>('screenshotFiles', { default: undefined })
const { t } = useI18n()
const { isDesktop } = useBreakpoints()
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
const effectiveTriageSummary = computed(() => {
if (props.triageContextSummary)
return props.triageContextSummary
return t('settings.dialogs.bug-report.triage-description')
})
const resolvedTitle = computed(() => props.title || t('settings.dialogs.bug-report.title'))
const resolvedSubtitle = computed(() => props.subtitle || t('settings.dialogs.bug-report.subtitle'))
const resolvedSubmitLabel = computed(() => props.submitLabel || t('settings.dialogs.bug-report.submit-label'))
function onSubmit() {
const trimmedDescription = description.value.trim()
if (!trimmedDescription)
return
const includeContext = includeTriageContext.value
const selectedScreenshotFiles = screenshotFiles.value ?? []
const context = includeContext
? (props.pageContext ?? createBugReportPageContext())
: null
const screenshotAttached = includeContext && (props.screenshotAttached || selectedScreenshotFiles.length > 0)
const formattedReport = buildBugReportPayload({
description: trimmedDescription,
includeTriageContext: includeContext,
context,
screenshotAttached,
})
emit('submit', {
description: trimmedDescription,
includeTriageContext: includeContext,
context,
screenshotAttached,
screenshotFiles: selectedScreenshotFiles,
formattedReport,
})
}
function onRequestTriageContext() {
includeTriageContext.value = true
emit('requestTriageContext')
}
</script>
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogPortal>
<DialogOverlay
:class="[
'fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm',
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
]"
/>
<DialogContent
:class="[
'fixed left-1/2 top-1/2 z-[9999] max-h-full max-w-2xl w-[92dvw] transform',
'flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none',
'backdrop-blur-md -translate-x-1/2 -translate-y-1/2',
'data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow',
'dark:bg-neutral-900',
]"
>
<div
:class="[
'mb-4 flex flex-col gap-1',
]"
>
<DialogTitle
:class="[
'text-lg font-semibold text-neutral-900 dark:text-neutral-100',
]"
>
{{ resolvedTitle }}
</DialogTitle>
<DialogDescription
:class="[
'text-sm text-neutral-600 dark:text-neutral-300',
]"
>
{{ resolvedSubtitle }}
</DialogDescription>
</div>
<BugReportForm
v-model:description="description"
v-model:include-triage-context="includeTriageContext"
v-model:upload-media-from-library="uploadMediaFromLibrary"
v-model:screenshot-files="screenshotFiles"
:sending="sending"
:submit-error="submitError"
:submit-label="resolvedSubmitLabel"
:triage-context-summary="effectiveTriageSummary"
@submit="onSubmit"
@request-triage-context="onRequestTriageContext"
@feedback="emit('feedback')"
/>
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
<DrawerPortal>
<DrawerOverlay
:class="[
'fixed inset-0 z-1000 bg-black/35 backdrop-blur-sm',
]"
/>
<DrawerContent
:class="[
'fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[90%]',
'flex flex-col rounded-t-[32px] bg-neutral-50/95 px-4 pt-4 outline-none',
'backdrop-blur-md dark:bg-neutral-900/95',
]"
:style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }"
>
<VisuallyHidden>
<DialogTitle>{{ resolvedTitle }}</DialogTitle>
</VisuallyHidden>
<DrawerHandle
:class="[
'[div&]:bg-neutral-400 [div&]:dark:bg-neutral-600',
]"
/>
<div
:class="[
'mb-4 mt-2 flex flex-col gap-1',
]"
>
<div
:class="[
'text-lg font-semibold text-neutral-900 dark:text-neutral-100',
]"
>
{{ resolvedTitle }}
</div>
<div
:class="[
'text-sm text-neutral-600 dark:text-neutral-300',
]"
>
{{ resolvedSubtitle }}
</div>
</div>
<BugReportForm
v-model:description="description"
v-model:include-triage-context="includeTriageContext"
v-model:upload-media-from-library="uploadMediaFromLibrary"
v-model:screenshot-files="screenshotFiles"
:sending="sending"
:submit-error="submitError"
:submit-label="resolvedSubmitLabel"
:triage-context-summary="effectiveTriageSummary"
@submit="onSubmit"
@request-triage-context="onRequestTriageContext"
@feedback="emit('feedback')"
/>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { Button, ContainerError, FieldCheckbox, FieldInputFile, Textarea } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
sending?: boolean
submitError?: unknown
submitLabel?: string
triageContextSummary?: string
}>(), {
sending: false,
submitError: undefined,
triageContextSummary: '',
})
const emit = defineEmits<{
(e: 'submit'): void
(e: 'requestTriageContext'): void
(e: 'feedback'): void
}>()
const description = defineModel<string>('description', { default: '' })
const includeTriageContext = defineModel<boolean>('includeTriageContext', { default: false })
const uploadMediaFromLibrary = defineModel<boolean>('uploadMediaFromLibrary', { default: false })
const screenshotFiles = defineModel<File[] | undefined>('screenshotFiles', { default: undefined })
const { t } = useI18n()
const canSubmit = computed(() => description.value.trim().length > 0 && !props.sending)
const triageDescription = computed(() => props.triageContextSummary || t('settings.dialogs.bug-report.triage-description'))
const resolvedSubmitLabel = computed(() => props.submitLabel || t('settings.dialogs.bug-report.submit-label'))
const selectedScreenshotCount = computed(() => screenshotFiles.value?.length ?? 0)
function onIncludeTriageContextChange(next: boolean) {
if (next) {
emit('requestTriageContext')
return
}
uploadMediaFromLibrary.value = false
screenshotFiles.value = undefined
}
function onUploadMediaFromLibraryChange(next: boolean) {
if (!next)
screenshotFiles.value = undefined
}
</script>
<template>
<div
:class="[
'min-h-0 min-w-0 w-full flex flex-1 flex-col gap-4',
]"
>
<Textarea
v-model="description"
:class="[
'min-h-44 text-sm',
]"
:placeholder="t('settings.dialogs.bug-report.description-placeholder')"
/>
<FieldCheckbox
v-model="includeTriageContext"
:label="t('settings.dialogs.bug-report.include-current-page-screenshot')"
:description="triageDescription"
@update:model-value="onIncludeTriageContextChange"
/>
<div
v-if="includeTriageContext"
:class="[
'flex flex-col gap-2',
]"
>
<FieldCheckbox
v-model="uploadMediaFromLibrary"
:label="t('settings.dialogs.bug-report.manual-media.label')"
:description="t('settings.dialogs.bug-report.manual-media.optional')"
@update:model-value="onUploadMediaFromLibraryChange"
/>
<FieldInputFile
v-if="uploadMediaFromLibrary"
v-model="screenshotFiles"
:label="t('settings.dialogs.bug-report.media-files.label')"
:description="t('settings.dialogs.bug-report.media-files.description')"
accept="image/*,video/*"
multiple
:placeholder="t('settings.dialogs.bug-report.media-files.placeholder')"
/>
<div
v-if="uploadMediaFromLibrary && selectedScreenshotCount > 0"
:class="[
'text-xs text-neutral-500 dark:text-neutral-400',
]"
>
{{ t('settings.dialogs.bug-report.media-files.selected-count', { count: selectedScreenshotCount }) }}
</div>
</div>
<div
:class="[
'min-h-0 flex-1',
]"
/>
<ContainerError
v-if="submitError"
:error="submitError"
height-preset="sm"
@feedback="emit('feedback')"
/>
<Button
:loading="sending"
:disabled="!canSubmit"
size="lg"
block
:class="[
'min-h-14 text-base font-semibold',
]"
@click="emit('submit')"
>
{{ resolvedSubmitLabel }}
</Button>
</div>
</template>
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { buildBugReportPayload, createBugReportPageContext } from './bug-report-payload'
describe('bug report payload helpers', () => {
it('builds markdown payload with description and optional context/screenshot flags', () => {
const payload = buildBugReportPayload({
description: 'Clicking send does nothing',
includeTriageContext: true,
context: {
url: 'https://airi.local/chat?room=debug',
title: 'AIRI Chat',
userAgent: 'test-agent',
viewport: '1440x900',
language: 'en-US',
timeZone: 'Asia/Shanghai',
timestamp: '2026-04-09T11:22:33.000Z',
},
screenshotAttached: true,
})
expect(payload).toContain('## Bug Report')
expect(payload).toContain('Clicking send does nothing')
expect(payload).toContain('## Triage Context')
expect(payload).toContain('- URL: https://airi.local/chat?room=debug')
expect(payload).toContain('- Screenshot attached: yes')
})
it('returns null page context when window is unavailable', () => {
const context = createBugReportPageContext(undefined)
expect(context).toBeNull()
})
it('extracts page context from a window-like object', () => {
const context = createBugReportPageContext({
location: {
href: 'https://airi.local/settings?tab=providers',
},
document: {
title: 'Settings',
},
navigator: {
userAgent: 'unit-test',
language: 'en-US',
},
innerWidth: 1280,
innerHeight: 720,
Intl: {
DateTimeFormat: () => ({
resolvedOptions: () => ({ timeZone: 'UTC' }),
}),
},
Date: {
now: () => 1_700_000_000_000,
},
})
expect(context).toEqual({
url: 'https://airi.local/settings?tab=providers',
title: 'Settings',
userAgent: 'unit-test',
viewport: '1280x720',
language: 'en-US',
timeZone: 'UTC',
timestamp: '2023-11-14T22:13:20.000Z',
})
})
})
@@ -0,0 +1,91 @@
export interface BugReportPageContext {
url: string
title: string
userAgent: string
viewport: string
language: string
timeZone: string
timestamp: string
}
export interface BuildBugReportPayloadOptions {
description: string
includeTriageContext?: boolean
context?: BugReportPageContext | null
screenshotAttached?: boolean
}
interface WindowLike {
location?: {
href?: string
}
document?: {
title?: string
}
navigator?: {
userAgent?: string
language?: string
}
innerWidth?: number
innerHeight?: number
Intl?: {
DateTimeFormat?: () => {
resolvedOptions?: () => {
timeZone?: string
}
}
}
Date?: {
now?: () => number
}
}
export function createBugReportPageContext(win: WindowLike | undefined = globalThis.window): BugReportPageContext | null {
if (!win)
return null
const now = win.Date?.now?.() ?? Date.now()
const timeZone = win.Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone ?? 'unknown'
return {
url: win.location?.href ?? 'unknown',
title: win.document?.title ?? '',
userAgent: win.navigator?.userAgent ?? 'unknown',
viewport: `${win.innerWidth ?? 0}x${win.innerHeight ?? 0}`,
language: win.navigator?.language ?? 'unknown',
timeZone,
timestamp: new Date(now).toISOString(),
}
}
export function buildBugReportPayload(options: BuildBugReportPayloadOptions): string {
const sections: string[] = [
'## Bug Report',
'',
options.description.trim() || '_No description provided._',
]
if (!options.includeTriageContext)
return sections.join('\n')
sections.push('', '## Triage Context')
if (options.context) {
sections.push(
`- URL: ${options.context.url}`,
`- Title: ${options.context.title || 'unknown'}`,
`- Viewport: ${options.context.viewport}`,
`- User Agent: ${options.context.userAgent}`,
`- Language: ${options.context.language}`,
`- Time Zone: ${options.context.timeZone}`,
`- Captured At: ${options.context.timestamp}`,
)
}
else {
sections.push('- Page context unavailable')
}
sections.push(`- Screenshot attached: ${options.screenshotAttached ? 'yes' : 'no'}`)
return sections.join('\n')
}
@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { BugReportDialogSubmitPayload } from './types'
import { Button } from '@proj-airi/ui'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BugReportDialog from './bug-report-dialog.vue'
import { createBugReportPageContext } from './bug-report-payload'
const props = defineProps<{
triggerLabel?: string
}>()
const emit = defineEmits<{
(e: 'submit', payload: BugReportDialogSubmitPayload): void
}>()
const { t } = useI18n()
const showDialog = shallowRef(false)
const description = shallowRef('')
const includeTriageContext = shallowRef(false)
const uploadMediaFromLibrary = shallowRef(false)
const sending = shallowRef(false)
const submitError = shallowRef<unknown>(undefined)
const pageContext = shallowRef(createBugReportPageContext())
const screenshotAttached = shallowRef(false)
const screenshotFiles = shallowRef<File[] | undefined>(undefined)
const submittedReport = shallowRef('')
const resolvedTriggerLabel = computed(() => props.triggerLabel || t('settings.dialogs.bug-report.trigger-label'))
function onRequestTriageContext() {
includeTriageContext.value = true
screenshotAttached.value = true
pageContext.value = createBugReportPageContext()
}
function onSubmit(payload: BugReportDialogSubmitPayload) {
sending.value = true
submitError.value = undefined
submittedReport.value = payload.formattedReport
emit('submit', payload)
globalThis.setTimeout(() => {
sending.value = false
showDialog.value = false
}, 250)
}
</script>
<template>
<div
:class="[
'flex flex-col gap-3',
]"
>
<Button
variant="danger"
:class="[
'w-fit',
]"
@click="showDialog = true"
>
<span
:class="[
'flex items-center gap-2',
]"
>
<span
:class="[
'i-solar:danger-circle-outline h-4 w-4',
]"
/>
{{ resolvedTriggerLabel }}
</span>
</Button>
<div
v-if="submittedReport"
:class="[
'rounded-lg border border-neutral-200/80 bg-neutral-50/80 p-3 text-xs',
'text-neutral-600 leading-relaxed dark:border-neutral-700/60 dark:bg-neutral-900/50 dark:text-neutral-300',
]"
>
<div
:class="[
'mb-2 text-xs font-semibold text-neutral-800 dark:text-neutral-200',
]"
>
{{ t('settings.dialogs.bug-report.last-submitted-preview') }}
</div>
<pre
:class="[
'max-h-40 overflow-auto whitespace-pre-wrap break-words',
]"
>{{ submittedReport }}</pre>
</div>
<BugReportDialog
v-model="showDialog"
v-model:description="description"
v-model:include-triage-context="includeTriageContext"
v-model:upload-media-from-library="uploadMediaFromLibrary"
v-model:screenshot-files="screenshotFiles"
:sending="sending"
:submit-error="submitError"
:page-context="pageContext"
:screenshot-attached="screenshotAttached"
@request-triage-context="onRequestTriageContext"
@submit="onSubmit"
/>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import BugReportTrigger from './bug-report-trigger.vue'
</script>
<template>
<Story
title="Dialogs / Bug Report"
group="dialogs"
>
<Variant
id="default-trigger"
title="Default Trigger"
>
<div
:class="[
'mx-auto max-w-xl p-4',
]"
>
<BugReportTrigger />
</div>
</Variant>
<Variant
id="chat-themed-trigger"
title="Chat Themed Trigger"
>
<div
:class="[
'mx-auto max-w-2xl rounded-2xl border border-neutral-200/70 bg-white/60 p-4',
'dark:border-neutral-700/70 dark:bg-neutral-900/70',
]"
>
<div
:class="[
'mb-3 text-sm text-neutral-600 dark:text-neutral-300',
]"
>
Use this to test reporting from a chat-like screen.
</div>
<BugReportTrigger trigger-label="Report Chat Bug" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,4 @@
export { default as BugReportDialog } from './bug-report-dialog.vue'
export * from './bug-report-payload'
export { default as BugReportTrigger } from './bug-report-trigger.vue'
export * from './types'
@@ -0,0 +1,10 @@
import type { BugReportPageContext } from './bug-report-payload'
export interface BugReportDialogSubmitPayload {
description: string
includeTriageContext: boolean
context: BugReportPageContext | null
screenshotAttached: boolean
screenshotFiles: File[]
formattedReport: string
}
@@ -2,5 +2,6 @@ export * from './about'
export { default as AboutDialogWithContent } from './about.vue'
export * from './audio-input'
export * from './background-picker'
export * from './bug-report'
export * from './onboarding'
export * from './validation-details'