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:
2026-06-11 14:02:39 +02:00
co-authored by Claude Opus 4.8
parent 71df696448
commit 9cc4c5af96
7 changed files with 820 additions and 616 deletions
+6
View File
@@ -14,6 +14,12 @@ Feature: Chat with a video's stored transcript
Scenario: A summary view offers a deeper-dive into the video Scenario: A summary view offers a deeper-dive into the video
When I view the summary When I view the summary
Then I see a "dig deeper" affordance that opens a chat about this video Then I see a "dig deeper" affordance that opens a chat about this video
And it opens the chat in place, below the summary, without leaving the page
Scenario: The summary and the chat are on one page
When I open the chat
Then the summary stays visible alongside the chat
And I can read the summary while I ask questions
Scenario: Ask a question answered from the stored transcript Scenario: Ask a question answered from the stored transcript
When I ask a question in the chat When I ask a question in the chat
+16 -7
View File
@@ -64,13 +64,21 @@ func (a *App) handleChat(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.render(w, r, ChatPage(chatView{ view := chatView{
VideoID: row.VideoID, VideoID: row.VideoID,
Title: displayTitle(*row), Title: displayTitle(*row),
Available: hasText, Available: hasText,
Models: a.Chat.Models(), Models: a.Chat.Models(),
Selected: a.Chat.DefaultModel(row.AIModel), Selected: a.Chat.DefaultModel(row.AIModel),
})) }
// HTMX (the in-place reveal from the summary) gets just the open chat section,
// swapped over the closed dock so the summary above it stays put. A no-JS
// navigation gets the full page: the whole summary plus the open chat.
if isHTMX(r) {
a.render(w, r, chatSection(view))
return
}
a.render(w, r, ChatPage(*row, view))
} }
// handleChatMessage answers one question against the stored transcript (POST). // handleChatMessage answers one question against the stored transcript (POST).
@@ -129,7 +137,7 @@ func (a *App) handleChatMessage(w http.ResponseWriter, r *http.Request) {
view.Truncated = reply.Truncated view.Truncated = reply.Truncated
} }
} }
a.renderChat(w, r, view) a.renderChatTurn(w, r, *row, view)
} }
// loadOwnedSummary fetches the summary for the path's video scoped to userID, or // loadOwnedSummary fetches the summary for the path's video scoped to userID, or
@@ -166,14 +174,15 @@ func (a *App) readTranscript(w http.ResponseWriter, r *http.Request, row store.S
return t.Content, true, true return t.Content, true, true
} }
// renderChat returns the chat panel fragment for an HTMX request, or the full // renderChatTurn returns the chat panel fragment for an HTMX answer (swapped in
// chat page otherwise (no-JS POST re-renders the whole page). // place within the open dock), or the full chat page otherwise — the no-JS POST
func (a *App) renderChat(w http.ResponseWriter, r *http.Request, v chatView) { // re-renders the whole summary + open chat with the new turn.
func (a *App) renderChatTurn(w http.ResponseWriter, r *http.Request, row store.SummaryRow, v chatView) {
if isHTMX(r) { if isHTMX(r) {
a.render(w, r, chatPanel(v)) a.render(w, r, chatPanel(v))
return return
} }
a.render(w, r, ChatPage(v)) a.render(w, r, ChatPage(row, v))
} }
// resolveModel keeps the posted model only when it is an offered option; anything // resolveModel keeps the posted model only when it is an offered option; anything
+35
View File
@@ -137,6 +137,8 @@ func TestChatEntryAffordanceOnSummaryView(t *testing.T) {
html := body(t, do(t, withChat, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))) html := body(t, do(t, withChat, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil)))
require.Contains(t, html, "Dig deeper", "the deeper-dive affordance is shown when chat is enabled") require.Contains(t, html, "Dig deeper", "the deeper-dive affordance is shown when chat is enabled")
require.Contains(t, html, "/v/"+videoX+"/chat", "it links to this video's chat") require.Contains(t, html, "/v/"+videoX+"/chat", "it links to this video's chat")
require.Contains(t, html, `id="chat-section"`, "the dock lives on the detail page")
require.Contains(t, html, `hx-get="/v/`+videoX+`/chat"`, "it opens the chat in place (HTMX), not a navigation")
// With no chat backend wired the affordance is absent (routes unmounted). // With no chat backend wired the affordance is absent (routes unmounted).
noChat := newApp(t) noChat := newApp(t)
@@ -145,6 +147,39 @@ func TestChatEntryAffordanceOnSummaryView(t *testing.T) {
require.NotContains(t, html, "Dig deeper", "no affordance when chat is disabled") require.NotContains(t, html, "Dig deeper", "no affordance when chat is disabled")
} }
// The summary and the chat live together (the integrated UX): the no-JS chat page
// renders the full summary alongside the chat, and the HTMX reveal returns just
// the open chat section as a fragment so it docks in below the summary already on
// screen — the summary is never navigated away from.
func TestChatIntegratedWithSummaryOnSamePage(t *testing.T) {
ctx := context.Background()
chatter := &fakeChatter{models: []string{"phi4-mini", "gemma4-26b"}}
app := newChatApp(t, chatter, nil)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "SUMMARY-BODY-MARKER"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript")
// No-JS full page: the summary payload and the chat are on one page.
html := body(t, getChat(t, app, videoX))
require.Contains(t, html, "SUMMARY-BODY-MARKER", "the summary text is shown on the chat page")
require.Contains(t, html, "takeaway one", "takeaways shown alongside the chat")
require.Contains(t, html, "highlight one", "highlights shown alongside the chat")
require.Contains(t, html, "Ask about this video", "the chat sits on the same page as the summary")
require.Contains(t, html, `name="question"`, "the ask form is present")
// HTMX reveal: the open chat section ONLY (a fragment) — no full-page chrome and
// no duplicated summary, so it swaps in below the summary already rendered.
req := httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/chat", nil)
req.Header.Set("HX-Request", "true")
frag := body(t, do(t, app, req))
require.NotContains(t, frag, "<html", "the reveal is a fragment, not a full page")
require.NotContains(t, frag, "SUMMARY-BODY-MARKER", "the reveal does not re-send the summary (it's already on screen)")
require.Contains(t, frag, `id="chat-section"`, "the fragment replaces the dock in place")
require.Contains(t, frag, `name="question"`, "the ask form is in the revealed section")
}
// THE KEY SAFETY ASSERTION (ADR-027): a chat answer is produced entirely from the // THE KEY SAFETY ASSERTION (ADR-027): a chat answer is produced entirely from the
// stored transcript — the model receives the stored text, and neither the // stored transcript — the model receives the stored text, and neither the
// summarize→fetch path nor the YouTube fetch path is ever touched. The tripwire // summarize→fetch path nor the YouTube fetch path is ever touched. The tripwire
+5 -3
View File
@@ -731,9 +731,11 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; } .detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
.detail li { margin-bottom: var(--s1); } .detail li { margin-bottom: var(--s1); }
/* deeper-dive chat (ADR-027) */ /* deeper-dive chat (ADR-027) — docks in place below the summary */
.dig-deeper { margin: var(--s3) 0 0; } .chat-dock { margin-top: var(--s5); border-top: 1px solid var(--line); padding-top: var(--s4); }
.chat .chat-scope { margin: 0 0 var(--s4); font-size: .9rem; } .chat-dock .chat-open { display: inline-block; }
.chat-heading { font-size: 1.1rem; margin: 0 0 var(--s2); }
.chat-scope { margin: 0 0 var(--s3); font-size: .9rem; }
.chat-panel { display: flex; flex-direction: column; gap: var(--s3); } .chat-panel { display: flex; flex-direction: column; gap: var(--s3); }
.chat-log { display: flex; flex-direction: column; gap: var(--s3); } .chat-log { display: flex; flex-direction: column; gap: var(--s3); }
.chat-turn { border-radius: var(--radius); padding: var(--s2) var(--s3); } .chat-turn { border-radius: var(--radius); padding: var(--s2) var(--s3); }
+101 -69
View File
@@ -410,86 +410,118 @@ templ noCaptionsCard(r store.SummaryRow) {
</li> </li>
} }
// DetailPage is the full summary view: text, highlights, takeaways, metadata, // summaryBody is the summary payload shared by the detail page and the no-JS
// and the action button group. chatEnabled adds the "dig deeper" affordance // chat page (so the chat page shows the same summary, not a separate view):
// (ADR-027) — a quiet link into the per-video chat over the stored transcript. // 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) { templ DetailPage(r store.SummaryRow, chatEnabled bool) {
@Layout("Tapir — " + displayTitle(r)) { @Layout("Tapir — " + displayTitle(r)) {
<article class="detail"> <article class="detail">
<p class="back"><a href="/"> Summaries</a></p> <p class="back"><a href="/"> Summaries</a></p>
<h1>{ displayTitle(r) }</h1> <h1>{ displayTitle(r) }</h1>
<p class="meta"> @summaryBody(r)
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>
}
if chatEnabled { if chatEnabled {
// Where the "I want more" reaction goes (ADR-027). A quiet link, not a @chatReveal(r.VideoID)
// 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>
} }
@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> </article>
} }
} }
// ChatPage is the per-video deeper-dive chat (ADR-027): a question/answer surface // chatReveal is the CLOSED dock at the foot of the summary: a quiet affordance,
// over the video's ALREADY-STORED transcript. The back link returns to the // not a loud CTA (it deepens value for a reader already here, never nudges). With
// summary it was entered from. All the interaction lives in chatPanel so the HTMX // JS it swaps itself for the open chat section in place (HTMX, summary stays
// answer-swap and the no-JS full-page render share one component. // above); without JS the same href navigates to the full chat page, which renders
templ ChatPage(v chatView) { // the summary alongside the chat. Either way the summary is never lost.
@Layout("Tapir — Ask — " + v.Title) { templ chatReveal(videoID string) {
<article class="detail chat"> <section id="chat-section" class="chat-dock">
<p class="back"><a href={ videoURL(v.VideoID) }> { v.Title }</a></p> <a
<h1>Ask about this video</h1> class="btn-secondary chat-open"
<p class="chat-scope muted">Answers come only from this video's stored transcript Tapir never fetches anything new here.</p> href={ chatURL(videoID) }
@chatPanel(v) 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> </article>
} }
} }
File diff suppressed because it is too large Load Diff
@@ -65,6 +65,7 @@ var scenarioCoverage = map[string]string{
// chat_transcript.feature (ADR-027) // chat_transcript.feature (ADR-027)
"A summary view offers a deeper-dive into the video": "TestChatEntryAffordanceOnSummaryView", "A summary view offers a deeper-dive into the video": "TestChatEntryAffordanceOnSummaryView",
"The summary and the chat are on one page": "TestChatIntegratedWithSummaryOnSamePage",
"Ask a question answered from the stored transcript": "TestChatAnswersFromStoredTranscriptWithoutAnyFetch", "Ask a question answered from the stored transcript": "TestChatAnswersFromStoredTranscriptWithoutAnyFetch",
"Chat never fetches captions or reaches YouTube": "TestChatAnswersFromStoredTranscriptWithoutAnyFetch", "Chat never fetches captions or reaches YouTube": "TestChatAnswersFromStoredTranscriptWithoutAnyFetch",
"A video with no stored transcript offers no chat": "TestChatUnavailableWhenNoStoredTranscript", "A video with no stored transcript offers no chat": "TestChatUnavailableWhenNoStoredTranscript",