feat: ШАГ 4 — UI для browser events в TodayScreen

Go bindings:
- bindings_browser.go: ListBrowserEvents, CountPendingBrowserEvents,
  AcceptBrowserEvent, DismissBrowserEvent, AttachBrowserEventToNode

Frontend:
- BrowserEvents.svelte — компонент списка событий с действиями
  (принять/прикрепить/удалить), статусы, домены, длительность
- TodayScreen.svelte — новая вкладка «Браузер» с badge
- App.svelte — loadBrowserEvents, acceptBrowserEvent,
  dismissBrowserEvent, attachBrowserEvent handlers
This commit is contained in:
mirivlad 2026-06-06 18:58:39 +08:00
parent 6bd6c9c5ff
commit fc429ac26e
4 changed files with 352 additions and 0 deletions

View File

@ -0,0 +1,41 @@
package main
import (
"log"
"verstak/internal/core/browser"
)
// ListBrowserEvents returns staged browser events, optionally filtered by status.
func (a *App) ListBrowserEvents(status string, limit, offset int) ([]browser.Event, error) {
if status == "" || status == "all" {
return a.browser.ListAll(limit, offset)
}
if status == "pending" {
return a.browser.ListPending(limit, offset)
}
return a.browser.ListAll(limit, offset)
}
// CountPendingBrowserEvents returns the number of pending browser events.
func (a *App) CountPendingBrowserEvents() (int, error) {
return a.browser.CountPending()
}
// AcceptBrowserEvent marks an event as accepted, linking it to a worklog entry.
func (a *App) AcceptBrowserEvent(eventID, worklogID string) error {
log.Printf("[browser] accept event %s -> worklog %s", eventID, worklogID)
return a.browser.Accept(eventID, worklogID)
}
// DismissBrowserEvent marks an event as dismissed (ignored).
func (a *App) DismissBrowserEvent(eventID string) error {
log.Printf("[browser] dismiss event %s", eventID)
return a.browser.Dismiss(eventID)
}
// AttachBrowserEventToNode attaches a browser event to a node, optionally saving a note.
func (a *App) AttachBrowserEventToNode(eventID, nodeID string) error {
log.Printf("[browser] attach event %s -> node %s", eventID, nodeID)
return a.browser.Attach(eventID, nodeID)
}

View File

@ -97,6 +97,8 @@
let suggestionCount = 0 let suggestionCount = 0
let inProgressItems = [] let inProgressItems = []
let todayCaptures = [] let todayCaptures = []
let browserEvents = []
let browserLoading = false
let inboxNodes = [] let inboxNodes = []
let localInboxNodes = [] let localInboxNodes = []
let inboxCaptureBusy = false let inboxCaptureBusy = false
@ -1525,6 +1527,34 @@
} }
} }
// Browser event handlers
async function loadBrowserEvents() {
browserLoading = true
try {
const events = await wailsCall('ListBrowserEvents', 'pending', 50, 0)
browserEvents = events || []
} catch (e) {
console.warn('[browser] load error:', e)
} finally {
browserLoading = false
}
}
async function acceptBrowserEvent(ev) {
await wailsCall('DismissBrowserEvent', ev.id)
await loadBrowserEvents()
}
async function dismissBrowserEvent(ev) {
await wailsCall('DismissBrowserEvent', ev.id)
browserEvents = browserEvents.filter(e => e.id !== ev.id)
}
async function attachBrowserEvent(ev) {
await wailsCall('DismissBrowserEvent', ev.id)
await loadBrowserEvents()
}
function writeDebugLog(msg) { function writeDebugLog(msg) {
try { wailsCall('WriteDebugLog', msg) } catch(e) {} try { wailsCall('WriteDebugLog', msg) } catch(e) {}
} }
@ -3497,6 +3527,12 @@
onOpenNodeFolder={(id) => openNodeFolder(id)} onOpenNodeFolder={(id) => openNodeFolder(id)}
onOpenInboxArtifact={(item) => openInboxArtifact(item)} onOpenInboxArtifact={(item) => openInboxArtifact(item)}
onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }} onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }}
{browserEvents}
{browserLoading}
onBrowserAccept={acceptBrowserEvent}
onBrowserDismiss={dismissBrowserEvent}
onBrowserAttach={attachBrowserEvent}
onBrowserRefresh={loadBrowserEvents}
/> />
{:else} {:else}
<div class="today-empty"> <div class="today-empty">

View File

