feat(web): chat with an uploaded transcript, reusing the ADR-027 path (ADR-033)

GET/POST /uploads/{id}/chat, mounted only when both an Uploader and a Chatter
are wired. The row comes from the user-scoped private store, so another user's
upload is a 404 before any model is involved. The owner's transcript goes to
the SAME Chatter with App.UploadChatMaxChars instead of the 18,000-char caption
default. The chat panel is shared, not forked: chatView gains PostURL/Subject
(empty = a YouTube video, so existing callers are unchanged). The done card
links to chat only when chat is wired.

Mutation-checked: dropping the upload budget, ignoring the done-status guard,
or scoping the lookup to the wrong user each turn tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:58:25 +02:00
co-authored by Claude Sonnet 5.5
parent a39f58b730
commit 4fdb212cef
7 changed files with 609 additions and 341 deletions
+22 -3
View File
@@ -44,11 +44,11 @@ templ UploadsPage(v uploadsView) {
}
// UploadPage is one upload's page: its title and the polling status region.
templ UploadPage(u domain.UploadedTranscript) {
templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
@Layout("Tapir — " + u.Title) {
<article class="account">
<h1>{ u.Title }</h1>
@uploadStatus(u)
@uploadStatus(u, chatEnabled)
<p><a href="/uploads">All recordings</a></p>
</article>
}
@@ -56,7 +56,7 @@ templ UploadPage(u domain.UploadedTranscript) {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
templ uploadStatus(u domain.UploadedTranscript) {
templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
switch u.Status {
case domain.UploadDone:
<div class="upload-status upload-done" role="status">
@@ -64,6 +64,9 @@ templ uploadStatus(u domain.UploadedTranscript) {
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by <span class="chip">{ u.Endpoint }</span>.
</p>
if chatEnabled {
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
}
<details open>
<summary>Transcript</summary>
<pre class="transcript">{ u.Content }</pre>
@@ -89,3 +92,19 @@ templ uploadStatus(u domain.UploadedTranscript) {
</div>
}
}
// UploadChatPage is the no-JS full page for chatting with an uploaded transcript:
// the same chat panel as a video's (ADR-027), scoped to this recording.
templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
@Layout("Tapir — Ask about " + u.Title) {
<article class="account">
<h1>{ u.Title }</h1>
<section id="chat-section" class="chat-dock chat-dock-open">
<h2 class="chat-heading">Ask about this recording</h2>
<p class="chat-scope muted">Answers come only from this recording's transcript, which is private to you.</p>
@chatPanel(v)
</section>
<p><a href={ templ.SafeURL("/uploads/" + u.ID) }>Back to the transcript</a></p>
</article>
}
}