<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import {
GnBadge,
GnButton,
GnCard,
GnConfirmDialog,
GnCopyButton,
GnEmptyState,
GnIconButton,
GnModal,
GnPageHeader,
GnTabs,
GnTextarea,
GnTimeline,
useToast,
} from "gnexus-ui-kit/vue";
import type { AnnotationShape, ReportDetail } from "@ltt/shared";
import * as api from "../api";
import { buildAiPrompt } from "../lib/aiPrompt";
import { useAuth } from "../stores/auth";
import AppShell from "../components/AppShell.vue";
import { ScreenshotViewer, ElementContext, EnvironmentInfo } from "@ltt/ui";
import CompactSelect from "../components/CompactSelect.vue";
const { t } = useI18n();
const toast = useToast();
const route = useRoute();
const router = useRouter();
const auth = useAuth();
const token = computed(() => String(route.params.token));
const report = ref<ReportDetail | null>(null);
const loading = ref(true);
const notFound = ref(false);
const savingStatus = ref(false);
const tab = ref("details");
const replayBusy = ref(false);
// ---------- replay via the browser extension ----------
// The panel can't reach the extension directly (that would need the extension
// id in externally_connectable, which differs per installation), so the
// extension ships a tiny relay content script that listens for window
// messages and forwards them to its background worker.
const EXT_SOURCE = "bugtrail-ext";
const PANEL_SOURCE = "bugtrail-panel";
interface ReplayResult {
ok: boolean;
data?: { played: number; total: number; failures: { index: number; type: string; reason: string }[] } | null;
error?: string;
}
/** Sends a message to the relay and waits for the matching reply, or null on timeout. */
function sendToExtension<T extends { ok: boolean; error?: string }>(
message: Record<string, unknown>,
replyType: string,
timeoutMs: number
): Promise<T | null> {
return new Promise((resolve) => {
const cleanup = () => {
window.removeEventListener("message", handler);
window.clearTimeout(timer);
};
const handler = (event: MessageEvent) => {
if (event.source !== window) return;
const data = event.data as { source?: string; type?: string } | null;
if (!data || data.source !== EXT_SOURCE || data.type !== replyType) return;
cleanup();
resolve(data as unknown as T);
};
const timer = window.setTimeout(() => {
cleanup();
resolve(null);
}, timeoutMs);
window.addEventListener("message", handler);
window.postMessage({ source: PANEL_SOURCE, ...message }, window.location.origin);
});
}
async function startReplay() {
if (!report.value || report.value.type !== "recording" || replayBusy.value) return;
replayBusy.value = true;
try {
// presence check first, so "no extension" fails fast instead of hanging
const pong = await sendToExtension<{ ok: boolean }>({ type: "ping" }, "pong", 1500);
if (!pong) {
toast.warning({ title: t("report.replayNoExtension") });
return;
}
const result = await sendToExtension<ReplayResult>(
{ type: "replay_report", token: report.value.share_token },
"replay_result",
5 * 60 * 1000
);
if (!result) {
toast.error({ title: t("report.replayNoResponse") });
return;
}
if (!result.ok) {
toast.error({ title: t("report.replayFailed"), text: result.error ?? undefined });
return;
}
const { played, total, failures } = result.data ?? { played: 0, total: 0, failures: [] };
if (failures?.length) {
toast.warning({
title: t("report.replayPartialTitle", { played, total }),
text: t("report.replayPartialText", { failed: failures.length }),
});
} else {
toast.success({ title: t("report.replayDone", { played, total }) });
}
} finally {
replayBusy.value = false;
}
}
const tabs = computed(() => {
const items = [{ id: "details", label: t("report.details") }];
if (report.value?.steps?.length) items.push({ id: "steps", label: t("report.steps") });
// the tab stays once the report is loaded — an empty block shows an
// empty state instead of disappearing
if (report.value) items.push({ id: "attachments", label: t("report.attachments") });
return items;
});
const editingDescription = ref(false);
const descriptionDraft = ref("");
const deleteOpen = ref(false);
const screenshot = computed(() => report.value?.attachments.find((a) => a.kind === "screenshot" || a.kind === "annotation") ?? null);
/** the screen recording travels with the report — shown right in the details */
const recordingVideo = computed(() => report.value?.attachments.find((a) => a.mime.startsWith("video/")) ?? null);
const otherAttachments = computed(() => report.value?.attachments.filter((a) => a !== screenshot.value && a !== recordingVideo.value) ?? []);
/** The share token doubles as authorization for the file download URL — no cookies needed. */
function fileUrl(fileId: string) {
return api.reportFileUrl(token.value, fileId);
}
const canEdit = computed(() => {
if (!report.value || !auth.user.value) return false;
return report.value.author.id === auth.user.value.id;
});
onMounted(async () => {
try {
report.value = await api.getReport(token.value);
} catch (e) {
if (e instanceof Error && e.message === "not-found") notFound.value = true;
else toast.error({ title: t("common.error") });
} finally {
loading.value = false;
}
});
async function setStatus(status: string) {
if (!report.value) return;
savingStatus.value = true;
try {
report.value = await api.updateReport(token.value, { status: status as ReportDetail["status"] });
toast.success({ title: t("report.status"), text: t(`reports.status.${status}`) });
} catch {
toast.error({ title: t("common.error") });
} finally {
savingStatus.value = false;
}
}
function startEditDescription() {
descriptionDraft.value = report.value?.description ?? "";
editingDescription.value = true;
}
async function saveDescription() {
if (!report.value) return;
try {
report.value = await api.updateReport(token.value, { description: descriptionDraft.value || null });
editingDescription.value = false;
toast.success({ title: t("common.save") });
} catch {
toast.error({ title: t("common.error") });
}
}
async function saveAnnotations(shapes: AnnotationShape[]) {
if (!report.value || !screenshot.value) return;
try {
// vector shapes persist on the attachment; the editor renders them over the original PNG
await api.updateAttachmentShapes(token.value, screenshot.value.id, shapes);
screenshot.value.annotation_shapes = shapes;
toast.success({ title: t("report.saveAnnotations") });
} catch {
toast.error({ title: t("common.error") });
}
}
async function deleteReport() {
if (!report.value) return;
try {
await api.deleteReport(token.value);
toast.success({ title: t("report.deleteReport") });
router.push("/");
} catch {
toast.error({ title: t("common.error") });
} finally {
deleteOpen.value = false;
}
}
/**
* Back navigation: in-app history means a plain back; a cold share-link visit
* has none, so fall back to the report's project page (then the projects list).
*/
function goBack() {
if (window.history.state?.back != null) {
router.back();
} else if (report.value?.project_share_token) {
router.push(`/p/${report.value.project_share_token}`);
} else {
router.push("/");
}
}
// ---------- AI prompt ----------
// A ready-to-paste bug-fix prompt for the developer's AI coding agent.
const aiPromptOpen = ref(false);
const aiPromptText = ref("");
function openAiPrompt() {
if (!report.value) return;
aiPromptText.value = buildAiPrompt(report.value, t, {
reportUrl: api.reportShareUrl(report.value),
fileUrl: (fileId) => api.reportFileUrl(report.value!.share_token, fileId),
});
aiPromptOpen.value = true;
}
function statusVariant(status: string) {
return status === "open" ? "warning" : status === "fixed" ? "success" : "secondary";
}
function stepTitle(type: string) {
return t(`report.stepTypes.${type}`);
}
function stepText(step: ReportDetail["steps"][number]) {
const data = step.data ?? {};
const parts: string[] = [];
const element = data.element as Record<string, unknown> | undefined;
if (element && typeof element === "object") {
const tag = element.tag as string | undefined;
const text = element.text_snippet as string | undefined;
if (tag) parts.push(`<${tag}>`);
if (text) parts.push(`"${text}"`);
const selector = element.unique_selector ?? element.selector;
if (typeof selector === "string") parts.push(selector);
}
if (typeof data.value === "string" && data.value) parts.push(`"${data.value}"`);
if (typeof data.value_length === "number") parts.push(`(${data.value_length} chars)`);
if (typeof data.from_url === "string") parts.push(String(data.from_url));
if (typeof data.to_url === "string") parts.push(`→ ${data.to_url}`);
if (typeof data.text === "string") parts.push(data.text);
return parts.join(" ") || "—";
}
function stepTime(offsetMs: number) {
return `${(offsetMs / 1000).toFixed(1)}s`;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleString();
}
</script>
<template>
<AppShell>
<div class="report-page">
<div v-if="notFound" class="report-page-notfound">
<GnEmptyState icon="ph-link-break" :title="t('reports.notFound')" :text="t('reports.notFoundHint')" />
</div>
<template v-else-if="report">
<div class="report-page-nav">
<GnButton variant="secondary" size="sm" icon="ph-arrow-left" @click="goBack()">
{{ t("report.backToProject") }}
</GnButton>
</div>
<GnPageHeader :title="report.title" :subtitle="report.page_title ?? undefined">
<template #actions>
<GnCopyButton :text="api.reportShareUrl(report)" :label="t('report.copyLink')" />
<GnButton variant="secondary" size="sm" icon="ph-robot" @click="openAiPrompt">
{{ t("report.aiPrompt") }}
</GnButton>
<GnButton
v-if="report.type === 'recording'"
variant="secondary"
size="sm"
icon="ph-play"
:disabled="replayBusy"
@click="startReplay"
>
{{ t("report.replay") }}
</GnButton>
<GnButton
v-if="canEdit"
variant="danger"
size="sm"
icon="ph-trash"
@click="deleteOpen = true"
>
{{ t("report.deleteReport") }}
</GnButton>
</template>
</GnPageHeader>
<div class="report-meta">
<GnBadge variant="accent" outline>{{ t(`reports.type.${report.type}`) }}</GnBadge>
<GnBadge :variant="statusVariant(report.status)">{{ t(`reports.status.${report.status}`) }}</GnBadge>
<span class="report-meta-item">{{ t("report.author") }}: {{ report.author.nickname }}</span>
<span class="report-meta-item">{{ t("report.created") }}: {{ fmtDate(report.created_at) }}</span>
</div>
<GnTabs v-model="tab" :items="tabs">
<template #details>
<div class="report-details-grid">
<div class="report-details-main">
<GnCard class="report-card">
<h3 class="report-card-title">{{ t("report.description") }}</h3>
<template v-if="editingDescription">
<GnTextarea v-model="descriptionDraft" :rows="5" />
<div class="report-card-actions">
<GnButton variant="secondary" size="sm" @click="editingDescription = false">
{{ t("common.cancel") }}
</GnButton>
<GnButton variant="accent" size="sm" @click="saveDescription">
{{ t("common.save") }}
</GnButton>
</div>
</template>
<template v-else>
<p class="report-description" :class="{ 'is-empty': !report.description }">
{{ report.description ?? t("report.noDescription") }}
</p>
<GnIconButton
v-if="canEdit"
icon="ph-pencil-simple"
:label="t('common.edit')"
size="sm"
@click="startEditDescription"
/>
</template>
</GnCard>
<GnCard v-if="screenshot" class="report-card">
<h3 class="report-card-title">{{ t("report.screenshot") }}</h3>
<ScreenshotViewer
:src="fileUrl(screenshot.file_id)"
:shapes="screenshot.annotation_shapes"
:editable="canEdit"
:labels="{
edit: t('report.editAnnotations'),
save: t('report.saveAnnotations'),
saving: t('report.savingAnnotations'),
cancel: t('common.cancel'),
expand: t('report.openFullscreen'),
}"
@save="saveAnnotations"
/>
</GnCard>
<GnCard v-if="recordingVideo" class="report-card">
<h3 class="report-card-title">{{ t("report.screenRecording") }}</h3>
<video :src="fileUrl(recordingVideo.file_id)" controls class="report-video" />
</GnCard>
<GnCard v-if="report.element" class="report-card">
<h3 class="report-card-title">{{ t("report.elementContext") }}</h3>
<ElementContext :element="report.element" />
</GnCard>
</div>
<div class="report-details-side">
<GnCard class="report-card">
<h3 class="report-card-title">{{ t("report.status") }}</h3>
<CompactSelect
v-if="canEdit"
:model-value="report.status"
:options="[
{ value: 'open', label: t('reports.status.open') },
{ value: 'fixed', label: t('reports.status.fixed') },
{ value: 'wont_fix', label: t('reports.status.wont_fix') },
]"
:width="'100%'"
:disabled="savingStatus"
@update:model-value="setStatus($event)"
/>
<GnBadge v-else :variant="statusVariant(report.status)">
{{ t(`reports.status.${report.status}`) }}
</GnBadge>
</GnCard>
<GnCard v-if="report.page_url" class="report-card">
<h3 class="report-card-title">{{ t("report.page") }}</h3>
<a :href="report.page_url" target="_blank" rel="noopener" class="report-page-url">
{{ report.page_url }}
</a>
</GnCard>
<GnCard class="report-card">
<h3 class="report-card-title">{{ t("report.environment") }}</h3>
<EnvironmentInfo :environment="report.environment" />
</GnCard>
</div>
</div>
</template>
<template v-if="report.steps?.length" #steps>
<GnCard class="report-card">
<GnTimeline
:items="
report.steps.map((step) => ({
key: step.id,
title: `#${step.step_index + 1} ${stepTitle(step.type)}`,
time: stepTime(step.offset_ms),
text: stepText(step),
icon:
step.type === 'click' ? 'ph-mouse-simple'
: step.type === 'input' ? 'ph-keyboard'
: step.type === 'url_change' ? 'ph-arrows-left-right'
: step.type === 'note' ? 'ph-note-pencil'
: step.type === 'screenshot' ? 'ph-camera'
: step.type === 'console' ? 'ph-warning'
: 'ph-navigation-arrow',
}))
"
>
<template #meta="{ item }">
<img
v-if="report!.steps.find((s) => s.id === item.key)?.screenshot_attachment_id"
:src="fileUrl(report!.steps.find((s) => s.id === item.key)!.screenshot_attachment_id!)"
class="step-screenshot"
alt=""
/>
</template>
</GnTimeline>
</GnCard>
</template>
<template #attachments>
<GnCard class="report-card">
<GnEmptyState
v-if="!otherAttachments.length"
:title="t('report.noAttachments')"
icon="ph-paperclip"
/>
<div v-else class="attachment-grid">
<a
v-for="attachment in otherAttachments"
:key="attachment.id"
class="attachment-item"
:href="fileUrl(attachment.file_id)"
target="_blank"
rel="noopener"
>
<span class="attachment-thumb">
<video
v-if="attachment.mime.startsWith('video/')"
:src="fileUrl(attachment.file_id)"
controls
muted
class="attachment-video"
/>
<img
v-else-if="attachment.mime.startsWith('image/')"
:src="fileUrl(attachment.file_id)"
:alt="attachment.filename"
loading="lazy"
/>
<i v-else class="ph ph-file-text" />
</span>
<span class="attachment-info">
<span class="attachment-name">{{ attachment.filename }}</span>
<span class="attachment-size">{{ (attachment.size / 1024).toFixed(1) }} KB</span>
</span>
</a>
</div>
</GnCard>
</template>
</GnTabs>
<GnModal v-model:open="aiPromptOpen" :title="t('report.aiPromptTitle')">
<div class="ai-prompt-form">
<p class="ai-prompt-hint">{{ t("report.aiPromptHint") }}</p>
<GnTextarea v-model="aiPromptText" :rows="18" class="ai-prompt-text" />
<div class="ai-prompt-actions">
<GnButton variant="secondary" size="sm" @click="aiPromptOpen = false">
{{ t("common.cancel") }}
</GnButton>
<GnCopyButton :text="aiPromptText" :label="t('report.aiPromptCopy')" />
</div>
</div>
</GnModal>
<GnConfirmDialog
v-model:open="deleteOpen"
:title="t('report.deleteReport')"
:message="t('report.deleteConfirm')"
:confirm-text="t('report.deleteReport')"
confirm-variant="danger"
@confirm="deleteReport"
/>
</template>
<p v-else class="report-page-loading">{{ t("common.loading") }}</p>
</div>
</AppShell>
</template>
<style scoped>
.report-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.report-page-nav {
display: flex;
}
.report-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.report-meta-item {
font-size: 12px;
opacity: 0.7;
}
.report-details-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@media (max-width: 800px) {
.report-details-grid {
grid-template-columns: 1fr;
}
}
.report-details-main,
.report-details-side {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.report-card {
/* kit .card is width:max-content/max-width:340px — report cards fill the grid;
the kit also wraps content in .card-content (padding:15px), so the padding
lives there and the card itself adds none */
width: 100%;
max-width: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.report-card :deep(.card-content) {
padding: 18px;
}
.report-card-title {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.6;
}
.report-description {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.report-description.is-empty {
opacity: 0.5;
}
.report-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.report-page-url {
overflow-wrap: anywhere;
font-size: 13px;
}
.step-screenshot {
max-width: 240px;
border: 1px solid var(--color-border, #2f334d);
margin-top: 6px;
}
.report-video {
width: 100%;
max-width: 768px;
display: block;
border: 1px solid var(--color-border, #2f334d);
}
/* kit .tabs caps itself at 900px and is a flex row — its .tabs-panels wrapper
shrink-wraps the content; the report layout spans the whole shell instead */
:deep(.tabs) {
max-width: none;
}
:deep(.tabs-panels) {
width: 100%;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.attachment-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid var(--ltt-border, rgba(192, 202, 245, 0.24));
color: inherit;
text-decoration: none;
font-size: 13px;
transition: border-color 0.15s ease;
}
.attachment-item:hover {
border-color: var(--ltt-accent, #7aa2f7);
}
.attachment-thumb {
display: flex;
align-items: center;
justify-content: center;
height: 150px;
overflow: hidden;
background: var(--ltt-surface-sunken, #10121c);
}
.attachment-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.attachment-video {
width: 100%;
height: 100%;
object-fit: contain;
background: var(--ltt-surface-sunken, #10121c);
}
.attachment-thumb .ph {
font-size: 40px;
opacity: 0.4;
}
.attachment-info {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-size {
opacity: 0.5;
font-size: 11px;
flex-shrink: 0;
}
.report-page-loading {
opacity: 0.6;
}
.ai-prompt-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.ai-prompt-hint {
margin: 0;
font-size: 12px;
opacity: 0.6;
}
.ai-prompt-form :deep(textarea.ai-prompt-text) {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
min-width: 480px;
/* the kit caps textarea height — a full prompt needs room to be reviewed */
min-height: 420px;
resize: vertical;
}
@media (max-width: 640px) {
.ai-prompt-text :deep(textarea) {
min-width: 0;
}
}
.ai-prompt-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
}
.report-page-notfound {
padding-top: 15vh;
}
</style>