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:
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user