diff --git a/frontend/e2e/command-palette.spec.js b/frontend/e2e/command-palette.spec.js index 9d6a3d7..29b63ce 100644 --- a/frontend/e2e/command-palette.spec.js +++ b/frontend/e2e/command-palette.spec.js @@ -52,7 +52,7 @@ test.describe('Command Palette', () => { 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(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(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity'); await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox'); diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js index 99b238d..6e72548 100644 --- a/frontend/e2e/plugin-manager-layout.spec.js +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -99,19 +99,19 @@ test.describe('E: Plugin Manager layout', () => { 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(); const tabs = page.locator('.workspace-tabs'); 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' }); - await expect(todayTab).toBeVisible(); - await expect(todayTab).toHaveAttribute('aria-selected', 'true'); + await expect(overviewTab).toBeVisible(); + await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); await expect(filesTab).toBeVisible(); await expect(filesTab).toHaveAttribute('aria-selected', 'false'); 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 expect(page.locator('.files-root')).toBeVisible(); diff --git a/frontend/e2e/ux-followup.spec.js b/frontend/e2e/ux-followup.spec.js index 9297916..a4a6b32 100644 --- a/frontend/e2e/ux-followup.spec.js +++ b/frontend/e2e/ux-followup.spec.js @@ -99,7 +99,7 @@ test.describe('UX follow-up fixes', () => { expect(workspaceBox.width).toBeGreaterThan(340); expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1); 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); expect(hasHorizontalOverflow).toBe(false); diff --git a/frontend/e2e/ux-today.spec.js b/frontend/e2e/ux-today.spec.js index f585b1b..fb63ef5 100644 --- a/frontend/e2e/ux-today.spec.js +++ b/frontend/e2e/ux-today.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js'; -test.describe('UX Today workspace flow', () => { +test.describe('UX Overview workspace flow', () => { let consoleCollector; test.beforeEach(async ({ page }) => { @@ -15,37 +15,40 @@ test.describe('UX Today workspace flow', () => { 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 }); 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(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'); - 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'); + const overview = page.locator('[data-overview-root]'); + await expect(overview).toBeVisible(); + await expect(overview.locator('[data-overview-section="continue"]')).toContainText('Continue working'); + await expect(overview.locator('[data-overview-section="recent"]')).toContainText('Recent changes'); + await expect(overview.locator('[data-overview-section="attention"]')).toContainText('Needs attention'); + await expect(overview.locator('[data-overview-section="quick-actions"]')).toContainText('Quick actions'); + await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('recent changes'); + 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 }) => { - await page.locator('[data-today-action="browser-inbox"]').click(); + test('Overview quick action opens Browser Inbox workspace tool', async ({ page }) => { + await page.locator('[data-overview-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 }); }); - 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 window.go.api.App.WritePluginSettings('verstak.browser-inbox', { 'captures:workspace:Project': [ { - captureId: 'today-capture-1', + captureId: 'overview-capture-1', capturedAt: '2026-06-30T08:00:00.000Z', kind: 'page', url: 'https://example.com/research', @@ -54,7 +57,7 @@ test.describe('UX Today workspace flow', () => { workspaceRootPath: 'Project', }, { - captureId: 'today-capture-2', + captureId: 'overview-capture-2', capturedAt: '2026-06-30T08:15:00.000Z', kind: 'selection', title: 'Quote to process', @@ -66,41 +69,108 @@ test.describe('UX Today workspace flow', () => { await window.go.api.App.WritePluginSettings('verstak.activity', { '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', type: 'note.saved', - title: 'Saved research note', - summary: 'Project/Notes/Research.md', + title: 'Overview', + 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', }, ], }); 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', summary: 'Turn recent captures into a worklog entry', minutes: 35, 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'); - await expect(today.locator('[data-today-summary="captures"]')).toContainText('2'); - await expect(today.locator('[data-today-summary="activity"]')).toContainText('1'); - await expect(today.locator('[data-today-summary="worklog"]')).toContainText('1'); + const overview = page.locator('[data-overview-root]'); + await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1'); + await expect(overview.locator('[data-overview-summary="files"]')).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"]'); - await expect(resume).toContainText('Resume next'); - await expect(resume).toContainText('Research Report'); - await resume.locator('[data-today-action="resume-primary"]').click(); + const resume = overview.locator('[data-overview-section="continue"]'); + await expect(resume).toContainText('Opened file "draft.md"'); + await expect(resume).not.toContainText('Research Report'); + await resume.locator('[data-overview-action="continue-primary"]').click(); - 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.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true'); + 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"'); }); }); diff --git a/frontend/src/lib/shell/CommandPalette.svelte b/frontend/src/lib/shell/CommandPalette.svelte index 4262ee4..c997495 100644 --- a/frontend/src/lib/shell/CommandPalette.svelte +++ b/frontend/src/lib/shell/CommandPalette.svelte @@ -13,7 +13,7 @@ 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-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-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' }, @@ -156,7 +156,7 @@ return; } const actionToTab = { - today: 'today', + overview: 'overview', files: 'files', activity: 'activity', 'browser-inbox': 'browser inbox', diff --git a/frontend/src/lib/shell/TodaySurface.svelte b/frontend/src/lib/shell/TodaySurface.svelte index ee40b74..616aa92 100644 --- a/frontend/src/lib/shell/TodaySurface.svelte +++ b/frontend/src/lib/shell/TodaySurface.svelte @@ -3,36 +3,79 @@ import * as App from '../../../wailsjs/go/api/App'; export let workspaceRootPath = ''; - export let workspaceTitle = ''; export let availableTools = []; 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 activeFilter = 'all'; let captures = []; - let activity = []; + let activityEvents = []; + let journalEntries = []; let worklogSuggestions = []; + let keyResources = []; + let loadedWorkspaceRoot = ''; + let toolProbe = 0; + $: hasNotes = hasTool('notes'); $: hasBrowserInbox = hasTool('browser inbox') || hasTool('inbox'); $: hasActivity = hasTool('activity'); $: 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 = [ - { key: 'captures', label: 'Captured', count: captures.length }, - { key: 'activity', label: 'Activity', count: activity.length }, - { key: 'worklog', label: 'Worklog', count: worklogSuggestions.length }, + { key: 'notes', label: 'Notes', count: countCategory(recentChanges, 'notes'), detail: countLabel(countCategory(recentChanges, 'notes'), 'recent change') }, + { key: 'files', label: 'Files', count: countCategory(recentChanges, 'files'), detail: countLabel(countCategory(recentChanges, 'files'), 'recent change') }, + { 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(() => { - loadToday(); + toolProbe += 1; }); + $: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) { + loadOverview(); + } + function hasTool(name) { + toolProbe; name = String(name || '').toLowerCase(); - return (availableTools || []).some(tool => { - const label = String(tool?.title || tool?.id || tool?.pluginId || '').toLowerCase(); + const fromProps = (availableTools || []).some(tool => { + const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase(); return label.includes(name); }); + if (fromProps) return true; + if (typeof document === 'undefined') return false; + return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => { + return String(tab.textContent || '').trim().toLowerCase().includes(name); + }); } function decodeTuple(response, fallback) { @@ -57,97 +100,307 @@ } 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) { - 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) { - 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) { - const labels = { - 'workspace.selected': 'Workspace selected', - 'case.selected': 'Workspace selected', - 'file.opened': 'File opened', - 'file.changed': 'File changed', - '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 actionForCategory(category) { + if (category === 'notes') return { kind: 'notes', label: 'Open Notes' }; + if (category === 'files') return { kind: 'files', label: 'Open Files' }; + if (category === 'captures') return { kind: 'browser-inbox', label: 'Review Inbox' }; + if (category === 'journal') return { kind: 'activity', label: 'View Activity' }; + return { kind: 'activity', label: 'View Activity' }; } - function worklogTitle(item) { - return item.title || item.summary || item.date || item.entryId || 'Worklog item'; + function buildRecentChanges(events, captureRows, journalRows) { + 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) { - if (captureRows.length > 0) { - const capture = captureRows[0]; - return { - title: captureTitle(capture), - meta: capture.url || capture.domain || capture.kind || 'Browser capture', - kind: 'browser-inbox', - action: 'Open Inbox', - }; + function isResumeEvent(item) { + const type = String(item?.type || '').toLowerCase(); + if (LOW_VALUE_RESUME_TYPES.has(type)) return false; + return type === 'file.opened' || + type === 'note.opened' || + type === 'note.saved' || + type === 'note.edited' || + 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; - const [browserSettings, activitySettings, journalSettings] = await Promise.all([ + const [browserSettings, activitySettings, journalSettings, resources] = await Promise.all([ readPluginSettings('verstak.browser-inbox'), readPluginSettings('verstak.activity'), readPluginSettings('verstak.journal'), + loadKeyResources(), ]); + if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return; captures = rowsFor(browserSettings, [ workspaceKey('captures:workspace:'), 'captures:global', 'captures', - ]).slice(0, 4); - - activity = rowsFor(activitySettings, [ + ]); + activityEvents = rowsFor(activitySettings, [ workspaceKey('events:workspace:'), 'events:global', 'events', - ]).slice(0, 4); - + ]); + journalEntries = rowsFor(journalSettings, [ + workspaceKey('worklog:workspace:'), + 'worklog', + ]); worklogSuggestions = rowsFor(journalSettings, [ workspaceKey('suggestions:workspace:'), - workspaceKey('worklog:workspace:'), 'suggestions', - 'worklog', - ]).slice(0, 4); - + ]); + keyResources = resources; loading = false; } @@ -156,124 +409,154 @@ } -
-
+
+
-

Today

-

{workspaceTitle || workspaceRootPath || 'Workspace'} overview

+

Overview

+

+ {#if loading} + Loading workspace context... + {:else if lastActive} + Last active {relativeTime(lastActive)} + {:else} + No recent workspace activity + {/if} +

- +
-
+
{#each summaryItems as item} -
+
{loading ? '...' : item.count} {item.label} + {loading ? 'Loading...' : item.detail}
{/each}
-
-
- Resume next - {#if loading} - Loading workspace signals... -

Recent captures, activity, and worklog suggestions will appear here.

- {:else if resumeItem} - {resumeItem.title} -

{resumeItem.meta}

- {:else} - No pending workspace signals -

Start with files, capture something from the browser, or review plugin activity.

- {/if} -
- {#if resumeItem} - - {:else if hasFiles} - - {/if} -
- -
-
-
-

Captured

- -
- {#if loading} -

Loading captures...

- {:else if captures.length} -
- {#each captures as capture} -
- {captureTitle(capture)} - {capture.url || capture.domain || capture.kind || 'Browser capture'} -
- {/each} +
+
+
+
+ Continue working + {#if loading} + Loading workspace signals... +

Recent files, notes, captures, and journal entries will appear here.

+ {:else if primaryContinue} + {primaryContinue.title} +

{primaryContinue.meta}

+ {:else} + No clear resume point yet +

Open files or notes to create a useful return point for this workspace.

+ {/if}
- {:else} -

No browser captures yet. Send a page, selection, link, or file from the browser extension.

- {/if} -
- -
-
-

Recent Activity

- {#if hasActivity} - + {#if primaryContinue} + + {:else} + {/if} -
- {#if loading} -

Loading activity...

- {:else if activity.length} -
- {#each activity as item} -
- {activityTitle(item)} - {item.occurredAt || item.receivedAt || item.type || 'Activity event'} -
- {/each} +
+ +
+
+
+

Recent changes

+

Latest meaningful activity in this workspace.

+
+
+ {#each FILTERS as filter} + + {/each} +
- {:else} -

No activity events yet. File changes, captures, and conversions will appear here.

- {/if} -
+ {#if loading} +

Loading recent changes...

+ {:else if filteredRecentChanges.length} +
+ {#each filteredRecentChanges as item} +
+
+ {item.title} + {item.meta} +
+ +
+ {/each} +
+ {:else} +

No meaningful changes for this filter yet.

+ {/if} +
+ -
-
-

Worklog Suggestions

-
- {#if loading} -

Loading worklog suggestions...

- {:else if worklogSuggestions.length} -
- {#each worklogSuggestions as item} -
- {worklogTitle(item)} - {item.minutes ? item.minutes + ' min' : item.date || 'Suggested worklog'} +
+ {#if loading} +

Loading pending items...

+ {:else if needsAttention.length} +
+ {#each needsAttention as item} +
+ {item.title} + {item.meta} + +
+ {/each} +
+ {:else} +

No pending captures or worklog suggestions.

+ {/if} +
{/if} - -
-
-

Quick Actions

-
-
- {#if hasFiles} - - {/if} - - {#if hasActivity} - - {/if} -
-
+
+
+

Quick actions

+
+
+ {#if hasNotes} + + {/if} + + + +
+
+ + {#if keyResources.length} +
+
+

Key resources

+
+
+ {#each keyResources as item} +
+ {item.title} + {item.meta} + +
+ {/each} +
+
+ {/if} +
@@ -309,16 +592,9 @@ font-size: 0.8rem; } - .today-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - padding: 0.75rem; - } - .today-summary { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.5rem; padding: 0.75rem 0.75rem 0; } @@ -326,7 +602,7 @@ .today-summary-item { min-width: 0; display: grid; - gap: 0.15rem; + gap: 0.16rem; padding: 0.65rem 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-lg); @@ -339,7 +615,8 @@ line-height: 1; } - .today-summary-item span { + .today-summary-item span, + .today-summary-item small { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -348,13 +625,33 @@ 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 { display: flex; align-items: center; justify-content: space-between; gap: 1rem; - margin: 0.75rem 0.75rem 0; - padding: 0.85rem 1rem; + padding: 0.9rem 1rem; border: 1px solid rgba(78, 204, 163, 0.24); border-radius: var(--vt-radius-lg); background: linear-gradient(135deg, rgba(78, 204, 163, 0.11), rgba(27, 36, 64, 0.6)); @@ -395,7 +692,6 @@ .today-panel { min-width: 0; - min-height: 10rem; display: flex; flex-direction: column; border: 1px solid var(--vt-color-border); @@ -403,6 +699,14 @@ background: var(--vt-color-surface); } + .overview-recent { + min-height: 24rem; + } + + .overview-panel.secondary { + background: var(--vt-color-surface-muted); + } + .today-panel-head { min-height: 2.8rem; display: flex; @@ -419,12 +723,59 @@ 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-actions button, - .today-header button { + .today-header button, + .today-resume button, + .overview-list button { min-height: 1.85rem; 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; + 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 { @@ -440,12 +791,20 @@ text-align: center; } + .today-empty.compact { + min-height: 5rem; + } + .today-list { display: grid; gap: 0.45rem; padding: 0.65rem; } + .today-list.compact { + gap: 0.4rem; + } + .today-row { min-width: 0; display: grid; @@ -456,6 +815,17 @@ 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 { min-width: 0; overflow: hidden; @@ -481,13 +851,20 @@ padding: 0.75rem; } - @media (max-width: 860px) { - .today-grid { + @media (max-width: 980px) { + .overview-layout, + .today-summary { grid-template-columns: 1fr; } - .today-summary { - grid-template-columns: 1fr; + .today-panel-head { + align-items: stretch; + flex-direction: column; + } + + .overview-filters { + overflow-x: auto; + justify-content: flex-start; } .today-resume { @@ -498,5 +875,10 @@ .today-resume button { width: 100%; } + + .overview-change-row { + grid-template-columns: 1fr; + align-items: stretch; + } } diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte index 3ad8d9e..1017cfa 100644 --- a/frontend/src/lib/shell/WorkspaceHost.svelte +++ b/frontend/src/lib/shell/WorkspaceHost.svelte @@ -14,7 +14,8 @@ let workspaceTools = []; let toolsLoaded = false; 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([ ['notes', 10], @@ -29,10 +30,10 @@ $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceType = selectedWorkspace?.type || 'workspace'; - $: displayedTools = selectedWorkspace ? [todayTool, ...workspaceTools] : []; + $: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : []; $: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null; $: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) { - activeToolKey = toolKey(todayTool); + activeToolKey = toolKey(overviewTool); } $: if (requestedToolKind && workspaceTools.length > 0) { const match = findWorkspaceTool(requestedToolKind); @@ -161,8 +162,7 @@ {#if activeTool.shell} {:else}