Add a secure repository Prompts review surface and prompt metadata API

Hop-State: A_06FN6MRSHG4B64ZV13086G8
Hop-Proposal: R_06FN6MQDE14FFNR65H1D28G
Hop-Task: T_06FN6J8ECE1Z9XQ5PQ3Z3D8
Hop-Attempt: AT_06FN6J8ECC1SC794JMZ0TF8
This commit is contained in:
Hop
2026-07-11 15:36:22 -07:00
parent c0c1161107
commit fee4f6dd31
15 changed files with 709 additions and 8 deletions
+167
View File
@@ -90,6 +90,44 @@
}) || null;
}
function repositoryPath() {
const issues = navigationLink('/issues');
if (issues) return new URL(issues.href, window.location.origin).pathname.replace(/\/issues$/, '');
const match = window.location.pathname.match(/^\/[^/]+\/[^/]+/);
return match ? match[0].replace(/\/$/, '') : null;
}
function promptsHref() {
const path = repositoryPath();
return path ? `${path}/issues?hop=prompts` : null;
}
function promptsPage() {
return new URLSearchParams(window.location.search).get('hop') === 'prompts';
}
function addPromptsNavigation() {
const navigation = repoNavigation();
const tasks = navigationLink('/issues');
const href = promptsHref();
if (!navigation || !tasks || !href) return;
let link = navigation.querySelector('.hop-native-prompts-tab');
if (!link) {
link = document.createElement('a');
link.className = tasks.className;
link.classList.add('hop-native-prompts-tab');
const icon = tasks.querySelector('svg');
if (icon) link.append(icon.cloneNode(true));
link.append(document.createTextNode(' Prompts'));
navigation.insertBefore(link, tasks);
}
link.href = href;
link.setAttribute('aria-label', 'Prompts');
link.classList.toggle('active', promptsPage());
if (promptsPage()) tasks.classList.remove('active');
}
function workflowLink(source, label) {
if (!source) return null;
const link = document.createElement('a');
@@ -111,6 +149,7 @@
if (!sidebar || sidebar.querySelector('.hop-native-workflow')) return;
const links = [
workflowLink(document.querySelector('.hop-native-prompts-tab'), 'Prompts'),
workflowLink(navigationLink('/issues'), 'Tasks'),
workflowLink(navigationLink('/pulls'), 'Proposals'),
workflowLink(navigationLink('/actions'), 'Evidence'),
@@ -140,6 +179,132 @@
sidebar.prepend(list);
}
function makeElement(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function formatTime(value) {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function promptFact(list, label, value) {
if (!value) return;
const row = document.createElement('div');
row.append(makeElement('dt', '', label), makeElement('dd', '', String(value)));
list.append(row);
}
function promptDetails(prompt) {
const body = makeElement('div', 'hop-prompt__body');
const record = document.createElement('div');
record.append(makeElement('h2', '', 'Captured request'));
record.append(makeElement('pre', '', prompt.prompt));
if (prompt.response_summary) {
record.append(makeElement('h2', '', 'Agent outcome'));
record.append(makeElement('div', 'hop-prompt__response', prompt.response_summary));
}
const facts = document.createElement('dl');
facts.className = 'hop-prompt__facts';
promptFact(facts, 'State', prompt.state_id);
promptFact(facts, 'Task', prompt.task_id);
promptFact(facts, 'Attempt', prompt.attempt_id);
promptFact(facts, 'Agent', [prompt.agent_name, prompt.agent_model].filter(Boolean).join(' · '));
promptFact(facts, 'Captured', formatTime(prompt.created_at));
promptFact(facts, 'Completed', formatTime(prompt.completed_at));
if (prompt.metadata && typeof prompt.metadata === 'object') {
Object.entries(prompt.metadata).slice(0, 8).forEach(([key, value]) => {
if (value === null || typeof value === 'object') return;
promptFact(facts, key.replace(/_/g, ' '), value);
});
}
body.append(record, facts);
return body;
}
function promptRow(prompt) {
const details = document.createElement('details');
details.className = 'hop-prompt';
const summary = document.createElement('summary');
const request = makeElement('div', 'hop-prompt__request', prompt.prompt);
request.title = prompt.prompt;
const meta = makeElement('div', 'hop-prompt__meta');
const status = makeElement('span', 'hop-prompt__status', prompt.status || 'running');
status.dataset.status = prompt.status || 'running';
meta.append(status);
const agent = [prompt.agent_name, prompt.agent_model].filter(Boolean).join(' · ');
meta.append(document.createTextNode(agent || formatTime(prompt.created_at)));
summary.append(request, meta);
details.append(summary, promptDetails(prompt));
return details;
}
function renderPromptPage() {
if (!promptsPage()) return;
const main = document.querySelector('[role="main"].page-content.repository.issue-list');
const path = repositoryPath();
if (!main || !path || main.dataset.hopPromptsRendered === 'true') return;
main.dataset.hopPromptsRendered = 'true';
main.setAttribute('aria-label', 'Prompts');
const navigation = main.querySelector(':scope > .secondary-nav');
for (const child of [...main.children]) {
if (child !== navigation) child.remove();
}
const page = makeElement('section', 'hop-prompts');
page.setAttribute('aria-labelledby', 'hop-prompts-title');
const header = makeElement('header', 'hop-prompts__header');
const heading = document.createElement('div');
heading.append(
makeElement('p', 'hop-prompts__eyebrow', 'Hop causal record'),
makeElement('h1', '', 'Prompts'),
makeElement('p', 'hop-prompts__lede', 'Review what was requested, which agent acted, and the result it reported. Every entry is tied to the task, attempt, and state that produced it.'),
);
heading.querySelector('h1').id = 'hop-prompts-title';
const refresh = makeElement('button', 'ui button hop-prompts__refresh', 'Refresh');
refresh.type = 'button';
header.append(heading, refresh);
const status = makeElement('p', 'hop-prompts__status', 'Loading prompt records…');
status.setAttribute('role', 'status');
page.append(header, status);
main.append(page);
document.title = `Prompts - ${path.slice(1).replace('/', ' / ')} - Hop`;
const load = async () => {
status.textContent = 'Loading prompt records…';
page.querySelector('.hop-prompt-list, .hop-prompts__empty')?.remove();
try {
const [owner, repo] = path.slice(1).split('/');
const response = await fetch(`/hop/api/v1/prompts?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`, {credentials: 'same-origin'});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload?.error?.message || 'Could not load prompt records');
const prompts = Array.isArray(payload.prompts) ? payload.prompts : [];
status.textContent = prompts.length === 1 ? '1 captured request' : `${prompts.length} captured requests`;
if (!prompts.length) {
const empty = makeElement('div', 'hop-prompts__empty');
empty.append(
makeElement('strong', '', 'No prompts have been reported yet.'),
document.createTextNode('When an agent starts work through Hop, its request, execution metadata, and final outcome will appear here.'),
);
page.append(empty);
return;
}
const list = makeElement('div', 'hop-prompt-list');
prompts.forEach((prompt) => list.append(promptRow(prompt)));
page.append(list);
} catch (error) {
status.textContent = error.message || 'Could not load prompt records.';
}
};
refresh.addEventListener('click', load);
load();
}
function replaceBrandImages() {
const upstreamBrandAsset = /\/img\/(?:avatar_default\.png|logo\.png|logo\.svg)(?:\?.*)?$/;
for (const image of document.querySelectorAll('img[src]')) {
@@ -154,7 +319,9 @@
replaceBrandImages();
for (const root of document.querySelectorAll(semanticRoots)) replaceExactText(root);
replaceAccessibleLabels(document);
addPromptsNavigation();
addWorkflowSummary();
renderPromptPage();
const title = replacePhrases(document.title);
if (title !== document.title) document.title = title;
document.documentElement.dataset.hopNative = 'true';