From 022719aaed835c2475c90561fc45294c473a8a45 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Thu, 4 Jun 2026 17:16:13 -0700 Subject: [PATCH] feat: live auto-refresh + cross-project "Next" view (DECK-11/14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DECK-11: shouldAutoRefresh (pure) + a 15s tick in the projects deck that reloads every 45s while it's the focused, active view (no background churn). - DECK-14: new ๐Ÿ“‹ "Next" rail view aggregating each project's manual note (preferred) or auto resume cue into one "what's next" list, sorted by activity, each with an Open button. collectNextItems is pure + unit-tested. - i18n nav.next / next.title / next.empty across all 4 locales. 101 tests. Co-Authored-By: Claude Opus 4.8 --- scripts/demo-capture.mjs | 5 ++++ src/renderer/index.html | 2 ++ src/renderer/locales/en.json | 3 +++ src/renderer/locales/ja.json | 3 +++ src/renderer/locales/ko.json | 3 +++ src/renderer/locales/zh.json | 3 +++ src/renderer/main.ts | 7 ++++-- src/renderer/nav.ts | 2 +- src/renderer/nextView.ts | 43 ++++++++++++++++++++++++++++++++++ src/renderer/projectsView.ts | 13 ++++++++++ src/renderer/styles.css | 9 +++++++ src/shared/autoRefresh.test.ts | 22 +++++++++++++++++ src/shared/autoRefresh.ts | 16 +++++++++++++ src/shared/nextItems.test.ts | 35 +++++++++++++++++++++++++++ src/shared/nextItems.ts | 30 ++++++++++++++++++++++++ 15 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/renderer/nextView.ts create mode 100644 src/shared/autoRefresh.test.ts create mode 100644 src/shared/autoRefresh.ts create mode 100644 src/shared/nextItems.test.ts create mode 100644 src/shared/nextItems.ts diff --git a/scripts/demo-capture.mjs b/scripts/demo-capture.mjs index a8e8f1a..ee7d1e5 100644 --- a/scripts/demo-capture.mjs +++ b/scripts/demo-capture.mjs @@ -54,6 +54,11 @@ await shot('demo-usage'); // 5) settings await showView('settings'); await shot('demo-settings'); +// 6) Next (cross-project "what's next") โ€” set a note on one project so it mixes notes + cues +await win.evaluate((p) => window.devdeck.setNote(p, 'ship the v0.3 release notes'), join(REPOS, 'acme-dashboard')); +await showView('next'); +await win.waitForTimeout(500); +await shot('demo-next'); await app.close(); console.log('done โ€” qa/shots/demo-*.png'); diff --git a/src/renderer/index.html b/src/renderer/index.html index 46c42aa..4ed37dc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -29,6 +29,7 @@ + @@ -51,6 +52,7 @@
+
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index a29fe51..bdc0ab5 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -5,6 +5,9 @@ "nav.usage": "Usage", "nav.settings": "Settings", "nav.language": "Language", + "nav.next": "Next", + "next.title": "What's next across your projects", + "next.empty": "Nothing queued โ€” add a note or open a session.", "proj.neglected_only": "Neglected only", "proj.hidden": "Hidden", "proj.no_branch": "(no branch)", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 5a8cc3b..5f6cd63 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -5,6 +5,9 @@ "nav.usage": "ไฝฟ็”จ้‡", "nav.settings": "่จญๅฎš", "nav.language": "่จ€่ชž", + "nav.next": "ๆฌกใซ", + "next.title": "ๆฌกใซใ‚„ใ‚‹ใ“ใจ๏ผˆๅ…จใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆ๏ผ‰", + "next.empty": "ๆฌกใฎใ‚ฟใ‚นใ‚ฏใฏใ‚ใ‚Šใพใ›ใ‚“ โ€” ใƒŽใƒผใƒˆใ‚’่ฟฝๅŠ ใ™ใ‚‹ใ‹ใ‚ปใƒƒใ‚ทใƒงใƒณใ‚’้–‹ใ„ใฆใใ ใ•ใ„ใ€‚", "proj.neglected_only": "ๆ”พ็ฝฎใฎใฟ", "proj.hidden": "้ž่กจ็คบ", "proj.no_branch": "(ใƒ–ใƒฉใƒณใƒใชใ—)", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index fe05d27..ec40d8e 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -5,6 +5,9 @@ "nav.usage": "์‚ฌ์šฉ๋Ÿ‰", "nav.settings": "์„ค์ •", "nav.language": "์–ธ์–ด", + "nav.next": "๋‹ค์Œ", + "next.title": "๋‹ค์Œ ํ•  ์ผ (์ „์ฒด ํ”„๋กœ์ ํŠธ)", + "next.empty": "ํ‘œ์‹œํ•  ๋‹ค์Œ ํ•  ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค โ€” ๋…ธํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์„ธ์…˜์„ ์—ฌ์„ธ์š”.", "proj.neglected_only": "๋ฐฉ์น˜๋งŒ", "proj.hidden": "์ˆจ๊น€", "proj.no_branch": "(๋ธŒ๋žœ์น˜ ์—†์Œ)", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 5e10973..1b1e780 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -5,6 +5,9 @@ "nav.usage": "็”จ้‡", "nav.settings": "่ฎพ็ฝฎ", "nav.language": "่ฏญ่จ€", + "nav.next": "ๅพ…ๅŠž", + "next.title": "ๆŽฅไธ‹ๆฅ่ฆๅš็š„๏ผˆๆ‰€ๆœ‰้กน็›ฎ๏ผ‰", + "next.empty": "ๆš‚ๆ— ๅพ…ๅŠž โ€” ๆทปๅŠ ๅค‡ๆณจๆˆ–ๆ‰“ๅผ€ไผš่ฏใ€‚", "proj.neglected_only": "ไป…ๆ˜พ็คบๆ็ฝฎ", "proj.hidden": "ๅทฒ้š่—", "proj.no_branch": "๏ผˆๆ— ๅˆ†ๆ”ฏ๏ผ‰", diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 246d09a..b35d1fd 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -2,6 +2,7 @@ import { mountProjects, renderProjects, reloadProjects } from './projectsView'; import { mountNav } from './nav'; import { mountUsage, showUsage } from './usageView'; import { mountSettings, showSettings } from './settingsView'; +import { mountNext, showNext } from './nextView'; import { setLanguage, tr, currentLang, SUPPORTED } from './i18n-runtime'; const toastHost = document.getElementById('toast-host')!; @@ -16,7 +17,7 @@ function applyStaticLabels(): void { const refreshBtn = document.querySelector('#refresh')!; refreshBtn.title = tr('app.refresh'); refreshBtn.setAttribute('aria-label', tr('app.refresh')); - const map: [string, string][] = [['[data-view="projects"]', 'nav.projects'], ['[data-view="usage"]', 'nav.usage'], ['[data-view="settings"]', 'nav.settings'], ['#lang-btn', 'nav.language']]; + const map: [string, string][] = [['[data-view="projects"]', 'nav.projects'], ['[data-view="usage"]', 'nav.usage'], ['[data-view="settings"]', 'nav.settings'], ['[data-view="next"]', 'nav.next'], ['#lang-btn', 'nav.language']]; for (const [sel, key] of map) { const el = document.querySelector(sel); if (el) { el.title = tr(key); el.setAttribute('aria-label', tr(key)); } } const chk = document.querySelector('#view-projects .chk'); if (chk?.lastChild) chk.lastChild.textContent = ' ' + tr('proj.neglected_only'); @@ -43,7 +44,8 @@ async function boot(): Promise { mountProjects(); mountUsage(); mountSettings(() => { applyStaticLabels(); reloadProjects(); }); - mountNav((view) => { if (view === 'usage') showUsage(); if (view === 'settings') showSettings(); }); + mountNext(); + mountNav((view) => { if (view === 'usage') showUsage(); if (view === 'settings') showSettings(); if (view === 'next') showNext(); }); document.getElementById('lang-btn')!.addEventListener('click', async () => { const i = SUPPORTED.indexOf(currentLang()); @@ -54,6 +56,7 @@ async function boot(): Promise { renderProjects(); if (document.getElementById('view-usage')!.classList.contains('active')) showUsage(); if (document.getElementById('view-settings')!.classList.contains('active')) showSettings(); + if (document.getElementById('view-next')!.classList.contains('active')) showNext(); }); } diff --git a/src/renderer/nav.ts b/src/renderer/nav.ts index 2198654..0cc893d 100644 --- a/src/renderer/nav.ts +++ b/src/renderer/nav.ts @@ -1,7 +1,7 @@ export function mountNav(onShow: (view: string) => void): void { const items = Array.from(document.querySelectorAll('.rail-item[data-view]')); const views = new Map(); - for (const id of ['projects', 'usage', 'settings']) views.set(id, document.getElementById('view-' + id)!); + for (const id of ['projects', 'usage', 'settings', 'next']) views.set(id, document.getElementById('view-' + id)!); function show(view: string): void { for (const it of items) { const isActive = it.dataset.view === view; diff --git a/src/renderer/nextView.ts b/src/renderer/nextView.ts new file mode 100644 index 0000000..8617134 --- /dev/null +++ b/src/renderer/nextView.ts @@ -0,0 +1,43 @@ +import { tr } from './i18n-runtime'; +import { collectNextItems, type NextItem } from '../shared/nextItems'; + +let viewEl: HTMLElement; + +async function load(): Promise { + const projects = await window.devdeck.listProjects(); + render(collectNextItems(projects)); +} + +function render(items: NextItem[]): void { + viewEl.replaceChildren(); + + const head = document.createElement('div'); head.className = 'next-head'; + head.textContent = `${tr('next.title')} ยท ${items.length}`; + viewEl.appendChild(head); + + if (items.length === 0) { + const e = document.createElement('div'); e.className = 'empty'; e.textContent = tr('next.empty'); + viewEl.appendChild(e); + return; + } + + const list = document.createElement('div'); list.className = 'next-list'; list.setAttribute('role', 'list'); + for (const it of items) { + const row = document.createElement('div'); row.className = 'next-row'; row.setAttribute('role', 'listitem'); + const main = document.createElement('div'); main.className = 'next-main'; + const name = document.createElement('span'); name.className = 'next-name'; name.textContent = it.name; + const text = document.createElement('span'); + text.className = 'next-text' + (it.kind === 'cue' ? ' next-text--cue' : ''); + text.textContent = (it.kind === 'cue' ? 'โ†ฉ ' : '') + it.text; + text.title = it.text; + main.append(name, text); + const open = document.createElement('button'); open.className = 'primary'; open.textContent = 'โ–ถ ' + tr('proj.open'); + open.addEventListener('click', () => window.devdeck.open([{ path: it.path, sessionId: null }])); + row.append(main, open); + list.appendChild(row); + } + viewEl.appendChild(list); +} + +export function mountNext(): void { viewEl = document.getElementById('view-next')!; } +export function showNext(): void { void load(); } diff --git a/src/renderer/projectsView.ts b/src/renderer/projectsView.ts index a9d4d19..00c4377 100644 --- a/src/renderer/projectsView.ts +++ b/src/renderer/projectsView.ts @@ -1,4 +1,7 @@ import { tr, localeTag } from './i18n-runtime'; +import { shouldAutoRefresh } from '../shared/autoRefresh'; + +const AUTO_REFRESH_MS = 45_000; type ProjectViewModel = Awaited>[number]; @@ -348,6 +351,16 @@ export function mountProjects(): void { window.addEventListener('focus', () => { if (document.getElementById('view-projects')!.classList.contains('active') && Date.now() - lastLoadMs > 10_000) reload(); }); + // Keep the always-open deck live: periodically re-scan while it is the focused, active view. + setInterval(() => { + if (shouldAutoRefresh({ + now: Date.now(), + lastLoadMs, + intervalMs: AUTO_REFRESH_MS, + viewActive: document.getElementById('view-projects')!.classList.contains('active'), + windowFocused: document.hasFocus(), + })) reload(); + }, 15_000); document.addEventListener('click', () => { document.querySelectorAll('.menu:not(.hidden)').forEach((m) => { m.classList.add('hidden'); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 0075605..45a629c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -124,6 +124,15 @@ button.primary:hover { background: var(--accent-hover); } .cardfoot { display: flex; align-items: center; gap: 8px; margin-top: 6px; } .cardfoot .spacer { flex: 1; } +.next-head { padding: 14px 16px 8px; font-size: 14px; font-weight: 600; color: var(--text); } +.next-list { display: flex; flex-direction: column; gap: 6px; padding: 0 16px 16px; } +.next-row { display: flex; align-items: center; gap: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } +.next-row:hover { background: var(--surface-hover); border-color: var(--border-strong); } +.next-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.next-name { font-size: 13px; font-weight: 600; color: var(--text); } +.next-text { font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.next-text--cue { color: var(--text-faint); } + #toast-host { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; } .toast { background: #2a1416; border: 1px solid #5c1a1a; color: #f3d6d6; padding: 10px 14px; border-radius: 8px; font-size: 13px; max-width: 360px; box-shadow: 0 6px 20px rgba(0,0,0,.4); } diff --git a/src/shared/autoRefresh.test.ts b/src/shared/autoRefresh.test.ts new file mode 100644 index 0000000..bc4247e --- /dev/null +++ b/src/shared/autoRefresh.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { shouldAutoRefresh } from './autoRefresh'; + +const base = { now: 100_000, lastLoadMs: 0, intervalMs: 45_000, viewActive: true, windowFocused: true }; + +describe('shouldAutoRefresh', () => { + it('refreshes when active, focused, and the interval has elapsed', () => { + expect(shouldAutoRefresh(base)).toBe(true); + }); + it('does not refresh too soon', () => { + expect(shouldAutoRefresh({ ...base, now: 10_000 })).toBe(false); + }); + it('does not refresh when the view is inactive', () => { + expect(shouldAutoRefresh({ ...base, viewActive: false })).toBe(false); + }); + it('does not refresh when the window is unfocused', () => { + expect(shouldAutoRefresh({ ...base, windowFocused: false })).toBe(false); + }); + it('refreshes exactly at the interval boundary', () => { + expect(shouldAutoRefresh({ ...base, now: 45_000, lastLoadMs: 0 })).toBe(true); + }); +}); diff --git a/src/shared/autoRefresh.ts b/src/shared/autoRefresh.ts new file mode 100644 index 0000000..948b6d0 --- /dev/null +++ b/src/shared/autoRefresh.ts @@ -0,0 +1,16 @@ +export interface AutoRefreshCtx { + now: number; + lastLoadMs: number; + intervalMs: number; + viewActive: boolean; + windowFocused: boolean; +} + +/** + * Whether the projects deck should auto-reload on this tick: only when it is the + * active view AND the window is focused (don't churn in the background) AND at + * least intervalMs has passed since the last load. + */ +export function shouldAutoRefresh(c: AutoRefreshCtx): boolean { + return c.viewActive && c.windowFocused && c.now - c.lastLoadMs >= c.intervalMs; +} diff --git a/src/shared/nextItems.test.ts b/src/shared/nextItems.test.ts new file mode 100644 index 0000000..a236f97 --- /dev/null +++ b/src/shared/nextItems.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { collectNextItems } from './nextItems'; + +type Src = Parameters[0][number]; +const s = (over: Partial = {}): Src => + ({ path: '/p', name: 'p', note: '', resumeCue: null, hidden: false, activityMs: 0, ...over }); + +describe('collectNextItems', () => { + it('uses the manual note when present (trimmed)', () => { + expect(collectNextItems([s({ name: 'a', note: ' fix the bug ' })])) + .toEqual([{ path: '/p', name: 'a', text: 'fix the bug', kind: 'note' }]); + }); + + it('falls back to the resume cue when there is no note', () => { + expect(collectNextItems([s({ name: 'b', resumeCue: { kind: 'lastMessage', text: 'do X' } })])) + .toEqual([{ path: '/p', name: 'b', text: 'do X', kind: 'cue' }]); + }); + + it('prefers a note over a cue', () => { + const out = collectNextItems([s({ note: 'note wins', resumeCue: { kind: 'lastMessage', text: 'cue' } })]); + expect(out[0]).toMatchObject({ text: 'note wins', kind: 'note' }); + }); + + it('skips projects with neither, and hidden projects', () => { + expect(collectNextItems([s({ note: '', resumeCue: null }), s({ hidden: true, note: 'x' })])).toEqual([]); + }); + + it('sorts by activity, most recent first', () => { + const out = collectNextItems([ + s({ name: 'old', note: 'o', activityMs: 1 }), + s({ name: 'new', note: 'n', activityMs: 9 }), + ]); + expect(out.map((i) => i.name)).toEqual(['new', 'old']); + }); +}); diff --git a/src/shared/nextItems.ts b/src/shared/nextItems.ts new file mode 100644 index 0000000..54887b8 --- /dev/null +++ b/src/shared/nextItems.ts @@ -0,0 +1,30 @@ +import type { ProjectViewModel } from './types'; + +export interface NextItem { + path: string; + name: string; + text: string; + kind: 'note' | 'cue'; +} + +type NextSource = Pick; + +/** + * Cross-project "what's next" list: each visible project's manual note (preferred) + * or, if it has none, its auto resume cue. Projects with neither are omitted. + * Sorted by recent activity. + */ +export function collectNextItems(projects: NextSource[]): NextItem[] { + const items: (NextItem & { activityMs: number | null })[] = []; + for (const p of projects) { + if (p.hidden) continue; + const note = (p.note ?? '').trim(); + if (note) { + items.push({ path: p.path, name: p.name, text: note, kind: 'note', activityMs: p.activityMs }); + } else if (p.resumeCue) { + items.push({ path: p.path, name: p.name, text: p.resumeCue.text, kind: 'cue', activityMs: p.activityMs }); + } + } + items.sort((a, b) => (b.activityMs ?? -Infinity) - (a.activityMs ?? -Infinity)); + return items.map((i) => ({ path: i.path, name: i.name, text: i.text, kind: i.kind })); +}