From b05601692957f79f457f1e4c1508eb30ee78787f Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 07:46:18 +0800 Subject: [PATCH] feat: refine workspace overview --- frontend/e2e/plugin-manager-layout.spec.js | 4 +- frontend/e2e/ux-today.spec.js | 71 +++- frontend/src/lib/shell/TodaySurface.svelte | 402 ++++++++++++++------- frontend/src/lib/test/wails-mock.js | 111 +++++- 4 files changed, 448 insertions(+), 140 deletions(-) diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js index 6e72548..bbd1b3b 100644 --- a/frontend/e2e/plugin-manager-layout.spec.js +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -78,13 +78,13 @@ test.describe('E: Plugin Manager layout', () => { const health = page.locator('[data-plugin-manager-summary="health"]'); await expect(health).toBeVisible(); - await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('7'); + await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('9'); await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0'); await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0'); const risk = page.locator('[data-plugin-manager-summary="risk"]'); await expect(risk).toBeVisible(); - await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('4'); + await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('5'); await expect(risk).toContainText('elevated permissions'); }); diff --git a/frontend/e2e/ux-today.spec.js b/frontend/e2e/ux-today.spec.js index fb63ef5..2e480a6 100644 --- a/frontend/e2e/ux-today.spec.js +++ b/frontend/e2e/ux-today.spec.js @@ -20,7 +20,7 @@ test.describe('UX Overview workspace flow', () => { const tabs = page.getByRole('tab'); await expect(tabs.nth(0)).toHaveText('Overview'); - await expect(tabs.nth(1)).toHaveText('Files'); + await expect(tabs.nth(1)).toHaveText('Notes'); await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Today' })).toHaveCount(0); @@ -29,18 +29,55 @@ test.describe('UX Overview workspace flow', () => { 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.locator('[data-overview-section="quick-actions"]')).toHaveCount(0); + + const summaryCards = overview.locator('button[data-overview-summary]'); + await expect(summaryCards).toHaveCount(6); + await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total'); + await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('0 recent changes'); + await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('0 captures to review'); + await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('0 recorded events'); + await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('0 journal entries'); await expect(overview).toContainText('No clear resume point yet'); await expect(overview).toContainText('No meaningful changes for this filter yet'); }); - test('Overview quick action opens Browser Inbox workspace tool', async ({ page }) => { - await page.locator('[data-overview-action="browser-inbox"]').click(); + test('Overview summary cards navigate to their corresponding workspace tools', async ({ page }) => { + const overview = page.locator('[data-overview-root]'); + await overview.locator('[data-overview-summary="notes"]').click(); + await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.notes-root')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('tab', { name: 'Overview' }).click(); + await overview.locator('[data-overview-summary="files"]').click(); + 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(); + await overview.locator('[data-overview-summary="captures"]').click(); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true'); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('tab', { name: 'Overview' }).click(); + await overview.locator('[data-overview-summary="activity"]').click(); + await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true'); + + await page.getByRole('tab', { name: 'Overview' }).click(); + await overview.locator('[data-overview-summary="journal"]').click(); + await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.journal-root')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('tab', { name: 'Overview' }).click(); + await overview.locator('[data-overview-summary="attention"]').click(); + await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true'); + }); + + test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => { + await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.files', 'disabled', false)); + await page.locator('[data-overview-action="refresh"]').click(); + + await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total'); }); test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => { @@ -135,19 +172,25 @@ test.describe('UX Overview workspace flow', () => { await page.locator('[data-overview-action="refresh"]').click(); 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="notes"]')).toContainText('1 total'); + await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 recent change'); + await expect(overview.locator('[data-overview-summary="files"]')).toContainText('1 recent change'); await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('2'); + await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('5 recorded events'); await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1'); await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); 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(); + const candidates = resume.locator('[data-overview-continue-item]'); + await expect(candidates).toHaveCount(4); + await expect(candidates.nth(0)).toContainText('Quote to process'); + await expect(candidates.nth(1)).toContainText('Research Report'); + await expect(candidates.nth(2)).toContainText('Edited note "Overview"'); + await expect(candidates.nth(3)).toContainText('Changed file "draft.md"'); + await candidates.nth(0).click(); - await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true'); - await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await page.getByRole('tab', { name: 'Overview' }).click(); const recent = overview.locator('[data-overview-section="recent"]'); @@ -158,6 +201,8 @@ test.describe('UX Overview workspace flow', () => { await expect(recent).not.toContainText('Selected file'); await expect(recent).not.toContainText('Workspace selected'); await expect(recent).not.toContainText('file.opened'); + await expect(recent.locator('[data-overview-recent-item]')).toHaveCount(5); + await expect(recent.locator('[data-overview-recent-item] button')).toHaveCount(0); await overview.locator('[data-overview-filter="notes"]').click(); await expect(recent).toContainText('Edited note "Overview"'); diff --git a/frontend/src/lib/shell/TodaySurface.svelte b/frontend/src/lib/shell/TodaySurface.svelte index 616aa92..1395e13 100644 --- a/frontend/src/lib/shell/TodaySurface.svelte +++ b/frontend/src/lib/shell/TodaySurface.svelte @@ -33,27 +33,29 @@ let journalEntries = []; let worklogSuggestions = []; let keyResources = []; + let totalNotes = 0; 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; + $: noteRecentChanges = countCategory(recentChanges, 'notes'); + $: fileRecentChanges = countCategory(recentChanges, 'files'); + $: unprocessedCaptures = captures.filter(item => item?.processed !== true); + $: needsAttention = buildNeedsAttention(unprocessedCaptures, worklogSuggestions); + $: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries); + $: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox'; $: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries); $: summaryItems = [ - { 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') }, + { key: 'notes', label: 'Notes', count: totalNotes, detail: totalAndRecentLabel(totalNotes, noteRecentChanges), actionKind: 'notes', actionLabel: 'Open Notes' }, + { key: 'files', label: 'Files', count: fileRecentChanges, detail: countLabel(fileRecentChanges, 'recent change'), actionKind: 'files', actionLabel: 'Open Files' }, + { key: 'captures', label: 'Captures', count: unprocessedCaptures.length, detail: captureReviewLabel(unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: 'Review Inbox' }, + { key: 'activity', label: 'Activity', count: activityEvents.length, detail: countLabel(activityEvents.length, 'recorded event'), actionKind: 'activity', actionLabel: 'View Activity' }, + { key: 'journal', label: 'Journal', count: journalEntries.length, detail: journalEntryLabel(journalEntries.length), actionKind: 'journal', actionLabel: 'Open Journal' }, + { key: 'attention', label: 'Needs attention', count: needsAttention.length, detail: countLabel(needsAttention.length, 'pending item'), actionKind: attentionActionKind, actionLabel: 'Review pending items' }, ]; onMount(() => { @@ -199,7 +201,7 @@ 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'; + if (type.startsWith('journal.') || type.startsWith('worklog.')) return 'journal'; return 'activity'; } @@ -228,7 +230,7 @@ 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' }; + if (category === 'journal') return { kind: 'journal', label: 'Open Journal' }; return { kind: 'activity', label: 'View Activity' }; } @@ -266,8 +268,8 @@ meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`, time: timeValue(item), absolute: absoluteTime(timeValue(item)), - actionKind: 'activity', - actionLabel: 'View Activity', + actionKind: 'journal', + actionLabel: 'Open Journal', })); return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12); } @@ -284,25 +286,25 @@ 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 => ({ + function continueItemFromActivity(item) { + const category = activityCategory(item); + const action = actionForCategory(category); + return { + id: item.activityId || `${item.type}:${timeValue(item)}`, + category, + title: activityTitle(item), + meta: itemTimeLabel(item), + time: timeValue(item), + absolute: absoluteTime(timeValue(item)), + actionKind: action.kind, + actionLabel: action.label, + }; + } + + function buildContinueItems(events, captureRows, journalRows) { + const captureCandidates = sortByTime(captureRows).map(item => ({ id: item.captureId || `capture:${timeValue(item)}`, + category: 'captures', title: `Review capture ${quoted(captureTitle(item))}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`, time: timeValue(item), @@ -310,6 +312,23 @@ actionKind: 'browser-inbox', actionLabel: 'Review Inbox', })); + const noteCandidates = sortByTime(events) + .filter(item => isResumeEvent(item) && activityCategory(item) === 'notes') + .map(continueItemFromActivity); + const fileCandidates = sortByTime(events) + .filter(item => ['file.changed', 'file.created'].includes(String(item?.type || '').toLowerCase())) + .map(continueItemFromActivity); + const journalCandidates = sortByTime(journalRows).map(item => ({ + id: item.entryId || `journal:${timeValue(item)}`, + category: 'journal', + title: `Continue journal entry ${quoted(journalTitle(item))}`, + meta: itemTimeLabel(item), + time: timeValue(item), + absolute: absoluteTime(timeValue(item)), + actionKind: 'journal', + actionLabel: 'Open Journal', + })); + return [...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4); } function buildNeedsAttention(captureRows, suggestions) { @@ -322,10 +341,10 @@ })); 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', + title: item.title || item.summary || 'Possible journal entry', meta: `${item.minutes ? item.minutes + ' min · ' : ''}${item.date || itemTimeLabel(item)}`, - actionKind: 'activity', - actionLabel: 'View Activity', + actionKind: 'journal', + actionLabel: 'Review candidate', })); return [...captureItems, ...suggestionItems].slice(0, 6); } @@ -338,36 +357,51 @@ return `${count} ${singular}${count === 1 ? '' : 's'}`; } + function totalAndRecentLabel(total, recent) { + return `${total} total · ${countLabel(recent, 'recent change')}`; + } + + function captureReviewLabel(count) { + return `${count} capture${count === 1 ? '' : 's'} to review`; + } + + function journalEntryLabel(count) { + return `${count} journal entr${count === 1 ? 'y' : 'ies'}`; + } + function lastActiveDate(items, captureRows, journalRows) { const source = sortByTime([...items, ...captureRows, ...journalRows])[0]; return timeValue(source); } - async function listFiles(relativeDir) { + async function listFiles(pluginId, relativeDir) { if (!App.ListVaultFiles) return []; try { - return decodeTuple(await App.ListVaultFiles('verstak.files', relativeDir), []); + return decodeTuple(await App.ListVaultFiles(pluginId, relativeDir), []); } catch (_) { return []; } } - async function loadKeyResources() { + async function loadWorkspaceResources() { const workspace = String(workspaceRootPath || '').trim(); - if (!workspace) return []; + if (!workspace) return { keyResources: [], totalNotes: 0 }; const [rootEntries, notesEntries] = await Promise.all([ - listFiles(workspace), - listFiles(`${workspace}/Notes`), + listFiles('verstak.files', workspace), + listFiles('verstak.notes', `${workspace}/Notes`), ]); + const noteFiles = notesEntries.filter(item => item?.type === 'file'); 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', - }]; + return { + totalNotes: noteFiles.length, + keyResources: overview ? [{ + 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() { @@ -378,7 +412,7 @@ readPluginSettings('verstak.browser-inbox'), readPluginSettings('verstak.activity'), readPluginSettings('verstak.journal'), - loadKeyResources(), + loadWorkspaceResources(), ]); if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return; @@ -400,7 +434,8 @@ workspaceKey('suggestions:workspace:'), 'suggestions', ]); - keyResources = resources; + keyResources = resources.keyResources; + totalNotes = resources.totalNotes; loading = false; } @@ -428,11 +463,20 @@
{#each summaryItems as item} -
+
+ {item.actionLabel} + {/each}
@@ -441,21 +485,33 @@
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} +

Pick up the next useful item in this workspace.

- {#if primaryContinue} - + {#if loading} +

Loading workspace signals...

+ {:else if continueItems.length} +
+ {#each continueItems as item} + + {/each} +
{:else} - +
+ No clear resume point yet +

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

+
{/if}
@@ -484,13 +540,19 @@ {:else if filteredRecentChanges.length}
{#each filteredRecentChanges as item} -
-
+
- -
+ + {item.actionLabel} + {/each}
{:else} @@ -505,7 +567,7 @@

Needs attention

-

Pending captures and worklog suggestions.

+

Pending captures and possible journal entries.

{#if loading} @@ -526,20 +588,6 @@ {/if} -
-
-

Quick actions

-
-
- {#if hasNotes} - - {/if} - - - -
-
- {#if keyResources.length}
@@ -594,7 +642,7 @@ .today-summary { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0.5rem; padding: 0.75rem 0.75rem 0; } @@ -607,6 +655,17 @@ border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + } + + .today-summary-item:hover, + .today-summary-item:focus-visible { + border-color: var(--vt-color-accent); + background: var(--vt-color-surface-hover); + outline: none; } .today-summary-item strong { @@ -616,7 +675,8 @@ } .today-summary-item span, - .today-summary-item small { + .today-summary-item small, + .today-summary-item em { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -630,6 +690,21 @@ font-weight: 600; } + .today-summary-item em { + color: var(--vt-color-accent); + font-size: 0.7rem; + font-style: normal; + } + + .today-summary-item.summary-attention { + border-color: rgba(255, 200, 87, 0.5); + background: var(--vt-color-warning-muted); + } + + .today-summary-item.summary-attention em { + color: var(--vt-color-warning); + } + .overview-layout { min-height: 0; display: grid; @@ -647,14 +722,12 @@ } .today-resume { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; + display: grid; + gap: 0.75rem; 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)); + background: var(--vt-color-accent-muted); } .today-resume-copy { @@ -671,23 +744,11 @@ letter-spacing: 0.05em; } - .today-resume-copy strong { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .today-resume-copy h3 { + margin: 0; color: var(--vt-color-text-primary); font-size: 0.98rem; - } - - .today-resume-copy p { - min-width: 0; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--vt-color-text-secondary); - font-size: 0.8rem; + font-weight: 600; } .today-panel { @@ -707,6 +768,11 @@ background: var(--vt-color-surface-muted); } + [data-overview-section='attention'] { + border-color: rgba(255, 200, 87, 0.45); + background: var(--vt-color-warning-muted); + } + .today-panel-head { min-height: 2.8rem; display: flex; @@ -741,9 +807,8 @@ .overview-filters button, .today-panel-head button, - .today-actions button, .today-header button, - .today-resume button, + .overview-continue-item, .overview-list button { min-height: 1.85rem; padding: 0.3rem 0.65rem; @@ -765,9 +830,8 @@ .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-continue-item:hover, .overview-list button:hover { border-color: var(--vt-color-accent); color: var(--vt-color-text-primary); @@ -813,12 +877,51 @@ border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); + color: inherit; + font: inherit; + text-align: left; } .overview-change-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.75rem; + width: 100%; + padding: 0.55rem 0.75rem; + border: 0; + border-bottom: 1px solid var(--vt-color-border); + border-radius: 0; + background: transparent; + cursor: pointer; + } + + .overview-change-row:hover, + .overview-change-row:focus-visible { + background: var(--vt-color-surface-hover); + outline: none; + } + + .overview-change-copy, + .overview-continue-item-copy { + min-width: 0; + display: grid; + gap: 0.2rem; + } + + .overview-row-action, + .overview-continue-item-action { + color: var(--vt-color-text-muted); + font-size: 0.72rem; + white-space: nowrap; + } + + .overview-recent .overview-list { + gap: 0; + padding: 0; + } + + .overview-recent .overview-change-row:last-child { + border-bottom: 0; } .overview-attention-row, @@ -844,16 +947,59 @@ font-size: 0.75rem; } - .today-actions { - display: flex; - flex-wrap: wrap; + .overview-continue-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; - padding: 0.75rem; + } + + .overview-continue-item { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; + text-align: left; + } + + .overview-continue-item strong, + .overview-continue-item-copy span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .overview-continue-item strong { + color: var(--vt-color-text-primary); + font-size: 0.8rem; + } + + .overview-continue-item-copy span { + color: var(--vt-color-text-muted); + font-size: 0.72rem; + } + + .overview-continue-empty { + display: grid; + gap: 0.22rem; + color: var(--vt-color-text-secondary); + } + + .overview-continue-empty p { + margin: 0; + color: var(--vt-color-text-muted); + font-size: 0.8rem; + } + + @media (max-width: 1120px) { + .today-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (max-width: 980px) { - .overview-layout, - .today-summary { + .overview-layout { grid-template-columns: 1fr; } @@ -867,12 +1013,11 @@ justify-content: flex-start; } - .today-resume { - align-items: stretch; - flex-direction: column; + .overview-continue-list { + grid-template-columns: 1fr; } - .today-resume button { + .overview-continue-item { width: 100%; } @@ -881,4 +1026,15 @@ align-items: stretch; } } + + @media (max-width: 620px) { + .today-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .overview-change-row { + grid-template-columns: 1fr; + gap: 0.3rem; + } + } diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 038d9c0..ca09499 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -134,6 +134,29 @@ rootPath: '/tmp/verstak-test/plugins/files', error: '' }, + 'verstak.notes': { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.notes', + name: 'Notes', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Workspace-scoped notes manager.', + source: 'official', + icon: 'edit', + provides: ['verstak/notes/v1'], + requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'], + permissions: ['files.read', 'files.write', 'files.delete', 'events.subscribe', 'workbench.open', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + workspaceItems: [{ id: 'verstak.notes.workspace', title: 'Notes', icon: 'edit', component: 'NotesView' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/notes', + error: '' + }, 'verstak.sync': { status: 'loaded', enabled: true, @@ -182,6 +205,31 @@ rootPath: '/tmp/verstak-test/plugins/activity', error: '' }, + 'verstak.journal': { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.journal', + name: 'Journal', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Workspace-scoped worklog journal.', + source: 'official', + icon: 'book-open', + provides: ['worklog', 'journal', 'report.worklog'], + optionalRequires: ['activity.reconstruction'], + permissions: ['storage.namespace', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], + sidebarItems: [{ id: 'verstak.journal.sidebar', title: 'Journal', icon: 'book-open', view: 'verstak.journal.view', position: 30 }], + workspaceItems: [{ id: 'verstak.journal.workspace', title: 'Journal', icon: 'book-open', component: 'JournalView' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/journal', + error: '' + }, 'verstak.browser-inbox': { status: 'loaded', enabled: true, @@ -234,7 +282,7 @@ }; var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' }; - var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync', 'verstak.activity', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] }; + var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] }; var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] }; var workbenchPreferences = {}; var openedResources = []; @@ -1381,6 +1429,11 @@ }.toString() + ')();'; } + function simplePluginBundle(pluginId, componentName, rootClass, title) { + var markup = '

' + title + '

'; + return '(function(){var Component={mount:function(containerEl){containerEl.innerHTML=' + JSON.stringify(markup) + ';},unmount:function(containerEl){containerEl.innerHTML="";}};window.VerstakPluginRegister(' + JSON.stringify(pluginId) + ',{components:{' + componentName + ':Component}});})();'; + } + function activityBundle() { return '(' + function () { var PLUGIN_ID = 'verstak.activity'; @@ -2148,9 +2201,15 @@ if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(filesPluginBundle()); } + if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') { + return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes')); + } if (pluginId === 'verstak.activity' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(activityBundle()); } + if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') { + return Promise.resolve(simplePluginBundle('verstak.journal', 'JournalView', 'journal-root', 'Journal')); + } if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(browserInboxBundle()); } @@ -2632,6 +2691,29 @@ rootPath: '/tmp/verstak-test/plugins/files', error: '' }, + 'verstak.notes': { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.notes', + name: 'Notes', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Workspace-scoped notes manager.', + source: 'official', + icon: 'edit', + provides: ['verstak/notes/v1'], + requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'], + permissions: ['files.read', 'files.write', 'files.delete', 'events.subscribe', 'workbench.open', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + workspaceItems: [{ id: 'verstak.notes.workspace', title: 'Notes', icon: 'edit', component: 'NotesView' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/notes', + error: '' + }, 'verstak.sync': { status: 'loaded', enabled: true, @@ -2680,6 +2762,31 @@ rootPath: '/tmp/verstak-test/plugins/activity', error: '' }, + 'verstak.journal': { + status: 'loaded', + enabled: true, + manifest: { + schemaVersion: 1, + id: 'verstak.journal', + name: 'Journal', + version: '0.1.0', + apiVersion: '0.1.0', + description: 'Workspace-scoped worklog journal.', + source: 'official', + icon: 'book-open', + provides: ['worklog', 'journal', 'report.worklog'], + optionalRequires: ['activity.reconstruction'], + permissions: ['storage.namespace', 'ui.register'], + frontend: { entry: 'frontend/dist/index.js' }, + contributes: { + views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], + sidebarItems: [{ id: 'verstak.journal.sidebar', title: 'Journal', icon: 'book-open', view: 'verstak.journal.view', position: 30 }], + workspaceItems: [{ id: 'verstak.journal.workspace', title: 'Journal', icon: 'book-open', component: 'JournalView' }] + } + }, + rootPath: '/tmp/verstak-test/plugins/journal', + error: '' + }, 'verstak.browser-inbox': { status: 'loaded', enabled: true, @@ -2731,7 +2838,7 @@ } }; vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' }; - vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync', 'verstak.activity', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] }; + vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] }; appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] }; workbenchPreferences = {}; openedResources = [];