fix: scope overview to available tools

This commit is contained in:
mirivlad 2026-07-14 20:21:40 +08:00
parent 9d06642ca6
commit 47d38bcd4d
3 changed files with 134 additions and 42 deletions

View File

@ -80,6 +80,54 @@ test.describe('UX Overview workspace flow', () => {
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
});
test('Overview hides Browser Inbox cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New workspace"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser Inbox is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
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', {

View File

@ -30,6 +30,7 @@
let keyResources = [];
let totalNotes = 0;
let loadedWorkspaceRoot = '';
let loadedToolKey = '';
let toolProbe = 0;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
@ -38,17 +39,25 @@
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
}
$: hasNotes = hasTool('notes', availableTools);
$: hasFiles = hasTool('files', availableTools);
$: hasBrowserInbox = hasTool('browser-inbox', availableTools);
$: hasActivity = hasTool('activity', availableTools);
$: hasJournal = hasTool('journal', availableTools);
$: hasTodos = hasTool('todo', availableTools);
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
$: overviewToolKey = [hasNotes, hasFiles, hasBrowserInbox, hasActivity, hasJournal, hasTodos].join('|');
$: FILTERS = ['all']
.concat(hasNotes ? ['notes'] : [])
.concat(hasFiles ? ['files'] : [])
.concat(hasBrowserInbox ? ['captures'] : [])
.concat(hasJournal ? ['journal'] : [])
.map((key) => ({ key, label: tr(`overview.filter.${key}`) }));
$: if (!FILTERS.some(filter => filter.key === activeFilter)) activeFilter = 'all';
$: recentChanges = filterAvailableItems(buildRecentChanges(activityEvents, captures, journalEntries), overviewToolKey);
$: filteredRecentChanges = activeFilter === 'all'
? recentChanges
: recentChanges.filter(item => item.category === activeFilter);
@ -58,18 +67,19 @@
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: needsAttention = filterAvailableItems(buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos), overviewToolKey);
$: continueItems = filterAvailableItems(buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos), overviewToolKey);
$: hasAttentionTools = hasBrowserInbox || hasTodos || (hasActivity && hasJournal);
$: attentionActionKind = needsAttention[0]?.actionKind || fallbackAttentionAction();
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [
{ key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
{ key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
{ key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
{ key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
{ key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
{ key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
];
hasNotes ? { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') } : null,
hasFiles ? { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') } : null,
hasBrowserInbox ? { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') } : null,
hasActivity ? { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') } : null,
hasJournal ? { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') } : null,
hasAttentionTools ? { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') } : null,
].filter(Boolean);
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
@ -78,7 +88,7 @@
onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
$: if (workspaceRootPath && (workspaceRootPath !== loadedWorkspaceRoot || overviewToolKey !== loadedToolKey)) {
loadOverview();
}
@ -89,11 +99,29 @@
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name);
});
if (fromProps) return true;
if (typeof document === 'undefined') return false;
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
return String(tab.textContent || '').trim().toLowerCase().includes(name);
});
return fromProps;
}
function actionIsAvailable(kind) {
if (kind === 'notes') return hasNotes;
if (kind === 'files') return hasFiles;
if (kind === 'browser-inbox') return hasBrowserInbox;
if (kind === 'activity') return hasActivity;
if (kind === 'journal') return hasJournal;
if (kind === 'todo') return hasTodos;
return false;
}
function filterAvailableItems(items, _toolKey) {
void _toolKey;
return (items || []).filter(item => actionIsAvailable(item?.actionKind));
}
function fallbackAttentionAction() {
if (hasBrowserInbox) return 'browser-inbox';
if (hasTodos) return 'todo';
if (hasActivity && hasJournal) return 'journal';
return '';
}
function decodeTuple(response, fallback) {
@ -477,12 +505,12 @@
}
}
async function loadWorkspaceResources() {
async function loadWorkspaceResources(toolState) {
const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return { keyResources: [], totalNotes: 0 };
const [rootEntries, notesEntries] = await Promise.all([
listFiles('verstak.files', workspace),
listFiles('verstak.notes', `${workspace}/Notes`),
toolState.files ? listFiles('verstak.files', workspace) : Promise.resolve([]),
toolState.notes ? listFiles('verstak.notes', `${workspace}/Notes`) : Promise.resolve([]),
]);
const noteFiles = notesEntries.filter(item => item?.type === 'file');
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
@ -492,43 +520,53 @@
id: overview.relativePath || overview.name,
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || 'Workspace overview note',
actionKind: hasNotes ? 'notes' : 'files',
actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
actionKind: toolState.notes ? 'notes' : 'files',
actionLabel: toolState.notes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [],
};
}
async function loadOverview() {
const workspaceAtStart = String(workspaceRootPath || '').trim();
const toolKeyAtStart = overviewToolKey;
const toolState = {
notes: hasNotes,
files: hasFiles,
browserInbox: hasBrowserInbox,
activity: hasActivity,
journal: hasJournal,
todos: hasTodos,
};
loadedWorkspaceRoot = workspaceAtStart;
loadedToolKey = toolKeyAtStart;
loading = true;
const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'),
App.ReadPluginDataNDJSON
toolState.browserInbox ? readPluginSettings('verstak.browser-inbox') : Promise.resolve({}),
toolState.activity ? readPluginSettings('verstak.activity') : Promise.resolve({}),
toolState.activity && App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, []))
.catch(() => [])
: Promise.resolve([]),
readPluginSettings('verstak.journal'),
readPluginSettings('verstak.todo'),
loadWorkspaceResources(),
toolState.journal ? readPluginSettings('verstak.journal') : Promise.resolve({}),
toolState.todos ? readPluginSettings('verstak.todo') : Promise.resolve({}),
loadWorkspaceResources(toolState),
]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
if (workspaceAtStart !== String(workspaceRootPath || '').trim() || toolKeyAtStart !== overviewToolKey) return;
captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = normalizeRows(activityRecords).filter(item => {
captures = toolState.browserInbox ? browserCaptureRowsForWorkspace(browserSettings) : [];
activityEvents = toolState.activity ? normalizeRows(activityRecords).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
return !tagged || tagged === workspaceAtStart;
});
journalEntries = rowsFor(journalSettings, [
}) : [];
journalEntries = toolState.journal ? rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'),
'worklog',
]);
todos = todoRowsForWorkspace(todoSettings);
workSessionCandidates = rowsFor(activitySettings, [
]) : [];
todos = toolState.todos ? todoRowsForWorkspace(todoSettings) : [];
workSessionCandidates = toolState.activity && toolState.journal ? rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'),
]);
]) : [];
keyResources = resources.keyResources;
totalNotes = resources.totalNotes;
loading = false;
@ -657,7 +695,7 @@
</main>
<aside class="overview-side">
{#if needsAttention.length || !loading}
{#if hasAttentionTools && (needsAttention.length || !loading)}
<section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head">
<div>

View File

@ -80,11 +80,13 @@
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
});
function toolKey(tool) {
@ -170,6 +172,10 @@
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() {
try {
toolsLoaded = false;