feat: scope overview browser captures by workspace

This commit is contained in:
mirivlad 2026-07-10 20:27:58 +08:00
parent 1d1eaa6bc7
commit 8dafde4519
5 changed files with 431 additions and 37 deletions

View File

@ -15,8 +15,8 @@ test.describe('Browser Inbox workflow', () => {
consoleCollector.assertNoErrors();
});
test('workspace inbox explains empty capture flow and primary actions', async ({ page }) => {
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 });
@ -24,13 +24,15 @@ test.describe('Browser Inbox workflow', () => {
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser captures yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('send a page, selection, link, or file from the browser extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
});
test('workspace inbox renders stored captures with conversion actions', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:workspace:Project': [{
'captures:global': [{
captureId: 'capture-e2e-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'file',
@ -58,4 +60,98 @@ test.describe('Browser Inbox workflow', () => {
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
});
test('global inbox assigns, reassigns, filters, marks, and deletes captures', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'inbox-unassigned',
capturedAt: '2026-06-30T08:20:00.000Z',
kind: 'page',
url: 'https://example.com/unassigned',
title: 'Unassigned research',
domain: 'example.com',
},
{
captureId: 'inbox-client-processed',
capturedAt: '2026-06-30T08:10:00.000Z',
kind: 'link',
url: 'https://client.example.com/processed',
title: 'Processed client link',
domain: 'client.example.com',
workspaceRootPath: 'ClientA',
processed: true,
},
{
captureId: 'inbox-project-open',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'selection',
title: 'Project quote',
text: 'A project quote',
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
const statusFilter = inbox.locator('[data-browser-inbox-filter="status"]');
const workspaceFilter = inbox.locator('[data-browser-inbox-filter="workspace"]');
await statusFilter.selectOption('unassigned');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
await workspaceFilter.selectOption('ClientA');
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toHaveCount(0);
await workspaceFilter.selectOption('');
await statusFilter.selectOption('unprocessed');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
const assignment = inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]');
await assignment.selectOption('ClientA');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('ClientA');
await inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]').selectOption('Project');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('Project');
await inbox.locator('[data-browser-inbox-action="clear-assignment"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath || '';
})).toBe('');
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').processed;
})).toBe(true);
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await inbox.locator('[data-browser-inbox-action="remove"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].some((capture) => capture.captureId === 'inbox-unassigned');
})).toBe(false);
});
});

View File

