diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index ecaae20..2c6ea9a 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -488,11 +488,10 @@ function renderQuestions(s) { const root = $('questions-section'); root.innerHTML = ''; + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); if (!s.questions || !s.questions.length) { root.append(el('p', { class: 'empty' }, 'no pending questions')); - return; } - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'questions' }); for (const q of s.questions) { const li = el('li', { class: 'question' }); @@ -576,7 +575,34 @@ li.append(cancelForm); ul.append(li); } - root.append(ul); + if (s.questions && s.questions.length) root.append(ul); + + // Answered question history + const hist = s.question_history || []; + if (hist.length) { + const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); + details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); + const hul = el('ul', { class: 'questions questions-answered' }); + for (const q of hist) { + const li = el('li', { class: 'question question-answered' }); + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, 'asked:'), + ); + li.append( + head, + el('div', { class: 'q-body' }, q.question), + el('div', { class: 'q-answer' }, + el('span', { class: 'msg-sep' }, 'answer: '), + el('span', { class: 'q-answer-text' }, q.answer || '(none)'), + ), + ); + hul.append(li); + } + details.append(hul); + root.append(details); + } } function renderInbox(s) { diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 5f92e40..416a466 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -517,6 +517,24 @@ summary:hover { color: var(--purple); } .qform .q-free input:focus { outline: 1px solid var(--amber); } .qform button { align-self: flex-start; } .qform-cancel { margin-top: 0.3em; } +.q-history { + margin-top: 0.8em; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.4em 0.7em; +} +.q-history summary { cursor: pointer; color: var(--muted); font-size: 0.9em; user-select: none; } +.questions-answered { + border: none; + box-shadow: none; + animation: none; + padding: 0; + margin-top: 0.5em; +} +.question-answered { opacity: 0.7; } +.question-answered .q-body { color: var(--muted); margin-bottom: 0.15em; } +.q-answer { font-size: 0.9em; color: var(--green, #a6e3a1); padding: 0.1em 0 0.4em 0; } +.q-answer-text { font-style: italic; } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 8617f05..c5dd7bb 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -147,6 +147,8 @@ struct StateSnapshot { /// we mark the row answered and fire `HelperEvent::OperatorAnswered` /// into the manager's inbox. questions: Vec, + /// Last 20 answered questions, newest-first. + question_history: Vec, /// State dirs (config history + claude creds + /state/ notes) that /// survive after a destroy-without-purge. The operator can re-spawn /// with the same name to resume, or PURG3 to wipe them. @@ -298,6 +300,8 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50), ); let questions = log_default("questions.pending", state.coord.questions.pending()); + let question_history = + log_default("questions.recent_answered", state.coord.questions.recent_answered(20)); axum::Json(StateSnapshot { hostname, @@ -310,6 +314,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J meta_inputs: read_meta_inputs(), operator_inbox, questions, + question_history, tombstones, port_conflicts, }) diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index c04c3bd..3416213 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -168,6 +168,21 @@ impl OperatorQuestions { rows.collect::>>() .map_err(Into::into) } + + /// Last `limit` answered questions, newest-first. + pub fn recent_answered(&self, limit: u64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at + FROM operator_questions + WHERE answered_at IS NOT NULL + ORDER BY answered_at DESC + LIMIT ?1", + )?; + let rows = stmt.query_map(params![limit as i64], row_to_question)?; + rows.collect::>>() + .map_err(Into::into) + } } fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result {