feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+52 -47
View File
@@ -128,57 +128,62 @@
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<!-- Phase 14 (module-owned since phase 34 task 02): "New chat" resets
the local (localStorage) conversation and clears the rendered list.
The binding lives in header.js — dispatches "bor:new-chat" which
app.js acts on (it owns the in-flight-turn guard + list reset). -->
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
the chat-actions row — New chat + Share share ONE
.chat-actions wrapper (a normal .chat-shell column child,
replacing the two pills as its direct children): a
HORIZONTAL row on desktop (side by side, left-aligned, each
pill at its intrinsic width — the row's align-items: center
beats the column's stretch) and a VERTICAL stack at the
existing ≤640px breakpoint (New chat above Share,
full-width). DOM order New chat → Share in both
orientations; the 46rem column contract is untouched
(PLAN §7). The kb-banner / stale-banner / steering /
announcer structure around the row is unchanged.
<!-- Phase 50 (owner-locked 2026-08-29, `TODO.md` L5): "Save" stores
the current conversation in Postgres (saved_chats) — admin-only.
SHIPS HIDDEN: app.js reveals it only when whoami says admin
(phase 16 absent-not-hidden — `hidden` is display:none, so
anonymous visitors see no trace). Handler in app.js, upsert +
?chat=<id> contract: an unlinked Save POSTs /api/chats (the
server auto-titles from the first question, 120-char cap) and
links the conversation to the created row's id; a re-Save PUTs
the SAME row (the same conversation never spawns a second row);
a 404 PUT unlinks and recreates; "New chat" unlinks. Booting at
/?chat=<id> (valid uuid, admin) GETs the row and renders its
messages through the SAME restore path as the phase-14 local
session (pixel-identical), links the conversation, and mirrors
it to localStorage — a later Save updates that row. A deleted /
unknown id degrades to the normal local restore with a banner. -->
<button type="button" class="save-chat-btn" id="save-chat-btn" aria-label="Save chat" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
<span class="save-chat-label">Save</span>
</button>
Phase 14 (module-owned since phase 34 task 02): "New chat"
resets the local (localStorage) conversation and clears the
rendered list. The binding lives in header.js — dispatches
"bor:new-chat" which app.js acts on (it owns the
in-flight-turn guard + list reset). -->
<div class="chat-actions">
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
<!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
control — every conversation auto-saves itself (app.js
persistConversation at the phase-14/20 save points). The
History page is the visible proof; a failed auto-save leaves
only a one-line status note (never a button, never a banner).
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
(phase 51's admin-only ship-hidden gate is gone with it; task
01 opened the save/share write surface to every visitor).
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
turns the current conversation into a PUBLIC read-only link —
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
migration 0009; the anonymous page is task 03). The
save-then-share contract: an UNSAVED (unlinked) conversation
is saved AND shared in ONE action — app.js POSTs /api/chats
with { messages, share: true } (the server sets the token in
the same commit) and links the conversation to the created
row; a saved (linked) one just POSTs /api/chats/<id>/share
(idempotent — the existing token comes back unchanged). On
success the ABSOLUTE link is copied to the clipboard; a
non-secure (http) homelab origin that rejects the clipboard
gets the inline link-field fallback instead (owner-locked —
app.js renders .share-link-fallback near the status line).
Admin-only — ships HIDDEN exactly like Save (absent-not-
hidden, phase 16); app.js reveals it at boot (the same
admin-reveal block) and binds the click to shareCurrentChat.
Unsharing lives on the History page's Share column (task 04). -->
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span class="share-chat-label">Share</span>
</button>
migration 0009). The save-then-share contract: an UNSAVED
(unlinked) conversation is saved AND shared in ONE action —
app.js POSTs /api/chats with { messages, share: true } (the
server sets the token in the same commit) and links the
conversation to the created row; a saved (linked) one just
POSTs /api/chats/<id>/share (idempotent — the existing token
comes back unchanged). On success the ABSOLUTE link is
copied to the clipboard; a non-secure (http) homelab origin
that rejects the clipboard gets the inline link-field
fallback instead (owner-locked — app.js renders
.share-link-fallback near the status line). app.js binds the
click to shareCurrentChat. Unsharing lives on the History
page's Share column (admin-only). -->
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span class="share-chat-label">Share</span>
</button>
</div>
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin