feat(paliadin/cross-surface-sync): t-paliad-161 Slice F — DB-driven history hydrate

Two Paliadin chat surfaces shared a user but not their conversation:
the inline drawer (paliadin-widget.ts) maintained `paliadin:widget:session`
+ `paliadin:widget:history:` while the standalone /paliadin page used
`paliadin:session` + `paliadin:history:`. A turn typed in the drawer
never surfaced on /paliadin and vice versa, and a localStorage wipe
tossed everything.

Fix in three coordinated parts:

1. **Shared session id.** The widget now uses the same `paliadin:session`
   key the standalone page already uses. One-time migration in
   bootSession copies any legacy `paliadin:widget:session` across so
   existing users keep their conversation thread, then deletes the legacy
   key. The widget's HISTORY_PREFIX also drops the `widget:` namespace
   so both surfaces' render-caches address the same bucket.

2. **DB-driven history.** New endpoint:

       GET /api/paliadin/history?session=<id>&limit=<N>

   Returns the caller's turns for the session, oldest → newest,
   gated by PaliadinOwnerEmail (same gate as POST /api/paliadin/turn).
   Backed by paliadinDB.ListHistoryForSession, which mirrors the
   existing visibility predicate (own rows always; all rows for
   global_admin). Default limit 50, capped at 200.

3. **Hydrate-on-mount, hydrate-on-open.**
   - paliadin.ts (standalone page): DOMContentLoaded calls
     hydrateFromServer() right after renderHistory() seeds from
     localStorage. DB rows replace the cache when present.
   - paliadin-widget.ts (inline drawer): revealIfOwner kicks
     hydrateFromServer in the background after rehydrateHistory paints
     the cache. openDrawer() also calls hydrateFromServer so a turn the
     user typed on /paliadin since the last drawer-open shows up
     without a manual reload.

   Reconciliation: DB > localStorage when DB has rows. DB call fails or
   returns empty → keep showing whatever's in cache (offline cushion).
   This kills the trap klaus warned about (paliad#19): every render
   reconciles against the server, no first-paint short-circuits.

Schema: zero migrations. paliad.paliadin_turns already carries
session_id + user_message + response + ts since the t-paliad-146 PoC;
this slice just adds a typed read path.

Backwards compatible: the standalone /paliadin page's session key is
unchanged; only the widget migrates onto it.

Builds + tests green; i18n unchanged.

Refs: m/paliad#19 (localStorage short-circuit), m/paliad#20 (inline modal),
      docs/design-paliadin-inline-2026-05-08.md §3.4.
This commit is contained in:
m
2026-05-08 21:43:51 +02:00
parent 936aca5925
commit 1782dfa910
5 changed files with 226 additions and 3 deletions

View File

@@ -47,8 +47,18 @@ interface TurnResponse {
sse_url: string;
}
const SESSION_KEY = "paliadin:widget:session";
const HISTORY_PREFIX = "paliadin:widget:history:";
// Shared session key — the inline drawer and the standalone /paliadin
// page must use the same browser-session id so both surfaces show the
// same conversation. Migration on first run: if a legacy
// `paliadin:widget:session` exists but the shared `paliadin:session`
// does not, copy across so the user doesn't lose drawer state on the
// rollover.
const SESSION_KEY = "paliadin:session";
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
// History bucket — render-cache only; DB is source of truth (server
// hydrates via /api/paliadin/history on every mount). The cache is keyed
// by session id so a session reset gives a clean slate.
const HISTORY_PREFIX = "paliadin:history:";
let sessionId: string;
let history: HistoryEntry[] = [];
@@ -74,9 +84,16 @@ document.addEventListener("DOMContentLoaded", () => {
function bootSession(): void {
let s = localStorage.getItem(SESSION_KEY);
if (!s) {
s = crypto.randomUUID();
// One-time migration: previous widget builds wrote
// `paliadin:widget:session` instead of the shared key. Carry over
// the existing id so the user keeps their conversation thread.
const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
s = legacy || crypto.randomUUID();
localStorage.setItem(SESSION_KEY, s);
}
// Drop the legacy key now that we've migrated; harmless if it's
// already absent.
localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
sessionId = s;
loadHistory();
}
@@ -123,6 +140,10 @@ async function revealIfOwner(): Promise<void> {
showTrigger();
renderStarters();
rehydrateHistory();
// Refresh from DB in the background so cross-surface activity (a
// turn typed on the standalone /paliadin page) shows up here without
// a manual reload.
void hydrateFromServer();
}
function isPaliadinOwner(me: MeResponse): boolean {
@@ -199,6 +220,10 @@ function openDrawer(): void {
refreshContextChip();
renderStarters();
// Pull the canonical conversation from the DB on every open so a
// turn the user typed on /paliadin (or another tab) since the last
// open is reflected here.
void hydrateFromServer();
setTimeout(() => {
document.getElementById("paliadin-widget-input")?.focus();
}, 60);
@@ -482,6 +507,67 @@ function rehydrateHistory(): void {
history.forEach((h) => appendBubble(h.role, h.text));
}
// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
// are typed as unknown to keep the contract loose.
interface PaliadinTurnRow {
turn_id: string;
session_id: string;
started_at: string;
user_message: string;
response?: string | null;
error_code?: string | null;
}
// Hydrate from the DB on every mount. Crash-resistant: a typed turn
// always lands in paliad.paliadin_turns, so even if the user closes
// the tab mid-flight or the device dies, the next mount picks it up.
//
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
// them entirely and overwrite the cache. If the DB call fails or
// returns empty, we keep whatever's in localStorage (offline cushion).
async function hydrateFromServer(): Promise<void> {
let rows: PaliadinTurnRow[] = [];
try {
const r = await fetch(
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
{ credentials: "same-origin" },
);
if (!r.ok) return;
const body = (await r.json()) as PaliadinTurnRow[] | null;
rows = Array.isArray(body) ? body : [];
} catch {
return;
}
if (!rows.length) return;
// Project DB rows into the {role, text, ts} shape the cache + render
// path expect. Each turn becomes two entries (user prompt then
// assistant response). Skip turns with no response (in-flight, or
// errored without a recovery) so the bubble doesn't show
// half-rendered placeholders on reload.
const reconstructed: HistoryEntry[] = [];
for (const row of rows) {
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
if (typeof row.response === "string" && row.response.length > 0) {
reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
}
}
history = reconstructed;
saveHistory();
// Re-render: clear the message list + replay the canonical history.
const messages = document.getElementById("paliadin-widget-messages");
const empty = document.getElementById("paliadin-widget-empty");
if (messages) {
// Strip every prior bubble but keep the empty-state placeholder so
// it can be hidden by hideEmpty() if we end up rendering anything.
messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
if (empty) empty.style.display = "none";
history.forEach((h) => appendBubble(h.role, h.text));
}
}
async function resetSession(): Promise<void> {
if (!confirm(t("paliadin.widget.reset.confirm"))) return;
history = [];

View File

@@ -47,6 +47,10 @@ document.addEventListener("DOMContentLoaded", () => {
wireStarters();
wireReset();
renderHistory();
// Pull the canonical conversation from the DB so a turn typed in the
// inline drawer (which shares this session id) shows up here on
// mount. DB > localStorage when both have data.
void hydrateFromServer();
});
function bootSession(): void {
@@ -422,6 +426,61 @@ function saveHistory(): void {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
}
// PaliadinTurnRow mirrors the JSON returned by /api/paliadin/history
// (services.PaliadinTurn). Fields we don't render yet are skipped.
interface PaliadinTurnRow {
turn_id: string;
session_id: string;
started_at: string;
user_message: string;
response?: string | null;
used_tools?: string[] | null;
rows_seen?: number[] | null;
classifier_tag?: string | null;
duration_ms?: number | null;
chip_count?: number | null;
}
// Hydrate from /api/paliadin/history, replacing the localStorage cache
// when the DB returns rows. Fail-quiet on network / auth errors —
// localStorage is a perfectly good offline fallback.
async function hydrateFromServer(): Promise<void> {
let rows: PaliadinTurnRow[] = [];
try {
const r = await fetch(
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
{ credentials: "same-origin" },
);
if (!r.ok) return;
const body = (await r.json()) as PaliadinTurnRow[] | null;
rows = Array.isArray(body) ? body : [];
} catch {
return;
}
if (!rows.length) return;
const reconstructed: HistoryEntry[] = [];
for (const row of rows) {
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
if (typeof row.response === "string" && row.response.length > 0) {
reconstructed.push({
role: "assistant",
text: row.response,
ts: row.started_at,
meta: {
used_tools: row.used_tools ?? undefined,
rows_seen: row.rows_seen ?? undefined,
classifier_tag: row.classifier_tag ?? undefined,
duration_ms: row.duration_ms ?? undefined,
chip_count: row.chip_count ?? undefined,
},
});
}
}
history = reconstructed;
saveHistory();
renderHistory();
}
function renderHistory(): void {
const stream = document.getElementById("paliadin-stream");
if (!stream) return;

View File

@@ -497,6 +497,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
protected.HandleFunc("GET /api/paliadin/turns/{id}", handlePaliadinTurnGet)
// Crash-resistant history hydrate (t-paliad-161 follow-up): both
// Paliadin surfaces use this to seed their UI from the DB before
// consulting localStorage.
protected.HandleFunc("GET /api/paliadin/history", handlePaliadinHistory)
protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset)
// Agent-suggested write path (t-paliad-161 Slice D). Owner-gated;
// drafts a deadline / appointment that lands in the approval pipeline.

View File

@@ -25,6 +25,8 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -352,6 +354,36 @@ func handlePaliadinTurnGet(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handlePaliadinHistory returns the caller's prior turns for a given
// browser session id, oldest → newest. Both Paliadin surfaces (the
// inline drawer and the standalone /paliadin page) hit this on mount
// to seed their UI with the canonical conversation BEFORE rendering
// any localStorage cache, so a crash / device swap / cross-surface
// jump shows the same threading.
//
// Query params:
// session — browser session id (required; empty → empty array)
// limit — max rows to return (default 50, capped at 200)
func handlePaliadinHistory(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r)
sessionID := strings.TrimSpace(r.URL.Query().Get("session"))
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
}
}
rows, err := paliadinSvc.ListHistoryForSession(r.Context(), uid, sessionID, limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, rows)
}
// handlePaliadinReset kills the caller's Paliadin tmux session so the
// next turn boots a fresh claude pane (per-user — see t-paliad-155).
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {

View File

@@ -67,6 +67,14 @@ type Paliadin interface {
// global_admin can see anyone's turn; everyone else only their own.
// Returns sql.ErrNoRows when the row is invisible or absent.
GetTurn(ctx context.Context, callerID uuid.UUID, turnID uuid.UUID) (*PaliadinTurn, error)
// ListHistoryForSession returns the caller's turns for a given browser
// session in chronological order (oldest → newest). Powers the
// crash-resistant chat history hydrate (t-paliad-161 follow-up): the
// inline drawer and the standalone /paliadin page share one session
// id, so a turn typed in the drawer surfaces on the standalone page
// (and vice versa) on next mount. DB is source of truth; localStorage
// is render-cache only.
ListHistoryForSession(ctx context.Context, callerID uuid.UUID, sessionID string, limit int) ([]PaliadinTurn, error)
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
}
@@ -471,6 +479,40 @@ func (s *paliadinDB) GetTurn(ctx context.Context, callerID, turnID uuid.UUID) (*
return &out, nil
}
// ListHistoryForSession returns the caller's turns for a given browser
// session id, oldest → newest. Both the inline drawer and the
// standalone /paliadin page hydrate from this on mount before
// consulting localStorage, so a crash / device swap / cross-surface
// jump still shows the same conversation. Limit defaults to 50.
//
// Visibility mirrors ListRecentTurns / GetTurn (own rows always; all
// rows for global_admin). Empty session_id returns no rows.
func (s *paliadinDB) ListHistoryForSession(ctx context.Context, callerID uuid.UUID, sessionID string, limit int) ([]PaliadinTurn, error) {
if strings.TrimSpace(sessionID) == "" {
return []PaliadinTurn{}, nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
out := make([]PaliadinTurn, 0, limit)
q := `
SELECT t.turn_id, t.user_id, t.session_id, t.started_at, t.finished_at, t.duration_ms,
t.user_message, t.response, t.response_tokens, t.used_tools, t.rows_seen,
t.chip_count, t.abandoned, t.page_origin, t.error_code, t.classifier_tag
FROM paliad.paliadin_turns t
WHERE t.session_id = $1
AND (t.user_id = $2
OR EXISTS (SELECT 1 FROM paliad.users gu
WHERE gu.id = $2 AND gu.global_role = 'global_admin'))
ORDER BY t.started_at ASC
LIMIT $3
`
if err := s.db.SelectContext(ctx, &out, q, sessionID, callerID, limit); err != nil {
return nil, fmt.Errorf("paliadin: list history: %w", err)
}
return out, nil
}
// PaliadinStats is the aggregate view shown on /admin/paliadin.
type PaliadinStats struct {
TotalTurns int `json:"total_turns"`