Improve workspace UX flows

This commit is contained in:
mirivlad 2026-06-30 17:38:18 +08:00
parent 46f754cc2d
commit 268e79d2f0
12 changed files with 1173 additions and 33 deletions

View File

@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Activity workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace activity explains empty event flow', async ({ page }) => {
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity).toBeVisible({ timeout: 10000 });
await expect(activity.locator('.activity-title')).toContainText('Activity');
await expect(activity.locator('.activity-count')).toHaveText('0 events');
await expect(activity.locator('.activity-empty')).toContainText('No activity events yet');
await expect(activity.locator('.activity-empty')).toContainText('File changes, browser captures, and conversions will appear here');
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
});
test('workspace activity renders stored events and worklog suggestions', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'activity-e2e-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
summary: 'Selected text from the article',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Saved note',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('2 events');
await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture');
await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note');
await expect(activity.locator('[data-activity-section="worklog-suggestions"]')).toContainText('Worklog suggestions');
await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('Project work on 2026-06-30');
await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('50 min');
});
});

View File

@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser Inbox workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace inbox explains empty capture flow and primary actions', async ({ page }) => {
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toContainText('Browser Inbox');
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-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': [{
captureId: 'capture-e2e-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'file',
url: 'https://example.com/report',
title: 'Research Report',
domain: 'example.com',
text: 'Selected page text',
fileName: 'report.txt',
fileText: 'Report file contents',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
await expect(inbox.locator('[data-browser-capture-id="capture-e2e-1"]')).toContainText('Research Report');
await expect(inbox.locator('.browser-inbox-detail-title')).toHaveText('Research Report');
await expect(inbox.locator('.browser-inbox-meta')).toContainText('example.com');
await expect(inbox.locator('.browser-inbox-text').first()).toContainText('Selected page text');
await expect(inbox.locator('[data-browser-inbox-action="create-note"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-link"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
});
});

View File

@ -45,4 +45,83 @@ test.describe('Command Palette', () => {
await expect(page.locator('.command-palette')).not.toBeVisible(); await expect(page.locator('.command-palette')).not.toBeVisible();
await expect(page.locator('.workspace-host')).toBeVisible(); await expect(page.locator('.workspace-host')).toBeVisible();
}); });
test('shows user workflow commands before diagnostics and opens workspace tools', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
const items = palette.locator('.command-palette-item');
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-today');
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
await expect(items.nth(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
await palette.locator('[data-command-palette-input]').fill('activity');
await expect(palette.locator('[data-command-id="verstak.shell.open-activity"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
});
test('starts file creation workflows from user commands', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('markdown');
await expect(palette.locator('[data-command-id="verstak.shell.create-markdown"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Note.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Note.md"]')).toBeVisible();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('text file');
await expect(palette.locator('[data-command-id="verstak.shell.create-text"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Text.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Text.txt"]')).toBeVisible();
});
test('runs sync workflow commands', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret');
if (err) throw new Error(err);
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await expect(palette.locator('[data-command-id="verstak.shell.sync-now"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Sync Now');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync settings');
await expect(palette.locator('[data-command-id="verstak.shell.open-sync-settings"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('.plugin-manager')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync');
});
}); });

View File

