feat(dashboard): entity list UX — REF column, name cells, detail pages (CUST-WP-0030)

- ref-cell.js: REF column component — click=copy deeplink, dblclick=open
- field-help.js: field registry + fieldRow helper with help-tip decoration;
  FK fields (task_id, workstream_id, repo_id) render as async-linked cells
  with entity-title bubble-help on hover
- GET /token-events/{id} endpoint + get-by-id tests
- GET /repos/by-id/{repo_id} UUID lookup endpoint
- Landing pages: /token-events/[id], /workstreams/[id], /repos/[slug], /tasks/[id]
- token-cost.md: REF + Name columns on all three tables; parallel fetch of
  workstreams/tasks for title resolution
- reference.md: entity detail page URL scheme documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 22:35:35 +02:00
parent acb30978cd
commit b28298a2ec
14 changed files with 748 additions and 15 deletions

View File

@@ -0,0 +1,263 @@
// Field-level help registry for dashboard entity detail pages.
//
// FIELD_HELP maps a field key to { label, description, doc? }.
// label — human-readable field name (used as bold heading in help-tip)
// description — one sentence explaining the field
// doc — optional anchor into /docs/ pages for "Learn more"
//
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
//
// Usage:
// import {fieldRow} from "./components/field-help.js";
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
import {API} from "./config.js";
void HelpTip; // silence unused-import linters
// ── Entity link registry ────────────────────────────────────────────────────
// Maps FK field names to fetch/URL/title resolution rules.
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
const FIELD_LINKS = {
task_id: {
apiUrl: id => `${API}/tasks/${id}`,
getUrl: (id, _d) => `/tasks/${id}`,
getTitle: d => d.title,
},
workstream_id: {
apiUrl: id => `${API}/workstreams/${id}`,
getUrl: (id, _d) => `/workstreams/${id}`,
getTitle: d => d.title || d.slug,
},
repo_id: {
apiUrl: id => `${API}/repos/by-id/${id}`,
getUrl: (_id, d) => `/repos/${d.slug}`,
getTitle: d => d.name || d.slug,
},
};
/**
* Render an entity-reference value as a link with an async help-tip showing
* the entity title. Falls back gracefully if the fetch fails.
*/
function _linkCell(key, id) {
const rule = FIELD_LINKS[key];
const shortId = String(id).slice(0, 8) + "…";
const a = document.createElement("a");
a.textContent = shortId;
a.href = "#";
a.style.fontFamily = "var(--monospace, monospace)";
a.title = String(id); // full UUID as native tooltip while async loads
fetch(rule.apiUrl(id))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const title = rule.getTitle(data);
const url = rule.getUrl(id, data);
if (url) a.href = url;
if (title) {
const tip = document.createElement("help-tip");
tip.setAttribute("label", title);
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
a.replaceWith(tip);
tip.appendChild(a);
}
})
.catch(() => { /* leave plain link */ });
return a;
}
export const FIELD_HELP = {
// ── TokenEvent ─────────────────────────────────────────────────────────────
id: {
label: "ID",
description: "Unique identifier for this token event (UUID v4).",
doc: "/docs/reference#token-events",
},
tokens_in: {
label: "Tokens In",
description: "Number of input (prompt) tokens consumed in this event.",
doc: "/docs/reference#token-events",
},
tokens_out: {
label: "Tokens Out",
description: "Number of output (completion) tokens generated in this event.",
doc: "/docs/reference#token-events",
},
tokens_total: {
label: "Tokens Total",
description: "Sum of input and output tokens — total cost proxy for this event.",
doc: "/docs/reference#token-events",
},
task_id: {
label: "Task ID",
description: "The task this token event was recorded against (if any).",
doc: "/docs/tasks",
},
workstream_id: {
label: "Workstream ID",
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
doc: "/docs/workstreams",
},
repo_id: {
label: "Repo ID",
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
doc: "/docs/repos",
},
session_id: {
label: "Session ID",
description: "Opaque identifier for the Claude Code session that produced this event.",
},
model: {
label: "Model",
description: "The Claude model used (e.g. claude-sonnet-4-6).",
doc: "/docs/reference#models",
},
agent: {
label: "Agent",
description: "The agent persona that produced this event (e.g. custodian, ralph).",
doc: "/docs/reference#agents",
},
ref_type: {
label: "Ref Type",
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
doc: "/docs/reference#token-events",
},
ref_id: {
label: "Ref ID",
description: "Free-form reference ID; interpretation depends on ref_type.",
doc: "/docs/reference#token-events",
},
note: {
label: "Note",
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
doc: "/docs/reference#token-note-taxonomy",
},
created_at: {
label: "Created At",
description: "Timestamp when this token event was recorded (UTC).",
},
// ── Workstream ──────────────────────────────────────────────────────────────
slug: {
label: "Slug",
description: "URL-safe short identifier for this entity.",
},
title: {
label: "Title",
description: "Human-readable name for this workstream or task.",
},
status: {
label: "Status",
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
doc: "/docs/workstream-lifecycle",
},
topic_id: {
label: "Topic ID",
description: "The topic this workstream is grouped under.",
doc: "/docs/reference#topics",
},
repo_goal_id: {
label: "Repo Goal ID",
description: "Optional link to a repo-level strategic goal this workstream advances.",
doc: "/docs/goals",
},
// ── Task ───────────────────────────────────────────────────────────────────
assignee: {
label: "Assignee",
description: "Who is responsible for completing this task (agent name or human).",
},
priority: {
label: "Priority",
description: "Relative urgency: high, medium, or low.",
},
due_date: {
label: "Due Date",
description: "Target completion date (ISO 8601).",
},
needs_human: {
label: "Needs Human",
description: "True if the task is blocked waiting for human input or approval.",
doc: "/interventions",
},
intervention_note: {
label: "Intervention Note",
description: "Why human intervention is required for this task.",
},
// ── Repo ───────────────────────────────────────────────────────────────────
repo_slug: {
label: "Repo Slug",
description: "Short identifier for the repository (matches the git remote slug).",
doc: "/docs/repos",
},
event_count: {
label: "Event Count",
description: "Total number of token events attributed to this entity.",
},
by_model: {
label: "By Model",
description: "Token totals broken down by Claude model.",
},
by_note: {
label: "By Note",
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
doc: "/docs/reference#token-note-taxonomy",
},
};
/**
* Render a single key-value row for an entity detail table.
* @param {string} key — field name
* @param {*} value — field value (stringified automatically)
* @returns {HTMLTableRowElement}
*/
export function fieldRow(key, value) {
const tr = document.createElement("tr");
// Key cell
const tdKey = document.createElement("td");
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
const help = FIELD_HELP[key];
if (help) {
const tip = document.createElement("help-tip");
tip.setAttribute("label", help.label);
tip.setAttribute("description", help.description);
if (help.doc) tip.setAttribute("doc", help.doc);
tip.textContent = help.label;
tdKey.appendChild(tip);
} else {
tdKey.textContent = key;
}
tr.appendChild(tdKey);
// Value cell
const tdVal = document.createElement("td");
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
let display;
if (value === null || value === undefined) {
display = document.createElement("span");
display.style.color = "var(--theme-foreground-faint,#aaa)";
display.textContent = "—";
} else if (key in FIELD_LINKS) {
display = _linkCell(key, value);
} else if (typeof value === "object") {
display = document.createElement("pre");
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
display.textContent = JSON.stringify(value, null, 2);
} else {
display = document.createElement("span");
display.textContent = String(value);
}
tdVal.appendChild(display);
tr.appendChild(tdVal);
return tr;
}

