From 8dafde4519a4a08d4f9ac965c59ceae39010124a Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 20:27:58 +0800 Subject: [PATCH] feat: scope overview browser captures by workspace --- frontend/e2e/browser-inbox.spec.js | 102 ++++++- frontend/e2e/ux-today.spec.js | 17 +- frontend/src/lib/shell/TodaySurface.svelte | 24 +- frontend/src/lib/test/wails-mock.js | 285 ++++++++++++++++-- .../core/browserreceiver/receiver_test.go | 40 +++ 5 files changed, 431 insertions(+), 37 deletions(-) diff --git a/frontend/e2e/browser-inbox.spec.js b/frontend/e2e/browser-inbox.spec.js index ceaf054..c92f4b3 100644 --- a/frontend/e2e/browser-inbox.spec.js +++ b/frontend/e2e/browser-inbox.spec.js @@ -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); + }); }); diff --git a/frontend/e2e/ux-today.spec.js b/frontend/e2e/ux-today.spec.js index fbf9cdb..f0d9591 100644 --- a/frontend/e2e/ux-today.spec.js +++ b/frontend/e2e/ux-today.spec.js @@ -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); diff --git a/frontend/src/lib/shell/TodaySurface.svelte b/frontend/src/lib/shell/TodaySurface.svelte index 33ce589..c4fecec 100644 --- a/frontend/src/lib/shell/TodaySurface.svelte +++ b/frontend/src/lib/shell/TodaySurface.svelte @@ -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', diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 9898925..d53455e 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -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' }], diff --git a/internal/core/browserreceiver/receiver_test.go b/internal/core/browserreceiver/receiver_test.go index 15a4c54..60c2342 100644 --- a/internal/core/browserreceiver/receiver_test.go +++ b/internal/core/browserreceiver/receiver_test.go @@ -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)