Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 94 additions & 7 deletions src/frontend/web/von_interface/static/js/vontology.js
Original file line number Diff line number Diff line change
Expand Up @@ -3217,6 +3217,7 @@ let __vontologySearchState = {
abortController: null,
requestGeneration: 0,
mode: 'search',
tasks: { items: [], status: 'idle', error: '' },
concepts: {
items: [],
status: 'idle',
Expand Down Expand Up @@ -3613,6 +3614,7 @@ export async function performVontologySearch(q) {

const { abortController, requestGeneration } = beginUnifiedSearchRequest('search');
__vontologySearchState.lastQuery = trimmedQuery;
__vontologySearchState.tasks = { items: [], status: 'idle', error: '' };
__vontologySearchState.concepts = { items: [], status: 'loading', error: '' };
__vontologySearchState.conversations = {
items: [], status: 'loading', error: '', nextCursor: null, coverage: null
Expand Down Expand Up @@ -3668,7 +3670,69 @@ export async function performVontologySearch(q) {
};
renderSearchResults();
});
await Promise.all([conceptTask, conversationTask]);
const taskSearch = loadTaskSearchResults({ requestGeneration, signal: abortController.signal });
await Promise.all([conceptTask, conversationTask, taskSearch]);
}

async function loadTaskSearchResults({ requestGeneration = __vontologySearchState.requestGeneration,
signal = __vontologySearchState.abortController?.signal, more = false } = {}) {
const previous = more ? __vontologySearchState.tasks.items : [];
__vontologySearchState.tasks = { items: previous, status: more ? 'loading-more' : 'loading', error: '' };
renderSearchResults();
try {
const params = new URLSearchParams({ query: __vontologySearchState.lastQuery,
search_mode: 'lexical', limit: '8', offset: String(previous.length), bulk_visibility: 'include' });
const response = await vontologyFetch(`/api/tasks/search?${params}`, { signal, cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!isCurrentUnifiedSearchRequest(requestGeneration)) return;
const page = Array.isArray(data.tasks) ? data.tasks : [];
const items = [...previous, ...page];
__vontologySearchState.tasks = { items, status: items.length ? 'ready' : 'empty', error: '',
hasMore: page.length > 0 && items.length < data.total,
incomplete: data.total_is_exhaustive === false };
} catch (error) {
if (error?.name === 'AbortError' || !isCurrentUnifiedSearchRequest(requestGeneration)) return;
__vontologySearchState.tasks = { items: previous, status: 'error', error: 'Task search is temporarily unavailable.' };
}
renderSearchResults();
}

function normaliseTaskSearchItem(item) {
return { ...item, id: item.task_concept_id, name: item.title || item.task_concept_id, searchType: 'task' };
}

function renderTaskSearchRow(item, keyboardIndex) {
const normalised = normaliseTaskSearchItem(item);
const row = document.createElement('div');
row.className = 'unified-search-task-item unified-search-conversation-item unified-search-option';
row.setAttribute('role', 'option');
row.dataset.searchIndex = String(keyboardIndex);
const main = document.createElement('div');
main.className = 'unified-search-conversation-main';
const title = document.createElement('span');
title.className = 'unified-search-conversation-title';
title.textContent = normalised.name;
main.appendChild(title);
const metadata = document.createElement('span');
metadata.className = 'unified-search-conversation-date';
metadata.textContent = [item.reference_code, item.status?.replaceAll('_', ' '), item.priority].filter(Boolean).join(' · ');
main.appendChild(metadata);
const query = __vontologySearchState.lastQuery.toLowerCase();
const fields = ['description', 'next_checkpoint', 'progress_signal', 'evidence', 'notes'];
const content = String(fields.map(key => item[key]).find(text => text && String(text).toLowerCase().includes(query)) || item.description || '');
if (content) {
const start = Math.max(0, content.toLowerCase().indexOf(query) - 60);
const snippet = document.createElement('span');
snippet.className = 'unified-search-conversation-snippet';
snippet.textContent = `${start ? '…' : ''}${content.slice(start, start + 220)}${content.length > start + 220 ? '…' : ''}`;
main.appendChild(snippet);
}
row.appendChild(main);
row.addEventListener('click', () => { void selectSearchItem(normalised); });
row.addEventListener('mouseenter', () => setActiveIndex(keyboardIndex));
row.addEventListener('mouseleave', () => setActiveIndex(-1));
return row;
}

function makeExactIdSearchErrorItem(conceptId, status, payload) {
Expand Down Expand Up @@ -3748,12 +3812,13 @@ function normaliseConversationSearchItem(item) {
function getOrderedSearchGroups() {
if (__vontologySearchState.mode === 'recovery') return ['conversations'];
return isConversationWorkspaceActive()
? ['conversations', 'concepts']
: ['concepts', 'conversations'];
? ['conversations', 'concepts', 'tasks']
: ['concepts', 'tasks', 'conversations'];
}

function rebuildSearchKeyboardItems() {
const providers = {
tasks: __vontologySearchState.tasks.items.map(normaliseTaskSearchItem),
concepts: __vontologySearchState.concepts.items.map(normaliseConceptSearchItem),
conversations: __vontologySearchState.mode === 'recovery'
? []
Expand Down Expand Up @@ -3880,7 +3945,7 @@ function renderConversationSearchRow(item, keyboardIndex, { recovery = false } =
}

function appendSearchProviderState(group, groupKey, provider) {
const label = groupKey === 'concepts' ? 'concepts' : 'conversations';
const label = groupKey;
if (provider.status === 'loading') {
group.appendChild(createSearchProviderNotice(`Searching ${label}\u2026`, 'is-loading'));
} else if (provider.status === 'loading-more') {
Expand Down Expand Up @@ -3930,17 +3995,19 @@ function renderSearchResults() {
getOrderedSearchGroups().forEach(groupKey => {
const provider = __vontologySearchState[groupKey];
const group = document.createElement('section');
const headingId = `unifiedSearch${groupKey === 'concepts' ? 'Concepts' : 'Conversations'}Heading`;
const headingId = `unifiedSearch${groupKey === 'concepts' ? 'Concepts' : (groupKey === 'tasks' ? 'Tasks' : 'Conversations')}Heading`;
const headingText = __vontologySearchState.mode === 'recovery' && groupKey === 'conversations'
? 'Removed conversations'
: (groupKey === 'concepts' ? 'Concepts' : 'Conversations');
: (groupKey === 'concepts' ? 'Concepts' : (groupKey === 'tasks' ? 'Tasks' : 'Conversations'));
group.className = `unified-search-group unified-search-group-${groupKey}`;
group.setAttribute('role', 'group');
group.setAttribute('aria-labelledby', headingId);
group.appendChild(createSearchGroupHeading(headingText, headingId));

provider.items.forEach(item => {
if (groupKey === 'concepts') {
if (groupKey === 'tasks') {
group.appendChild(renderTaskSearchRow(item, keyboardIndexByKey.get(`task:${item.task_concept_id}`)));
} else if (groupKey === 'concepts') {
const normalised = normaliseConceptSearchItem(item);
const index = normalised.disabled ? null : keyboardIndexByKey.get(`concept:${normalised.id}`);
group.appendChild(renderConceptSearchRow(item, Number.isInteger(index) ? index : null));
Expand All @@ -3956,6 +4023,17 @@ function renderSearchResults() {
});
appendSearchProviderState(group, groupKey, provider);

if (groupKey === 'tasks' && provider.incomplete) {
group.appendChild(createSearchProviderNotice('Some tasks may not appear in this search.', 'is-partial'));
}
if (groupKey === 'tasks' && provider.hasMore) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'unified-search-more-button';
more.textContent = 'More tasks';
more.addEventListener('click', () => { void loadTaskSearchResults({ more: true }); });
group.appendChild(more);
}
if (groupKey === 'conversations' && provider.nextCursor) {
const more = document.createElement('button');
more.type = 'button';
Expand Down Expand Up @@ -3992,6 +4070,7 @@ function clearSearchResults({ invalidate = false } = {}) {
__vontologySearchState.items = [];
__vontologySearchState.activeIndex = -1;
__vontologySearchState.lastQuery = '';
__vontologySearchState.tasks = { items: [], status: 'idle', error: '' };
__vontologySearchState.concepts = { items: [], status: 'idle', error: '' };
__vontologySearchState.conversations = {
items: [], status: 'idle', error: '', nextCursor: null, coverage: null
Expand Down Expand Up @@ -4107,6 +4186,7 @@ async function openRemovedConversations() {
if (input) input.value = '';
const { abortController, requestGeneration } = beginUnifiedSearchRequest('recovery');
__vontologySearchState.lastQuery = '';
__vontologySearchState.tasks = { items: [], status: 'idle', error: '' };
__vontologySearchState.concepts = { items: [], status: 'idle', error: '' };
__vontologySearchState.conversations = {
items: [], status: 'loading', error: '', nextCursor: null, coverage: null
Expand Down Expand Up @@ -4272,6 +4352,13 @@ async function selectSearchItem(item) {
const __perfStartSearchSel = (typeof window !== 'undefined' && window.performance ? performance.now() : Date.now());
if (!item) return;
if (item.disabled) return;
if (item.searchType === 'task') {
if (elements.vontologySearchInput) elements.vontologySearchInput.value = item.name || '';
clearSearchResults({ invalidate: true });
const { openTaskInPanel } = await import('./components/taskPanel.js');
await openTaskInPanel(item.id);
return;
}
if (item.searchType === 'conversation') {
const input = elements?.vontologySearchInput;
if (input) input.value = item.name || '';
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/web/von_interface/templates/von_interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ <h2 class="vontology-title" id="vontologyGlobalTitle">
<span class="visually-hidden">Search</span>
</h2>
<div class="vontology-search" id="globalVontologySearchWrapper">
<input type="search" id="vontologySearchInput" placeholder="Search conversations and concepts…"
aria-label="Search conversations and concepts" role="combobox" aria-autocomplete="list" aria-expanded="false"
<input type="search" id="vontologySearchInput" placeholder="Search conversations, concepts and tasks…"
aria-label="Search conversations, concepts and tasks" role="combobox" aria-autocomplete="list" aria-expanded="false"
aria-haspopup="listbox" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
name="_vontology_search" />
<div id="vontologySearchResults" class="vontology-search-results" role="listbox"
Expand Down
8 changes: 7 additions & 1 deletion tests/frontend/conversationSearch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('unified global conversation and concept search', () => {

test('shows conversations first in the Conversations workspace, with concepts still available', async () => {
global.fetch.mockImplementation((url) => {
if (String(url).includes('/api/tasks/search')) return Promise.resolve(jsonResponse({ tasks: [], total: 0 }));
if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false }));
if (String(url).includes('/vontology/api/vontology/search')) {
return Promise.resolve(jsonResponse({
Expand All @@ -78,7 +79,7 @@ describe('unified global conversation and concept search', () => {

const headings = [...document.querySelectorAll('.unified-search-group-heading')]
.map(node => node.textContent);
expect(headings).toEqual(['Conversations', 'Concepts']);
expect(headings).toEqual(['Conversations', 'Concepts', 'Tasks']);
expect(document.getElementById('vontologySearchResults').textContent).toContain('Detector calibration');
expect(document.getElementById('vontologySearchResults').textContent).toContain('old exact detector phrase');
expect(document.getElementById('vontologySearchResults').textContent).toContain('Detector');
Expand All @@ -92,6 +93,7 @@ describe('unified global conversation and concept search', () => {

test('one failed provider does not suppress results from the other', async () => {
global.fetch.mockImplementation((url) => {
if (String(url).includes('/api/tasks/search')) return Promise.resolve(jsonResponse({ tasks: [], total: 0 }));
if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false }));
if (String(url).includes('/vontology/api/vontology/search')) {
return Promise.resolve(jsonResponse({}, { ok: false, status: 503 }));
Expand All @@ -112,6 +114,7 @@ describe('unified global conversation and concept search', () => {
test('renders the fast provider while the other provider is still pending', async () => {
let resolveConversation;
global.fetch.mockImplementation((url) => {
if (String(url).includes('/api/tasks/search')) return Promise.resolve(jsonResponse({ tasks: [], total: 0 }));
if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false }));
if (String(url).includes('/vontology/api/vontology/search')) {
return Promise.resolve(jsonResponse({
Expand All @@ -136,6 +139,7 @@ describe('unified global conversation and concept search', () => {
test('passes the opaque cursor and appends more conversations', async () => {
let conversationCall = 0;
global.fetch.mockImplementation((url) => {
if (String(url).includes('/api/tasks/search')) return Promise.resolve(jsonResponse({ tasks: [], total: 0 }));
if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false }));
const raw = String(url);
if (raw.includes('/vontology/api/vontology/search')) {
Expand Down Expand Up @@ -166,6 +170,7 @@ describe('unified global conversation and concept search', () => {
test('clearing the native search input immediately invalidates a late response', async () => {
let resolveConversation;
global.fetch.mockImplementation((url) => {
if (String(url).includes('/api/tasks/search')) return Promise.resolve(jsonResponse({ tasks: [], total: 0 }));
if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false }));
if (String(url).includes('/vontology/api/vontology/search')) {
return Promise.resolve(jsonResponse({ results: [] }));
Expand Down Expand Up @@ -193,6 +198,7 @@ describe('unified global conversation and concept search', () => {

test('selecting a conversation dispatches the canonical open event', async () => {
global.fetch.mockImplementation((url) => {
if (String(url).includes('/api/tasks/search')) return Promise.resolve(jsonResponse({ tasks: [], total: 0 }));
if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false }));
if (String(url).includes('/vontology/api/vontology/search')) {
return Promise.resolve(jsonResponse({ results: [] }));
Expand Down
Loading
Loading