feat(notes): attach selected Notes/Todos as Agent context (#6495)

* feat: add Notes and Todos as agent chat context

- Send selected Note/Todo IDs from the Notes UI
- Load selected notes with owner scoping in chat routes
- Format note content and checklist items for the model
- Inject selected notes as untrusted chat context
- Add a selected-context indicator beside Shell Access
- Keep selected context associated with the active chat session

* Improved selected notes context handling and discouragin redundant note lookup and permission requests

* Fix selected notes UI when switching chats

* fix(notes): harden selected context lifecycle and validation

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
Beyrem Weslati
2026-10-13 18:57:19 +01:00
committed by GitHub
co-authored by Alexandre Teixeira
parent a8c147b238
commit 8feb50e894
9 changed files with 324 additions and 2 deletions
+15
View File
@@ -1178,6 +1178,21 @@
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</button>
<!-- Selected Notes/Todos indicator -->
<button
type="button"
class="input-icon-btn tool-indicator"
title="Selected Notes/Todos context — click to clear"
id="selected-notes-context-btn"
aria-label="Clear selected Notes/Todos context"
style="display:none;"
>
<span id="selected-notes-context-count" style="font-size:11px;margin-left:2px;"></span>
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
<line x1="6" y1="6" x2="18" y2="18"/>
<line x1="18" y1="6" x2="6" y2="18"/>
</svg>
</button>
<!-- Workspace indicator (hidden until a folder is set) -->
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
<svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
+68
View File
@@ -65,6 +65,8 @@ import { invalidateSettings } from './appConfig.js';
let _contextHeaderAnchorEl = null;
let _contextHeaderPopupCleanup = null;
let _pendingToolApproval = null;
const _selectedNoteIdsBySession = new Map();
let _pendingSelectedNoteIds = [];
let _lastPrivateBrowserUrl = '';
function _isPrivateBrowserTool(tool) {
const name = String(tool || '').toLowerCase();
@@ -240,6 +242,46 @@ import { invalidateSettings } from './appConfig.js';
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
});
window.addEventListener('odysseus:notes-context-selected', (event) => {
const noteIds = event.detail?.noteIds;
if (!Array.isArray(noteIds) || noteIds.length === 0) return;
const sessionId = sessionModule.getCurrentSessionId?.();
if (!sessionId && !sessionModule.hasPendingChat?.()) {
console.warn('Cannot attach Notes context: no active or pending chat');
return;
}
if (sessionId) {
_selectedNoteIdsBySession.set(sessionId, [...noteIds]);
} else {
_pendingSelectedNoteIds = [...noteIds];
}
if (event.detail) event.detail.accepted = true;
_renderSelectedNotesIndicator(noteIds);
});
window.addEventListener('odysseus:session-changed', (event) => {
const sessionId = event.detail?.sessionId;
if (sessionId && event.detail?.materialized && _pendingSelectedNoteIds.length) {
_selectedNoteIdsBySession.set(sessionId, _pendingSelectedNoteIds);
}
_pendingSelectedNoteIds = [];
_renderSelectedNotesIndicator(
sessionId ? (_selectedNoteIdsBySession.get(sessionId) || []) : []
);
});
document.getElementById('selected-notes-context-btn')?.addEventListener('click', () => {
const sessionId = sessionModule.getCurrentSessionId?.();
if (sessionId) {
_selectedNoteIdsBySession.delete(sessionId);
} else {
_pendingSelectedNoteIds = [];
}
_renderSelectedNotesIndicator([]);
});
function _fmtContextNumber(n) {
const v = Number(n || 0);
return v ? v.toLocaleString() : '?';
@@ -290,6 +332,26 @@ import { invalidateSettings } from './appConfig.js';
return Math.max(50, Math.min(95, Math.round(n)));
}
function _renderSelectedNotesIndicator(noteIds){
const button = document.getElementById('selected-notes-context-btn');
const count = document.getElementById('selected-notes-context-count');
if (!button || !count) return;
const total = Array.isArray(noteIds) ? noteIds.length : 0;
if(!total){
button.style.display = 'none';
count.textContent = '';
return;
}
count.textContent = `${total} note${total === 1 ? '' : 's'} selected`;
button.style.display = '';
button.title = `${total} selected Note/Todo${total === 1 ? '' : 's'} — click to clear`;
button.setAttribute(
'aria-label',
`Clear ${total} selected Note/Todo${total === 1 ? '' : 's'} context`
);
}
function _liveSessionModule() {
return (window.sessionModule && window.sessionModule.getCurrentSessionId)
? window.sessionModule
@@ -2497,6 +2559,12 @@ import { invalidateSettings } from './appConfig.js';
const fd = new FormData();
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
fd.append('session', streamSessionId);
const selectedNoteIds = _selectedNoteIdsBySession.get(streamSessionId) || [];
if(selectedNoteIds.length){
fd.append('selected_note_ids', JSON.stringify(selectedNoteIds))
}
if (approvalForSend) {
fd.append('tool_approval_id', approvalForSend.approval_id);
fd.append('tool_approval_decision', approvalForSend.decision);
+14
View File
@@ -1230,6 +1230,7 @@ export function openPanel() {
<label class="memory-bulk-check-all"><input type="checkbox" id="notes-select-all" /> All</label>
<span id="notes-selected-count">0 Selected</span>
<span style="flex:1"></span>
<button id="notes-send-agent" class="memory-toolbar-btn" disabled><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>Send to Agent</button>
<button id="notes-bulk-archive" class="memory-toolbar-btn" disabled>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>Archive
</button>
@@ -1379,6 +1380,17 @@ export function openPanel() {
_renderNotes();
_updateBulkBar();
});
document.getElementById('notes-send-agent').addEventListener('click', async () => {
const ids = [..._selectedIds];
if (!ids.length) return;
const detail = { noteIds: ids, accepted: false };
window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail }));
if (!detail.accepted) {
uiModule.showToast('Choose a chat or model before attaching notes');
return;
}
_exitSelectMode();
})
document.getElementById('notes-bulk-archive').addEventListener('click', async () => {
const ids = [..._selectedIds];
if (!ids.length) return;
@@ -1511,10 +1523,12 @@ function _exitSelectMode() {
function _updateBulkBar() {
const count = _selectedIds.size;
const countEl = document.getElementById('notes-selected-count');
const sendAgentBtn = document.getElementById('notes-send-agent');
const archiveBtn = document.getElementById('notes-bulk-archive');
const deleteBtn = document.getElementById('notes-bulk-delete');
const allEl = document.getElementById('notes-select-all');
if (countEl) countEl.textContent = `${count} Selected`;
if (sendAgentBtn) sendAgentBtn.disabled = count === 0;
if (archiveBtn) archiveBtn.disabled = count === 0;
if (deleteBtn) deleteBtn.disabled = count === 0;
if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id));
+15
View File
@@ -351,6 +351,9 @@ let _sessionListFocused = false;
function _deselectCurrentSession(sid) {
if (currentSessionId !== sid) return;
currentSessionId = null;
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
detail: { sessionId: null },
}));
uiModule.el('chat-history').innerHTML = '';
uiModule.el('current-meta').textContent = 'Odysseus Chat';
Storage.remove('lastSessionId');
@@ -2065,6 +2068,9 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
try { window.documentModule.clearSelection(); } catch {}
}
currentSessionId = id;
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
detail: { sessionId: id ? id : null },
}));
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
// Identify Assistant / task-output sessions so we don't "trap" the user
// there on return. Skipped from both `lastSessionId` persistence and the
@@ -2445,6 +2451,9 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
_skipAutoSelect = true;
_suppressNextSessionLoading = true;
currentSessionId = null;
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
detail: { sessionId: null },
}));
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
Storage.remove('lastSessionId');
history.replaceState(null, '', window.location.pathname);
@@ -2555,6 +2564,9 @@ export async function materializePendingSession() {
}
_pendingChat = null;
currentSessionId = payload.id;
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
detail: { sessionId: payload.id, materialized: true },
}));
if (!isIncognito) {
Storage.set('lastSessionId', payload.id);
history.replaceState(null, '', '#' + payload.id);
@@ -2626,6 +2638,9 @@ export function getCurrentEndpointUrl() {
export function setCurrentSessionId(id) {
_sessionNavToken++;
currentSessionId = id;
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
detail: { sessionId: id ? id : null },
}));
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
if (!id) {
_suppressNextSessionLoading = true;