diff --git a/apps/desktop/src/App.jsx b/apps/desktop/src/App.jsx index 7a44782..96e6f4b 100644 --- a/apps/desktop/src/App.jsx +++ b/apps/desktop/src/App.jsx @@ -37,7 +37,11 @@ import { deriveDashboardSkill, normalizeEditableTags } from './dashboardMetadata.js'; -import { normalizeHistory } from './historyEntries.js'; +import { + historyRequestForFilter, + isHistoryRequestCurrent, + normalizeHistory +} from './historyEntries.js'; import { normalizeDoctorReport, normalizeStaleDeploymentRepairResult @@ -451,6 +455,7 @@ export default function App() { const appUpdateAutoCheckedRef = useRef(false); const usageRankingRequestRef = useRef(0); const rankingImportRequestRef = useRef(0); + const historyRequestRef = useRef(0); const authoritativeGenerationRef = useRef(0); const pageRef = useRef(page); const dismissNotice = () => setNotice(''); @@ -1941,6 +1946,9 @@ export default function App() { if (nextPage !== 'rankings') { cancelUsageRankingRequest(); } + if (nextPage !== 'history') { + historyRequestRef.current += 1; + } setPage(nextPage); } @@ -1960,24 +1968,33 @@ export default function App() { function openHistory() { setSelectedName(''); navigateToPage('history'); - void loadHistory(); + void loadHistory(historyFilter); } - async function loadHistory() { + async function loadHistory(nextFilter = historyFilter) { + const requestId = historyRequestRef.current + 1; + historyRequestRef.current = requestId; + setHistoryFilter(nextFilter); setError(''); + setHistory((current) => ({ ...current, entries: [] })); if (!window.__TAURI_INTERNALS__) { - setHistory(normalizeHistory(previewHistory())); + if (!isHistoryRequestCurrent(historyRequestRef.current, requestId)) return; + setHistory(normalizeHistory(previewHistory(nextFilter))); setStatus('prototype'); return; } setStatus('loading_history'); try { - const historyResult = await invoke('list_history', { request: { limit: 200 } }); + const historyResult = await invoke('list_history', { + request: historyRequestForFilter(nextFilter) + }); + if (!isHistoryRequestCurrent(historyRequestRef.current, requestId)) return; setHistory(normalizeHistory(historyResult)); setStatus('ready'); } catch (historyError) { + if (!isHistoryRequestCurrent(historyRequestRef.current, requestId)) return; setError(historyError.message || String(historyError) || 'Unable to load history.'); setStatus('ready'); } @@ -4069,7 +4086,7 @@ export default function App() { filter={historyFilter} history={history} status={status} - onFilter={setHistoryFilter} + onFilter={loadHistory} onRefresh={loadHistory} /> ) : page === 'rankings' ? ( diff --git a/apps/desktop/src/cardLayout.test.js b/apps/desktop/src/cardLayout.test.js index 32b8c89..c947fa3 100644 --- a/apps/desktop/src/cardLayout.test.js +++ b/apps/desktop/src/cardLayout.test.js @@ -909,7 +909,16 @@ test('history page combines skill usage and operation logs', () => { assert.match(historyPageSource, / { assert.match(appSource, /const rowSubtitle = historyRowSubtitle\(entry, isUsage \|\| isReference\);/); assert.match(appSource, /function historyRowSubtitle\(entry, isUsage\)/); assert.match(appSource, /const defaultOperationSubtitle = entry\.operationType && entry\.actor/); - assert.match(appSource, /const groupedEntries = groupHistoryEntriesByDay\(filteredEntries\)/); + assert.match(appSource, /const groupedEntries = groupHistoryEntriesByDay\(entries\)/); + assert.doesNotMatch(historyPageSource, /entries\.filter\(\(entry\) => entry\.kind === filter\)/); + assert.match(historyPageSource, /Loading history\.\.\./); + assert.match(historyPageSource, /role="status" aria-live="polite"/); assert.match(appSource, /function groupHistoryEntriesByDay/); assert.match(appSource, /className="historyDayBlock"/); assert.match(appSource, /function HistoryRow/); @@ -1140,12 +1152,21 @@ test('rankings is an accessible top-level page separate from history', () => { assert.match(appSource, /includeUnmanaged: true/); assert.match(appSource, /Not imported/); assert.match(appSource, /Includes skills not imported into SkillBox/); - const loadHistorySource = appComponentSource.match(/async function loadHistory\(\)[\s\S]*?function openRankings/)?.[0] || ''; + const loadHistorySource = appComponentSource.match(/async function loadHistory\(nextFilter = historyFilter\)[\s\S]*?function openRankings/)?.[0] || ''; assert.match(loadHistorySource, /invoke\('list_history'/); assert.doesNotMatch(loadHistorySource, /list_skill_usage_rankings|Promise\.all/); assert.match(tauriSource, /async fn list_skill_usage_rankings/); assert.match(tauriSource, /skillbox_core::list_skill_usage_rankings/); - assert.match(css, /\.historyTypeTabs\s*\{[^}]*repeat\(3,/s); + assert.match(css, /\.historyTypeTabs\s*\{[^}]*repeat\(4,/s); + assert.match( + css, + /@media \(max-width: 1180px\) \{[\s\S]*?\.historyTypeTabs\s*\{[^}]*grid-template-columns:\s*repeat\(2,/s + ); + assert.match( + css, + /@media \(max-width: 1180px\) \{[\s\S]*?\.historyTypeTabs\s*\{[^}]*height:\s*auto;[\s\S]*?min-height:\s*46px;/s + ); + assert.doesNotMatch(css, /\.historyTypeTabs\s*\{[^}]*repeat\(3,/s); assert.match(css, /\.usageRankingTable\s*\{/); assert.match(css, /\.usageRankingTopGrid\s*\{/); assert.match(css, /\.usageRankingTopCard\.leader\s*\{/); diff --git a/apps/desktop/src/components/history.jsx b/apps/desktop/src/components/history.jsx index 9c205da..7f2df4b 100644 --- a/apps/desktop/src/components/history.jsx +++ b/apps/desktop/src/components/history.jsx @@ -38,11 +38,9 @@ export function HistoryPage({ }, { id: 'operation', label: 'Operations', count: numberOrZero(history.operationCount) } ]; - const filteredEntries = - filter === 'all' ? entries : entries.filter((entry) => entry.kind === filter); - const groupedEntries = groupHistoryEntriesByDay(filteredEntries); const isLoading = status === 'loading_history'; - const visibleCount = filteredEntries.length; + const groupedEntries = groupHistoryEntriesByDay(entries); + const visibleCount = isLoading ? 0 : entries.length; return ( @@ -76,7 +74,12 @@ export function HistoryPage({ - {filteredEntries.length > 0 ? ( + {isLoading ? ( +
+ Loading history... + Loading the selected history filter. +
+ ) : entries.length > 0 ? (
{groupedEntries.map((group) => (
diff --git a/apps/desktop/src/historyEntries.js b/apps/desktop/src/historyEntries.js index 86297fd..5f4753f 100644 --- a/apps/desktop/src/historyEntries.js +++ b/apps/desktop/src/historyEntries.js @@ -1,5 +1,34 @@ import { numberOrZero } from './skills.js'; +export const HISTORY_PAGE_LIMIT = 200; + +const HISTORY_FILTER_KINDS = Object.freeze({ + all: null, + skill_usage: 'skill_usage', + usage_reference: 'usage_reference', + operation: 'operation' +}); + +export function historyFilterKind(filter = 'all') { + return HISTORY_FILTER_KINDS[filter] || null; +} + +export function historyRequestForFilter(filter = 'all') { + const request = { limit: HISTORY_PAGE_LIMIT }; + const kind = historyFilterKind(filter); + if (kind) request.kind = kind; + return request; +} + +export function isHistoryRequestCurrent(currentRequestId, requestId) { + return currentRequestId === requestId; +} + +export function filterHistoryEntries(entries = [], filter = 'all') { + const kind = historyFilterKind(filter); + return kind ? entries.filter((entry) => entry.kind === kind) : entries; +} + export function normalizeHistory(result = {}) { const entries = (result?.entries || []).map((entry) => ({ id: entry.id || '', diff --git a/apps/desktop/src/historyEntries.test.js b/apps/desktop/src/historyEntries.test.js index 84c4364..3637a50 100644 --- a/apps/desktop/src/historyEntries.test.js +++ b/apps/desktop/src/historyEntries.test.js @@ -1,7 +1,47 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { groupHistoryEntriesByDay } from './historyEntries.js'; +import { + filterHistoryEntries, + groupHistoryEntriesByDay, + isHistoryRequestCurrent, + historyRequestForFilter +} from './historyEntries.js'; + +test('maps History filters to bounded server-side queries', () => { + assert.deepEqual(historyRequestForFilter('all'), { limit: 200 }); + assert.deepEqual(historyRequestForFilter('skill_usage'), { + limit: 200, + kind: 'skill_usage' + }); + assert.deepEqual(historyRequestForFilter('usage_reference'), { + limit: 200, + kind: 'usage_reference' + }); + assert.deepEqual(historyRequestForFilter('operation'), { + limit: 200, + kind: 'operation' + }); +}); + +test('preview history filtering uses the same selected-kind contract', () => { + const entries = [ + { id: 'call', kind: 'skill_usage' }, + { id: 'reference', kind: 'usage_reference' }, + { id: 'operation', kind: 'operation' } + ]; + + assert.deepEqual( + filterHistoryEntries(entries, 'usage_reference').map((entry) => entry.id), + ['reference'] + ); + assert.deepEqual(filterHistoryEntries(entries, 'all'), entries); +}); + +test('stale History responses and errors cannot pass the request generation gate', () => { + assert.equal(isHistoryRequestCurrent(2, 1), false); + assert.equal(isHistoryRequestCurrent(2, 2), true); +}); test('groups entries from the same calendar day together', () => { const groups = groupHistoryEntriesByDay([ diff --git a/apps/desktop/src/previewData.js b/apps/desktop/src/previewData.js index b04d53a..85adc03 100644 --- a/apps/desktop/src/previewData.js +++ b/apps/desktop/src/previewData.js @@ -1,4 +1,5 @@ import { compactPath, defaultSkillStatus, joinPath } from './skills.js'; +import { filterHistoryEntries } from './historyEntries.js'; export const previewPaths = { root: '~/.skillbox', @@ -472,56 +473,58 @@ export function previewUserSkillsInbound(mode = 'behind') { }; } -export function previewHistory() { +export function previewHistory(filter = 'all') { const now = Date.now(); + const entries = [ + { + id: 'preview-usage-release-helper', + kind: 'skill_usage', + timestamp: new Date(now - 12 * 60 * 1000).toISOString(), + title: 'Skill call: release-helper', + subtitle: 'codex in ~/.agents/skills', + skill_name: 'release-helper', + agent_id: 'codex', + runtime_root: '~/.agents/skills' + }, + { + id: 'preview-operation-install', + kind: 'operation', + timestamp: Math.floor((now - 42 * 60 * 1000) / 1000).toString(), + title: 'Installed docs-reviewer', + subtitle: 'install_remote_skill by desktop', + status: 'succeeded', + operation_type: 'install_remote_skill', + actor: 'desktop', + entity_type: 'skill', + entity_name: 'docs-reviewer' + }, + { + id: 'preview-reference-research-digest', + kind: 'usage_reference', + timestamp: new Date(now - 70 * 60 * 1000).toISOString(), + title: 'History reference: research-digest', + subtitle: 'cursor in ~/.cursor/skills', + skill_name: 'research-digest', + agent_id: 'cursor', + runtime_root: '~/.cursor/skills' + }, + { + id: 'preview-usage-design-audit', + kind: 'skill_usage', + timestamp: new Date(now - 2 * 60 * 60 * 1000).toISOString(), + title: 'Skill call: design-audit', + subtitle: 'claude-code in ~/.claude/skills', + skill_name: 'design-audit', + agent_id: 'claude-code', + runtime_root: '~/.claude/skills' + } + ]; + return { skill_usage_count: 3, skill_reference_count: 1, operation_count: 2, - entries: [ - { - id: 'preview-usage-release-helper', - kind: 'skill_usage', - timestamp: new Date(now - 12 * 60 * 1000).toISOString(), - title: 'Skill call: release-helper', - subtitle: 'codex in ~/.agents/skills', - skill_name: 'release-helper', - agent_id: 'codex', - runtime_root: '~/.agents/skills' - }, - { - id: 'preview-operation-install', - kind: 'operation', - timestamp: Math.floor((now - 42 * 60 * 1000) / 1000).toString(), - title: 'Installed docs-reviewer', - subtitle: 'install_remote_skill by desktop', - status: 'succeeded', - operation_type: 'install_remote_skill', - actor: 'desktop', - entity_type: 'skill', - entity_name: 'docs-reviewer' - }, - { - id: 'preview-reference-research-digest', - kind: 'usage_reference', - timestamp: new Date(now - 70 * 60 * 1000).toISOString(), - title: 'History reference: research-digest', - subtitle: 'cursor in ~/.cursor/skills', - skill_name: 'research-digest', - agent_id: 'cursor', - runtime_root: '~/.cursor/skills' - }, - { - id: 'preview-usage-design-audit', - kind: 'skill_usage', - timestamp: new Date(now - 2 * 60 * 60 * 1000).toISOString(), - title: 'Skill call: design-audit', - subtitle: 'claude-code in ~/.claude/skills', - skill_name: 'design-audit', - agent_id: 'claude-code', - runtime_root: '~/.claude/skills' - } - ] + entries: filterHistoryEntries(entries, filter) }; } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index d28e715..ebb2b8a 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1638,7 +1638,7 @@ button { .historyTypeTabs { width: 580px; flex: 0 0 auto; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .usageRankingPanel { @@ -6722,6 +6722,13 @@ dd { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .historyTypeTabs { + width: min(580px, 100%); + height: auto; + min-height: 46px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sideStack { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -6865,7 +6872,7 @@ dd { .historyTypeTabs { width: 100%; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); } .usageRankingControls { diff --git a/crates/skillbox-core/src/tests.rs b/crates/skillbox-core/src/tests.rs index b742174..c09b66f 100644 --- a/crates/skillbox-core/src/tests.rs +++ b/crates/skillbox-core/src/tests.rs @@ -7029,6 +7029,76 @@ fn history_lists_skill_usage_and_operations_together() { assert_eq!(usage_only.entries[0].kind, HistoryEntryKind::SkillUsage); } +#[test] +fn history_kind_query_finds_older_references_beyond_mixed_page_limit() { + let root = temp_dir("history-kind-query-limit"); + let managed_root = root.join("SkillBox"); + let runtime_root = root.join("project").join(".codex").join("skills"); + fs::create_dir_all(&runtime_root).unwrap(); + + for index in 0..205 { + record_test_call( + RecordSkillUsageRequest { + skill_name: format!("recent-call-{index}"), + agent_id: "codex".to_string(), + runtime_root: runtime_root.clone(), + event_id: Some(format!("recent-call-event-{index}")), + used_at: Some("2026-07-31T23:59:00Z".to_string()), + prompt_excerpt: None, + metadata: Some(serde_json::json!({"source": "agent_hook"})), + }, + &managed_root, + ) + .unwrap(); + } + record_skill_usage( + RecordSkillUsageRequest { + skill_name: "older-reference".to_string(), + agent_id: "codex".to_string(), + runtime_root: runtime_root.clone(), + event_id: Some("older-reference-event".to_string()), + used_at: Some("2026-07-01T00:00:00Z".to_string()), + prompt_excerpt: None, + metadata: None, + }, + &managed_root, + ) + .unwrap(); + + let all_history = list_history( + HistoryFilter { + limit: Some(200), + ..HistoryFilter::default() + }, + &managed_root, + ) + .unwrap(); + let references = list_history( + HistoryFilter { + kind: Some(HistoryEntryKind::UsageReference), + limit: Some(200), + }, + &managed_root, + ) + .unwrap(); + + assert_eq!(all_history.skill_usage_count, 205); + assert_eq!(all_history.skill_reference_count, 1); + assert_eq!(all_history.entries.len(), 200); + assert!(all_history + .entries + .iter() + .all(|entry| entry.kind == HistoryEntryKind::SkillUsage)); + assert_eq!(references.skill_usage_count, 205); + assert_eq!(references.skill_reference_count, 1); + assert_eq!(references.entries.len(), 1); + assert_eq!( + references.entries[0].skill_name.as_deref(), + Some("older-reference") + ); + assert_eq!(references.entries[0].kind, HistoryEntryKind::UsageReference); +} + #[test] fn history_abbreviates_full_sha_values_in_operation_titles() { let managed_root = temp_dir("history-short-sha").join("SkillBox");