refactor(web): dock chat inline below the summary, integrated view (ADR-027)
The chat was a separate page — opening it left the summary behind, the very
context you're asking about. Now the chat docks open IN PLACE below the summary:
"Dig deeper" reveals the chat section (HTMX, outerHTML over the closed dock) so
the summary stays on screen above it; no navigation. The no-JS fallback renders
the full summary AND the open chat on one page (the same integrated view), so
progressive enhancement holds.
Extracts a shared summaryBody templ so the detail page and the chat page render
one identical summary, not two divergent ones. GET /v/{id}/chat returns just the
open chat section as a fragment for the inline reveal, or the full summary+chat
page for a no-JS navigation; POST swaps the panel inline or re-renders the whole
page. Tests assert summary+chat coexist on the page and the reveal is a fragment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+101
-69
@@ -410,86 +410,118 @@ templ noCaptionsCard(r store.SummaryRow) {
|
||||
</li>
|
||||
}
|
||||
|
||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||
// and the action button group. chatEnabled adds the "dig deeper" affordance
|
||||
// (ADR-027) — a quiet link into the per-video chat over the stored transcript.
|
||||
// summaryBody is the summary payload shared by the detail page and the no-JS
|
||||
// chat page (so the chat page shows the same summary, not a separate view):
|
||||
// metadata, embed, source, the action toggles, then the attention-saving order
|
||||
// Takeaways → Highlights → Summary (UX review A8).
|
||||
templ summaryBody(r store.SummaryRow) {
|
||||
<p class="meta">
|
||||
if detailMeta(r) != "" {
|
||||
<span>{ detailMeta(r) }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
</p>
|
||||
if url, ok := embedURL(r.ProviderVideoID); ok {
|
||||
<div class="embed">
|
||||
<iframe
|
||||
src={ url }
|
||||
title={ displayTitle(r) }
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
if r.URL != "" {
|
||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||
}
|
||||
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
||||
if len(r.Takeaways) > 0 {
|
||||
<section>
|
||||
<h2>Takeaways</h2>
|
||||
<ul>
|
||||
for _, t := range r.Takeaways {
|
||||
<li>{ t }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
if len(r.Highlights) > 0 {
|
||||
<section>
|
||||
<h2>Highlights</h2>
|
||||
<ul>
|
||||
for _, h := range r.Highlights {
|
||||
<li>{ h }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p class="body">{ r.Summary }</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
// DetailPage is the full summary view: the summary payload, then (when chat is
|
||||
// enabled) the deeper-dive dock (ADR-027) — a reveal that opens the chat IN PLACE
|
||||
// below the summary, so the summary stays on screen as the context being asked
|
||||
// about rather than being navigated away from.
|
||||
templ DetailPage(r store.SummaryRow, chatEnabled bool) {
|
||||
@Layout("Tapir — " + displayTitle(r)) {
|
||||
<article class="detail">
|
||||
<p class="back"><a href="/">← Summaries</a></p>
|
||||
<h1>{ displayTitle(r) }</h1>
|
||||
<p class="meta">
|
||||
if detailMeta(r) != "" {
|
||||
<span>{ detailMeta(r) }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
</p>
|
||||
if url, ok := embedURL(r.ProviderVideoID); ok {
|
||||
<div class="embed">
|
||||
<iframe
|
||||
src={ url }
|
||||
title={ displayTitle(r) }
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
if r.URL != "" {
|
||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||
}
|
||||
@summaryBody(r)
|
||||
if chatEnabled {
|
||||
// Where the "I want more" reaction goes (ADR-027). A quiet link, not a
|
||||
// loud CTA — it deepens value for a reader already here, never nudges.
|
||||
<p class="dig-deeper"><a class="btn-secondary" href={ chatURL(r.VideoID) }>Dig deeper — ask about this video →</a></p>
|
||||
@chatReveal(r.VideoID)
|
||||
}
|
||||
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
||||
// Lead with the attention-saving payload: Takeaways ("is this worth my
|
||||
// time?") first, then Highlights, then the full Summary last (UX review
|
||||
// A8). Takeaways/Highlights are conditional, so a video without them falls
|
||||
// through to the Summary leading naturally.
|
||||
if len(r.Takeaways) > 0 {
|
||||
<section>
|
||||
<h2>Takeaways</h2>
|
||||
<ul>
|
||||
for _, t := range r.Takeaways {
|
||||
<li>{ t }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
if len(r.Highlights) > 0 {
|
||||
<section>
|
||||
<h2>Highlights</h2>
|
||||
<ul>
|
||||
for _, h := range r.Highlights {
|
||||
<li>{ h }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p class="body">{ r.Summary }</p>
|
||||
</section>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// ChatPage is the per-video deeper-dive chat (ADR-027): a question/answer surface
|
||||
// over the video's ALREADY-STORED transcript. The back link returns to the
|
||||
// summary it was entered from. All the interaction lives in chatPanel so the HTMX
|
||||
// answer-swap and the no-JS full-page render share one component.
|
||||
templ ChatPage(v chatView) {
|
||||
@Layout("Tapir — Ask — " + v.Title) {
|
||||
<article class="detail chat">
|
||||
<p class="back"><a href={ videoURL(v.VideoID) }>← { v.Title }</a></p>
|
||||
<h1>Ask about this video</h1>
|
||||
<p class="chat-scope muted">Answers come only from this video's stored transcript — Tapir never fetches anything new here.</p>
|
||||
@chatPanel(v)
|
||||
// chatReveal is the CLOSED dock at the foot of the summary: a quiet affordance,
|
||||
// not a loud CTA (it deepens value for a reader already here, never nudges). With
|
||||
// JS it swaps itself for the open chat section in place (HTMX, summary stays
|
||||
// above); without JS the same href navigates to the full chat page, which renders
|
||||
// the summary alongside the chat. Either way the summary is never lost.
|
||||
templ chatReveal(videoID string) {
|
||||
<section id="chat-section" class="chat-dock">
|
||||
<a
|
||||
class="btn-secondary chat-open"
|
||||
href={ chatURL(videoID) }
|
||||
hx-get={ string(chatURL(videoID)) }
|
||||
hx-target="#chat-section"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Dig deeper — ask about this video →
|
||||
</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
// chatSection is the OPEN dock: heading + scope note + the chat panel, swapped in
|
||||
// over the closed reveal (same #chat-section id, outerHTML). It is the HTMX reveal
|
||||
// response AND the inline chat block on the no-JS chat page.
|
||||
templ chatSection(v chatView) {
|
||||
<section id="chat-section" class="chat-dock chat-dock-open">
|
||||
<h2 class="chat-heading">Ask about this video</h2>
|
||||
<p class="chat-scope muted">Answers come only from this video's stored transcript — Tapir never fetches anything new here.</p>
|
||||
@chatPanel(v)
|
||||
</section>
|
||||
}
|
||||
|
||||
// ChatPage is the no-JS full-page render of the chat: the whole summary followed
|
||||
// by the open chat dock, so a visitor without JS sees the same integrated view
|
||||
// (summary beside the conversation) that JS users get inline via the reveal.
|
||||
templ ChatPage(r store.SummaryRow, v chatView) {
|
||||
@Layout("Tapir — " + displayTitle(r)) {
|
||||
<article class="detail">
|
||||
<p class="back"><a href="/">← Summaries</a></p>
|
||||
<h1>{ displayTitle(r) }</h1>
|
||||
@summaryBody(r)
|
||||
@chatSection(v)
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user