View File

@@ -0,0 +1,101 @@
// refCell(index, recordType, id) → HTMLElement
//
// Renders a 1-based row number in a table cell.
// Single click — copies deep-link to clipboard and flashes "Copied!".
// Double click — opens deep-link in a new tab.
//
// Deep-link format: <origin>/data/<recordType>/<id>
//
// Usage:
// import {refCell} from "./components/ref-cell.js";
// // in an Inputs.table format callback:
// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) }
const _STYLE_ID = "refcell-global-style";
if (!document.getElementById(_STYLE_ID)) {
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
.ref-cell {
display: inline-block;
font-family: var(--monospace, monospace);
font-size: 0.78rem;
color: var(--theme-foreground-focus, #3b82f6);
cursor: pointer;
user-select: none;
padding: 0 2px;
border-radius: 3px;
transition: background 0.1s;
}
.ref-cell:hover {
background: var(--theme-foreground-faint, #e8f0fe);
}
.ref-cell-toast {
position: fixed;
z-index: 10000;
background: var(--theme-background, #fff);
border: 1px solid var(--theme-foreground-faint, #ddd);
border-radius: 6px;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
color: var(--theme-foreground, #333);
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
}
.ref-cell-toast.ref-cell-toast-visible { opacity: 1; }
`;
document.head.appendChild(s);
}
function _showToast(anchorEl, text) {
const toast = document.createElement("div");
toast.className = "ref-cell-toast";
toast.textContent = text;
document.body.appendChild(toast);
const rect = anchorEl.getBoundingClientRect();
const gap = 6;
toast.style.left = `${rect.left}px`;
toast.style.top = `${rect.top - toast.offsetHeight - gap}px`;
requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible"));
setTimeout(() => {
toast.classList.remove("ref-cell-toast-visible");
toast.addEventListener("transitionend", () => toast.remove(), {once: true});
}, 1200);
}
export function refCell(index, recordType, id) {
const deepLink = `${location.origin}/${recordType}/${id}`;
const el = document.createElement("span");
el.className = "ref-cell";
el.title = `Click to copy link · Double-click to open\n${deepLink}`;
el.textContent = String(index);
let clickTimer = null;
el.addEventListener("click", (e) => {
e.stopPropagation();
// Use a short delay so a double-click cancels the single-click handler.
clickTimer = setTimeout(async () => {
try {
await navigator.clipboard.writeText(deepLink);
_showToast(el, "Copied!");
} catch {
// Fallback for environments where clipboard API is blocked.
_showToast(el, deepLink);
}
}, 180);
});
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
clearTimeout(clickTimer);
window.open(deepLink, "_blank", "noopener,noreferrer");
});
return el;
}