From b4ef3b38b028299aaa102831c8a46904132a471b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 28 Jun 2026 03:35:35 +0800 Subject: [PATCH] Scope inbox and activity by workspace --- plugins/activity/frontend/src/index.js | 137 ++++++++++++++++-- plugins/browser-inbox/frontend/src/index.js | 147 ++++++++++++++++++-- scripts/smoke-activity-plugin.js | 44 ++++-- scripts/smoke-browser-inbox-plugin.js | 55 ++++++-- 4 files changed, 342 insertions(+), 41 deletions(-) diff --git a/plugins/activity/frontend/src/index.js b/plugins/activity/frontend/src/index.js index 5c9b83c..581883a 100644 --- a/plugins/activity/frontend/src/index.js +++ b/plugins/activity/frontend/src/index.js @@ -8,6 +8,8 @@ var PLUGIN_ID = 'verstak.activity'; var MAX_EVENTS = 250; + var GLOBAL_KEY = 'events:global'; + var WORKSPACE_PREFIX = 'events:workspace:'; var ACTIVITY_EVENTS = [ 'file.opened', 'file.changed', @@ -77,6 +79,41 @@ return String(value == null ? '' : value); } + function encodeKey(value) { + return encodeURIComponent(text(value).trim()); + } + + function cleanWorkspace(value) { + return text(value).trim().replace(/^\/+|\/+$/g, ''); + } + + function workspaceFromProps(props) { + var node = props && props.workspaceNode; + return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId)) + || (node && (node.rootPath || node.name || node.id))); + } + + function workspaceFromPayload(payload) { + var explicit = cleanWorkspace(payload && (payload.workspaceRootPath || payload.workspaceName || payload.workspaceNodeId)); + if (explicit) return explicit; + var path = cleanWorkspace(payload && payload.path); + if (!path || path.indexOf('/') === -1) return ''; + return cleanWorkspace(path.split('/')[0]); + } + + function scopeFromProps(props) { + var workspaceRoot = workspaceFromProps(props); + if (!workspaceRoot) { + return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' }; + } + return { + mode: 'workspace', + key: WORKSPACE_PREFIX + encodeKey(workspaceRoot), + label: workspaceRoot, + workspaceRoot: workspaceRoot + }; + } + function activityId() { return 'activity-' + Date.now() + '-' + Math.random().toString(36).slice(2); } @@ -117,8 +154,9 @@ ]); } - function eventToActivity(event) { + function eventToActivity(event, scope) { var payload = eventPayload(event); + var workspaceRoot = workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || ''; return { activityId: activityId(), type: text(event && event.name).trim() || 'activity.event', @@ -127,11 +165,12 @@ occurredAt: text(payload.occurredAt || payload.capturedAt || (event && event.timestamp) || new Date().toISOString()), receivedAt: new Date().toISOString(), sourcePluginId: text((event && event.pluginId) || payload.pluginId || payload.sourcePluginId), + workspaceRootPath: workspaceRoot, payload: payload }; } - function manualActivity() { + function manualActivity(scope) { return { activityId: activityId(), type: 'activity.manual', @@ -140,11 +179,12 @@ occurredAt: new Date().toISOString(), receivedAt: new Date().toISOString(), sourcePluginId: PLUGIN_ID, + workspaceRootPath: (scope && scope.workspaceRoot) || '', payload: {} }; } - function normalizeStoredEvents(value) { + function normalizeStoredEvents(value, storageKey) { if (!Array.isArray(value)) return []; return value.filter(function (item) { return item && typeof item === 'object' && item.activityId; @@ -157,11 +197,43 @@ occurredAt: text(item.occurredAt || item.timestamp || item.receivedAt), receivedAt: text(item.receivedAt), sourcePluginId: text(item.sourcePluginId || item.pluginId), + workspaceRootPath: cleanWorkspace(item.workspaceRootPath || (item.payload && (item.payload.workspaceRootPath || item.payload.workspaceName))), + _storageKey: storageKey || '', payload: item.payload && typeof item.payload === 'object' ? item.payload : {} }; }).slice(0, MAX_EVENTS); } + function storageEvents(activityList) { + return activityList.map(function (item) { + return { + activityId: item.activityId, + type: item.type, + title: item.title, + summary: item.summary, + occurredAt: item.occurredAt, + receivedAt: item.receivedAt, + sourcePluginId: item.sourcePluginId, + workspaceRootPath: item.workspaceRootPath, + payload: item.payload || {} + }; + }); + } + + function sortEvents(activityList) { + return activityList.slice().sort(function (a, b) { + return text(b.occurredAt || b.receivedAt).localeCompare(text(a.occurredAt || a.receivedAt)); + }).slice(0, MAX_EVENTS); + } + + function globalEventKeys(settings) { + var keys = [GLOBAL_KEY]; + Object.keys(settings || {}).forEach(function (key) { + if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key); + }); + return keys; + } + function formatDate(value) { if (!value) return ''; var date = new Date(value); @@ -177,6 +249,7 @@ containerEl.className = 'activity-root'; containerEl.setAttribute('data-plugin-id', PLUGIN_ID); + var scope = scopeFromProps(props || {}); var events = []; var statusText = 'Loading activity...'; var statusClass = ''; @@ -184,7 +257,7 @@ var unsubscribers = []; var toolbar = el('div', { className: 'activity-toolbar' }); - var titleEl = el('span', { className: 'activity-title', textContent: 'Activity' }); + var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? 'Activity' : 'Activity · ' + scope.label }); var countEl = el('span', { className: 'activity-count' }); var statusEl = el('span', { className: 'activity-status' }); var manualBtn = el('button', { @@ -192,7 +265,7 @@ 'data-activity-action': 'manual', textContent: 'Record', onClick: function () { - addActivity(manualActivity()); + addActivity(manualActivity(scope)); } }); var clearBtn = el('button', { @@ -200,6 +273,10 @@ 'data-activity-action': 'clear', textContent: 'Clear', onClick: function () { + if (scope.mode === 'global') { + clearGlobal().then(render); + return; + } events = []; persist().then(render); } @@ -217,14 +294,38 @@ function persist() { if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); - return api.settings.write('events', events).catch(function (err) { + var toStore = scope.mode === 'global' + ? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) + : events; + return api.settings.write(scope.key, storageEvents(toStore)).catch(function (err) { statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; }); } + function clearGlobal() { + if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') { + events = []; + return Promise.resolve(); + } + return api.settings.read().then(function (settings) { + var keys = globalEventKeys(settings || {}); + events = []; + return Promise.all(keys.map(function (key) { + return api.settings.write(key, []); + })); + }).then(function () { + statusText = 'Activity cleared'; + statusClass = ''; + }).catch(function (err) { + statusText = 'Could not clear activity: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + function addActivity(activity) { - events = [activity].concat(events).slice(0, MAX_EVENTS); + activity._storageKey = scope.key; + events = sortEvents([activity].concat(events)); statusText = 'Activity recorded'; statusClass = ''; return persist().then(render); @@ -264,8 +365,20 @@ function loadStored() { if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); - return api.settings.read('events').then(function (stored) { - events = normalizeStoredEvents(stored); + if (scope.mode === 'global') { + return api.settings.read().then(function (settings) { + var all = []; + globalEventKeys(settings || {}).forEach(function (key) { + all = all.concat(normalizeStoredEvents((settings || {})[key], key)); + }); + events = sortEvents(all); + }).catch(function (err) { + statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + return api.settings.read(scope.key).then(function (stored) { + events = normalizeStoredEvents(stored, scope.key); }).catch(function (err) { statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; @@ -276,12 +389,14 @@ if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve(); return Promise.all(ACTIVITY_EVENTS.map(function (eventName) { return api.events.subscribe(eventName, function (event) { - return addActivity(eventToActivity(event)); + var eventWorkspace = workspaceFromPayload(eventPayload(event)); + if (scope.mode === 'workspace' && eventWorkspace && eventWorkspace !== scope.workspaceRoot) return Promise.resolve(); + return addActivity(eventToActivity(event, scope)); }).then(function (unsubscribe) { if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); }); })).then(function () { - statusText = 'Listening for activity'; + statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity'; statusClass = ''; }).catch(function (err) { statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err)); diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index e4df8ed..de64a29 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -9,6 +9,8 @@ var PLUGIN_ID = 'verstak.browser-inbox'; var CAPTURE_EVENTS = ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link']; var MAX_CAPTURES = 100; + var GLOBAL_KEY = 'captures:global'; + var WORKSPACE_PREFIX = 'captures:workspace:'; function injectStyles() { if (document.getElementById('browser-inbox-style-injected')) return; @@ -76,6 +78,37 @@ return String(value == null ? '' : value); } + function encodeKey(value) { + return encodeURIComponent(text(value).trim()); + } + + function cleanWorkspace(value) { + return text(value).trim().replace(/^\/+|\/+$/g, ''); + } + + function workspaceFromProps(props) { + var node = props && props.workspaceNode; + return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId)) + || (node && (node.rootPath || node.name || node.id))); + } + + function workspaceFromPayload(payload) { + return cleanWorkspace(payload && (payload.workspaceRootPath || payload.workspaceName || payload.workspaceNodeId)); + } + + function scopeFromProps(props) { + var workspaceRoot = workspaceFromProps(props); + if (!workspaceRoot) { + return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' }; + } + return { + mode: 'workspace', + key: WORKSPACE_PREFIX + encodeKey(workspaceRoot), + label: workspaceRoot, + workspaceRoot: workspaceRoot + }; + } + function cleanKind(value) { value = text(value).trim(); return value === 'selection' || value === 'link' || value === 'page' ? value : 'page'; @@ -90,7 +123,7 @@ return event.payload; } - function captureFromEvent(event) { + function captureFromEvent(event, scope) { var payload = eventPayload(event); var captureId = text(payload.captureId).trim(); if (!captureId) { @@ -106,11 +139,12 @@ domain: text(payload.domain).trim(), text: text(payload.text).trim(), source: text(payload.source).trim(), - browserName: text(payload.browserName).trim() + browserName: text(payload.browserName).trim(), + workspaceRootPath: workspaceFromPayload(payload) || (scope && scope.workspaceRoot) || '' }; } - function normalizeStoredCaptures(value) { + function normalizeStoredCaptures(value, storageKey) { if (!Array.isArray(value)) return []; return value.filter(function (item) { return item && typeof item === 'object' && item.captureId; @@ -125,11 +159,45 @@ domain: text(item.domain), text: text(item.text), source: text(item.source), - browserName: text(item.browserName) + browserName: text(item.browserName), + workspaceRootPath: cleanWorkspace(item.workspaceRootPath), + _storageKey: storageKey || '' }; }).slice(0, MAX_CAPTURES); } + function storageCaptures(captureList) { + return captureList.map(function (item) { + return { + captureId: item.captureId, + capturedAt: item.capturedAt, + receivedAt: item.receivedAt, + kind: item.kind, + url: item.url, + title: item.title, + domain: item.domain, + text: item.text, + source: item.source, + browserName: item.browserName, + workspaceRootPath: item.workspaceRootPath + }; + }); + } + + function sortCaptures(captureList) { + return captureList.slice().sort(function (a, b) { + return text(b.capturedAt || b.receivedAt).localeCompare(text(a.capturedAt || a.receivedAt)); + }).slice(0, MAX_CAPTURES); + } + + function globalCaptureKeys(settings) { + var keys = [GLOBAL_KEY]; + Object.keys(settings || {}).forEach(function (key) { + if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key); + }); + return keys; + } + function formatDate(value) { if (!value) return ''; var date = new Date(value); @@ -145,6 +213,7 @@ containerEl.className = 'browser-inbox-root'; containerEl.setAttribute('data-plugin-id', PLUGIN_ID); + var scope = scopeFromProps(props || {}); var captures = []; var selectedId = ''; var statusText = 'Connecting to receiver events...'; @@ -153,7 +222,7 @@ var unsubscribers = []; var toolbar = el('div', { className: 'browser-inbox-toolbar' }); - var titleEl = el('span', { className: 'browser-inbox-title', textContent: 'Browser Inbox' }); + 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 clearBtn = el('button', { @@ -161,6 +230,10 @@ 'data-browser-inbox-action': 'clear', textContent: 'Clear', onClick: function () { + if (scope.mode === 'global') { + clearGlobal().then(render); + return; + } captures = []; selectedId = ''; persist().then(render); @@ -182,12 +255,37 @@ function persist() { if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); - return api.settings.write('captures', captures).catch(function (err) { + var toStore = scope.mode === 'global' + ? captures.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) + : captures; + return api.settings.write(scope.key, storageCaptures(toStore)).catch(function (err) { statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; }); } + function clearGlobal() { + if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') { + captures = []; + selectedId = ''; + return Promise.resolve(); + } + 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, []); + })); + }).then(function () { + statusText = 'Inbox cleared'; + statusClass = ''; + }).catch(function (err) { + statusText = 'Could not clear inbox: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + function selectedCapture() { for (var i = 0; i < captures.length; i += 1) { if (captures[i].captureId === selectedId) return captures[i]; @@ -200,7 +298,8 @@ return item.captureId === capture.captureId; }); if (existing) return Promise.resolve(); - captures = [capture].concat(captures).slice(0, MAX_CAPTURES); + capture._storageKey = scope.key; + captures = sortCaptures([capture].concat(captures)); selectedId = capture.captureId; statusText = 'Capture received'; statusClass = ''; @@ -212,7 +311,16 @@ return item.captureId !== captureId; }); if (selectedId === captureId) selectedId = captures[0] ? captures[0].captureId : ''; - return persist().then(render); + 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); } function renderList() { @@ -290,8 +398,21 @@ function loadStored() { if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); - return api.settings.read('captures').then(function (stored) { - captures = normalizeStoredCaptures(stored); + if (scope.mode === 'global') { + return api.settings.read().then(function (settings) { + 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(scope.key).then(function (stored) { + captures = normalizeStoredCaptures(stored, scope.key); if (!selectedId && captures[0]) selectedId = captures[0].captureId; }).catch(function (err) { statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err)); @@ -303,12 +424,14 @@ 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) { - return addCapture(captureFromEvent(event)); + var eventWorkspace = workspaceFromPayload(eventPayload(event)); + if (scope.mode === 'workspace' && eventWorkspace && eventWorkspace !== scope.workspaceRoot) return Promise.resolve(); + return addCapture(captureFromEvent(event, scope)); }).then(function (unsubscribe) { if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); }); })).then(function () { - statusText = 'Receiver ready'; + statusText = scope.mode === 'global' ? 'Receiver ready for all workspaces' : 'Receiver ready for workspace'; statusClass = ''; }).catch(function (err) { statusText = 'Receiver unavailable: ' + (err && err.message ? err.message : String(err)); diff --git a/scripts/smoke-activity-plugin.js b/scripts/smoke-activity-plugin.js index b107e48..7e5a4df 100644 --- a/scripts/smoke-activity-plugin.js +++ b/scripts/smoke-activity-plugin.js @@ -136,8 +136,8 @@ function makeApi(initialSettings = {}) { }; }, }, - storedEvents() { - return settings.events || []; + storedEvents(key = 'events') { + return settings[key] || []; }, }; } @@ -146,10 +146,10 @@ async function flush() { for (let i = 0; i < 10; i += 1) await Promise.resolve(); } -async function mountWithApi(api, document = makeDocument()) { +async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) { const component = loadComponent(document); const container = new FakeNode('div'); - component.mount(container, { workspaceNode: { name: 'Project' } }, api); + component.mount(container, props, api); await flush(); return { component, container, document }; } @@ -176,31 +176,57 @@ async function mountWithApi(api, document = makeDocument()) { }); await flush(); - const stored = api.storedEvents(); + const projectKey = 'events:workspace:Project'; + const clientKey = 'events:workspace:ClientA'; + const globalKey = 'events:global'; + const stored = api.storedEvents(projectKey); if (stored.length !== 1) throw new Error(`expected one stored activity event, got ${stored.length}`); if (stored[0].type !== 'browser.capture.selection') throw new Error('stored event type mismatch'); if (stored[0].sourcePluginId !== 'verstak.browser-inbox') throw new Error('stored event source plugin mismatch'); + if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage'); if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered'); if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered'); + const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' }); + if (clientView.container.textContent.includes('Example Article')) throw new Error('Project activity leaked into ClientA workspace view'); + await api.handlers['note.saved']({ + name: 'note.saved', + pluginId: 'verstak.notes', + timestamp: '2026-06-27T00:10:00Z', + payload: { + title: 'Client note', + path: 'ClientA/Notes/Client.md', + workspaceRootPath: 'ClientA', + }, + }); + await flush(); + if (api.storedEvents(clientKey).length !== 1) throw new Error('ClientA activity was not stored under ClientA workspace key'); + if (!clientView.container.textContent.includes('Client note')) throw new Error('ClientA activity was not rendered'); + component.unmount && component.unmount(clientView.container); + + const globalView = await mountWithApi(api, {}); + if (!globalView.container.textContent.includes('Example Article')) throw new Error('global activity did not aggregate Project activity'); + if (!globalView.container.textContent.includes('Client note')) throw new Error('global activity did not aggregate ClientA activity'); + component.unmount && component.unmount(globalView.container); + const manualButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'manual'); if (!manualButton) throw new Error('manual activity button not found'); manualButton.click(); await flush(); - if (api.storedEvents().length !== 2) throw new Error('manual activity was not stored'); + if (api.storedEvents(projectKey).length !== 2) throw new Error('manual activity was not stored'); if (!container.textContent.includes('Manual activity')) throw new Error('manual activity was not rendered'); const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear'); if (!clearButton) throw new Error('clear activity button not found'); clearButton.click(); await flush(); - if (api.storedEvents().length !== 0) throw new Error('clear action did not remove activity events'); + if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events'); component.unmount && component.unmount(container); - if (api.unsubscribed.length !== 9) throw new Error(`expected 9 unsubscribers, got ${api.unsubscribed.length}`); + if (api.unsubscribed.length !== 27) throw new Error(`expected 27 unsubscribers, got ${api.unsubscribed.length}`); const persistedApi = makeApi({ - events: [{ + 'events:workspace:Project': [{ activityId: 'persisted-1', type: 'note.saved', title: 'Saved note', diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index 4ed6b4d..37661f2 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -154,8 +154,8 @@ function makeApi(initialSettings = {}) { return { ...settings }; }, }, - getStoredCaptures() { - return settings.captures || []; + getStoredCaptures(key = 'captures') { + return settings[key] || []; }, }; } @@ -164,10 +164,10 @@ async function flush() { for (let i = 0; i < 8; i += 1) await Promise.resolve(); } -async function mountWithApi(api, document = makeDocument()) { +async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) { const component = loadComponent(document); const container = new FakeNode('div'); - component.mount(container, { workspaceNode: { name: 'Project' } }, api); + component.mount(container, props, api); await flush(); return { component, container, document }; } @@ -196,9 +196,13 @@ async function mountWithApi(api, document = makeDocument()) { }); await flush(); - const captures = api.getStoredCaptures(); + const projectKey = 'captures:workspace:Project'; + const clientKey = 'captures:workspace:ClientA'; + const globalKey = 'captures:global'; + const captures = api.getStoredCaptures(projectKey); 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'); 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'); @@ -220,18 +224,51 @@ async function mountWithApi(api, document = makeDocument()) { }, }); await flush(); - if (api.getStoredCaptures().length !== 1) throw new Error('duplicate capture was stored'); + if (api.getStoredCaptures(projectKey).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')) { + throw new Error('Project capture leaked into ClientA workspace view'); + } + await api.handlers['browser.capture.page']({ + name: 'browser.capture.page', + timestamp: '2026-06-27T00:10:00Z', + payload: { + captureId: 'capture-2', + capturedAt: '2026-06-27T00:10:00.000Z', + kind: 'page', + url: 'https://client.example.com/', + title: 'Client Page', + domain: 'client.example.com', + workspaceRootPath: 'ClientA', + }, + }); + await flush(); + if (api.getStoredCaptures(clientKey).length !== 1) throw new Error('ClientA capture was not stored under ClientA workspace key'); + if (!walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) { + throw new Error('ClientA capture was not rendered'); + } + component.unmount && component.unmount(clientView.container); + + const globalView = await mountWithApi(api, {}); + if (!walk(globalView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) { + throw new Error('global browser inbox did not aggregate Project capture'); + } + 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'); + } + 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().length !== 0) throw new Error('clear action did not empty stored captures'); + if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('clear action did not empty stored captures'); component.unmount && component.unmount(container); - if (api.unsubscribed.length !== 3) throw new Error('component did not unsubscribe all capture handlers'); + if (api.unsubscribed.length !== 9) throw new Error('component did not unsubscribe all capture handlers'); - const persistedApi = makeApi({ captures: [captures[0]] }); + const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] }); const persisted = await mountWithApi(persistedApi); if (!walk(persisted.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) { throw new Error('persisted capture was not rendered on mount');