Files
paliad/frontend/src/client/paliadin-widget.ts
m 1782dfa910 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.
2026-05-08 21:43:51 +02:00

599 lines
21 KiB
TypeScript

// paliadin-widget.ts — runtime for the inline Paliadin floating button +
// slide-out drawer (t-paliad-161).
//
// Lifecycle:
// 1. On DOMContentLoaded, fetch /api/me. If the email matches the
// Paliadin owner gate (the same gate the standalone /paliadin
// route uses) AND the route is one where the widget shows, reveal
// the trigger button.
// 2. Click trigger or press Cmd+J / Ctrl+J → open drawer + populate
// starter prompts from paliadin-starters.ts.
// 3. Submit form → POST /api/paliadin/turn with structured context
// from computePaliadinContext() → consume the SSE stream → render
// assistant bubble.
// 4. Conversation history persists in localStorage per session id.
//
// Notes:
// - Cmd+K is reserved for the global search palette (client/search.ts).
// The widget uses Cmd+J / Ctrl+J as the keyboard trigger.
// - The standalone /paliadin page's client (client/paliadin.ts) is
// unchanged — this widget reuses /api/paliadin/turn but ships its
// own UI and history bucket so the two surfaces stay independent.
// - Visibility predicate mirrors paliadin-context.shouldSendContext()
// so the widget never sends a turn from a route where it shouldn't
// show.
import { initI18n, getLang, t } from "./i18n";
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
import { startersFor, type Starter } from "./paliadin-starters";
import { renderResponseHTML } from "./paliadin-render";
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
interface MeResponse {
id: string;
email: string;
display_name?: string;
global_role?: string;
}
interface HistoryEntry {
role: "user" | "assistant";
text: string;
ts: string;
}
interface TurnResponse {
turn_id: string;
sse_url: string;
}
// 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[] = [];
let drawerOpen = false;
let activeStream: EventSource | null = null;
let pending = false;
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
const lateWidgetPolls = new Map<string, LatePollHandle>();
document.addEventListener("DOMContentLoaded", () => {
const trigger = document.getElementById("paliadin-widget-trigger");
const drawer = document.getElementById("paliadin-widget-drawer");
if (!trigger || !drawer) return; // page didn't include the widget — skip silently
initI18n();
bootSession();
void revealIfOwner();
wireTrigger();
wireDrawerControls();
wireForm();
wireKeyboardShortcut();
});
function bootSession(): void {
let s = localStorage.getItem(SESSION_KEY);
if (!s) {
// 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();
}
function loadHistory(): void {
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
if (!stored) {
history = [];
return;
}
try {
const parsed = JSON.parse(stored);
history = Array.isArray(parsed) ? parsed.slice(-30) : [];
} catch {
history = [];
}
}
function saveHistory(): void {
try {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history.slice(-30)));
} catch {
/* localStorage quota or disabled — non-fatal */
}
}
async function revealIfOwner(): Promise<void> {
if (!shouldSendContext(window.location.pathname)) return; // route excluded
let me: MeResponse;
try {
const r = await fetch("/api/me", { credentials: "same-origin" });
if (!r.ok) return;
me = await r.json();
} catch {
return;
}
// The server-side handler returns 404 for non-owners on every paliadin
// route, so we don't need to know the owner email client-side. Probe
// /api/paliadin/me-check (a 200/404 endpoint) — but that endpoint
// doesn't exist; instead reuse the same reveal hook the sidebar uses,
// which checks an `is_paliadin_owner` flag the /api/me payload includes
// when paliadinSvc is wired and the caller matches.
if (!isPaliadinOwner(me)) return;
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 {
// Server-driven flag (matches the pattern client/sidebar.ts uses to
// reveal the /paliadin sidebar entry). Fallback to email match only
// if the flag is absent — this keeps the widget working on a server
// build that hasn't shipped the flag yet.
const flag = (me as unknown as { is_paliadin_owner?: boolean }).is_paliadin_owner;
if (typeof flag === "boolean") return flag;
// Fallback: hardcoded owner match. Same string as
// services.PaliadinOwnerEmail in Go — keep in sync.
return (me.email || "").toLowerCase() === "matthias.siebels@hoganlovells.com";
}
function showTrigger(): void {
const trigger = document.getElementById("paliadin-widget-trigger");
if (trigger) trigger.style.display = "";
}
function wireTrigger(): void {
const trigger = document.getElementById("paliadin-widget-trigger");
trigger?.addEventListener("click", () => openDrawer());
}
function wireDrawerControls(): void {
document.getElementById("paliadin-widget-close")?.addEventListener("click", () => closeDrawer());
document.getElementById("paliadin-widget-scrim")?.addEventListener("click", () => closeDrawer());
document.getElementById("paliadin-widget-reset")?.addEventListener("click", () => void resetSession());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerOpen) {
e.preventDefault();
closeDrawer();
}
});
}
function wireKeyboardShortcut(): void {
// Cmd+J / Ctrl+J — open or close the drawer. Cmd+K is reserved for
// global search (client/search.ts), so we use J ("Junior assistant").
document.addEventListener("keydown", (e) => {
const isCmdJ = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "j";
if (!isCmdJ) return;
const trigger = document.getElementById("paliadin-widget-trigger");
if (!trigger || trigger.style.display === "none") return; // widget not revealed
e.preventDefault();
e.stopPropagation();
if (drawerOpen) {
closeDrawer();
} else {
openDrawer();
}
});
}
function openDrawer(): void {
if (drawerOpen) return;
drawerOpen = true;
const drawer = document.getElementById("paliadin-widget-drawer");
const scrim = document.getElementById("paliadin-widget-scrim");
if (drawer) {
drawer.style.display = "";
drawer.dataset.open = "true";
drawer.setAttribute("aria-hidden", "false");
}
if (scrim) {
scrim.style.display = "";
}
// Force reflow so the slide-in animation runs (CSS transitions need a
// flip from off-canvas to on-canvas).
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
drawer?.offsetWidth;
if (drawer) drawer.classList.add("paliadin-widget-drawer--visible");
if (scrim) scrim.classList.add("paliadin-widget-scrim--visible");
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);
}
function closeDrawer(): void {
if (!drawerOpen) return;
drawerOpen = false;
const drawer = document.getElementById("paliadin-widget-drawer");
const scrim = document.getElementById("paliadin-widget-scrim");
drawer?.classList.remove("paliadin-widget-drawer--visible");
scrim?.classList.remove("paliadin-widget-scrim--visible");
// Wait for transition before display:none so the slide-out animates.
setTimeout(() => {
if (drawerOpen) return; // re-opened during transition
if (drawer) {
drawer.style.display = "none";
drawer.dataset.open = "false";
drawer.setAttribute("aria-hidden", "true");
}
if (scrim) scrim.style.display = "none";
}, 220);
}
function refreshContextChip(): void {
const chip = document.getElementById("paliadin-widget-context-chip");
const value = document.getElementById("paliadin-widget-context-value");
if (!chip || !value) return;
const ctx = computePaliadinContext();
if (!ctx) {
chip.style.display = "none";
return;
}
const labelParts: string[] = [];
if (ctx.primary_entity_type === "project") {
labelParts.push(getLang() === "en" ? "Project" : "Akte");
} else if (ctx.primary_entity_type === "deadline") {
labelParts.push(getLang() === "en" ? "Deadline" : "Frist");
} else if (ctx.primary_entity_type === "appointment") {
labelParts.push(getLang() === "en" ? "Appointment" : "Termin");
}
labelParts.push(humanRouteName(ctx.route_name));
value.textContent = labelParts.join(" · ");
chip.style.display = "";
}
function humanRouteName(route: string): string {
// Prefer i18n key if present; fall back to a tidied form of the
// route key itself.
const key = "paliadin.widget.route." + route;
const translated = t(key);
if (translated && translated !== key) return translated;
return route;
}
function renderStarters(): void {
const host = document.getElementById("paliadin-widget-starters");
if (!host) return;
const route = routeNameFor(window.location.pathname);
const lang = getLang();
const list = startersFor(route);
host.innerHTML = "";
list.forEach((s) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "paliadin-widget-starter";
btn.textContent = lang === "en" ? s.label_en : s.label_de;
btn.addEventListener("click", () => onStarterClick(s));
host.appendChild(btn);
});
}
function onStarterClick(s: Starter): void {
const lang = getLang();
const promptText = lang === "en" ? s.prompt_en : s.prompt_de;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (!input) return;
if (!promptText) {
input.value = "";
input.focus();
return;
}
// Prompts that end with ": " are intentional partial seeds — leave
// the textarea so the user finishes the sentence.
if (promptText.endsWith(": ")) {
input.value = promptText;
input.focus();
input.setSelectionRange(promptText.length, promptText.length);
return;
}
input.value = promptText;
// Auto-send for fully-formed prompts.
void sendTurn();
}
function wireForm(): void {
const form = document.getElementById("paliadin-widget-form") as HTMLFormElement | null;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (!form || !input) return;
form.addEventListener("submit", (e) => {
e.preventDefault();
void sendTurn();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendTurn();
}
});
}
async function sendTurn(): Promise<void> {
if (pending) return;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (!input) return;
const text = input.value.trim();
if (!text) return;
input.value = "";
hideEmpty();
appendBubble("user", text);
history.push({ role: "user", text, ts: new Date().toISOString() });
saveHistory();
pending = true;
setSendDisabled(true);
const placeholder = appendBubble("assistant", "Paliadin denkt nach …");
placeholder.dataset.streaming = "true";
let turnRes: TurnResponse;
try {
const ctx = computePaliadinContext();
const r = await fetch("/api/paliadin/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
user_message: text,
session_id: sessionId,
page_origin: window.location.pathname + window.location.search,
context: ctx ?? undefined,
}),
});
if (!r.ok) throw new Error("HTTP " + r.status);
turnRes = await r.json();
} catch {
setBubbleText(placeholder, t("paliadin.error.upstream"));
placeholder.classList.add("paliadin-widget-bubble--error");
placeholder.dataset.streaming = "false";
pending = false;
setSendDisabled(false);
return;
}
const es = new EventSource(turnRes.sse_url);
activeStream = es;
let fullText = "";
es.addEventListener("content", (ev) => {
try {
const data = JSON.parse((ev as MessageEvent).data);
fullText = String(data.text || "");
setBubbleText(placeholder, fullText);
} catch {
/* ignore parse error */
}
});
es.addEventListener("end", () => {
placeholder.dataset.streaming = "false";
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
saveHistory();
cleanupStream();
});
es.addEventListener("error", () => {
const errText = t("paliadin.error.connection_lost");
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
placeholder.classList.add("paliadin-widget-bubble--error");
placeholder.classList.add("paliadin-widget-bubble--late-pending");
placeholder.dataset.streaming = "false";
placeholder.dataset.turnId = turnRes.turn_id;
startWidgetLatePoll(turnRes.turn_id, placeholder);
cleanupStream();
});
es.addEventListener("ping", () => {
/* heartbeat */
});
}
function cleanupStream(): void {
activeStream?.close();
activeStream = null;
pending = false;
setSendDisabled(false);
}
function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
lateWidgetPolls.get(turnId)?.cancel();
const handle = pollForLateResponse({
turnId,
onLateResponse: (turn) => {
lateWidgetPolls.delete(turnId);
applyWidgetLateResponse(bubble, turn);
},
onGiveUp: () => {
lateWidgetPolls.delete(turnId);
},
});
lateWidgetPolls.set(turnId, handle);
}
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
if (!turn.response) return;
bubble.classList.remove(
"paliadin-widget-bubble--error",
"paliadin-widget-bubble--late-pending",
);
bubble.classList.add("paliadin-widget-bubble--late");
setBubbleText(bubble, turn.response);
// Append a small "(verspätet)" tag so the late arrival is visible.
const tag = document.createElement("span");
tag.className = "paliadin-widget-bubble-late-tag";
tag.textContent = " · " + t("paliadin.late.marker");
bubble.appendChild(tag);
history.push({
role: "assistant",
text: turn.response,
ts: new Date().toISOString(),
});
saveHistory();
}
function setSendDisabled(disabled: boolean): void {
const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = disabled;
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
if (input) input.disabled = disabled;
}
function hideEmpty(): void {
const empty = document.getElementById("paliadin-widget-empty");
if (empty) empty.style.display = "none";
}
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
const messages = document.getElementById("paliadin-widget-messages");
const wrap = document.createElement("div");
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
const body = document.createElement("div");
body.className = "paliadin-widget-bubble-text";
// Assistant bubbles get the same markdown + chip pipeline as the
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
// stay plain text — no need to interpret the user's typed markup.
if (role === "assistant") {
body.innerHTML = renderResponseHTML(text);
} else {
body.textContent = text;
}
wrap.appendChild(body);
messages?.appendChild(wrap);
if (messages) messages.scrollTop = messages.scrollHeight;
return wrap;
}
function setBubbleText(bubble: HTMLElement, text: string): void {
const body = bubble.querySelector(".paliadin-widget-bubble-text");
if (body) {
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
if (isAssistant) {
(body as HTMLElement).innerHTML = renderResponseHTML(text);
} else {
body.textContent = text;
}
}
const messages = document.getElementById("paliadin-widget-messages");
if (messages) messages.scrollTop = messages.scrollHeight;
}
function rehydrateHistory(): void {
if (!history.length) return;
hideEmpty();
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 = [];
saveHistory();
const messages = document.getElementById("paliadin-widget-messages");
if (messages) {
messages.innerHTML = "";
const empty = document.createElement("div");
empty.className = "paliadin-widget-empty";
empty.id = "paliadin-widget-empty";
const label = document.createElement("p");
label.className = "paliadin-widget-empty-prompt";
label.setAttribute("data-i18n", "paliadin.widget.empty");
label.textContent = t("paliadin.widget.empty");
const starters = document.createElement("div");
starters.className = "paliadin-widget-starters";
starters.id = "paliadin-widget-starters";
empty.appendChild(label);
empty.appendChild(starters);
messages.appendChild(empty);
renderStarters();
}
try {
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
} catch {
/* non-fatal */
}
}