@ -83,7 +83,7 @@ test.describe('UX Overview workspace flow', () => {
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:workspace:Project': [
'captures:global': [
{
captureId: 'overview-capture-1',
capturedAt: '2026-06-30T08:00:00.000Z',
@ -101,6 +101,19 @@ test.describe('UX Overview workspace flow', () => {
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-client-capture',
capturedAt: '2026-06-30T08:30:00.000Z',
kind: 'page',
title: 'Client capture must stay global',
workspaceRootPath: 'ClientA',
},
{
captureId: 'overview-unassigned-capture',
capturedAt: '2026-06-30T08:35:00.000Z',
kind: 'page',
title: 'Unassigned capture must stay global',
},
],
});
await window.go.api.App.WritePluginSettings('verstak.activity', {
@ -215,6 +228,8 @@ test.describe('UX Overview workspace flow', () => {
await expect(recent).not.toContainText('Selected file');
await expect(recent).not.toContainText('Workspace selected');
await expect(recent).not.toContainText('file.opened');
await expect(recent).not.toContainText('Client capture must stay global');
await expect(recent).not.toContainText('Unassigned capture must stay global');
await expect(recent.locator('[data-overview-recent-item]')).toHaveCount(5);
await expect(recent.locator('[data-overview-recent-item] button')).toHaveCount(0);

View File

@ -111,6 +111,24 @@
});
}
function browserCaptureRowsForWorkspace(settings) {
const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return [];
const seen = new Set();
return [
'captures:global',
workspaceKey('captures:workspace:'),
'captures',
].flatMap(key => normalizeRows(settings?.[key])).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
if (tagged !== workspace) return false;
const captureId = String(item.captureId || '');
if (!captureId || seen.has(captureId)) return !captureId;
seen.add(captureId);
return true;
});
}
function timeValue(item) {
return item?.capturedAt || item?.endedAt || item?.startedAt || item?.occurredAt || item?.receivedAt || item?.updatedAt || item?.modifiedAt || item?.date || item?.time || '';
}
@ -419,11 +437,7 @@
]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
captures = rowsFor(browserSettings, [
workspaceKey('captures:workspace:'),
'captures:global',
'captures',
]);
captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = rowsFor(activitySettings, [
workspaceKey('events:workspace:'),
'events:global',

View File

@ -239,11 +239,11 @@
name: 'Browser Inbox',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped inbox for browser captures.',
description: 'Global browser capture queue with explicit workspace assignment.',
source: 'official',
icon: 'inbox',
provides: ['browser.inbox'],
permissions: ['events.subscribe', 'storage.namespace', 'ui.register'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
@ -2087,6 +2087,9 @@
function browserInboxBundle() {
return '(' + function () {
var PLUGIN_ID = 'verstak.browser-inbox';
var GLOBAL_KEY = 'captures:global';
var LEGACY_KEY = 'captures';
var WORKSPACE_PREFIX = 'captures:workspace:';
function injectStyles() {
if (document.getElementById('mock-browser-inbox-style')) return;
@ -2096,6 +2099,7 @@
'.browser-inbox-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0f0}',
'.browser-inbox-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}',
'.browser-inbox-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.browser-inbox-count,.browser-inbox-status{font-size:.74rem;color:#8b8ba8}.browser-inbox-spacer{flex:1}',
'.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;flex:1;flex-wrap:wrap}.browser-inbox-select,.browser-inbox-input{min-height:1.85rem;max-width:12rem;border:1px solid #1a3a5c;border-radius:4px;background:#101020;color:#e0e0f0;padding:.25rem .4rem;font-size:.76rem}.browser-inbox-input{width:12rem}',
'.browser-inbox-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.browser-inbox-btn:hover{background:#1a4a7a}.browser-inbox-btn:disabled{opacity:.45;cursor:default}.browser-inbox-btn.danger{border-color:#633;color:#ffb0b0}',
'.browser-inbox-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(260px,360px) minmax(0,1fr)}.browser-inbox-list{min-height:0;overflow:auto;border-right:1px solid #16213e;background:#101020}.browser-inbox-detail{min-width:0;min-height:0;overflow:auto;padding:1rem;display:flex;flex-direction:column;gap:.75rem}',
'.browser-inbox-empty,.browser-inbox-detail-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#8b8ba8;font-size:.84rem;line-height:1.45;text-align:center}.browser-inbox-detail-empty{height:auto;margin:auto}',
@ -2113,8 +2117,11 @@
if (key === 'textContent') node.textContent = attrs[key];
else if (key === 'className') node.className = attrs[key];
else if (key === 'disabled') node.disabled = !!attrs[key];
else if (key === 'value') node.value = attrs[key];
else if (key.indexOf('data-') === 0) node.setAttribute(key, attrs[key]);
else if (key === 'onClick') node.addEventListener('click', attrs[key]);
else if (key === 'onChange') node.addEventListener('change', attrs[key]);
else if (key === 'onInput') node.addEventListener('input', attrs[key]);
else node[key] = attrs[key];
});
(children || []).forEach(function (child) {
@ -2136,10 +2143,46 @@
return 'captures:workspace:' + encodeURIComponent(root || '');
}
function cleanWorkspace(value) {
return String(value == null ? '' : value).trim().replace(/^\/+|\/+$/g, '');
}
function workspaceFromKey(key) {
key = String(key || '');
if (key.indexOf(WORKSPACE_PREFIX) !== 0) return '';
try {
return cleanWorkspace(decodeURIComponent(key.slice(WORKSPACE_PREFIX.length)));
} catch (_) {
return '';
}
}
function rows(value) {
return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : [];
}
function normalizeRows(value, storageKey) {
return rows(value).filter(function (item) { return item.captureId; }).map(function (item) {
var workspaceRootPath = cleanWorkspace(item.workspaceRootPath || item.workspaceName) || workspaceFromKey(storageKey);
return Object.assign({}, item, {
workspaceRootPath: workspaceRootPath,
workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath),
processed: item.processed === true
});
});
}
function sortCaptures(items) {
var seen = {};
return items.filter(function (item) {
if (!item || !item.captureId || seen[item.captureId]) return false;
seen[item.captureId] = true;
return true;
}).sort(function (a, b) {
return String(b.capturedAt || b.receivedAt || '').localeCompare(String(a.capturedAt || a.receivedAt || ''));
});
}
function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
}
@ -2147,10 +2190,13 @@
function renderBrowserInbox(containerEl, props, api) {
injectStyles();
var rootPath = workspaceRoot(props || {});
var key = workspaceKey(rootPath);
var captures = [];
var selectedId = '';
var statusText = 'Ready for browser captures';
var workspaceOptions = [];
var statusFilter = 'all';
var workspaceFilter = '';
var searchQuery = '';
containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
@ -2158,14 +2204,49 @@
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser Inbox · ' + rootPath : 'Browser Inbox' });
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',
onChange: function (event) {
statusFilter = String(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',
onChange: function (event) {
workspaceFilter = cleanWorkspace(event.target.value);
selectedId = '';
render();
}
});
var searchInput = el('input', {
className: 'browser-inbox-input',
type: 'search',
placeholder: 'Search captures',
'data-browser-inbox-filter': 'search',
onInput: function (event) {
searchQuery = String(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 () {
captures = [];
selectedId = '';
persist().then(render);
clearScope().then(render);
}
});
var body = el('div', { className: 'browser-inbox-body' });
@ -2174,6 +2255,10 @@
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
filtersEl.appendChild(statusFilterEl);
if (!rootPath) filtersEl.appendChild(workspaceFilterEl);
filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl);
toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(clearBtn);
@ -2183,29 +2268,123 @@
root.appendChild(body);
containerEl.appendChild(root);
function option(value, label) {
return el('option', { value: value, textContent: label });
}
function workspaceRoots() {
var roots = workspaceOptions.slice();
captures.forEach(function (capture) {
var workspace = cleanWorkspace(capture.workspaceRootPath);
if (workspace && roots.indexOf(workspace) === -1) roots.push(workspace);
});
if (rootPath && roots.indexOf(rootPath) === -1) roots.push(rootPath);
return roots.sort(function (a, b) { return a.localeCompare(b); });
}
function renderWorkspaceFilterOptions() {
if (rootPath) return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
});
workspaceFilterEl.value = workspaceFilter;
}
function visibleCaptures() {
return captures.filter(function (capture) {
var workspace = cleanWorkspace(capture.workspaceRootPath);
if (rootPath && workspace !== rootPath) return false;
if (!rootPath && workspaceFilter && workspace !== workspaceFilter) return false;
if (statusFilter === 'unassigned' && workspace) return false;
if (statusFilter === 'unprocessed' && capture.processed === true) return false;
if (statusFilter === 'processed' && capture.processed !== true) return false;
if (!searchQuery) return true;
return [title(capture), capture.url, capture.domain, capture.text, workspace].join('\n').toLowerCase().indexOf(searchQuery) !== -1;
});
}
function readSettings() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve({});
return api.settings.read().then(function (settings) { return settings || {}; }).catch(function () { return {}; });
}
function capturesFromSettings(settings) {
var keys = [GLOBAL_KEY, LEGACY_KEY];
Object.keys(settings || {}).forEach(function (name) {
if (name.indexOf(WORKSPACE_PREFIX) === 0) keys.push(name);
});
var all = [];
keys.forEach(function (name) {
all = all.concat(normalizeRows((settings || {})[name], name));
});
return sortCaptures(all);
}
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 String(entry && entry.type || '').toLowerCase() === 'folder';
}).map(function (entry) {
return cleanWorkspace(entry.relativePath || entry.name);
}).filter(function (workspace) {
return workspace && workspace.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(key, captures.slice()).catch(function (err) {
return api.settings.write(GLOBAL_KEY, sortCaptures(captures)).catch(function (err) {
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
});
}
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 clearScope() {
var ids = (rootPath ? captures.filter(function (capture) {
return cleanWorkspace(capture.workspaceRootPath) === rootPath;
}) : captures).map(function (capture) { return capture.captureId; });
captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; });
selectedId = '';
statusText = rootPath ? 'Workspace captures cleared' : 'Inbox cleared';
return persist();
}
function removeCapture(captureId) {
captures = captures.filter(function (item) { return item.captureId !== captureId; });
selectedId = captures[0] ? captures[0].captureId : '';
statusText = 'Capture removed';
selectedId = '';
statusText = 'Capture deleted';
return persist().then(render);
}
function assignWorkspace(captureId, workspace) {
workspace = cleanWorkspace(workspace);
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, { workspaceRootPath: workspace, workspaceName: workspace });
});
if (workspace && workspaceOptions.indexOf(workspace) === -1) workspaceOptions.push(workspace);
statusText = workspace ? 'Capture assigned to ' + workspace : 'Capture is unassigned';
return persist().then(render);
}
function setProcessed(captureId, processed) {
captures = captures.map(function (capture) {
return capture.captureId === captureId ? Object.assign({}, capture, { processed: processed === true }) : capture;
});
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
return persist().then(render);
}
@ -2216,14 +2395,18 @@
function renderList() {
listEl.innerHTML = '';
if (captures.length === 0) {
var visible = visibleCaptures();
if (visible.length === 0) {
listEl.appendChild(el('div', {
className: 'browser-inbox-empty',
textContent: 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
textContent: captures.length === 0
? 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
: 'No captures match the current filters.'
}));
return;
}
captures.forEach(function (capture) {
visible.forEach(function (capture) {
var workspace = cleanWorkspace(capture.workspaceRootPath);
var row = el('div', {
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : ''),
'data-browser-capture-id': capture.captureId,
@ -2238,6 +2421,7 @@
]),
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId || '' })
]);
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: (workspace || 'Unassigned') + ' · ' + (capture.processed ? 'Processed' : 'Unprocessed') }));
if (capture.text) row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
listEl.appendChild(row);
});
@ -2260,28 +2444,73 @@
el('div', { className: 'browser-inbox-meta-label', textContent: 'Domain' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
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 assignment = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-assignment': capture.captureId,
onChange: function (event) { assignWorkspace(capture.captureId, event.target.value); }
});
assignment.appendChild(option('', 'Unassigned'));
workspaceRoots().forEach(function (workspace) {
assignment.appendChild(option(workspace, workspace));
});
assignment.value = capture.workspaceRootPath || '';
var assignmentRow = el('div', { className: 'browser-inbox-detail-actions' }, [assignment]);
if (capture.workspaceRootPath) {
assignmentRow.appendChild(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'clear-assignment',
textContent: 'Clear assignment',
onClick: function () { assignWorkspace(capture.captureId, ''); }
}));
}
detailEl.appendChild(assignmentRow);
if (capture.text) detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
if (capture.fileText) detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.fileText }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, [
el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-note', textContent: 'Create Note', onClick: function () { conversionAction('note', capture); } }),
el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-link', textContent: 'Create Link', onClick: function () { conversionAction('link', capture); } }),
el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-file', textContent: 'Create File', onClick: function () { conversionAction('file', capture); } }),
el('button', { className: 'browser-inbox-btn danger', 'data-browser-inbox-action': 'remove', textContent: 'Remove', onClick: function () { removeCapture(capture.captureId); } })
]));
var actionButtons = [
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', 'data-browser-inbox-action': 'create-note', textContent: 'Create Note', onClick: function () { conversionAction('note', capture); } }));
if (capture.url) actionButtons.push(el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-link', textContent: 'Create Link', onClick: function () { conversionAction('link', capture); } }));
if (capture.kind === 'file') actionButtons.push(el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-file', textContent: 'Create File', onClick: function () { conversionAction('file', capture); } }));
}
actionButtons.push(el('button', { className: 'browser-inbox-btn danger', 'data-browser-inbox-action': 'remove', textContent: 'Delete', onClick: function () { removeCapture(capture.captureId); } }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
}
function renderCount() {
var visible = visibleCaptures();
countEl.textContent = visible.length === captures.length
? captures.length + ' item' + (captures.length === 1 ? '' : 's')
: visible.length + ' of ' + captures.length + ' items';
clearBtn.disabled = rootPath
? !captures.some(function (capture) { return cleanWorkspace(capture.workspaceRootPath) === rootPath; })
: captures.length === 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;
renderList();
renderDetail();
}
readSettings().then(function (settings) {
captures = rows(settings[key]);
Promise.all([readSettings(), loadWorkspaceOptions()]).then(function (result) {
captures = capturesFromSettings(result[0]);
selectedId = captures[0] ? captures[0].captureId : '';
render();
});
@ -3238,11 +3467,11 @@
name: 'Browser Inbox',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped inbox for browser captures.',
description: 'Global browser capture queue with explicit workspace assignment.',
source: 'official',
icon: 'inbox',
provides: ['browser.inbox'],
permissions: ['events.subscribe', 'storage.namespace', 'ui.register'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],

View File

@ -197,6 +197,46 @@ func TestReceiverAnnotatesCaptureWithCurrentWorkspace(t *testing.T) {
}
}
func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus, func() string { return "" })
body := `{
"schemaVersion": 1,
"captureId": "capture-unassigned",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if _, ok := payload["workspaceRootPath"]; ok {
t.Fatalf("workspaceRootPath = %v, want absent for an unassigned capture", payload["workspaceRootPath"])
}
if _, ok := payload["workspaceName"]; ok {
t.Fatalf("workspaceName = %v, want absent for an unassigned capture", payload["workspaceName"])
}
}
func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)