@ -1,6 +1,11 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js'; import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
async function openFilesTool(page) {
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
}
test.describe('G: Files Plugin', () => { test.describe('G: Files Plugin', () => {
let consoleCollector; let consoleCollector;
@ -29,6 +34,7 @@ test.describe('G: Files Plugin', () => {
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => { test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 }); await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
@ -42,6 +48,7 @@ test.describe('G: Files Plugin', () => {
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => { test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click(); await page.locator('[data-files-action="new-folder"]').click();
@ -90,6 +97,7 @@ test.describe('G: Files Plugin', () => {
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => { test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) { for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) {
@ -108,6 +116,7 @@ test.describe('G: Files Plugin', () => {
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => { test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click(); await page.locator('[data-files-action="new-folder"]').click();
@ -132,6 +141,7 @@ test.describe('G: Files Plugin', () => {
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => { test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click(); await page.locator('[data-files-action="new-folder"]').click();
@ -166,6 +176,7 @@ test.describe('G: Files Plugin', () => {
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => { test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick(); await page.locator('[data-file-name="Notes"]').dblclick();
@ -191,6 +202,7 @@ test.describe('G: Files Plugin', () => {
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => { test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick(); await page.locator('[data-file-name="Notes"]').dblclick();

View File

@ -84,15 +84,21 @@ test.describe('E: Plugin Manager layout', () => {
await expect(selected).toHaveText('Test'); await expect(selected).toHaveText('Test');
}); });
test('workspace tools render as tabs with Files as one tab', async ({ page }) => { test('workspace tools render Today first with Files as one tab', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const tabs = page.locator('.workspace-tabs'); const tabs = page.locator('.workspace-tabs');
await expect(tabs).toBeVisible({ timeout: 10000 }); await expect(tabs).toBeVisible({ timeout: 10000 });
const todayTab = tabs.locator('[role="tab"]').filter({ hasText: 'Today' });
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' }); const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
await expect(todayTab).toBeVisible();
await expect(todayTab).toHaveAttribute('aria-selected', 'true');
await expect(filesTab).toBeVisible(); await expect(filesTab).toBeVisible();
await expect(filesTab).toHaveAttribute('aria-selected', 'true'); await expect(filesTab).toHaveAttribute('aria-selected', 'false');
await expect(page.locator('.workspace-tool')).toHaveCount(0); await expect(page.locator('.workspace-tool')).toHaveCount(0);
await expect(page.locator('.today-root')).toBeVisible();
await filesTab.click();
await expect(page.locator('.files-root')).toBeVisible(); await expect(page.locator('.files-root')).toBeVisible();
}); });

View File

@ -58,6 +58,7 @@ test.describe('UX quick wins', () => {
await page.goto('/'); await page.goto('/');
await waitForAppReady(page); await waitForAppReady(page);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
const files = page.locator('.files-root'); const files = page.locator('.files-root');
await expect(files).toBeVisible({ timeout: 10000 }); await expect(files).toBeVisible({ timeout: 10000 });

View File

@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('UX Today workspace flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace opens with Today before plugin tools', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
const tabs = page.getByRole('tab');
await expect(tabs.nth(0)).toHaveText('Today');
await expect(tabs.nth(1)).toHaveText('Files');
await expect(page.getByRole('tab', { name: 'Today' })).toHaveAttribute('aria-selected', 'true');
const today = page.locator('.today-root');
await expect(today).toBeVisible();
await expect(today.locator('[data-today-section="captured"]')).toContainText('Captured');
await expect(today.locator('[data-today-section="activity"]')).toContainText('Recent Activity');
await expect(today.locator('[data-today-section="worklog"]')).toContainText('Worklog Suggestions');
await expect(today.locator('[data-today-section="quick-actions"]')).toContainText('Quick Actions');
await expect(today).toContainText('No browser captures yet');
await expect(today).toContainText('No activity events yet');
});
test('Today quick action opens Browser Inbox workspace tool', async ({ page }) => {
await page.locator('[data-today-action="browser-inbox"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
});
});

View File

@ -25,6 +25,7 @@
let workspaceNodes = []; let workspaceNodes = [];
let selectedWorkspaceName = ''; let selectedWorkspaceName = '';
let activeWorkspaceToolKey = '';
let navigationStack = []; let navigationStack = [];
let navigationIndex = -1; let navigationIndex = -1;
let applyingNavigation = false; let applyingNavigation = false;
@ -113,6 +114,7 @@
activeSettingsPanelId, activeSettingsPanelId,
openedResource, openedResource,
selectedWorkspaceName, selectedWorkspaceName,
activeWorkspaceToolKey,
}; };
} }
@ -139,6 +141,7 @@
activeSettingsPanelId = snapshot.activeSettingsPanelId; activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource; openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName; selectedWorkspaceName = snapshot.selectedWorkspaceName;
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : ''); emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
applyingNavigation = false; applyingNavigation = false;
} }
@ -272,11 +275,17 @@
function onWorkbenchOpened(e) { function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId); debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
if (currentView === 'workspace') pushNavigation();
openedResource = e.detail; openedResource = e.detail;
currentView = 'workbench'; currentView = 'workbench';
pushNavigation(); pushNavigation();
} }
function onWorkspaceToolSelected(e) {
activeWorkspaceToolKey = e.detail?.toolKey || '';
if (currentView === 'workspace') pushNavigation();
}
function onWorkspaceSelected(e) { function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName); debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
selectedWorkspaceName = e.detail?.workspaceName || ''; selectedWorkspaceName = e.detail?.workspaceName || '';
@ -351,6 +360,7 @@
window.addEventListener('verstak:close-settings', onCloseSettings); window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened); window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected); window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
window.addEventListener('verstak:navigate-back', onNavigateBack); window.addEventListener('verstak:navigate-back', onNavigateBack);
window.addEventListener('verstak:navigate-forward', onNavigateForward); window.addEventListener('verstak:navigate-forward', onNavigateForward);
window.addEventListener('verstak:close-workbench', onCloseWorkbench); window.addEventListener('verstak:close-workbench', onCloseWorkbench);
@ -385,7 +395,11 @@
{:else if currentView === 'workbench'} {:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} /> <WorkbenchHost {openedResource} />
{:else if currentView === 'workspace' || currentView === 'workspace-empty'} {:else if currentView === 'workspace' || currentView === 'workspace-empty'}
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} /> <WorkspaceHost
selectedWorkspaceName={selectedWorkspaceName}
nodes={workspaceNodes}
bind:activeToolKey={activeWorkspaceToolKey}
/>
{:else} {:else}
<ViewContainer {activeView} {activeViewPluginId} /> <ViewContainer {activeView} {activeViewPluginId} />
{/if} {/if}

View File

@ -12,6 +12,17 @@
let statusType = ''; let statusType = '';
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']); const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
const shellCommands = [
{ id: 'verstak.shell.open-today', title: 'Open Today', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'today' },
{ id: 'verstak.shell.open-files', title: 'Open Files', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
{ id: 'verstak.shell.open-activity', title: 'Open Activity', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
{ id: 'verstak.shell.open-browser-inbox', title: 'Open Browser Inbox', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
{ id: 'verstak.shell.create-markdown', title: 'Create Markdown File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
{ id: 'verstak.shell.create-text', title: 'Create Text File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
{ id: 'verstak.shell.sync-now', title: 'Sync Now', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
{ id: 'verstak.shell.open-sync-settings', title: 'Open Sync Settings', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
{ id: 'verstak.shell.open-plugin-manager', title: 'Open Plugin Manager', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
];
$: normalizedQuery = query.trim().toLowerCase(); $: normalizedQuery = query.trim().toLowerCase();
$: filteredCommands = commands.filter((command) => { $: filteredCommands = commands.filter((command) => {
@ -33,7 +44,7 @@
App.GetContributions().catch(() => ({})), App.GetContributions().catch(() => ({})),
]); ]);
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin])); const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
commands = (contributions.commands || []) const pluginCommands = (contributions.commands || [])
.filter((command) => { .filter((command) => {
const plugin = pluginById.get(command.pluginId); const plugin = pluginById.get(command.pluginId);
if (!plugin) return false; if (!plugin) return false;
@ -44,9 +55,12 @@
return { return {
...command, ...command,
pluginName: plugin?.manifest?.name || command.pluginId, pluginName: plugin?.manifest?.name || command.pluginId,
priority: 1000,
}; };
}) });
.sort((a, b) => { commands = [...shellCommands, ...pluginCommands].sort((a, b) => {
const priority = (a.priority || 1000) - (b.priority || 1000);
if (priority) return priority;
const title = String(a.title || a.id).localeCompare(String(b.title || b.id)); const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
if (title) return title; if (title) return title;
return String(a.pluginId).localeCompare(String(b.pluginId)); return String(a.pluginId).localeCompare(String(b.pluginId));
@ -78,9 +92,87 @@
}, 4000); }, 4000);
} }
function clickWorkspaceTool(label) {
const tabs = Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]'));
const tab = tabs.find((node) => String(node.textContent || '').trim().toLowerCase() === label);
if (tab) {
tab.click();
return true;
}
return false;
}
async function openWorkspaceTool(label) {
if (clickWorkspaceTool(label)) return;
const selectedWorkspace = document.querySelector('.wt-node.selected .wt-label');
const firstWorkspace = document.querySelector('.wt-label');
const workspaceButton = selectedWorkspace || firstWorkspace;
if (workspaceButton) {
workspaceButton.click();
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
clickWorkspaceTool(label);
}
async function startFilesCreate(action) {
await openWorkspaceTool('files');
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
const button = document.querySelector(`[data-files-action="${action}"]`);
if (!button) {
throw new Error(`Files action not available: ${action}`);
}
button.click();
}
async function runShellCommand(command) {
if (command.shellAction === 'plugin-manager') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: {} }));
return;
}
if (command.shellAction === 'sync-settings') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: 'verstak.sync', panelId: 'verstak.sync.settings' }
}));
return;
}
if (command.shellAction === 'sync-now') {
const result = await App.PluginSyncNow('verstak.sync');
if (typeof result === 'string' && result) {
throw new Error(result);
}
if (Array.isArray(result) && result[1]) {
throw new Error(result[1]);
}
return;
}
if (command.shellAction === 'create-markdown') {
await startFilesCreate('new-markdown');
return;
}
if (command.shellAction === 'create-text') {
await startFilesCreate('new-text');
return;
}
const actionToTab = {
today: 'today',
files: 'files',
activity: 'activity',
'browser-inbox': 'browser inbox',
};
await openWorkspaceTool(actionToTab[command.shellAction] || command.shellAction);
}
async function runCommand(command) { async function runCommand(command) {
if (!command) return; if (!command) return;
try { try {
if (command.shellAction) {
await runShellCommand(command);
closePalette();
setStatus('success', `${command.title || command.id} handled`);
return;
}
const result = await executePluginCommand(command.pluginId, command.id, { const result = await executePluginCommand(command.pluginId, command.id, {
source: 'command-palette', source: 'command-palette',
}); });

View File

@ -0,0 +1,327 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
export let workspaceRootPath = '';
export let workspaceTitle = '';
export let availableTools = [];
const dispatch = createEventDispatcher();
let loading = true;
let captures = [];
let activity = [];
let worklogSuggestions = [];
$: hasBrowserInbox = hasTool('browser inbox') || hasTool('inbox');
$: hasActivity = hasTool('activity');
$: hasFiles = hasTool('files');
onMount(() => {
loadToday();
});
function hasTool(name) {
name = String(name || '').toLowerCase();
return (availableTools || []).some(tool => {
const label = String(tool?.title || tool?.id || tool?.pluginId || '').toLowerCase();
return label.includes(name);
});
}
function decodeTuple(response, fallback) {
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : (response[0] || fallback);
return response || fallback;
}
async function readPluginSettings(pluginId) {
try {
return decodeTuple(await App.ReadPluginSettings(pluginId), {});
} catch (_) {
return {};
}
}
function workspaceKey(prefix) {
return prefix + encodeURIComponent(String(workspaceRootPath || '').trim());
}
function normalizeRows(value) {
return Array.isArray(value) ? value.filter(item => item && typeof item === 'object') : [];
}
function rowsFor(settings, keys) {
return keys.flatMap(key => normalizeRows(settings?.[key]));
}
function captureTitle(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
}
function activityTitle(item) {
return item.title || item.summary || item.type || item.activityId || 'Activity event';
}
function worklogTitle(item) {
return item.title || item.summary || item.date || item.entryId || 'Worklog item';
}
async function loadToday() {
loading = true;
const [browserSettings, activitySettings, journalSettings] = await Promise.all([
readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'),
readPluginSettings('verstak.journal'),
]);
captures = rowsFor(browserSettings, [
workspaceKey('captures:workspace:'),
'captures:global',
'captures',
]).slice(0, 4);
activity = rowsFor(activitySettings, [
workspaceKey('events:workspace:'),
'events:global',
'events',
]).slice(0, 4);
worklogSuggestions = rowsFor(journalSettings, [
workspaceKey('suggestions:workspace:'),
workspaceKey('worklog:workspace:'),
'suggestions',
'worklog',
]).slice(0, 4);
loading = false;
}
function openTool(kind) {
dispatch('openTool', { kind });
}
</script>
<div class="today-root" aria-label="Today">
<div class="today-header">
<div>
<h2>Today</h2>
<p>{workspaceTitle || workspaceRootPath || 'Workspace'} overview</p>
</div>
<button type="button" on:click={loadToday}>Refresh</button>
</div>
<div class="today-grid">
<section class="today-panel" data-today-section="captured">
<div class="today-panel-head">
<h3>Captured</h3>
<button type="button" data-today-action="browser-inbox" on:click={() => openTool('browser-inbox')}>Open Inbox</button>
</div>
{#if loading}
<p class="today-empty">Loading captures...</p>
{:else if captures.length}
<div class="today-list">
{#each captures as capture}
<div class="today-row">
<strong>{captureTitle(capture)}</strong>
<span>{capture.url || capture.domain || capture.kind || 'Browser capture'}</span>
</div>
{/each}
</div>
{:else}
<p class="today-empty">No browser captures yet. Send a page, selection, link, or file from the browser extension.</p>
{/if}
</section>
<section class="today-panel" data-today-section="activity">
<div class="today-panel-head">
<h3>Recent Activity</h3>
{#if hasActivity}
<button type="button" data-today-action="activity" on:click={() => openTool('activity')}>Open Activity</button>
{/if}
</div>
{#if loading}
<p class="today-empty">Loading activity...</p>
{:else if activity.length}
<div class="today-list">
{#each activity as item}
<div class="today-row">
<strong>{activityTitle(item)}</strong>
<span>{item.occurredAt || item.receivedAt || item.type || 'Activity event'}</span>
</div>
{/each}
</div>
{:else}
<p class="today-empty">No activity events yet. File changes, captures, and conversions will appear here.</p>
{/if}
</section>
<section class="today-panel" data-today-section="worklog">
<div class="today-panel-head">
<h3>Worklog Suggestions</h3>
</div>
{#if loading}
<p class="today-empty">Loading worklog suggestions...</p>
{:else if worklogSuggestions.length}
<div class="today-list">
{#each worklogSuggestions as item}
<div class="today-row">
<strong>{worklogTitle(item)}</strong>
<span>{item.minutes ? item.minutes + ' min' : item.date || 'Suggested worklog'}</span>
</div>
{/each}
</div>
{:else}
<p class="today-empty">No worklog suggestions yet. Activity will be grouped into suggestions once work starts.</p>
{/if}
</section>
<section class="today-panel" data-today-section="quick-actions">
<div class="today-panel-head">
<h3>Quick Actions</h3>
</div>
<div class="today-actions">
{#if hasFiles}
<button type="button" data-today-action="files" on:click={() => openTool('files')}>Open Files</button>
{/if}
<button type="button" on:click={() => openTool('browser-inbox')}>Process Captures</button>
{#if hasActivity}
<button type="button" on:click={() => openTool('activity')}>Review Activity</button>
{/if}
</div>
</section>
</div>
</div>
<style>
.today-root {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: #101020;
color: #e0e0f0;
overflow: auto;
}
.today-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid #16213e;
background: #12122a;
}
.today-header h2 {
margin: 0;
font-size: 1.05rem;
}
.today-header p {
margin: 0.25rem 0 0;
color: #8b8ba8;
font-size: 0.8rem;
}
.today-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
padding: 0.75rem;
}
.today-panel {
min-width: 0;
min-height: 10rem;
display: flex;
flex-direction: column;
border: 1px solid #16213e;
border-radius: 8px;
background: #15152c;
}
.today-panel-head {
min-height: 2.8rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid rgba(22, 33, 62, 0.8);
}
.today-panel h3 {
margin: 0;
color: #f0f0ff;
font-size: 0.9rem;
}
.today-panel-head button,
.today-actions button,
.today-header button {
min-height: 1.85rem;
padding: 0.3rem 0.65rem;
font-size: 0.76rem;
}
.today-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 1rem;
color: #8b8ba8;
font-size: 0.82rem;
line-height: 1.45;
text-align: center;
}
.today-list {
display: grid;
gap: 0.45rem;
padding: 0.65rem;
}
.today-row {
min-width: 0;
display: grid;
gap: 0.2rem;
padding: 0.55rem;
border: 1px solid rgba(78, 204, 163, 0.16);
border-radius: 6px;
background: #101827;
}
.today-row strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f4f7fb;
font-size: 0.85rem;
}
.today-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #8b8ba8;
font-size: 0.75rem;
}
.today-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
}
@media (max-width: 860px) {
.today-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,14 +1,17 @@
<script> <script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte'; import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import TodaySurface from './TodaySurface.svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
export let selectedWorkspaceName = ''; export let selectedWorkspaceName = '';
export let nodes = []; export let nodes = [];
export let activeToolKey = '';
let contributions = {}; let contributions = {};
let plugins = []; let plugins = [];
let workspaceTools = []; let workspaceTools = [];
let activeToolKey = ''; let toolsLoaded = false;
const todayTool = { id: '__today', title: 'Today', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
const toolOrder = new Map([ const toolOrder = new Map([
['notes', 10], ['notes', 10],
@ -23,9 +26,10 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace'; $: workspaceType = selectedWorkspace?.type || 'workspace';
$: activeTool = workspaceTools.find(tool => toolKey(tool) === activeToolKey) || workspaceTools[0] || null; $: displayedTools = selectedWorkspace ? [todayTool, ...workspaceTools] : [];
$: if (workspaceTools.length > 0 && (!activeToolKey || !workspaceTools.some(tool => toolKey(tool) === activeToolKey))) { $: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
activeToolKey = toolKey(workspaceTools[0]); $: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
activeToolKey = toolKey(todayTool);
} }
$: if (selectedWorkspaceName) loadTools(); $: if (selectedWorkspaceName) loadTools();
@ -49,8 +53,30 @@
}); });
} }
function selectTool(tool) {
activeToolKey = toolKey(tool);
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
detail: {
toolKey: activeToolKey,
toolId: tool?.id || '',
pluginId: tool?.pluginId || '',
},
}));
}
function openWorkspaceTool(event) {
const kind = String(event?.detail?.kind || '').toLowerCase();
const match = workspaceTools.find(tool => {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
if (kind === 'browser-inbox') return text.includes('browser') || text.includes('inbox');
return text.includes(kind);
});
if (match) selectTool(match);
}
async function loadTools() { async function loadTools() {
try { try {
toolsLoaded = false;
const [c, p] = await Promise.all([ const [c, p] = await Promise.all([
App.GetContributions().catch(() => ({})), App.GetContributions().catch(() => ({})),
App.GetPlugins().catch(() => []), App.GetPlugins().catch(() => []),
@ -65,6 +91,8 @@
workspaceTools = sortWorkspaceTools((contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId))); workspaceTools = sortWorkspaceTools((contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId)));
} catch (e) { } catch (e) {
console.error('[WorkspaceHost] loadTools error:', e); console.error('[WorkspaceHost] loadTools error:', e);
} finally {
toolsLoaded = true;
} }
} }
</script> </script>
@ -78,16 +106,16 @@
</div> </div>
</div> </div>
{#if workspaceTools.length > 0} {#if displayedTools.length > 0}
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools"> <div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
{#each workspaceTools as tool (tool.id + tool.pluginId)} {#each displayedTools as tool (tool.id + tool.pluginId)}
<button <button
class:active={toolKey(tool) === toolKey(activeTool)} class:active={toolKey(tool) === toolKey(activeTool)}
role="tab" role="tab"
aria-selected={toolKey(tool) === toolKey(activeTool)} aria-selected={toolKey(tool) === toolKey(activeTool)}
type="button" type="button"
title={tool.pluginId} title={tool.pluginId}
on:click={() => activeToolKey = toolKey(tool)} on:click={() => selectTool(tool)}
> >
{tool.title || tool.id} {tool.title || tool.id}
</button> </button>
@ -95,12 +123,21 @@
</div> </div>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}> <div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
{#if activeTool} {#if activeTool}
{#if activeTool.shell}
<TodaySurface
{workspaceRootPath}
{workspaceTitle}
availableTools={workspaceTools}
on:openTool={openWorkspaceTool}
/>
{:else}
<PluginBundleHost <PluginBundleHost
pluginId={activeTool.pluginId} pluginId={activeTool.pluginId}
componentId={activeTool.component} componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }} componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
/> />
{/if} {/if}
{/if}
</div> </div>
{:else} {:else}
<div class="workspace-empty"> <div class="workspace-empty">

View File

@ -1002,21 +1002,424 @@
} }
function activityBundle() { function activityBundle() {
return [ return '(' + function () {
"(function(){", var PLUGIN_ID = 'verstak.activity';
"var ActivityView={mount:function(containerEl){containerEl.innerHTML='';var root=document.createElement('div');root.className='activity-root';root.setAttribute('data-plugin-id','verstak.activity');var title=document.createElement('h2');title.textContent='Activity';var body=document.createElement('div');body.textContent='Global activity feed';root.appendChild(title);root.appendChild(body);containerEl.appendChild(root);},unmount:function(containerEl){containerEl.innerHTML='';}};",
"window.VerstakPluginRegister('verstak.activity',{components:{ActivityView:ActivityView}});", function injectStyles() {
"})();" if (document.getElementById('mock-activity-style')) return;
var style = document.createElement('style');
style.id = 'mock-activity-style';
style.textContent = [
'.activity-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0f0}',
'.activity-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}',
'.activity-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.activity-count,.activity-status{font-size:.74rem;color:#8b8ba8}.activity-spacer{flex:1}',
'.activity-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.activity-btn:hover{background:#1a4a7a}.activity-btn:disabled{opacity:.45;cursor:default}.activity-btn.danger{border-color:#633;color:#ffb0b0}',
'.activity-suggestions{display:grid;gap:.5rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.75);background:#111126}.activity-suggestions-title{font-size:.76rem;font-weight:600;color:#8b8ba8;text-transform:uppercase}.activity-suggestion{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;padding:.55rem .65rem;border:1px solid rgba(78,204,163,.24);border-radius:4px;background:#14142c}.activity-suggestion-title{font-size:.84rem;font-weight:600;color:#f4f7fb}.activity-suggestion-summary{margin-top:.22rem;color:#aaa;font-size:.76rem;line-height:1.4}.activity-suggestion-minutes{color:#4ecca3;font-size:.76rem;white-space:nowrap}',
'.activity-list{flex:1;min-height:0;overflow:auto;background:#101020}.activity-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}.activity-row{display:grid;grid-template-columns:9.5rem minmax(0,1fr);gap:.75rem;padding:.72rem .85rem;border-bottom:1px solid rgba(22,33,62,.7)}.activity-time{font-size:.72rem;color:#777;white-space:nowrap}.activity-main{min-width:0}.activity-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}.activity-type{font-size:.68rem;color:#4ecca3;text-transform:uppercase;letter-spacing:.04em}.activity-title-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e0e0f0;font-size:.86rem}.activity-summary{margin-top:.25rem;color:#aaa;font-size:.78rem;line-height:1.4}.activity-source{margin-top:.25rem;color:#777;font-size:.72rem}',
'@media(max-width:760px){.activity-row,.activity-suggestion{grid-template-columns:1fr;gap:.25rem}.activity-status{width:100%}}'
].join(''); ].join('');
document.head.appendChild(style);
}
function el(tag, attrs, children) {
var node = document.createElement(tag);
attrs = attrs || {};
Object.keys(attrs).forEach(function (key) {
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.indexOf('data-') === 0) node.setAttribute(key, attrs[key]);
else if (key === 'onClick') node.addEventListener('click', attrs[key]);
else node[key] = attrs[key];
});
(children || []).forEach(function (child) {
if (child) node.appendChild(child);
});
return node;
}
function workspaceRoot(props) {
return String(
props.workspaceRootPath ||
(props.workspaceNode && (props.workspaceNode.rootPath || props.workspaceNode.name || props.workspaceNode.id)) ||
props.workspaceName ||
''
).trim();
}
function workspaceKey(root) {
return 'events:workspace:' + encodeURIComponent(root || '');
}
function rows(value) {
return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : [];
}
function eventTime(value) {
var date = new Date(value || 0);
return isNaN(date.getTime()) ? 0 : date.getTime();
}
function eventDay(activity) {
var value = activity.occurredAt || activity.receivedAt || new Date().toISOString();
return String(value).slice(0, 10);
}
function formatDate(value) {
var date = new Date(value || 0);
if (isNaN(date.getTime())) return '-';
return date.toLocaleString(undefined, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function title(activity) {
return activity.title || activity.summary || activity.type || activity.activityId || 'Activity event';
}
function summarize(group) {
return group.map(title).filter(Boolean).slice(0, 3).join('; ') || group.length + ' activity events';
}
function suggestions(events, root) {
var groups = {};
events.forEach(function (activity) {
var workspace = activity.workspaceRootPath || root || 'Global';
var day = eventDay(activity);
var key = workspace + '|' + day;
groups[key] = groups[key] || { workspaceRootPath: workspace, date: day, events: [] };
groups[key].events.push(activity);
});
return Object.keys(groups).map(function (key) {
var group = groups[key];
var ordered = group.events.slice().sort(function (a, b) { return eventTime(a.occurredAt || a.receivedAt) - eventTime(b.occurredAt || b.receivedAt); });
return {
suggestionId: 'worklog:' + group.workspaceRootPath + ':' + group.date,
title: group.workspaceRootPath + ' work on ' + group.date,
summary: summarize(ordered),
minutes: Math.max(25, ordered.length * 25)
};
}).sort(function (a, b) { return b.suggestionId.localeCompare(a.suggestionId); });
}
function renderActivity(containerEl, props, api) {
injectStyles();
var rootPath = workspaceRoot(props || {});
var key = workspaceKey(rootPath);
var events = [];
var statusText = 'Listening for workspace activity';
containerEl.innerHTML = '';
var root = el('div', { className: 'activity-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'activity-toolbar' });
var titleEl = el('span', { className: 'activity-title', textContent: rootPath ? 'Activity · ' + rootPath : 'Activity' });
var countEl = el('span', { className: 'activity-count' });
var statusEl = el('span', { className: 'activity-status' });
var clearBtn = el('button', {
className: 'activity-btn danger',
'data-activity-action': 'clear',
textContent: 'Clear',
onClick: function () {
events = [];
persist().then(render);
}
});
var suggestionsEl = el('div', { className: 'activity-suggestions', 'data-activity-section': 'worklog-suggestions' });
var listEl = el('div', { className: 'activity-list' });
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
toolbar.appendChild(el('span', { className: 'activity-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(clearBtn);
root.appendChild(toolbar);
root.appendChild(suggestionsEl);
root.appendChild(listEl);
containerEl.appendChild(root);
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 persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(key, events.slice()).catch(function (err) {
statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err));
});
}
function renderSuggestions() {
suggestionsEl.innerHTML = '';
var items = suggestions(events, rootPath);
if (!items.length) return;
suggestionsEl.appendChild(el('div', { className: 'activity-suggestions-title', textContent: 'Worklog suggestions' }));
items.forEach(function (item) {
suggestionsEl.appendChild(el('div', { className: 'activity-suggestion', 'data-worklog-suggestion': item.suggestionId }, [
el('div', {}, [
el('div', { className: 'activity-suggestion-title', textContent: item.title }),
el('div', { className: 'activity-suggestion-summary', textContent: item.summary })
]),
el('div', { className: 'activity-suggestion-minutes', textContent: item.minutes + ' min' })
]));
});
}
function renderList() {
listEl.innerHTML = '';
if (!events.length) {
listEl.appendChild(el('div', {
className: 'activity-empty',
textContent: 'No activity events yet. File changes, browser captures, and conversions will appear here.'
}));
return;
}
events.slice().sort(function (a, b) { return eventTime(b.occurredAt || b.receivedAt) - eventTime(a.occurredAt || a.receivedAt); }).forEach(function (activity) {
listEl.appendChild(el('div', { className: 'activity-row', 'data-activity-id': activity.activityId }, [
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt || activity.receivedAt) }),
el('div', { className: 'activity-main' }, [
el('div', { className: 'activity-row-head' }, [
el('span', { className: 'activity-type', textContent: activity.type || 'activity.event' }),
el('span', { className: 'activity-title-text', textContent: title(activity) })
]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
activity.sourcePluginId ? el('div', { className: 'activity-source', textContent: activity.sourcePluginId }) : null
])
]));
});
}
function render() {
countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's');
clearBtn.disabled = events.length === 0;
statusEl.textContent = statusText;
renderSuggestions();
renderList();
}
readSettings().then(function (settings) {
events = rows(settings[key]);
render();
});
render();
}
var ActivityView = {
mount: renderActivity,
unmount: function (containerEl) { containerEl.innerHTML = ''; }
};
window.VerstakPluginRegister('verstak.activity', { components: { ActivityView: ActivityView } });
}.toString() + ')();';
} }
function browserInboxBundle() { function browserInboxBundle() {
return [ return '(' + function () {
"(function(){", var PLUGIN_ID = 'verstak.browser-inbox';
"var BrowserInboxView={mount:function(containerEl){containerEl.innerHTML='';var root=document.createElement('div');root.className='browser-inbox-root';root.setAttribute('data-plugin-id','verstak.browser-inbox');var title=document.createElement('h2');title.textContent='Browser Inbox';var body=document.createElement('div');body.textContent='Global browser inbox';root.appendChild(title);root.appendChild(body);containerEl.appendChild(root);},unmount:function(containerEl){containerEl.innerHTML='';}};",
"window.VerstakPluginRegister('verstak.browser-inbox',{components:{BrowserInboxView:BrowserInboxView}});", function injectStyles() {
"})();" if (document.getElementById('mock-browser-inbox-style')) return;
var style = document.createElement('style');
style.id = 'mock-browser-inbox-style';
style.textContent = [
'.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-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}',
'.browser-inbox-row{display:grid;gap:.25rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.75);cursor:pointer}.browser-inbox-row:hover{background:#17172d}.browser-inbox-row.selected{background:#1a2a3a}.browser-inbox-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}.browser-inbox-kind{color:#4ecca3;font-size:.68rem;text-transform:uppercase}.browser-inbox-row-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.86rem}.browser-inbox-row-url,.browser-inbox-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;color:#8b8ba8;font-size:.74rem}',
'.browser-inbox-detail-title{font-size:1rem;font-weight:600;color:#f4f7fb;overflow-wrap:anywhere}.browser-inbox-meta{display:grid;grid-template-columns:7rem minmax(0,1fr);gap:.35rem .75rem;font-size:.78rem}.browser-inbox-meta-label{color:#777}.browser-inbox-meta-value{color:#ccc;overflow-wrap:anywhere}.browser-inbox-text{padding:.75rem;border:1px solid #24304f;border-radius:6px;background:#101020;color:#ddd;font-size:.84rem;line-height:1.5;white-space:pre-wrap}.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
'@media(max-width:760px){.browser-inbox-body{grid-template-columns:1fr}.browser-inbox-list{border-right:0;border-bottom:1px solid #16213e;max-height:45vh}.browser-inbox-meta{grid-template-columns:1fr}}'
].join(''); ].join('');
document.head.appendChild(style);
}
function el(tag, attrs, children) {
var node = document.createElement(tag);
attrs = attrs || {};
Object.keys(attrs).forEach(function (key) {
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.indexOf('data-') === 0) node.setAttribute(key, attrs[key]);
else if (key === 'onClick') node.addEventListener('click', attrs[key]);
else node[key] = attrs[key];
});
(children || []).forEach(function (child) {
if (child) node.appendChild(child);
});
return node;
}
function workspaceRoot(props) {
return String(
props.workspaceRootPath ||
(props.workspaceNode && (props.workspaceNode.rootPath || props.workspaceNode.name || props.workspaceNode.id)) ||
props.workspaceName ||
''
).trim();
}
function workspaceKey(root) {
return 'captures:workspace:' + encodeURIComponent(root || '');
}
function rows(value) {
return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : [];
}
function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
}
function renderBrowserInbox(containerEl, props, api) {
injectStyles();
var rootPath = workspaceRoot(props || {});
var key = workspaceKey(rootPath);
var captures = [];
var selectedId = '';
var statusText = 'Ready for browser captures';
containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
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 clearBtn = el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'clear',
textContent: 'Clear',
onClick: function () {
captures = [];
selectedId = '';
persist().then(render);
}
});
var body = el('div', { className: 'browser-inbox-body' });
var listEl = el('div', { className: 'browser-inbox-list' });
var detailEl = el('div', { className: 'browser-inbox-detail' });
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(clearBtn);
body.appendChild(listEl);
body.appendChild(detailEl);
root.appendChild(toolbar);
root.appendChild(body);
containerEl.appendChild(root);
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 persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(key, captures.slice()).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];
}
return captures[0] || null;
}
function removeCapture(captureId) {
captures = captures.filter(function (item) { return item.captureId !== captureId; });
selectedId = captures[0] ? captures[0].captureId : '';
statusText = 'Capture removed';
return persist().then(render);
}
function conversionAction(kind, capture) {
statusText = 'Ready to create ' + kind + ': ' + title(capture);
render();
}
function renderList() {
listEl.innerHTML = '';
if (captures.length === 0) {
listEl.appendChild(el('div', {
className: 'browser-inbox-empty',
textContent: 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
}));
return;
}
captures.forEach(function (capture) {
var row = el('div', {
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : ''),
'data-browser-capture-id': capture.captureId,
onClick: function () {
selectedId = capture.captureId;
render();
}
}, [
el('div', { className: 'browser-inbox-row-head' }, [
el('span', { className: 'browser-inbox-kind', textContent: capture.kind || 'capture' }),
el('span', { className: 'browser-inbox-row-title', textContent: title(capture) })
]),
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId || '' })
]);
if (capture.text) row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
listEl.appendChild(row);
});
}
function renderDetail() {
detailEl.innerHTML = '';
var capture = selectedCapture();
if (!capture) {
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: 'Select a capture to inspect it.' }));
return;
}
selectedId = capture.captureId;
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: title(capture) }));
detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [
el('div', { className: 'browser-inbox-meta-label', textContent: 'Kind' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }),
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 || '-' })
]));
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); } })
]));
}
function render() {
countEl.textContent = captures.length + ' item' + (captures.length === 1 ? '' : 's');
clearBtn.disabled = captures.length === 0;
statusEl.textContent = statusText;
renderList();
renderDetail();
}
readSettings().then(function (settings) {
captures = rows(settings[key]);
selectedId = captures[0] ? captures[0].captureId : '';
render();
});
render();
}
var BrowserInboxView = {
mount: renderBrowserInbox,
unmount: function (containerEl) { containerEl.innerHTML = ''; }
};
window.VerstakPluginRegister('verstak.browser-inbox', { components: { BrowserInboxView: BrowserInboxView } });
}.toString() + ')();';
} }
function platformTestBundle() { function platformTestBundle() {