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:
@@ -29,6 +29,9 @@ docker compose --env-file "$env_file" cp \
|
||||
docker compose --env-file "$env_file" cp \
|
||||
deploy/gitea/public/assets/css/hop-home.css \
|
||||
gitea:/data/gitea/public/assets/css/hop-home.css
|
||||
docker compose --env-file "$env_file" cp \
|
||||
deploy/gitea/public/assets/css/hop-prompts.css \
|
||||
gitea:/data/gitea/public/assets/css/hop-prompts.css
|
||||
docker compose --env-file "$env_file" cp \
|
||||
deploy/gitea/public/assets/img/hop.svg \
|
||||
gitea:/data/gitea/public/assets/img/hop.svg
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
.hop-prompts {
|
||||
margin: 0 auto;
|
||||
max-width: 1080px;
|
||||
padding: 2.25rem clamp(1rem, 3vw, 2.5rem) 4rem;
|
||||
}
|
||||
|
||||
.hop-prompts__header {
|
||||
align-items: end;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hop-prompts__eyebrow {
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 0.4rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hop-prompts h1 {
|
||||
color: var(--color-text);
|
||||
font-size: 1.55rem;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hop-prompts__lede {
|
||||
color: var(--color-text-light-2);
|
||||
line-height: 1.55;
|
||||
margin: 0.65rem 0 0;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.hop-prompts__refresh {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hop-prompts__status {
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 0.9rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.hop-prompts__empty {
|
||||
border: 1px dashed var(--color-secondary);
|
||||
color: var(--color-text-light-2);
|
||||
line-height: 1.6;
|
||||
margin-top: 1.25rem;
|
||||
max-width: 720px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.hop-prompts__empty strong {
|
||||
color: var(--color-text);
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.hop-prompt-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.hop-prompt {
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.hop-prompt summary {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 0.75rem 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
list-style: none;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.hop-prompt summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.hop-prompt__request {
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hop-prompt__meta {
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 0.82rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hop-prompt__status {
|
||||
align-items: center;
|
||||
color: var(--color-text-light-2);
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
margin-right: 0.55rem;
|
||||
}
|
||||
|
||||
.hop-prompt__status::before {
|
||||
background: var(--color-text-light-3);
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
height: 0.45rem;
|
||||
width: 0.45rem;
|
||||
}
|
||||
|
||||
.hop-prompt__status[data-status="completed"]::before { background: var(--color-green); }
|
||||
.hop-prompt__status[data-status="failed"]::before { background: var(--color-red); }
|
||||
.hop-prompt__status[data-status="cancelled"]::before { background: var(--color-orange); }
|
||||
.hop-prompt__status[data-status="running"]::before { background: var(--color-primary); }
|
||||
|
||||
.hop-prompt__body {
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(14rem, 0.55fr);
|
||||
padding: 1.25rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.hop-prompt h2,
|
||||
.hop-prompt h3 {
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.07em;
|
||||
margin: 0 0 0.55rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hop-prompt pre,
|
||||
.hop-prompt__response {
|
||||
background: var(--color-box-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--radius-medium);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.9rem 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hop-prompt__response { color: var(--color-text-light-1); }
|
||||
|
||||
.hop-prompt__facts {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hop-prompt__facts div {
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
padding-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.hop-prompt__facts dt {
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hop-prompt__facts dd {
|
||||
color: var(--color-text);
|
||||
font-size: 0.88rem;
|
||||
margin: 0.2rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.hop-prompts__header, .hop-prompt__body { align-items: start; grid-template-columns: 1fr; }
|
||||
.hop-prompts__header { flex-direction: column; }
|
||||
.hop-prompt summary { grid-template-columns: 1fr; }
|
||||
.hop-prompt__meta { text-align: left; }
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-home.css?v=4">
|
||||
<script src="{{AssetUrlPrefix}}/js/hop-native.js?v=8"></script>
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-prompts.css?v=1">
|
||||
<script src="{{AssetUrlPrefix}}/js/hop-native.js?v=9"></script>
|
||||
|
||||
Reference in New Issue
Block a user