Skip to content

Commit 1b8a7a2

Browse files
Ripwordsclaude
andcommitted
feat(dashboard): add Comments tab to report detail page
Renders a two-way synced comment thread with polling every 20 s, inline edit/delete for own comments, and markdown rendering via marked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa39cf9 commit 1b8a7a2

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
<!-- apps/dashboard/app/components/report-drawer/comments-tab.vue -->
2+
<!-- Two-way synced comment thread for a report. Polls every 20 s for new -->
3+
<!-- comments coming in via GitHub webhook. Dashboard users can post, edit, -->
4+
<!-- and delete their own comments; owners can delete any comment. -->
5+
<script setup lang="ts">
6+
import type { CommentDTO } from "@reprojs/shared"
7+
8+
interface Props {
9+
projectId: string
10+
reportId: string
11+
}
12+
const props = defineProps<Props>()
13+
14+
const { renderMarkdown } = useMarkdown()
15+
const { data, refresh } = useApi<{ items: CommentDTO[] }>(
16+
`/api/projects/${props.projectId}/reports/${props.reportId}/comments`,
17+
)
18+
19+
// Poll every 20 s so inbound GitHub comments appear without a manual reload.
20+
let pollTimer: ReturnType<typeof setInterval> | null = null
21+
onMounted(() => {
22+
pollTimer = setInterval(refresh, 20_000)
23+
})
24+
onUnmounted(() => {
25+
if (pollTimer !== null) clearInterval(pollTimer)
26+
})
27+
28+
defineExpose({ refresh })
29+
30+
// ---- composer state ----
31+
const composerBody = ref("")
32+
const composerLoading = ref(false)
33+
const composerError = ref<string | null>(null)
34+
35+
async function submitComment() {
36+
const body = composerBody.value.trim()
37+
if (!body) return
38+
composerLoading.value = true
39+
composerError.value = null
40+
try {
41+
await $fetch(`/api/projects/${props.projectId}/reports/${props.reportId}/comments`, {
42+
method: "POST",
43+
credentials: "include",
44+
body: { body },
45+
})
46+
composerBody.value = ""
47+
await refresh()
48+
} catch (e: unknown) {
49+
const err = e as { statusMessage?: string; message?: string }
50+
composerError.value = err.statusMessage ?? err.message ?? "Failed to post comment"
51+
} finally {
52+
composerLoading.value = false
53+
}
54+
}
55+
56+
// ---- inline edit state ----
57+
const editingId = ref<string | null>(null)
58+
const editBody = ref("")
59+
const editLoading = ref(false)
60+
const editError = ref<string | null>(null)
61+
62+
function startEdit(comment: CommentDTO) {
63+
editingId.value = comment.id
64+
editBody.value = comment.body
65+
editError.value = null
66+
}
67+
function cancelEdit() {
68+
editingId.value = null
69+
editBody.value = ""
70+
editError.value = null
71+
}
72+
async function submitEdit(commentId: string) {
73+
const body = editBody.value.trim()
74+
if (!body) return
75+
editLoading.value = true
76+
editError.value = null
77+
try {
78+
await $fetch(
79+
`/api/projects/${props.projectId}/reports/${props.reportId}/comments/${commentId}`,
80+
{
81+
method: "PATCH",
82+
credentials: "include",
83+
body: { body },
84+
},
85+
)
86+
cancelEdit()
87+
await refresh()
88+
} catch (e: unknown) {
89+
const err = e as { statusMessage?: string; message?: string }
90+
editError.value = err.statusMessage ?? err.message ?? "Failed to update comment"
91+
} finally {
92+
editLoading.value = false
93+
}
94+
}
95+
96+
// ---- delete ----
97+
const deleteLoading = ref<string | null>(null)
98+
99+
async function deleteComment(commentId: string) {
100+
deleteLoading.value = commentId
101+
try {
102+
await $fetch(
103+
`/api/projects/${props.projectId}/reports/${props.reportId}/comments/${commentId}`,
104+
{
105+
method: "DELETE",
106+
credentials: "include",
107+
},
108+
)
109+
await refresh()
110+
} catch {
111+
// Silent fail — comment stays in UI; user can retry
112+
} finally {
113+
deleteLoading.value = null
114+
}
115+
}
116+
117+
// ---- session ----
118+
const { user } = useSession()
119+
120+
function authorLabel(comment: CommentDTO): string {
121+
const a = comment.author
122+
if (a.kind === "dashboard") return a.name ?? a.email ?? "Dashboard user"
123+
return a.githubLogin ?? "GitHub user"
124+
}
125+
126+
function authorInitials(comment: CommentDTO): string {
127+
return authorLabel(comment).slice(0, 2).toUpperCase()
128+
}
129+
130+
function authorAvatar(comment: CommentDTO): string | undefined {
131+
return comment.author.avatarUrl ?? undefined
132+
}
133+
134+
function isOwn(comment: CommentDTO): boolean {
135+
if (!user.value?.id) return false
136+
return comment.author.kind === "dashboard" && comment.author.id === user.value.id
137+
}
138+
139+
function relTime(iso: string | Date): string {
140+
const ms = Date.now() - new Date(iso).getTime()
141+
const s = Math.floor(ms / 1000)
142+
if (s < 60) return `${s}s ago`
143+
const m = Math.floor(s / 60)
144+
if (m < 60) return `${m}m ago`
145+
const h = Math.floor(m / 60)
146+
if (h < 24) return `${h}h ago`
147+
return `${Math.floor(h / 24)}d ago`
148+
}
149+
</script>
150+
151+
<template>
152+
<div class="flex flex-col h-full">
153+
<!-- Comment list -->
154+
<div class="flex-1 overflow-y-auto p-5 space-y-4 text-sm">
155+
<div v-if="!data?.items?.length" class="text-muted">No comments yet.</div>
156+
157+
<div v-for="comment in data?.items" :key="comment.id" class="flex items-start gap-3">
158+
<UAvatar
159+
:src="authorAvatar(comment)"
160+
:text="authorInitials(comment)"
161+
size="sm"
162+
class="flex-shrink-0 mt-0.5"
163+
/>
164+
165+
<div class="flex-1 min-w-0">
166+
<div class="flex items-baseline gap-2 mb-1">
167+
<span class="font-medium text-default truncate">{{ authorLabel(comment) }}</span>
168+
<span
169+
v-if="comment.source === 'github'"
170+
class="inline-flex items-center gap-1 text-xs text-muted px-1.5 py-0.5 rounded bg-muted/10"
171+
>
172+
<UIcon name="i-simple-icons-github" class="size-3" />
173+
GitHub
174+
</span>
175+
<span class="text-xs text-muted ml-auto flex-shrink-0">
176+
{{ relTime(comment.createdAt) }}
177+
</span>
178+
</div>
179+
180+
<!-- Inline edit mode -->
181+
<template v-if="editingId === comment.id">
182+
<UTextarea v-model="editBody" :rows="3" class="w-full mb-2" autofocus />
183+
<div v-if="editError" class="text-xs text-error mb-2">{{ editError }}</div>
184+
<div class="flex gap-2">
185+
<UButton size="xs" :loading="editLoading" @click="submitEdit(comment.id)">
186+
Save
187+
</UButton>
188+
<UButton size="xs" variant="ghost" @click="cancelEdit">Cancel</UButton>
189+
</div>
190+
</template>
191+
192+
<!-- Rendered body -->
193+
<template v-else>
194+
<!-- eslint-disable vue/no-v-html -->
195+
<div
196+
class="prose prose-sm dark:prose-invert max-w-none text-default"
197+
v-html="renderMarkdown(comment.body)"
198+
/>
199+
<!-- eslint-enable vue/no-v-html -->
200+
201+
<div v-if="isOwn(comment)" class="flex gap-2 mt-1.5">
202+
<button
203+
type="button"
204+
class="text-xs text-muted hover:text-default"
205+
@click="startEdit(comment)"
206+
>
207+
Edit
208+
</button>
209+
<button
210+
type="button"
211+
class="text-xs text-muted hover:text-error"
212+
:disabled="deleteLoading === comment.id"
213+
@click="deleteComment(comment.id)"
214+
>
215+
{{ deleteLoading === comment.id ? "Deleting…" : "Delete" }}
216+
</button>
217+
</div>
218+
</template>
219+
</div>
220+
</div>
221+
</div>
222+
223+
<!-- Composer -->
224+
<div class="border-t border-default p-4 space-y-2">
225+
<UTextarea v-model="composerBody" placeholder="Add a comment…" :rows="3" class="w-full" />
226+
<div v-if="composerError" class="text-xs text-error">{{ composerError }}</div>
227+
<div class="flex justify-end">
228+
<UButton
229+
size="sm"
230+
:loading="composerLoading"
231+
:disabled="!composerBody.trim()"
232+
@click="submitComment"
233+
>
234+
Comment
235+
</UButton>
236+
</div>
237+
</div>
238+
</div>
239+
</template>

apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { LogsAttachment, ReportSummaryDTO } from "@reprojs/shared"
1212
import AppErrorState from "~/components/common/app-error-state.vue"
1313
import AppLoadingSkeleton from "~/components/common/app-loading-skeleton.vue"
1414
import ActivityTab from "~/components/report-drawer/activity-tab.vue"
15+
import CommentsTab from "~/components/report-drawer/comments-tab.vue"
1516
import ConsoleTab from "~/components/report-drawer/console-tab.vue"
1617
import CookiesTab from "~/components/report-drawer/cookies-tab.vue"
1718
import NetworkTab from "~/components/report-drawer/network-tab.vue"
@@ -54,6 +55,7 @@ type TabId =
5455
| "network"
5556
| "replay"
5657
| "activity"
58+
| "comments"
5759
| "cookies"
5860
| "system"
5961
| "raw"
@@ -93,6 +95,7 @@ const tabs = computed(() => {
9395
base.push({ id: "replay", label: "Replay", hasData: report.value?.hasReplay ?? false })
9496
}
9597
base.push({ id: "activity", label: "Activity" })
98+
base.push({ id: "comments", label: "Comments" })
9699
if (report.value?.source !== "expo") {
97100
base.push({ id: "cookies", label: "Cookies", hasData: cookiesHasData.value })
98101
}
@@ -104,9 +107,11 @@ const tabs = computed(() => {
104107
// After a triage mutation, re-fetch the report row and refresh the activity
105108
// feed so the timeline reflects the new event immediately.
106109
const activityRef = ref<InstanceType<typeof ActivityTab> | null>(null)
110+
const commentsRef = ref<InstanceType<typeof CommentsTab> | null>(null)
107111
async function onPatched() {
108112
await refresh()
109113
if (activityRef.value) await activityRef.value.refresh()
114+
if (commentsRef.value) await commentsRef.value.refresh()
110115
}
111116
112117
const triageOpen = ref(false)
@@ -225,6 +230,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
225230
:project-id="projectId"
226231
:report="report"
227232
/>
233+
<CommentsTab
234+
v-else-if="activeTab === 'comments'"
235+
ref="commentsRef"
236+
:project-id="projectId"
237+
:report-id="report.id"
238+
/>
228239
<CookiesTab v-else-if="activeTab === 'cookies'" :project-id="projectId" :report="report" />
229240
<div v-else-if="activeTab === 'system'" class="p-5">
230241
<UCard :ui="{ body: 'p-4' }">

0 commit comments

Comments
 (0)