feat: make browser inbox globally assignable

This commit is contained in:
mirivlad 2026-07-10 20:19:08 +08:00
parent c31ee7e0ce
commit eb19cce2a4
4 changed files with 437 additions and 110 deletions

25
docs/BROWSER_INBOX.md Normal file
View File

@ -0,0 +1,25 @@
# Browser Inbox
Browser Inbox is one global queue of browser captures. New and changed records are
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
remain readable for migration compatibility.
Each capture contains `workspaceRootPath` and `processed`. An empty
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter
all captures by assignment, workspace, processed state, and text search. A capture
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
deleted.
The local browser receiver adds the currently active workspace before publishing a
capture event. When it has no active workspace, the capture remains unassigned. The
frontend never assigns an untagged capture merely because a workspace view happens
to be open.
Workspace assignment currently uses the top-level vault folder path as the
identifier because the core workspace model has no separate immutable ID. In the
current model that path is also the displayed workspace name, so a workspace rename
requires a later reassignment of existing captures.
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly
matches the selected workspace. Unassigned captures remain visible only in the
global Browser Inbox.

View File

@ -26,6 +26,10 @@
'.browser-inbox-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
'.browser-inbox-title{font-size:.82rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb)}',
'.browser-inbox-count{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.browser-inbox-input,.browser-inbox-select{box-sizing:border-box;min-height:1.85rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.76rem;padding:.25rem .42rem}',
'.browser-inbox-input{width:min(15rem,100%)}',
'.browser-inbox-select{max-width:12rem}',
'.browser-inbox-spacer{flex:1}',
'.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
'.browser-inbox-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
@ -44,6 +48,9 @@
'.browser-inbox-row-title{font-size:.86rem;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.browser-inbox-row-url{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.browser-inbox-row-text{font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
'.browser-inbox-row-meta{display:flex;align-items:center;gap:.35rem;min-width:0;font-size:.68rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.browser-inbox-badge{display:inline-flex;align-items:center;min-height:1.1rem;padding:0 .3rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-sm,4px);white-space:nowrap}',
'.browser-inbox-badge.unassigned{border-color:rgba(240,180,75,.5);color:#ffd37a}.browser-inbox-badge.processed{border-color:rgba(116,190,148,.45);color:#9fe0bc}',
'.browser-inbox-detail{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:auto;padding:1rem;gap:.75rem}',
'.browser-inbox-detail-empty{margin:auto;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
'.browser-inbox-detail-title{font-size:1rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb);word-break:break-word}',
@ -52,6 +59,8 @@
'.browser-inbox-meta-value{color:var(--vt-color-text-secondary,#b7c0d4);min-width:0;overflow-wrap:anywhere}',
'.browser-inbox-text{border:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface,#15152c);border-radius:var(--vt-radius-lg,8px);padding:.75rem;font-size:.85rem;line-height:1.5;color:var(--vt-color-text-primary,#f4f7fb);white-space:pre-wrap;overflow-wrap:anywhere}',
'.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
'.browser-inbox-assignment{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}',
'.browser-inbox-detail-note{font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3);line-height:1.4}',
'.browser-inbox-settings{display:grid;gap:.85rem;padding:1rem;max-width:560px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb)}',
'.browser-inbox-settings-field{display:grid;gap:.3rem}',
'.browser-inbox-settings-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
@ -204,7 +213,7 @@
return event.payload;
}
function captureFromEvent(event, scope) {
function captureFromEvent(event) {
var payload = eventPayload(event);
var captureId = text(payload.captureId).trim();
if (!captureId) {
@ -226,15 +235,28 @@
fileDataBase64: text(payload.fileDataBase64).trim(),
source: text(payload.source).trim(),
browserName: text(payload.browserName).trim(),
workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || ''
// The receiver provides the active workspace. Untagged captures remain unassigned.
workspaceRootPath: workspaceFromPayload(payload)
};
}
function workspaceFromStorageKey(storageKey) {
var key = text(storageKey);
if (key.indexOf(WORKSPACE_PREFIX) !== 0) return '';
try {
return cleanWorkspace(decodeURIComponent(key.slice(WORKSPACE_PREFIX.length)));
} catch (_) {
return '';
}
}
function normalizeStoredCaptures(value, storageKey) {
if (!Array.isArray(value)) return [];
return value.filter(function (item) {
return item && typeof item === 'object' && item.captureId;
}).map(function (item) {
// Workspace root paths are the current stable identifiers; core has no immutable workspace ID yet.
var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey);
return {
captureId: text(item.captureId),
capturedAt: text(item.capturedAt),
@ -251,8 +273,9 @@
fileDataBase64: text(item.fileDataBase64).trim(),
source: text(item.source),
browserName: text(item.browserName),
workspaceRootPath: cleanWorkspace(item.workspaceRootPath),
workspaceName: cleanWorkspace(item.workspaceName || item.workspaceRootPath),
workspaceRootPath: workspaceRootPath,
workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath),
processed: item.processed === true,
_storageKey: storageKey || ''
};
}).slice(0, MAX_CAPTURES);
@ -277,7 +300,8 @@
source: item.source,
browserName: item.browserName,
workspaceRootPath: item.workspaceRootPath,
workspaceName: item.workspaceName || item.workspaceRootPath || ''
workspaceName: item.workspaceName || item.workspaceRootPath || '',
processed: item.processed === true
};
});
}
@ -296,7 +320,7 @@
}
function globalCaptureKeys(settings) {
var keys = [LEGACY_KEY, GLOBAL_KEY];
var keys = [GLOBAL_KEY, LEGACY_KEY];
Object.keys(settings || {}).forEach(function (key) {
if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key);
});
@ -326,27 +350,73 @@
var disposed = false;
var unsubscribers = [];
var domainBindings = {};
var workspaceOptions = [];
var statusFilter = 'all';
var workspaceFilter = '';
var searchQuery = '';
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? 'Browser Inbox' : 'Browser Inbox · ' + scope.label });
var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' });
var statusFilterEl = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-filter': 'status',
'aria-label': 'Capture status filter',
onChange: function (event) {
statusFilter = text(event && event.target && event.target.value) || 'all';
selectedId = '';
render();
}
}, [
el('option', { value: 'all', textContent: 'All captures' }),
el('option', { value: 'unassigned', textContent: 'Unassigned' }),
el('option', { value: 'unprocessed', textContent: 'Unprocessed' }),
el('option', { value: 'processed', textContent: 'Processed' })
]);
var workspaceFilterEl = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-filter': 'workspace',
'aria-label': 'Workspace filter',
onChange: function (event) {
workspaceFilter = cleanWorkspace(event && event.target && event.target.value);
selectedId = '';
render();
}
});
var searchInput = el('input', {
className: 'browser-inbox-input',
type: 'search',
placeholder: 'Search captures',
'data-browser-inbox-filter': 'search',
'aria-label': 'Search captures',
onInput: function (event) {
searchQuery = text(event && event.target && event.target.value).trim().toLowerCase();
selectedId = '';
renderList();
renderDetail();
renderCount();
}
});
var clearBtn = el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'clear',
textContent: 'Clear',
onClick: function () {
if (scope.mode === 'global') {
clearGlobal().then(render);
return;
}
captures = [];
selectedId = '';
persist().then(render);
clearScope().then(render);
}
});
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
filtersEl.appendChild(statusFilterEl);
if (scope.mode === 'global') {
filtersEl.appendChild(workspaceFilterEl);
} else {
filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: 'Assigned to this workspace' }));
}
filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl);
toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(clearBtn);
@ -359,68 +429,106 @@
containerEl.appendChild(toolbar);
containerEl.appendChild(body);
function option(value, label) {
return el('option', { value: value, textContent: label });
}
function workspaceRoots() {
var roots = workspaceOptions.slice();
captures.forEach(function (capture) {
var root = cleanWorkspace(capture && capture.workspaceRootPath);
if (root && roots.indexOf(root) === -1) roots.push(root);
});
if (scope.workspaceRoot && roots.indexOf(scope.workspaceRoot) === -1) roots.push(scope.workspaceRoot);
return roots.sort(function (a, b) {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
}
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (root) {
workspaceFilterEl.appendChild(option(root, root));
});
workspaceFilterEl.value = workspaceFilter;
}
function visibleCaptures() {
return captures.filter(function (capture) {
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false;
if (statusFilter === 'unassigned' && workspaceRoot) return false;
if (statusFilter === 'unprocessed' && capture.processed === true) return false;
if (statusFilter === 'processed' && capture.processed !== true) return false;
if (!searchQuery) return true;
return [displayTitle(capture), capture.url, capture.domain, capture.text, workspaceRoot].join('\n').toLowerCase().indexOf(searchQuery) !== -1;
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
if (scope.mode === 'global') {
var grouped = {};
captures.forEach(function (item) {
var key = item._storageKey || GLOBAL_KEY;
grouped[key] = grouped[key] || [];
grouped[key].push(item);
});
return Promise.all(Object.keys(grouped).map(function (key) {
return api.settings.write(key, storageCaptures(sortCaptures(grouped[key])));
})).catch(function (err) {
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
});
}
var toStore = captures;
return api.settings.write(scope.key, storageCaptures(toStore)).catch(function (err) {
return api.settings.write(GLOBAL_KEY, storageCaptures(sortCaptures(captures))).catch(function (err) {
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
});
}
function clearGlobal() {
function removeCaptures(captureIds, successText) {
var ids = {};
captureIds.forEach(function (captureId) {
ids[captureId] = true;
});
captures = captures.filter(function (item) {
return !ids[item.captureId];
});
if (ids[selectedId]) selectedId = '';
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') {
captures = [];
selectedId = '';
return Promise.resolve();
return persist().then(function () {
statusText = successText;
statusClass = '';
});
}
return api.settings.read().then(function (settings) {
var keys = globalCaptureKeys(settings || {});
captures = [];
selectedId = '';
return Promise.all(keys.map(function (key) {
return api.settings.write(key, []);
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
return !ids[item.captureId];
});
return api.settings.write(key, storageCaptures(next));
}));
}).then(function () {
statusText = 'Inbox cleared';
statusText = successText;
statusClass = '';
}).catch(function (err) {
statusText = 'Could not clear inbox: ' + (err && err.message ? err.message : String(err));
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
});
}
function clearScope() {
var ids = scope.mode === 'global'
? captures.map(function (capture) { return capture.captureId; })
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; });
return removeCaptures(ids, scope.mode === 'global' ? 'Inbox cleared' : 'Workspace captures cleared');
}
function selectedCapture() {
for (var i = 0; i < captures.length; i += 1) {
if (captures[i].captureId === selectedId) return captures[i];
var visible = visibleCaptures();
for (var i = 0; i < visible.length; i += 1) {
if (visible[i].captureId === selectedId) return visible[i];
}
return captures[0] || null;
return visible[0] || null;
}
function addCapture(capture) {
capture = applyDomainBinding(capture);
if (scope.mode === 'workspace' && capture.workspaceRootPath && capture.workspaceRootPath !== scope.workspaceRoot) {
return Promise.resolve();
}
var existing = captures.some(function (item) {
return item.captureId === capture.captureId;
});
if (existing) return Promise.resolve();
capture._storageKey = storageKeyForCapture(capture);
captures = sortCaptures([capture].concat(captures));
selectedId = capture.captureId;
statusText = 'Capture received';
@ -437,27 +545,33 @@
return capture;
}
function storageKeyForCapture(capture) {
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
if (workspaceRoot) return WORKSPACE_PREFIX + encodeKey(workspaceRoot);
return scope.key;
function assignWorkspace(captureId, workspaceRoot) {
workspaceRoot = cleanWorkspace(workspaceRoot);
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, {
workspaceRootPath: workspaceRoot,
workspaceName: workspaceRoot
});
});
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
return persist().then(render);
}
function removeCapture(captureId) {
captures = captures.filter(function (item) {
return item.captureId !== captureId;
return removeCaptures([captureId], 'Capture deleted');
}
function setProcessed(captureId, processed) {
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, { processed: processed === true });
});
if (selectedId === captureId) selectedId = captures[0] ? captures[0].captureId : '';
if (scope.mode !== 'global') return persist().then(render);
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') return persist().then(render);
return api.settings.read().then(function (settings) {
return Promise.all(globalCaptureKeys(settings || {}).map(function (key) {
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
return item.captureId !== captureId;
});
return api.settings.write(key, storageCaptures(next));
}));
}).then(render);
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
return persist().then(render);
}
function createNoteFromCapture(capture) {
@ -589,13 +703,18 @@
function renderList() {
listEl.innerHTML = '';
if (captures.length === 0) {
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.' }));
var visible = visibleCaptures();
if (visible.length === 0) {
var emptyText = captures.length === 0
? 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.'
: 'No captures match the current filters.';
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: emptyText }));
return;
}
captures.forEach(function (capture) {
visible.forEach(function (capture) {
var workspaceRoot = cleanWorkspace(capture.workspaceRootPath);
var row = el('div', {
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : ''),
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : '') + (capture.processed ? ' processed' : ''),
'data-browser-capture-id': capture.captureId,
onClick: function () {
selectedId = capture.captureId;
@ -608,6 +727,16 @@
]),
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId })
]);
row.appendChild(el('div', { className: 'browser-inbox-row-meta' }, [
el('span', {
className: 'browser-inbox-badge' + (workspaceRoot ? '' : ' unassigned'),
textContent: workspaceRoot || 'Unassigned'
}),
el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed ? 'Processed' : 'Unprocessed'
})
]));
if (capture.text) {
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
}
@ -634,8 +763,43 @@
el('div', { className: 'browser-inbox-meta-label', textContent: 'Captured' }),
el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' })
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
]));
var assignmentSelect = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-assignment': capture.captureId,
'aria-label': 'Assign capture workspace',
onChange: function (event) {
assignWorkspace(capture.captureId, event && event.target && event.target.value);
}
});
assignmentSelect.appendChild(option('', 'Unassigned'));
workspaceRoots().forEach(function (workspaceRoot) {
assignmentSelect.appendChild(option(workspaceRoot, workspaceRoot));
});
assignmentSelect.value = capture.workspaceRootPath || '';
var assignmentControls = [
el('span', { className: 'browser-inbox-meta-label', textContent: 'Assign workspace' }),
assignmentSelect
];
if (capture.workspaceRootPath) {
assignmentControls.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'clear-assignment',
textContent: 'Clear assignment',
onClick: function () {
assignWorkspace(capture.captureId, '');
}
}));
}
detailEl.appendChild(el('div', { className: 'browser-inbox-assignment' }, assignmentControls));
if (!capture.workspaceRootPath) {
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-note', textContent: 'Assign a workspace before creating a note, link, or file.' }));
}
if (capture.text) {
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
}
@ -643,6 +807,14 @@
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.fileText }));
}
var actionButtons = [];
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'toggle-processed',
textContent: capture.processed ? 'Mark Unprocessed' : 'Mark Processed',
onClick: function () {
setProcessed(capture.captureId, !capture.processed);
}
}));
if (capture.workspaceRootPath) {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
@ -676,7 +848,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'remove',
textContent: 'Remove',
textContent: 'Delete',
onClick: function () {
removeCapture(capture.captureId);
}
@ -684,9 +856,23 @@
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
}
function renderCount() {
var visibleCount = visibleCaptures().length;
var total = captures.length;
countEl.textContent = visibleCount === total
? total + ' item' + (total === 1 ? '' : 's')
: visibleCount + ' of ' + total + ' items';
var scopeCount = scope.mode === 'global'
? total
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).length;
clearBtn.disabled = scopeCount === 0;
}
function render() {
countEl.textContent = captures.length + ' item' + (captures.length === 1 ? '' : 's');
clearBtn.disabled = captures.length === 0;
statusFilterEl.value = statusFilter;
searchInput.value = searchQuery;
renderWorkspaceFilterOptions();
renderCount();
statusEl.textContent = statusText;
statusEl.className = 'browser-inbox-status' + (statusClass ? ' ' + statusClass : '');
renderList();
@ -695,44 +881,46 @@
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
if (scope.mode === 'global') {
return api.settings.read().then(function (settings) {
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
var all = [];
globalCaptureKeys(settings || {}).forEach(function (key) {
all = all.concat(normalizeStoredCaptures((settings || {})[key], key));
});
captures = sortCaptures(all);
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
}).catch(function (err) {
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
});
}
return api.settings.read().then(function (settings) {
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
var scopedCaptures = normalizeStoredCaptures((settings || {})[scope.key], scope.key);
var globalCaptures = normalizeStoredCaptures((settings || {})[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) {
return item.workspaceRootPath === scope.workspaceRoot;
var keys = globalCaptureKeys(settings || {});
var all = [];
var hasLegacyCaptures = false;
keys.forEach(function (key) {
var stored = normalizeStoredCaptures((settings || {})[key], key);
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
all = all.concat(stored);
});
var legacyCaptures = normalizeStoredCaptures((settings || {})[LEGACY_KEY], LEGACY_KEY).filter(function (item) {
return item.workspaceRootPath === scope.workspaceRoot;
});
captures = sortCaptures(scopedCaptures.concat(globalCaptures, legacyCaptures));
captures = sortCaptures(all);
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
// Keep legacy records readable, then mirror the canonical state into the global queue.
return hasLegacyCaptures ? persist() : undefined;
}).catch(function (err) {
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (entries) {
workspaceOptions = (Array.isArray(entries) ? entries : []).filter(function (entry) {
return text(entry && entry.type).toLowerCase() === 'folder';
}).map(function (entry) {
return cleanWorkspace(entry.relativePath || entry.name);
}).filter(function (workspaceRoot) {
return workspaceRoot && workspaceRoot.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function subscribeEvents() {
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
return api.events.subscribe(eventName, function (event) {
var eventWorkspace = workspaceFromPayload(eventPayload(event));
if (scope.mode === 'workspace' && eventWorkspace && eventWorkspace !== scope.workspaceRoot) return Promise.resolve();
return addCapture(captureFromEvent(event, scope));
return addCapture(captureFromEvent(event));
}).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
});
@ -746,7 +934,7 @@
}
render();
loadStored().then(function () {
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
if (disposed) return;
render();
return subscribeEvents();

View File

@ -4,7 +4,7 @@
"name": "Browser Inbox",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Workspace-scoped inbox for browser captures delivered through the local receiver event protocol.",
"description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"source": "official",
"icon": "inbox",
"provides": [
@ -16,6 +16,7 @@
"events.subscribe",
"events.publish",
"browser.receiver.manage",
"files.read",
"files.write",
"storage.namespace",
"ui.register"

View File

@ -143,6 +143,10 @@ function makeApi(initialSettings = {}) {
const fileWrites = [];
const fileByteWrites = [];
const publishedEvents = [];
const workspaceEntries = [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
{ name: 'Project', relativePath: 'Project', type: 'folder' },
];
const receiverPairing = {
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
receiverToken: 'initial-browser-token',
@ -178,6 +182,7 @@ function makeApi(initialSettings = {}) {
},
},
files: {
list: async () => workspaceEntries.map((entry) => ({ ...entry })),
writeText: async (relativePath, content, options = {}) => {
if (nextWriteError) {
const err = nextWriteError;
@ -269,6 +274,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
domain: 'example.com',
text: 'Selected text from the page',
browserName: 'Firefox',
workspaceRootPath: 'Project',
},
});
await flush();
@ -276,10 +282,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const projectKey = 'captures:workspace:Project';
const clientKey = 'captures:workspace:ClientA';
const globalKey = 'captures:global';
const captures = api.getStoredCaptures(projectKey);
const captures = api.getStoredCaptures(globalKey);
if (captures.length !== 1) throw new Error(`expected one stored capture, got ${captures.length}`);
if (captures[0].captureId !== 'capture-1') throw new Error('stored capture id mismatch');
if (api.getStoredCaptures(globalKey).length !== 0) throw new Error('workspace capture leaked into global storage');
if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('workspace capture was stored in a legacy workspace key');
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1');
if (!row) throw new Error('capture row was not rendered');
@ -298,10 +304,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
title: 'Example Article',
domain: 'example.com',
text: 'Duplicate selected text',
workspaceRootPath: 'Project',
},
});
await flush();
if (api.getStoredCaptures(projectKey).length !== 1) throw new Error('duplicate capture was stored');
if (api.getStoredCaptures(globalKey).length !== 1) throw new Error('duplicate capture was stored');
const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) {
@ -321,7 +328,9 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
},
});
await flush();
if (api.getStoredCaptures(clientKey).length !== 1) throw new Error('ClientA capture was not stored under ClientA workspace key');
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('ClientA capture was not stored in the global queue');
}
if (!walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
throw new Error('ClientA capture was not rendered');
}
@ -334,16 +343,42 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!walk(globalView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
throw new Error('global browser inbox did not aggregate ClientA capture');
}
await api.handlers['browser.capture.page']({
name: 'browser.capture.page',
timestamp: '2026-06-27T00:20:00Z',
payload: {
captureId: 'capture-unassigned',
capturedAt: '2026-06-27T00:20:00.000Z',
kind: 'page',
url: 'https://inbox.example.com/unassigned',
title: 'Unassigned Page',
domain: 'inbox.example.com',
},
});
await flush();
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-unassigned' && !capture.workspaceRootPath)) {
throw new Error('untagged receiver event was not retained as unassigned');
}
const unassignedWorkspace = await mountWithApi(api, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
if (walk(unassignedWorkspace.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-unassigned')) {
throw new Error('unassigned capture leaked into a workspace inbox');
}
component.unmount && component.unmount(unassignedWorkspace.container);
component.unmount && component.unmount(globalView.container);
const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear');
if (!clearButton) throw new Error('clear button not found');
clearButton.click();
await flush();
if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('clear action did not empty stored captures');
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) {
throw new Error('workspace clear action did not remove the Project capture');
}
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('workspace clear action removed a capture from another workspace');
}
component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 12) throw new Error('component did not unsubscribe all capture handlers');
if (api.unsubscribed.length !== 16) throw new Error('component did not unsubscribe all capture handlers');
const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] });
const persisted = await mountWithApi(persistedApi);
@ -431,11 +466,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
},
});
await flush();
if (bindingApi.getStoredCaptures('captures:workspace:ClientA').length !== 1) {
throw new Error('domain-bound capture was not stored under ClientA workspace key');
}
if (bindingApi.getStoredCaptures('captures:global').length !== 0) {
throw new Error('domain-bound capture was stored in global queue');
if (!bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'bound-client-capture' && capture.workspaceRootPath === 'ClientA')) {
throw new Error('domain-bound capture was not stored with its ClientA assignment');
}
const bindingClient = await mountWithApi(bindingApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (!walk(bindingClient.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) {
@ -460,16 +492,97 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
},
});
await flush();
if (!bindingApi.getStoredCaptures('captures:workspace:Project').some((capture) => capture.captureId === 'explicit-project-capture')) {
throw new Error('explicit workspace capture was not stored under its payload workspace');
if (!bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'explicit-project-capture' && capture.workspaceRootPath === 'Project')) {
throw new Error('explicit workspace capture was not stored with its payload workspace');
}
if (bindingApi.getStoredCaptures('captures:workspace:ClientA').some((capture) => capture.captureId === 'explicit-project-capture')) {
if (bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'explicit-project-capture' && capture.workspaceRootPath === 'ClientA')) {
throw new Error('domain binding overrode explicit workspaceRootPath');
}
component.unmount && component.unmount(bindingGlobal.container);
component.unmount && component.unmount(bindingClient.container);
component.unmount && component.unmount(bindingAggregate.container);
const assignmentApi = makeApi({
'captures:global': [
{
captureId: 'assignment-unassigned',
capturedAt: '2026-06-29T00:20:00.000Z',
kind: 'page',
url: 'https://inbox.example.com/unassigned',
title: 'Unassigned capture',
domain: 'inbox.example.com',
},
{
captureId: 'assignment-client-processed',
capturedAt: '2026-06-29T00:10:00.000Z',
kind: 'page',
url: 'https://inbox.example.com/client',
title: 'Processed client capture',
domain: 'inbox.example.com',
workspaceRootPath: 'ClientA',
processed: true,
},
],
});
const assignmentView = await mountWithApi(assignmentApi, {});
const statusFilter = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
if (!statusFilter) throw new Error('global status filter was not rendered');
statusFilter.value = 'unassigned';
statusFilter.dispatchEvent('change');
await flush();
if (!walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'assignment-unassigned')) {
throw new Error('unassigned filter did not render the unassigned capture');
}
if (walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'assignment-client-processed')) {
throw new Error('unassigned filter leaked an assigned capture');
}
statusFilter.value = 'all';
statusFilter.dispatchEvent('change');
await flush();
const assignmentSelect = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-assignment') === 'assignment-unassigned');
if (!assignmentSelect) throw new Error('workspace assignment control was not rendered');
assignmentSelect.value = 'ClientA';
assignmentSelect.dispatchEvent('change');
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.workspaceRootPath === 'ClientA')) {
throw new Error('assignment did not persist ClientA workspace root path');
}
const reassignmentSelect = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-assignment') === 'assignment-unassigned');
reassignmentSelect.value = 'Project';
reassignmentSelect.dispatchEvent('change');
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.workspaceRootPath === 'Project')) {
throw new Error('reassignment did not persist Project workspace root path');
}
const clearAssignmentButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear-assignment');
if (!clearAssignmentButton) throw new Error('clear assignment action was not rendered');
clearAssignmentButton.click();
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && !capture.workspaceRootPath)) {
throw new Error('clear assignment did not make the capture unassigned');
}
const processedButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'toggle-processed');
if (!processedButton) throw new Error('processed state action was not rendered');
processedButton.click();
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === true)) {
throw new Error('mark processed did not persist state');
}
const unprocessedButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'toggle-processed');
unprocessedButton.click();
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
throw new Error('mark unprocessed did not persist state');
}
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove');
deleteButton.click();
await flush();
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
throw new Error('delete did not remove the capture from global storage');
}
component.unmount && component.unmount(assignmentView.container);
const conversionApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-selection',