feat: replace Today workspace surface with Overview
This commit is contained in:
parent
5b4c792de5
commit
b845e8b5e2
|
|
@ -52,7 +52,7 @@ test.describe('Command Palette', () => {
|
||||||
const palette = page.locator('.command-palette');
|
const palette = page.locator('.command-palette');
|
||||||
await expect(palette).toBeVisible();
|
await expect(palette).toBeVisible();
|
||||||
const items = palette.locator('.command-palette-item');
|
const items = palette.locator('.command-palette-item');
|
||||||
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-today');
|
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-overview');
|
||||||
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
|
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(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
|
||||||
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
|
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
|
||||||
|
|
|
||||||
|
|
@ -99,19 +99,19 @@ test.describe('E: Plugin Manager layout', () => {
|
||||||
await expect(selected).toHaveText('Test');
|
await expect(selected).toHaveText('Test');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workspace tools render Today first with Files as one tab', async ({ page }) => {
|
test('workspace tools render Overview 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 overviewTab = tabs.locator('[role="tab"]').filter({ hasText: 'Overview' });
|
||||||
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
|
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
|
||||||
await expect(todayTab).toBeVisible();
|
await expect(overviewTab).toBeVisible();
|
||||||
await expect(todayTab).toHaveAttribute('aria-selected', 'true');
|
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
|
||||||
await expect(filesTab).toBeVisible();
|
await expect(filesTab).toBeVisible();
|
||||||
await expect(filesTab).toHaveAttribute('aria-selected', 'false');
|
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 expect(page.locator('[data-overview-root]')).toBeVisible();
|
||||||
|
|
||||||
await filesTab.click();
|
await filesTab.click();
|
||||||
await expect(page.locator('.files-root')).toBeVisible();
|
await expect(page.locator('.files-root')).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ test.describe('UX follow-up fixes', () => {
|
||||||
expect(workspaceBox.width).toBeGreaterThan(340);
|
expect(workspaceBox.width).toBeGreaterThan(340);
|
||||||
expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1);
|
expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1);
|
||||||
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
|
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'Today' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||||
|
|
||||||
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
|
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
|
||||||
expect(hasHorizontalOverflow).toBe(false);
|
expect(hasHorizontalOverflow).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
test.describe('UX Today workspace flow', () => {
|
test.describe('UX Overview workspace flow', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|
@ -15,37 +15,40 @@ test.describe('UX Today workspace flow', () => {
|
||||||
consoleCollector.assertNoErrors();
|
consoleCollector.assertNoErrors();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workspace opens with Today before plugin tools', async ({ page }) => {
|
test('workspace opens with Overview before plugin tools', async ({ page }) => {
|
||||||
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const tabs = page.getByRole('tab');
|
const tabs = page.getByRole('tab');
|
||||||
await expect(tabs.nth(0)).toHaveText('Today');
|
await expect(tabs.nth(0)).toHaveText('Overview');
|
||||||
await expect(tabs.nth(1)).toHaveText('Files');
|
await expect(tabs.nth(1)).toHaveText('Files');
|
||||||
await expect(page.getByRole('tab', { name: 'Today' })).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Today' })).toHaveCount(0);
|
||||||
|
|
||||||
const today = page.locator('.today-root');
|
const overview = page.locator('[data-overview-root]');
|
||||||
await expect(today).toBeVisible();
|
await expect(overview).toBeVisible();
|
||||||
await expect(today.locator('[data-today-section="captured"]')).toContainText('Captured');
|
await expect(overview.locator('[data-overview-section="continue"]')).toContainText('Continue working');
|
||||||
await expect(today.locator('[data-today-section="activity"]')).toContainText('Recent Activity');
|
await expect(overview.locator('[data-overview-section="recent"]')).toContainText('Recent changes');
|
||||||
await expect(today.locator('[data-today-section="worklog"]')).toContainText('Worklog Suggestions');
|
await expect(overview.locator('[data-overview-section="attention"]')).toContainText('Needs attention');
|
||||||
await expect(today.locator('[data-today-section="quick-actions"]')).toContainText('Quick Actions');
|
await expect(overview.locator('[data-overview-section="quick-actions"]')).toContainText('Quick actions');
|
||||||
await expect(today).toContainText('No browser captures yet');
|
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('recent changes');
|
||||||
await expect(today).toContainText('No activity events yet');
|
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('unprocessed captures');
|
||||||
|
await expect(overview).toContainText('No clear resume point yet');
|
||||||
|
await expect(overview).toContainText('No meaningful changes for this filter yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Today quick action opens Browser Inbox workspace tool', async ({ page }) => {
|
test('Overview quick action opens Browser Inbox workspace tool', async ({ page }) => {
|
||||||
await page.locator('[data-today-action="browser-inbox"]').click();
|
await page.locator('[data-overview-action="browser-inbox"]').click();
|
||||||
|
|
||||||
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
|
||||||
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Today summarizes available work and highlights the next resume item', async ({ page }) => {
|
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
'captures:workspace:Project': [
|
'captures:workspace:Project': [
|
||||||
{
|
{
|
||||||
captureId: 'today-capture-1',
|
captureId: 'overview-capture-1',
|
||||||
capturedAt: '2026-06-30T08:00:00.000Z',
|
capturedAt: '2026-06-30T08:00:00.000Z',
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
url: 'https://example.com/research',
|
url: 'https://example.com/research',
|
||||||
|
|
@ -54,7 +57,7 @@ test.describe('UX Today workspace flow', () => {
|
||||||
workspaceRootPath: 'Project',
|
workspaceRootPath: 'Project',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
captureId: 'today-capture-2',
|
captureId: 'overview-capture-2',
|
||||||
capturedAt: '2026-06-30T08:15:00.000Z',
|
capturedAt: '2026-06-30T08:15:00.000Z',
|
||||||
kind: 'selection',
|
kind: 'selection',
|
||||||
title: 'Quote to process',
|
title: 'Quote to process',
|
||||||
|
|
@ -66,41 +69,108 @@ test.describe('UX Today workspace flow', () => {
|
||||||
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
'events:workspace:Project': [
|
'events:workspace:Project': [
|
||||||
{
|
{
|
||||||
activityId: 'today-activity-1',
|
activityId: 'overview-selected-file',
|
||||||
|
occurredAt: '2026-06-30T08:50:00.000Z',
|
||||||
|
type: 'file.selected',
|
||||||
|
title: 'Selected file',
|
||||||
|
summary: 'Project/draft.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-opened-file',
|
||||||
|
occurredAt: '2026-06-30T08:40:00.000Z',
|
||||||
|
type: 'file.opened',
|
||||||
|
title: 'draft.md',
|
||||||
|
summary: 'Project/draft.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-note',
|
||||||
occurredAt: '2026-06-30T08:25:00.000Z',
|
occurredAt: '2026-06-30T08:25:00.000Z',
|
||||||
type: 'note.saved',
|
type: 'note.saved',
|
||||||
title: 'Saved research note',
|
title: 'Overview',
|
||||||
summary: 'Project/Notes/Research.md',
|
summary: 'Project/Notes/Overview.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-file',
|
||||||
|
occurredAt: '2026-06-30T08:20:00.000Z',
|
||||||
|
type: 'file.changed',
|
||||||
|
title: 'draft.md',
|
||||||
|
summary: 'Project/draft.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-workspace-selected',
|
||||||
|
occurredAt: '2026-06-30T08:10:00.000Z',
|
||||||
|
type: 'case.selected',
|
||||||
|
title: 'Workspace selected',
|
||||||
workspaceRootPath: 'Project',
|
workspaceRootPath: 'Project',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await window.go.api.App.WritePluginSettings('verstak.journal', {
|
await window.go.api.App.WritePluginSettings('verstak.journal', {
|
||||||
'suggestions:workspace:Project': [
|
'worklog:workspace:Project': [
|
||||||
{
|
{
|
||||||
entryId: 'today-worklog-1',
|
entryId: 'overview-journal-1',
|
||||||
|
date: '2026-06-30',
|
||||||
title: 'Write project summary',
|
title: 'Write project summary',
|
||||||
summary: 'Turn recent captures into a worklog entry',
|
summary: 'Turn recent captures into a worklog entry',
|
||||||
minutes: 35,
|
minutes: 35,
|
||||||
workspaceRootPath: 'Project',
|
workspaceRootPath: 'Project',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'suggestions:workspace:Project': [
|
||||||
|
{
|
||||||
|
suggestionId: 'overview-suggestion-1',
|
||||||
|
date: '2026-06-30',
|
||||||
|
title: 'Project work on 2026-06-30',
|
||||||
|
minutes: 50,
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('.today-header').getByRole('button', { name: 'Refresh' }).click();
|
await page.locator('[data-overview-action="refresh"]').click();
|
||||||
|
|
||||||
const today = page.locator('.today-root');
|
const overview = page.locator('[data-overview-root]');
|
||||||
await expect(today.locator('[data-today-summary="captures"]')).toContainText('2');
|
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1');
|
||||||
await expect(today.locator('[data-today-summary="activity"]')).toContainText('1');
|
await expect(overview.locator('[data-overview-summary="files"]')).toContainText('1');
|
||||||
await expect(today.locator('[data-today-summary="worklog"]')).toContainText('1');
|
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('2');
|
||||||
|
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1');
|
||||||
|
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
|
||||||
|
|
||||||
const resume = today.locator('[data-today-section="resume"]');
|
const resume = overview.locator('[data-overview-section="continue"]');
|
||||||
await expect(resume).toContainText('Resume next');
|
await expect(resume).toContainText('Opened file "draft.md"');
|
||||||
await expect(resume).toContainText('Research Report');
|
await expect(resume).not.toContainText('Research Report');
|
||||||
await resume.locator('[data-today-action="resume-primary"]').click();
|
await resume.locator('[data-overview-action="continue-primary"]').click();
|
||||||
|
|
||||||
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
|
||||||
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
const recent = overview.locator('[data-overview-section="recent"]');
|
||||||
|
await expect(recent).toContainText('Edited note "Overview"');
|
||||||
|
await expect(recent).toContainText('Changed file "draft.md"');
|
||||||
|
await expect(recent).toContainText('Captured page "Research Report"');
|
||||||
|
await expect(recent).toContainText('Added journal entry "Write project summary"');
|
||||||
|
await expect(recent).not.toContainText('Selected file');
|
||||||
|
await expect(recent).not.toContainText('Workspace selected');
|
||||||
|
await expect(recent).not.toContainText('file.opened');
|
||||||
|
|
||||||
|
await overview.locator('[data-overview-filter="notes"]').click();
|
||||||
|
await expect(recent).toContainText('Edited note "Overview"');
|
||||||
|
await expect(recent).not.toContainText('Changed file "draft.md"');
|
||||||
|
await expect(recent).not.toContainText('Research Report');
|
||||||
|
|
||||||
|
await overview.locator('[data-overview-filter="captures"]').click();
|
||||||
|
await expect(recent).toContainText('Captured page "Research Report"');
|
||||||
|
await expect(recent).toContainText('Captured selection "Quote to process"');
|
||||||
|
await expect(recent).not.toContainText('Edited note "Overview"');
|
||||||
|
|
||||||
|
await overview.locator('[data-overview-filter="journal"]').click();
|
||||||
|
await expect(recent).toContainText('Added journal entry "Write project summary"');
|
||||||
|
await expect(recent).not.toContainText('Changed file "draft.md"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
const shellCommands = [
|
const shellCommands = [
|
||||||
{ id: 'verstak.shell.open-today', title: 'Open Today', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'today' },
|
{ id: 'verstak.shell.open-overview', title: 'Open Overview', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
|
||||||
{ id: 'verstak.shell.open-files', title: 'Open Files', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
|
{ 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-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.open-browser-inbox', title: 'Open Browser Inbox', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
|
||||||
|
|
@ -156,7 +156,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const actionToTab = {
|
const actionToTab = {
|
||||||
today: 'today',
|
overview: 'overview',
|
||||||
files: 'files',
|
files: 'files',
|
||||||
activity: 'activity',
|
activity: 'activity',
|
||||||
'browser-inbox': 'browser inbox',
|
'browser-inbox': 'browser inbox',
|
||||||
|
|
|
||||||
|
|
@ -3,36 +3,79 @@
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
export let workspaceRootPath = '';
|
export let workspaceRootPath = '';
|
||||||
export let workspaceTitle = '';
|
|
||||||
export let availableTools = [];
|
export let availableTools = [];
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
const FILTERS = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'files', label: 'Files' },
|
||||||
|
{ key: 'captures', label: 'Captures' },
|
||||||
|
{ key: 'journal', label: 'Journal' },
|
||||||
|
];
|
||||||
|
const LOW_VALUE_RECENT_TYPES = new Set([
|
||||||
|
'workspace.selected',
|
||||||
|
'case.selected',
|
||||||
|
'file.selected',
|
||||||
|
'file.opened',
|
||||||
|
'note.opened',
|
||||||
|
]);
|
||||||
|
const LOW_VALUE_RESUME_TYPES = new Set([
|
||||||
|
'workspace.selected',
|
||||||
|
'case.selected',
|
||||||
|
'file.selected',
|
||||||
|
]);
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
let activeFilter = 'all';
|
||||||
let captures = [];
|
let captures = [];
|
||||||
let activity = [];
|
let activityEvents = [];
|
||||||
|
let journalEntries = [];
|
||||||
let worklogSuggestions = [];
|
let worklogSuggestions = [];
|
||||||
|
let keyResources = [];
|
||||||
|
let loadedWorkspaceRoot = '';
|
||||||
|
let toolProbe = 0;
|
||||||
|
|
||||||
|
$: hasNotes = hasTool('notes');
|
||||||
$: hasBrowserInbox = hasTool('browser inbox') || hasTool('inbox');
|
$: hasBrowserInbox = hasTool('browser inbox') || hasTool('inbox');
|
||||||
$: hasActivity = hasTool('activity');
|
$: hasActivity = hasTool('activity');
|
||||||
$: hasFiles = hasTool('files');
|
$: hasFiles = hasTool('files');
|
||||||
|
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
|
||||||
|
$: filteredRecentChanges = activeFilter === 'all'
|
||||||
|
? recentChanges
|
||||||
|
: recentChanges.filter(item => item.category === activeFilter);
|
||||||
|
$: needsAttention = buildNeedsAttention(captures, worklogSuggestions);
|
||||||
|
$: continueItems = buildContinueItems(activityEvents, captures);
|
||||||
|
$: primaryContinue = continueItems[0] || null;
|
||||||
|
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries);
|
||||||
$: summaryItems = [
|
$: summaryItems = [
|
||||||
{ key: 'captures', label: 'Captured', count: captures.length },
|
{ key: 'notes', label: 'Notes', count: countCategory(recentChanges, 'notes'), detail: countLabel(countCategory(recentChanges, 'notes'), 'recent change') },
|
||||||
{ key: 'activity', label: 'Activity', count: activity.length },
|
{ key: 'files', label: 'Files', count: countCategory(recentChanges, 'files'), detail: countLabel(countCategory(recentChanges, 'files'), 'recent change') },
|
||||||
{ key: 'worklog', label: 'Worklog', count: worklogSuggestions.length },
|
{ key: 'captures', label: 'Captures', count: captures.length, detail: countLabel(captures.length, 'unprocessed capture') },
|
||||||
|
{ key: 'journal', label: 'Journal / Activity', count: countCategory(recentChanges, 'journal'), detail: countLabel(countCategory(recentChanges, 'journal'), 'entry or event') },
|
||||||
|
{ key: 'attention', label: 'Needs attention', count: needsAttention.length, detail: countLabel(needsAttention.length, 'pending item') },
|
||||||
];
|
];
|
||||||
$: resumeItem = nextResumeItem(captures, worklogSuggestions, activity, hasActivity);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadToday();
|
toolProbe += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
|
||||||
function hasTool(name) {
|
function hasTool(name) {
|
||||||
|
toolProbe;
|
||||||
name = String(name || '').toLowerCase();
|
name = String(name || '').toLowerCase();
|
||||||
return (availableTools || []).some(tool => {
|
const fromProps = (availableTools || []).some(tool => {
|
||||||
const label = String(tool?.title || tool?.id || tool?.pluginId || '').toLowerCase();
|
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
|
||||||
return label.includes(name);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeTuple(response, fallback) {
|
function decodeTuple(response, fallback) {
|
||||||
|
|
@ -57,97 +100,307 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowsFor(settings, keys) {
|
function rowsFor(settings, keys) {
|
||||||
return keys.flatMap(key => normalizeRows(settings?.[key]));
|
const workspace = String(workspaceRootPath || '').trim();
|
||||||
|
return keys.flatMap(key => normalizeRows(settings?.[key])).filter(item => {
|
||||||
|
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
|
||||||
|
return !tagged || !workspace || tagged === workspace;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeValue(item) {
|
||||||
|
return item?.capturedAt || item?.occurredAt || item?.receivedAt || item?.updatedAt || item?.modifiedAt || item?.date || item?.time || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeMs(item) {
|
||||||
|
const value = timeValue(item);
|
||||||
|
if (!value) return 0;
|
||||||
|
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(String(value)) ? `${value}T00:00:00` : value;
|
||||||
|
const date = new Date(normalized);
|
||||||
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByTime(rows) {
|
||||||
|
return [...rows].sort((a, b) => timeMs(b) - timeMs(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileName(path) {
|
||||||
|
const value = String(path || '').split('/').filter(Boolean).pop() || '';
|
||||||
|
return value || String(path || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromPath(path) {
|
||||||
|
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoted(value) {
|
||||||
|
const clean = String(value || '').trim();
|
||||||
|
return clean ? `"${clean}"` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityName(item) {
|
||||||
|
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
|
||||||
|
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
|
||||||
|
const title = String(item?.title || payload.title || '').trim();
|
||||||
|
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
|
||||||
|
return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikePath(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
return text.includes('/') || /\.[a-z0-9]{1,8}$/i.test(text) ? text : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureTitle(capture) {
|
function captureTitle(capture) {
|
||||||
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
|
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalTitle(entry) {
|
||||||
|
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTimeLabel(item) {
|
||||||
|
const value = timeValue(item);
|
||||||
|
if (!value) return 'No timestamp';
|
||||||
|
return relativeTime(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteTime(value) {
|
||||||
|
const ms = timeMs({ occurredAt: value });
|
||||||
|
if (!ms) return '';
|
||||||
|
return new Date(ms).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(value) {
|
||||||
|
const ms = timeMs({ occurredAt: value });
|
||||||
|
if (!ms) return 'No timestamp';
|
||||||
|
const diff = Date.now() - ms;
|
||||||
|
if (diff < 0) return absoluteTime(value);
|
||||||
|
const minute = 60 * 1000;
|
||||||
|
const hour = 60 * minute;
|
||||||
|
const day = 24 * hour;
|
||||||
|
if (diff < minute) return 'Just now';
|
||||||
|
if (diff < hour) return `${Math.floor(diff / minute)} min ago`;
|
||||||
|
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
||||||
|
if (diff < 2 * day) return 'Yesterday';
|
||||||
|
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`;
|
||||||
|
const date = new Date(ms);
|
||||||
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityCategory(item) {
|
||||||
|
const type = String(item?.type || '').toLowerCase();
|
||||||
|
if (type.startsWith('note.')) return 'notes';
|
||||||
|
if (type.startsWith('file.')) return 'files';
|
||||||
|
if (type.startsWith('browser.capture')) return 'captures';
|
||||||
|
if (type.startsWith('journal.') || type.startsWith('worklog.') || type.startsWith('action.')) return 'journal';
|
||||||
|
return 'activity';
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityTitle(item) {
|
function activityTitle(item) {
|
||||||
return item.title || item.summary || humanActivityType(item.type) || 'Activity event';
|
const type = String(item?.type || '').toLowerCase();
|
||||||
|
const name = entityName(item);
|
||||||
|
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
|
||||||
|
if (type === 'note.opened') return `Opened note ${quoted(name)}`;
|
||||||
|
if (type === 'note.created') return `Created note ${quoted(name)}`;
|
||||||
|
if (type === 'file.opened') return `Opened file ${quoted(name)}`;
|
||||||
|
if (type === 'file.changed') return `Changed file ${quoted(name)}`;
|
||||||
|
if (type === 'file.created') return `Created file ${quoted(name)}`;
|
||||||
|
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
|
||||||
|
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
|
||||||
|
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
|
||||||
|
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
|
||||||
|
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
|
||||||
|
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
|
||||||
|
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
|
||||||
|
if (type === 'action.started') return 'Work session detected';
|
||||||
|
if (type === 'workspace.opened') return 'Workspace opened';
|
||||||
|
return item?.title || item?.summary || 'Workspace activity';
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanActivityType(type) {
|
function actionForCategory(category) {
|
||||||
const labels = {
|
if (category === 'notes') return { kind: 'notes', label: 'Open Notes' };
|
||||||
'workspace.selected': 'Workspace selected',
|
if (category === 'files') return { kind: 'files', label: 'Open Files' };
|
||||||
'case.selected': 'Workspace selected',
|
if (category === 'captures') return { kind: 'browser-inbox', label: 'Review Inbox' };
|
||||||
'file.opened': 'File opened',
|
if (category === 'journal') return { kind: 'activity', label: 'View Activity' };
|
||||||
'file.changed': 'File changed',
|
return { kind: 'activity', label: 'View Activity' };
|
||||||
'note.saved': 'Note edited',
|
|
||||||
'browser.capture.received': 'Browser capture received',
|
|
||||||
'browser.capture.page': 'Page captured',
|
|
||||||
'browser.capture.selection': 'Selection captured',
|
|
||||||
'browser.capture.link': 'Link captured',
|
|
||||||
'browser.capture.file': 'File captured',
|
|
||||||
'browser.capture.converted': 'Capture converted',
|
|
||||||
'action.started': 'Work session detected'
|
|
||||||
};
|
|
||||||
return labels[String(type || '').toLowerCase()] || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function worklogTitle(item) {
|
function buildRecentChanges(events, captureRows, journalRows) {
|
||||||
return item.title || item.summary || item.date || item.entryId || 'Worklog item';
|
const activityItems = sortByTime(events)
|
||||||
|
.filter(item => !LOW_VALUE_RECENT_TYPES.has(String(item?.type || '').toLowerCase()))
|
||||||
|
.map(item => {
|
||||||
|
const category = activityCategory(item);
|
||||||
|
const action = actionForCategory(category);
|
||||||
|
return {
|
||||||
|
id: item.activityId || `${item.type}:${timeValue(item)}`,
|
||||||
|
category,
|
||||||
|
title: activityTitle(item),
|
||||||
|
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
|
||||||
|
time: timeValue(item),
|
||||||
|
absolute: absoluteTime(timeValue(item)),
|
||||||
|
actionKind: action.kind,
|
||||||
|
actionLabel: action.label,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const captureItems = captureRows.map(item => ({
|
||||||
|
id: item.captureId || `capture:${timeValue(item)}`,
|
||||||
|
category: 'captures',
|
||||||
|
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
|
||||||
|
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
|
||||||
|
time: timeValue(item),
|
||||||
|
absolute: absoluteTime(timeValue(item)),
|
||||||
|
actionKind: 'browser-inbox',
|
||||||
|
actionLabel: 'Review Inbox',
|
||||||
|
}));
|
||||||
|
const journalItems = journalRows.map(item => ({
|
||||||
|
id: item.entryId || `journal:${timeValue(item)}`,
|
||||||
|
category: 'journal',
|
||||||
|
title: `Added journal entry ${quoted(journalTitle(item))}`,
|
||||||
|
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
|
||||||
|
time: timeValue(item),
|
||||||
|
absolute: absoluteTime(timeValue(item)),
|
||||||
|
actionKind: 'activity',
|
||||||
|
actionLabel: 'View Activity',
|
||||||
|
}));
|
||||||
|
return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextResumeItem(captureRows, worklogRows, activityRows, activityAvailable) {
|
function isResumeEvent(item) {
|
||||||
if (captureRows.length > 0) {
|
const type = String(item?.type || '').toLowerCase();
|
||||||
const capture = captureRows[0];
|
if (LOW_VALUE_RESUME_TYPES.has(type)) return false;
|
||||||
return {
|
return type === 'file.opened' ||
|
||||||
title: captureTitle(capture),
|
type === 'note.opened' ||
|
||||||
meta: capture.url || capture.domain || capture.kind || 'Browser capture',
|
type === 'note.saved' ||
|
||||||
kind: 'browser-inbox',
|
type === 'note.edited' ||
|
||||||
action: 'Open Inbox',
|
type === 'file.changed' ||
|
||||||
};
|
type === 'file.created' ||
|
||||||
|
type === 'browser.capture.converted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContinueItems(events, captureRows) {
|
||||||
|
const fromEvents = sortByTime(events)
|
||||||
|
.filter(isResumeEvent)
|
||||||
|
.map(item => {
|
||||||
|
const category = activityCategory(item);
|
||||||
|
const action = actionForCategory(category);
|
||||||
|
return {
|
||||||
|
id: item.activityId || `${item.type}:${timeValue(item)}`,
|
||||||
|
title: activityTitle(item),
|
||||||
|
meta: itemTimeLabel(item),
|
||||||
|
time: timeValue(item),
|
||||||
|
absolute: absoluteTime(timeValue(item)),
|
||||||
|
actionKind: action.kind,
|
||||||
|
actionLabel: action.label,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (fromEvents.length) return fromEvents.slice(0, 4);
|
||||||
|
return sortByTime(captureRows).slice(0, 3).map(item => ({
|
||||||
|
id: item.captureId || `capture:${timeValue(item)}`,
|
||||||
|
title: `Review capture ${quoted(captureTitle(item))}`,
|
||||||
|
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
|
||||||
|
time: timeValue(item),
|
||||||
|
absolute: absoluteTime(timeValue(item)),
|
||||||
|
actionKind: 'browser-inbox',
|
||||||
|
actionLabel: 'Review Inbox',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNeedsAttention(captureRows, suggestions) {
|
||||||
|
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
|
||||||
|
id: item.captureId || `capture:${timeValue(item)}`,
|
||||||
|
title: captureTitle(item),
|
||||||
|
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
||||||
|
actionKind: 'browser-inbox',
|
||||||
|
actionLabel: 'Review Inbox',
|
||||||
|
}));
|
||||||
|
const suggestionItems = sortByTime(suggestions).slice(0, 4).map(item => ({
|
||||||
|
id: item.suggestionId || item.entryId || `suggestion:${timeValue(item)}`,
|
||||||
|
title: item.title || item.summary || 'Pending worklog suggestion',
|
||||||
|
meta: `${item.minutes ? item.minutes + ' min · ' : ''}${item.date || itemTimeLabel(item)}`,
|
||||||
|
actionKind: 'activity',
|
||||||
|
actionLabel: 'View Activity',
|
||||||
|
}));
|
||||||
|
return [...captureItems, ...suggestionItems].slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCategory(items, category) {
|
||||||
|
return items.filter(item => item.category === category).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLabel(count, singular) {
|
||||||
|
return `${count} ${singular}${count === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActiveDate(items, captureRows, journalRows) {
|
||||||
|
const source = sortByTime([...items, ...captureRows, ...journalRows])[0];
|
||||||
|
return timeValue(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFiles(relativeDir) {
|
||||||
|
if (!App.ListVaultFiles) return [];
|
||||||
|
try {
|
||||||
|
return decodeTuple(await App.ListVaultFiles('verstak.files', relativeDir), []);
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
if (worklogRows.length > 0) {
|
|
||||||
const item = worklogRows[0];
|
|
||||||
return {
|
|
||||||
title: worklogTitle(item),
|
|
||||||
meta: item.minutes ? item.minutes + ' min suggested' : item.date || 'Suggested worklog',
|
|
||||||
kind: 'activity',
|
|
||||||
action: activityAvailable ? 'Review Activity' : 'Refresh',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (activityRows.length > 0) {
|
|
||||||
const item = activityRows[0];
|
|
||||||
return {
|
|
||||||
title: activityTitle(item),
|
|
||||||
meta: item.occurredAt || item.receivedAt || item.type || 'Activity event',
|
|
||||||
kind: 'activity',
|
|
||||||
action: 'Open Activity',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadToday() {
|
async function loadKeyResources() {
|
||||||
|
const workspace = String(workspaceRootPath || '').trim();
|
||||||
|
if (!workspace) return [];
|
||||||
|
const [rootEntries, notesEntries] = await Promise.all([
|
||||||
|
listFiles(workspace),
|
||||||
|
listFiles(`${workspace}/Notes`),
|
||||||
|
]);
|
||||||
|
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
|
||||||
|
if (!overview) return [];
|
||||||
|
return [{
|
||||||
|
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 ? 'Open Notes' : 'Open Files',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
const workspaceAtStart = String(workspaceRootPath || '').trim();
|
||||||
|
loadedWorkspaceRoot = workspaceAtStart;
|
||||||
loading = true;
|
loading = true;
|
||||||
const [browserSettings, activitySettings, journalSettings] = await Promise.all([
|
const [browserSettings, activitySettings, journalSettings, resources] = await Promise.all([
|
||||||
readPluginSettings('verstak.browser-inbox'),
|
readPluginSettings('verstak.browser-inbox'),
|
||||||
readPluginSettings('verstak.activity'),
|
readPluginSettings('verstak.activity'),
|
||||||
readPluginSettings('verstak.journal'),
|
readPluginSettings('verstak.journal'),
|
||||||
|
loadKeyResources(),
|
||||||
]);
|
]);
|
||||||
|
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
|
||||||
|
|
||||||
captures = rowsFor(browserSettings, [
|
captures = rowsFor(browserSettings, [
|
||||||
workspaceKey('captures:workspace:'),
|
workspaceKey('captures:workspace:'),
|
||||||
'captures:global',
|
'captures:global',
|
||||||
'captures',
|
'captures',
|
||||||
]).slice(0, 4);
|
]);
|
||||||
|
activityEvents = rowsFor(activitySettings, [
|
||||||
activity = rowsFor(activitySettings, [
|
|
||||||
workspaceKey('events:workspace:'),
|
workspaceKey('events:workspace:'),
|
||||||
'events:global',
|
'events:global',
|
||||||
'events',
|
'events',
|
||||||
]).slice(0, 4);
|
]);
|
||||||
|
journalEntries = rowsFor(journalSettings, [
|
||||||
|
workspaceKey('worklog:workspace:'),
|
||||||
|
'worklog',
|
||||||
|
]);
|
||||||
worklogSuggestions = rowsFor(journalSettings, [
|
worklogSuggestions = rowsFor(journalSettings, [
|
||||||
workspaceKey('suggestions:workspace:'),
|
workspaceKey('suggestions:workspace:'),
|
||||||
workspaceKey('worklog:workspace:'),
|
|
||||||
'suggestions',
|
'suggestions',
|
||||||
'worklog',
|
]);
|
||||||
]).slice(0, 4);
|
keyResources = resources;
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,124 +409,154 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="today-root" aria-label="Today">
|
<div class="today-root overview-root" aria-label="Overview" data-overview-root>
|
||||||
<div class="today-header">
|
<div class="today-header overview-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Today</h2>
|
<h2>Overview</h2>
|
||||||
<p>{workspaceTitle || workspaceRootPath || 'Workspace'} overview</p>
|
<p title={lastActive ? absoluteTime(lastActive) : ''}>
|
||||||
|
{#if loading}
|
||||||
|
Loading workspace context...
|
||||||
|
{:else if lastActive}
|
||||||
|
Last active {relativeTime(lastActive)}
|
||||||
|
{:else}
|
||||||
|
No recent workspace activity
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" on:click={loadToday}>Refresh</button>
|
<button type="button" data-overview-action="refresh" on:click={loadOverview}>Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="today-summary" aria-label="Today summary">
|
<div class="today-summary overview-summary" aria-label="Workspace overview summary">
|
||||||
{#each summaryItems as item}
|
{#each summaryItems as item}
|
||||||
<div class="today-summary-item" data-today-summary={item.key}>
|
<div class="today-summary-item overview-summary-item" data-overview-summary={item.key}>
|
||||||
<strong>{loading ? '...' : item.count}</strong>
|
<strong>{loading ? '...' : item.count}</strong>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
<small>{loading ? 'Loading...' : item.detail}</small>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="today-resume" data-today-section="resume">
|
<div class="overview-layout">
|
||||||
<div class="today-resume-copy">
|
<main class="overview-main">
|
||||||
<span>Resume next</span>
|
<section class="today-resume overview-continue" data-overview-section="continue">
|
||||||
{#if loading}
|
<div class="today-resume-copy overview-continue-copy">
|
||||||
<strong>Loading workspace signals...</strong>
|
<span>Continue working</span>
|
||||||
<p>Recent captures, activity, and worklog suggestions will appear here.</p>
|
{#if loading}
|
||||||
{:else if resumeItem}
|
<strong>Loading workspace signals...</strong>
|
||||||
<strong>{resumeItem.title}</strong>
|
<p>Recent files, notes, captures, and journal entries will appear here.</p>
|
||||||
<p>{resumeItem.meta}</p>
|
{:else if primaryContinue}
|
||||||
{:else}
|
<strong title={primaryContinue.title}>{primaryContinue.title}</strong>
|
||||||
<strong>No pending workspace signals</strong>
|
<p title={primaryContinue.absolute}>{primaryContinue.meta}</p>
|
||||||
<p>Start with files, capture something from the browser, or review plugin activity.</p>
|
{:else}
|
||||||
{/if}
|
<strong>No clear resume point yet</strong>
|
||||||
</div>
|
<p>Open files or notes to create a useful return point for this workspace.</p>
|
||||||
{#if resumeItem}
|
{/if}
|
||||||
<button type="button" data-today-action="resume-primary" on:click={() => openTool(resumeItem.kind)}>{resumeItem.action}</button>
|
|
||||||
{:else if hasFiles}
|
|
||||||
<button type="button" data-today-action="resume-primary" on:click={() => openTool('files')}>Open Files</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
{:else}
|
{#if primaryContinue}
|
||||||
<p class="today-empty">No browser captures yet. Send a page, selection, link, or file from the browser extension.</p>
|
<button type="button" data-overview-action="continue-primary" on:click={() => openTool(primaryContinue.actionKind)}>{primaryContinue.actionLabel}</button>
|
||||||
{/if}
|
{:else}
|
||||||
</section>
|
<button type="button" data-overview-action="continue-primary" on:click={() => openTool('files')}>Open Files</button>
|
||||||
|
|
||||||
<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}
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
{#if loading}
|
|
||||||
<p class="today-empty">Loading activity...</p>
|
<section class="today-panel overview-panel overview-recent" data-overview-section="recent">
|
||||||
{:else if activity.length}
|
<div class="today-panel-head overview-panel-head">
|
||||||
<div class="today-list">
|
<div>
|
||||||
{#each activity as item}
|
<h3>Recent changes</h3>
|
||||||
<div class="today-row">
|
<p>Latest meaningful activity in this workspace.</p>
|
||||||
<strong>{activityTitle(item)}</strong>
|
</div>
|
||||||
<span>{item.occurredAt || item.receivedAt || item.type || 'Activity event'}</span>
|
<div class="overview-filters" aria-label="Recent changes filter">
|
||||||
</div>
|
{#each FILTERS as filter}
|
||||||
{/each}
|
<button
|
||||||
|
type="button"
|
||||||
|
class:is-active={activeFilter === filter.key}
|
||||||
|
aria-pressed={activeFilter === filter.key}
|
||||||
|
data-overview-filter={filter.key}
|
||||||
|
on:click={() => activeFilter = filter.key}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{#if loading}
|
||||||
<p class="today-empty">No activity events yet. File changes, captures, and conversions will appear here.</p>
|
<p class="today-empty">Loading recent changes...</p>
|
||||||
{/if}
|
{:else if filteredRecentChanges.length}
|
||||||
</section>
|
<div class="today-list overview-list">
|
||||||
|
{#each filteredRecentChanges as item}
|
||||||
|
<div class="today-row overview-change-row" data-overview-recent-item={item.category}>
|
||||||
|
<div>
|
||||||
|
<strong title={item.title}>{item.title}</strong>
|
||||||
|
<span title={item.absolute}>{item.meta}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" on:click={() => openTool(item.actionKind)}>{item.actionLabel}</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="today-empty">No meaningful changes for this filter yet.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<section class="today-panel" data-today-section="worklog">
|
<aside class="overview-side">
|
||||||
<div class="today-panel-head">
|
{#if needsAttention.length || !loading}
|
||||||
<h3>Worklog Suggestions</h3>
|
<section class="today-panel overview-panel" data-overview-section="attention">
|
||||||
</div>
|
<div class="today-panel-head overview-panel-head">
|
||||||
{#if loading}
|
<div>
|
||||||
<p class="today-empty">Loading worklog suggestions...</p>
|
<h3>Needs attention</h3>
|
||||||
{:else if worklogSuggestions.length}
|
<p>Pending captures and worklog suggestions.</p>
|
||||||
<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>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{#if loading}
|
||||||
{:else}
|
<p class="today-empty">Loading pending items...</p>
|
||||||
<p class="today-empty">No worklog suggestions yet. Activity will be grouped into suggestions once work starts.</p>
|
{:else if needsAttention.length}
|
||||||
|
<div class="today-list overview-list compact">
|
||||||
|
{#each needsAttention as item}
|
||||||
|
<div class="today-row overview-attention-row">
|
||||||
|
<strong title={item.title}>{item.title}</strong>
|
||||||
|
<span>{item.meta}</span>
|
||||||
|
<button type="button" on:click={() => openTool(item.actionKind)}>{item.actionLabel}</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="today-empty compact">No pending captures or worklog suggestions.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="today-panel" data-today-section="quick-actions">
|
<section class="today-panel overview-panel secondary" data-overview-section="quick-actions">
|
||||||
<div class="today-panel-head">
|
<div class="today-panel-head overview-panel-head">
|
||||||
<h3>Quick Actions</h3>
|
<h3>Quick actions</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="today-actions">
|
<div class="today-actions overview-actions">
|
||||||
{#if hasFiles}
|
{#if hasNotes}
|
||||||
<button type="button" data-today-action="files" on:click={() => openTool('files')}>Open Files</button>
|
<button type="button" data-overview-action="notes" on:click={() => openTool('notes')}>Open Notes</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" on:click={() => openTool('browser-inbox')}>Process Captures</button>
|
<button type="button" data-overview-action="files" on:click={() => openTool('files')}>Open Files</button>
|
||||||
{#if hasActivity}
|
<button type="button" data-overview-action="activity" on:click={() => openTool('activity')}>Review Activity</button>
|
||||||
<button type="button" on:click={() => openTool('activity')}>Review Activity</button>
|
<button type="button" data-overview-action="browser-inbox" on:click={() => openTool('browser-inbox')}>Open Inbox</button>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
{#if keyResources.length}
|
||||||
|
<section class="today-panel overview-panel secondary" data-overview-section="key-resources">
|
||||||
|
<div class="today-panel-head overview-panel-head">
|
||||||
|
<h3>Key resources</h3>
|
||||||
|
</div>
|
||||||
|
<div class="today-list overview-list compact">
|
||||||
|
{#each keyResources as item}
|
||||||
|
<div class="today-row overview-resource-row">
|
||||||
|
<strong title={item.title}>{item.title}</strong>
|
||||||
|
<span title={item.meta}>{item.meta}</span>
|
||||||
|
<button type="button" on:click={() => openTool(item.actionKind)}>{item.actionLabel}</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -309,16 +592,9 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-summary {
|
.today-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 0.75rem 0;
|
padding: 0.75rem 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +602,7 @@
|
||||||
.today-summary-item {
|
.today-summary-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.15rem;
|
gap: 0.16rem;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border: 1px solid var(--vt-color-border);
|
border: 1px solid var(--vt-color-border);
|
||||||
border-radius: var(--vt-radius-lg);
|
border-radius: var(--vt-radius-lg);
|
||||||
|
|
@ -339,7 +615,8 @@
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-summary-item span {
|
.today-summary-item span,
|
||||||
|
.today-summary-item small {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -348,13 +625,33 @@
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-summary-item span {
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-layout {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(17rem, 23rem);
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-main,
|
||||||
|
.overview-side {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.today-resume {
|
.today-resume {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin: 0.75rem 0.75rem 0;
|
padding: 0.9rem 1rem;
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border: 1px solid rgba(78, 204, 163, 0.24);
|
border: 1px solid rgba(78, 204, 163, 0.24);
|
||||||
border-radius: var(--vt-radius-lg);
|
border-radius: var(--vt-radius-lg);
|
||||||
background: linear-gradient(135deg, rgba(78, 204, 163, 0.11), rgba(27, 36, 64, 0.6));
|
background: linear-gradient(135deg, rgba(78, 204, 163, 0.11), rgba(27, 36, 64, 0.6));
|
||||||
|
|
@ -395,7 +692,6 @@
|
||||||
|
|
||||||
.today-panel {
|
.today-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 10rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid var(--vt-color-border);
|
border: 1px solid var(--vt-color-border);
|
||||||
|
|
@ -403,6 +699,14 @@
|
||||||
background: var(--vt-color-surface);
|
background: var(--vt-color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-recent {
|
||||||
|
min-height: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel.secondary {
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.today-panel-head {
|
.today-panel-head {
|
||||||
min-height: 2.8rem;
|
min-height: 2.8rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -419,12 +723,59 @@
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-panel-head p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-filters {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 1px solid var(--vt-color-border);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-filters button,
|
||||||
.today-panel-head button,
|
.today-panel-head button,
|
||||||
.today-actions button,
|
.today-actions button,
|
||||||
.today-header button {
|
.today-header button,
|
||||||
|
.today-resume button,
|
||||||
|
.overview-list button {
|
||||||
min-height: 1.85rem;
|
min-height: 1.85rem;
|
||||||
padding: 0.3rem 0.65rem;
|
padding: 0.3rem 0.65rem;
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-filters button {
|
||||||
|
min-height: 1.55rem;
|
||||||
|
padding: 0.16rem 0.5rem;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-filters button.is-active,
|
||||||
|
.overview-filters button:hover,
|
||||||
|
.today-panel-head button:hover,
|
||||||
|
.today-actions button:hover,
|
||||||
|
.today-header button:hover,
|
||||||
|
.today-resume button:hover,
|
||||||
|
.overview-list button:hover {
|
||||||
|
border-color: var(--vt-color-accent);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-filters button.is-active {
|
||||||
|
background: var(--vt-color-accent-muted);
|
||||||
|
color: var(--vt-color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-empty {
|
.today-empty {
|
||||||
|
|
@ -440,12 +791,20 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-empty.compact {
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.today-list {
|
.today-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-list.compact {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.today-row {
|
.today-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -456,6 +815,17 @@
|
||||||
background: var(--vt-color-surface-muted);
|
background: var(--vt-color-surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-change-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-attention-row,
|
||||||
|
.overview-resource-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.today-row strong {
|
.today-row strong {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -481,13 +851,20 @@
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 980px) {
|
||||||
.today-grid {
|
.overview-layout,
|
||||||
|
.today-summary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-summary {
|
.today-panel-head {
|
||||||
grid-template-columns: 1fr;
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-filters {
|
||||||
|
overflow-x: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-resume {
|
.today-resume {
|
||||||
|
|
@ -498,5 +875,10 @@
|
||||||
.today-resume button {
|
.today-resume button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-change-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
let workspaceTools = [];
|
let workspaceTools = [];
|
||||||
let toolsLoaded = false;
|
let toolsLoaded = false;
|
||||||
let requestedToolKind = '';
|
let requestedToolKind = '';
|
||||||
const todayTool = { id: '__today', title: 'Today', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
|
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
|
||||||
|
const overviewTool = { id: '__overview', title: 'Overview', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
|
||||||
|
|
||||||
const toolOrder = new Map([
|
const toolOrder = new Map([
|
||||||
['notes', 10],
|
['notes', 10],
|
||||||
|
|
@ -29,10 +30,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';
|
||||||
$: displayedTools = selectedWorkspace ? [todayTool, ...workspaceTools] : [];
|
$: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : [];
|
||||||
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
|
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
|
||||||
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
|
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
|
||||||
activeToolKey = toolKey(todayTool);
|
activeToolKey = toolKey(overviewTool);
|
||||||
}
|
}
|
||||||
$: if (requestedToolKind && workspaceTools.length > 0) {
|
$: if (requestedToolKind && workspaceTools.length > 0) {
|
||||||
const match = findWorkspaceTool(requestedToolKind);
|
const match = findWorkspaceTool(requestedToolKind);
|
||||||
|
|
@ -161,8 +162,7 @@
|
||||||
{#if activeTool.shell}
|
{#if activeTool.shell}
|
||||||
<TodaySurface
|
<TodaySurface
|
||||||
{workspaceRootPath}
|
{workspaceRootPath}
|
||||||
{workspaceTitle}
|
availableTools={displayedTools}
|
||||||
availableTools={workspaceTools}
|
|
||||||
on:openTool={openWorkspaceTool}
|
on:openTool={openWorkspaceTool}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue