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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+656
-537
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user