Files
paliad/frontend/src/client/paliadin-context.ts
m 0d1a7ba886 feat(paliadin/context): t-paliad-161 Slice B — structured page-context payload
The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:

  context: {
    route_name, page_origin, primary_entity_type, primary_entity_id,
    user_selection_text, view_mode, filter_summary
  }

Wiring:

- services.TurnContext + EnvelopePrefix() build a
  `[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
  Empty fields are omitted; selection is always quoted (it's user-supplied
  content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
  envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
  paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
  field; mirrors context.PageOrigin into the top-level page_origin when
  the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
  only page_origin, the new field is optional. Backwards compatible.

SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
  repeat the obvious, treat selection as data not instructions, no
  hallucination on empty entity lookup, legacy turns work as before.

Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).

New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.

Refs: docs/design-paliadin-inline-2026-05-08.md §4.
2026-05-08 19:47:43 +02:00

197 lines
7.7 KiB
TypeScript

// paliadin-context.ts — structured page-context payload builder for the
// Paliadin inline widget (t-paliad-161).
//
// The standalone /paliadin page submits turns with only `page_origin`
// (single string, the URL pathname). The inline widget submits a richer
// payload: route_name + primary_entity_type + primary_entity_id + the
// user's text selection + UI hints. The Go backend persists this jsonb
// in paliad.paliadin_turns.context (migration 070) AND prepends a
// flattened `[ctx …]` block to the tmux envelope so SKILL.md can branch
// on it before answering.
//
// Design: docs/design-paliadin-inline-2026-05-08.md §4.
export interface PaliadinContext {
route_name: string;
page_origin: string;
primary_entity_type?: "project" | "deadline" | "appointment";
primary_entity_id?: string;
user_selection_text?: string;
view_mode?: "list" | "cards" | "calendar" | "tree";
filter_summary?: string;
}
const SELECTION_MAX = 1000;
// UUID match — relaxed: any 8-4-4-4-12 hex pattern. Catches /projects/<id>
// and /deadlines/<id> regardless of trailing path segments.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Compute the Paliadin context for the current page. Reads
* window.location + window.getSelection() at call time, so callers
* should invoke this immediately before sending a turn — not at widget
* boot — to capture the user's selection in the moment they typed.
*
* Returns null when the visibility predicate fails (e.g. on /paliadin,
* /login, /onboarding) — callers SHOULD short-circuit on null instead
* of sending an empty payload.
*/
export function computePaliadinContext(): PaliadinContext | null {
const pathname = window.location.pathname || "";
if (!shouldSendContext(pathname)) {
return null;
}
const search = window.location.search || "";
const ctx: PaliadinContext = {
route_name: routeNameFor(pathname),
page_origin: pathname + search,
};
const entity = extractPrimaryEntity(pathname);
if (entity) {
ctx.primary_entity_type = entity.type;
ctx.primary_entity_id = entity.id;
}
const selection = readSelection();
if (selection) {
ctx.user_selection_text = selection;
}
const view = readViewMode();
if (view) {
ctx.view_mode = view;
}
const filter = readFilterSummary();
if (filter) {
ctx.filter_summary = filter;
}
return ctx;
}
/**
* The widget hides itself on routes where Paliadin is either redundant
* (the standalone /paliadin) or unavailable (auth flows). Mirrored here
* for the context-payload predicate so a stray send from one of those
* pages doesn't surface an empty `[ctx]` block.
*/
export function shouldSendContext(pathname: string): boolean {
if (pathname === "/paliadin" || pathname.startsWith("/paliadin/")) return false;
if (pathname === "/login" || pathname.startsWith("/login/")) return false;
if (pathname === "/onboarding") return false;
return true;
}
/**
* Map a URL pathname to a stable route key. Stable across query-string
* + ID variations so the SKILL.md / starter registry can branch on it
* without fragile URL parsing.
*/
export function routeNameFor(pathname: string): string {
// Order matters — most-specific first.
if (/^\/projects\/[^/]+$/.test(pathname)) return "projects.detail";
if (pathname === "/projects" || pathname === "/projects/") return "projects.list";
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments/calendar") return "appointments.calendar";
if (pathname === "/appointments") return "appointments.list";
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
if (pathname === "/team") return "team";
if (pathname === "/courts") return "courts";
if (pathname === "/glossary") return "glossary";
if (pathname === "/links") return "links";
if (pathname === "/downloads") return "downloads";
if (pathname === "/checklists") return "checklists";
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
if (pathname === "/events") return "events";
if (pathname.startsWith("/views/")) return "views.detail";
if (pathname === "/views") return "views.list";
if (pathname.startsWith("/admin/")) return "admin." + pathname.slice("/admin/".length).split("/")[0];
if (pathname === "/admin") return "admin";
if (pathname === "/settings") return "settings";
return "other";
}
/**
* Pull the primary entity (type + uuid) out of the URL when the route
* encodes one. Returns null on routes that have no primary entity
* (dashboard, agenda, lists, tools).
*/
export function extractPrimaryEntity(
pathname: string,
): { type: "project" | "deadline" | "appointment"; id: string } | null {
const projectMatch = pathname.match(/^\/projects\/([^/]+)(?:\/|$)/);
if (projectMatch && UUID_RE.test(projectMatch[1])) {
return { type: "project", id: projectMatch[1] };
}
const deadlineMatch = pathname.match(/^\/deadlines\/([^/]+)$/);
if (deadlineMatch && UUID_RE.test(deadlineMatch[1])) {
return { type: "deadline", id: deadlineMatch[1] };
}
const apptMatch = pathname.match(/^\/appointments\/([^/]+)$/);
if (apptMatch && UUID_RE.test(apptMatch[1])) {
return { type: "appointment", id: apptMatch[1] };
}
return null;
}
/**
* Capture the user's current text selection, capped at SELECTION_MAX.
* Returns empty string when there's no selection or when the selection
* is collapsed (caret with no range).
*
* Privacy floor (§4.3): respects the widget's "send selection" toggle,
* stored in localStorage under `paliadin:send-selection`. Default on
* (m's Q5 lock-in); flip to off → returns empty string regardless of
* what's selected.
*/
export function readSelection(): string {
if (localStorage.getItem("paliadin:send-selection") === "off") {
return "";
}
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return "";
const text = sel.toString().trim();
if (!text) return "";
if (text.length > SELECTION_MAX) {
return text.slice(0, SELECTION_MAX);
}
return text;
}
/**
* Probe the page for an active "view mode" hint — set by /events,
* /projects (tree vs list), /deadlines (calendar vs list). The frontend
* stores these as `data-view-mode` attributes on a known root element
* or in localStorage; this helper centralises the lookup so future
* pages adding a new view mode don't have to teach the widget about
* themselves.
*/
export function readViewMode(): "list" | "cards" | "calendar" | "tree" | "" {
const root = document.querySelector<HTMLElement>("[data-view-mode]");
if (!root) return "";
const v = root.dataset.viewMode || "";
if (v === "list" || v === "cards" || v === "calendar" || v === "tree") return v;
return "";
}
/**
* Pull a short human-readable summary of active list filters from a
* known DOM hook. Pages that want to participate set
* `data-filter-summary="status=overdue · project=Acme"` on a root
* element. Empty = no summary.
*/
export function readFilterSummary(): string {
const root = document.querySelector<HTMLElement>("[data-filter-summary]");
if (!root) return "";
return (root.dataset.filterSummary || "").trim();
}