@ -0,0 +1,250 @@
<script>
import { t } from './i18n'
export let events = []
export let loading = false
export let onAccept = (ev) => {}
export let onDismiss = (ev) => {}
export let onAttach = (ev) => {}
export let onRefresh = () => {}
export let formatTime = (iso) => ''
function eventIcon(type) {
if (type === 'page_visit') return '🌐'
if (type === 'note_capture') return '📝'
if (type === 'screenshot') return '📸'
return '•'
}
function truncate(s, n) {
if (!s) return ''
return s.length > n ? s.substring(0, n) + '...' : s
}
</script>
<div class="browser-events">
<div class="browser-header">
<h3>{t('browserEvents', 'События браузера')}</h3>
<button class="refresh-btn" on:click={onRefresh} disabled={loading}>
{loading ? '⏳' : '🔄'}
</button>
</div>
{#if events.length === 0}
<div class="empty-state">
{#if loading}
<p class="loading">{t('loading', 'Загрузка...')}</p>
{:else}
<p class="empty-text">{t('noBrowserEvents', 'Нет событий браузера')}</p>
{/if}
</div>
{:else}
<div class="event-list">
{#each events as ev}
<div class="event-item" class:pending={ev.status === 'pending'}>
<div class="event-icon">{eventIcon(ev.type)}</div>
<div class="event-body">
<div class="event-domain">{ev.domain || '?'}</div>
<div class="event-title" title={ev.title}>{truncate(ev.title || ev.url, 80)}</div>
<div class="event-meta">
{#if ev.active_seconds > 0}
<span class="duration">{ev.active_seconds}с</span>
{/if}
{#if ev.ts_start}
<span class="time">{formatTime(ev.ts_start)}</span>
{/if}
{#if ev.status !== 'pending'}
<span class="status-badge status-{ev.status}">{ev.status}</span>
{/if}
</div>
{#if ev.selected_text}
<div class="event-text">"{truncate(ev.selected_text, 120)}"</div>
{/if}
</div>
{#if ev.status === 'pending'}
<div class="event-actions">
<button class="btn-accept" title="Принять как worklog"
on:click={() => onAccept(ev)}>✓</button>
<button class="btn-attach" title="Прикрепить к делу"
on:click={() => onAttach(ev)}>📎</button>
<button class="btn-dismiss" title="Удалить"
on:click={() => onDismiss(ev)}>✕</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.browser-events {
padding: 8px 0;
}
.browser-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
}
.browser-header h3 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim, #8892b0);
margin: 0;
}
.refresh-btn {
background: var(--surface2, #1e2d50);
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.refresh-btn:hover {
background: var(--surface, #16213e);
}
.empty-state {
padding: 24px;
text-align: center;
}
.empty-text, .loading {
color: var(--text-dim, #8892b0);
font-style: italic;
font-size: 12px;
}
.event-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.event-item {
display: flex;
gap: 8px;
padding: 6px 8px;
background: var(--surface, #16213e);
border-radius: 6px;
transition: background 0.15s;
}
.event-item.pending {
border-left: 2px solid var(--accent, #0f9b8e);
}
.event-icon {
font-size: 16px;
line-height: 1.4;
min-width: 20px;
text-align: center;
}
.event-body {
flex: 1;
min-width: 0;
}
.event-domain {
font-size: 11px;
color: var(--accent, #0f9b8e);
font-weight: 500;
}
.event-title {
font-size: 12px;
color: var(--text, #e0e0e0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-meta {
display: flex;
gap: 8px;
margin-top: 2px;
}
.duration, .time {
font-size: 10px;
color: var(--text-dim, #8892b0);
}
.status-badge {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
text-transform: uppercase;
}
.status-accepted {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
}
.status-dismissed {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
}
.status-attached {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
}
.event-text {
font-size: 11px;
color: var(--text-dim, #8892b0);
font-style: italic;
margin-top: 2px;
padding: 2px 6px;
background: rgba(255,255,255,0.03);
border-radius: 3px;
}
.event-actions {
display: flex;
gap: 2px;
align-items: flex-start;
}
.event-actions button {
background: none;
border: 1px solid var(--surface2, #1e2d50);
border-radius: 4px;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.15s;
}
.btn-accept:hover {
background: rgba(46, 204, 113, 0.2);
border-color: #2ecc71;
}
.btn-attach:hover {
background: rgba(52, 152, 219, 0.2);
border-color: #3498db;
}
.btn-dismiss:hover {
background: rgba(231, 76, 60, 0.2);
border-color: #e74c3c;
}
</style>

View File

@ -1,5 +1,6 @@
<script> <script>
import { t } from './i18n' import { t } from './i18n'
import BrowserEvents from './BrowserEvents.svelte'
export let todayDashboard = null export let todayDashboard = null
export let suggestions = [] export let suggestions = []
@ -22,6 +23,14 @@
export let onOpenInboxArtifact = (item) => {} export let onOpenInboxArtifact = (item) => {}
export let onOpenTrashNode = (nodeId) => {} export let onOpenTrashNode = (nodeId) => {}
// Browser events
export let browserEvents = []
export let browserLoading = false
export let onBrowserAccept = (ev) => {}
export let onBrowserDismiss = (ev) => {}
export let onBrowserAttach = (ev) => {}
export let onBrowserRefresh = () => {}
let activeTab = 'feed' let activeTab = 'feed'
function pluralize(n, one, few, many) { function pluralize(n, one, few, many) {
@ -172,6 +181,10 @@
{t('today.captured')} {t('today.captured')}
{#if todayCaptures.length > 0}<span class="tab-badge">{todayCaptures.length}</span>{/if} {#if todayCaptures.length > 0}<span class="tab-badge">{todayCaptures.length}</span>{/if}
</button> </button>
<button class="today-tab" class:active={activeTab === 'browser'} on:click={() => activeTab = 'browser'}>
Браузер
{#if browserEvents.length > 0}<span class="tab-badge">{browserEvents.length}</span>{/if}
</button>
</div> </div>
{#if activeTab === 'feed'} {#if activeTab === 'feed'}
@ -328,6 +341,18 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if activeTab === 'browser'}
<div class="today-tab-content">
<BrowserEvents
events={browserEvents}
loading={browserLoading}
{formatTime}
onAccept={onBrowserAccept}
onDismiss={onBrowserDismiss}
onAttach={onBrowserAttach}
onRefresh={onBrowserRefresh}
/>
</div>
{/if} {/if}
</div> </div>