From cb424bde979823be5611b9f4a98459997a91f9d8 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Tue, 11 Aug 2026 00:54:27 +0800 Subject: [PATCH 1/4] fix(dream): resolve report locale before prompting LLM --- .../agent/__tests__/dao_dream_app.test.ts | 61 +++++++++++- .../agent/__tests__/dao_dream_runner.test.ts | 27 ++++- .../__tests__/dao_weekly_dream_runner.test.ts | 25 ++++- .../ui/webui/resources/agent/dao_dream_app.ts | 99 ++++++++++--------- .../webui/resources/agent/dao_dream_runner.ts | 17 ++-- .../agent/dao_weekly_dream_runner.ts | 14 ++- 6 files changed, 178 insertions(+), 65 deletions(-) diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts index 1b1e8d69..972b35b9 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts @@ -35,6 +35,7 @@ vi.mock('../dao_share_image.js', () => ({ vi.mock('../i18n/i18n.js', () => ({ initI18n: vi.fn(async () => undefined), + currentLocale: () => 'zh-CN', t: (key: string, vars?: Record) => { const templates: Record = { 'chat.dream.card_date': 'About {date}', @@ -118,6 +119,12 @@ vi.mock('../vendor/pi_runtime_bundle.js', () => ({ import '../dao_dream_app.js'; +const dreamAppCtor = customElements.get('dao-dream-app') as + CustomElementConstructor & { + invokeLifecycleCallbacksForTesting?: boolean; + }; +dreamAppCtor.invokeLifecycleCallbacksForTesting = true; + type TestDreamApp = HTMLElement & {updateComplete: Promise}; type TestDreamAppPrototype = { renderActivityHeatmap_: () => ReturnType; @@ -291,6 +298,19 @@ describe('dao-dream-app routing', () => { expect(el.shadowRoot!.textContent).toContain('Thu, Jun 11'); }); + it('opens the activity heatmap at the newest dates', async () => { + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(760); + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(220); + bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-12')]); + + const el = await mountDreamApp('/'); + const heatmap = + el.shadowRoot!.querySelector('.heatmap-scroll'); + + expect(heatmap).toBeTruthy(); + expect(heatmap!.scrollLeft).toBe(540); + }); + it('loads dream history for dao://dream/history', async () => { bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-10')]); @@ -395,7 +415,7 @@ describe('dao-dream-app routing', () => { expect(root.querySelectorAll('.rhythm-slot')).toHaveLength(4); expect(root.querySelector( '.rhythm-slot[data-peak="true"]')?.textContent) - .toContain('125 min'); + .toContain('2小时 5分钟'); expect(root.querySelectorAll('.theme-card')).toHaveLength(1); expect(root.querySelector('.theme-card')?.textContent) .toContain('Rust async programming'); @@ -424,13 +444,46 @@ describe('dao-dream-app routing', () => { .toContain('Main thread'); }); + it('uses legacy report content instead of its generic heading in history', + async () => { + bridgeMocks.callNative.mockResolvedValueOnce([{ + ...report('2026-06-19'), + reportMarkdown: + '## 昨天的主线\n完成了发布流程整理,并验证了关键配置。', + }]); + + const el = await mountDreamApp('/'); + const historySummary = + el.shadowRoot!.querySelector('.history-kind')?.textContent || ''; + + expect(historySummary).toContain( + '完成了发布流程整理,并验证了关键配置。'); + expect(historySummary).not.toContain('昨天的主线'); + }); + + it('keeps a valid structured theme when recap summary is empty', async () => { + const stats = JSON.parse(recapMaterialStats()); + stats.recap.summary = ''; + bridgeMocks.callNative.mockResolvedValueOnce([{ + ...report('2026-06-19', '[]', JSON.stringify(stats)), + reportMarkdown: '## 昨天的主线\n旧版正文不应覆盖结构化主题。', + }]); + + const el = await mountDreamApp('/'); + const historySummary = + el.shadowRoot!.querySelector('.history-kind')?.textContent || ''; + + expect(historySummary).toContain('Rust async programming'); + expect(historySummary).not.toContain('旧版正文'); + }); + it('uses measured foreground buckets instead of model-estimated rhythm', async () => { const stats = JSON.parse(recapMaterialStats()); stats.foreground_seconds_by_bucket = { morning: 600, afternoon: 7200, - evening: 1800, + evening: 7500, night: 0, }; stats.recap.time_buckets = { @@ -449,8 +502,8 @@ describe('dao-dream-app routing', () => { expect(slots.map(slot => slot.textContent)).toEqual([ expect.stringContaining('10 min'), - expect.stringContaining('120 min'), - expect.stringContaining('30 min'), + expect.stringContaining('2小时'), + expect.stringContaining('2小时 5分钟'), expect.stringContaining('0 min'), ]); }); diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts index 40427d0c..4e0dbbfa 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts @@ -7,6 +7,12 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; const callLLMStreaming = vi.fn(); const recordApiCall = vi.fn(); const addWebUIListener = vi.fn(); +const i18nMocks = vi.hoisted(() => ({ + initialized: false, + locale: 'zh-CN', + initI18n: vi.fn(), + currentLocale: vi.fn(), +})); vi.mock('../agent_bridge.js', () => ({ addWebUIListener: (...args: unknown[]) => addWebUIListener(...args), @@ -33,7 +39,8 @@ vi.mock('../llm_config.js', () => ({ }), })); vi.mock('../i18n/i18n.js', () => ({ - currentLocale: () => 'zh-CN', + initI18n: () => i18nMocks.initI18n(), + currentLocale: () => i18nMocks.currentLocale(), })); import {extractJson, runDream} from '../dao_dream_runner.js'; @@ -98,6 +105,15 @@ describe('runDream', () => { beforeEach(() => { callLLMStreaming.mockReset(); recordApiCall.mockReset(); + i18nMocks.initialized = false; + i18nMocks.locale = 'zh-CN'; + i18nMocks.initI18n.mockReset(); + i18nMocks.initI18n.mockImplementation(async () => { + i18nMocks.initialized = true; + }); + i18nMocks.currentLocale.mockReset(); + i18nMocks.currentLocale.mockImplementation( + () => i18nMocks.initialized ? i18nMocks.locale : 'en'); }); it('parses a valid response and caps confidence at 0.8', async () => { @@ -139,7 +155,8 @@ describe('runDream', () => { expect(recordApiCall).toHaveBeenCalledWith(11, 7, 2, 6); }); - it('asks the model to keep user-facing text in the current locale', async () => { + it('injects the resolved locale into the report prompts', async () => { + i18nMocks.locale = 'fr'; respondWith(VALID); await runDream('2026-06-11', {}); @@ -149,9 +166,13 @@ describe('runDream', () => { }>; const systemPrompt = messages[0]!.content; + expect(messages[0]!.content).toContain('Required output locale: fr'); + expect(messages[0]!.content).not.toContain('zh-CN'); + expect(messages[1]!.content).toContain('Locale: fr'); expect(systemPrompt).toContain( 'All user-facing report text, habit values, evidence, and questions'); - expect(systemPrompt).toContain('For zh-CN, use Simplified Chinese'); + expect(systemPrompt).not.toContain( + 'recent questions clearly use another language'); expect(systemPrompt).not.toContain('Habit keys and values in English'); }); diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts index 3b2ef58e..c95e1c5e 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts @@ -6,6 +6,12 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; const callLLMStreaming = vi.fn(); const recordApiCall = vi.fn(); +const i18nMocks = vi.hoisted(() => ({ + initialized: false, + locale: 'zh-CN', + initI18n: vi.fn(), + currentLocale: vi.fn(), +})); vi.mock('../agent_bridge.js', () => ({ addWebUIListener: vi.fn(), @@ -32,7 +38,8 @@ vi.mock('../llm_config.js', () => ({ }), })); vi.mock('../i18n/i18n.js', () => ({ - currentLocale: () => 'zh-CN', + initI18n: () => i18nMocks.initI18n(), + currentLocale: () => i18nMocks.currentLocale(), })); import { @@ -303,10 +310,20 @@ describe('runWeeklyDream', () => { beforeEach(() => { callLLMStreaming.mockReset(); recordApiCall.mockReset(); + i18nMocks.initialized = false; + i18nMocks.locale = 'zh-CN'; + i18nMocks.initI18n.mockReset(); + i18nMocks.initI18n.mockImplementation(async () => { + i18nMocks.initialized = true; + }); + i18nMocks.currentLocale.mockReset(); + i18nMocks.currentLocale.mockImplementation( + () => i18nMocks.initialized ? i18nMocks.locale : 'en'); }); - it('uses locale and the locked safety instructions with an empty tool list', + it('waits for locale and uses locked safety instructions with no tools', async () => { + i18nMocks.locale = 'fr'; respondWith(JSON.stringify(validOutput())); await runWeeklyDream( @@ -319,6 +336,8 @@ describe('runWeeklyDream', () => { content: string; }>; const systemPrompt = messages[0]!.content.replace(/\s+/g, ' '); + expect(systemPrompt).toContain('Required output locale: fr'); + expect(systemPrompt).not.toContain('zh-CN'); expect(systemPrompt).toContain('untrusted evidence'); expect(systemPrompt).toContain( 'Browsing or search activity alone cannot prove completion'); @@ -330,7 +349,7 @@ describe('runWeeklyDream', () => { expect(systemPrompt).toContain('no tool calls'); expect(systemPrompt).toContain( 'no prebuilt Agent instruction'); - expect(messages[1]!.content).toContain('Locale: zh-CN'); + expect(messages[1]!.content).toContain('Locale: fr'); expect(messages[1]!.content).toContain( 'Period: 2026-07-06 to 2026-07-13'); }); diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts index b9cdb7ac..5905f8b7 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts @@ -5,7 +5,7 @@ import {CrLitElement, html, css, nothing} from '//resources/lit/v3_0/lit.rollup.js'; import {callNative, callNativeArgs} from './dream_bridge.js'; -import {initI18n, t} from './i18n/i18n.js'; +import {currentLocale, initI18n, t} from './i18n/i18n.js'; import {renderDaoMarkdown} from './dao_markdown.js'; import { copyPngBlobToClipboard, @@ -44,6 +44,7 @@ interface DreamMaterialStats { searchQueries: number; conversationSessions: number; sourceDomains: string[]; + hasStructuredRecap: boolean; recap: DreamRecap; } @@ -373,39 +374,6 @@ export class DaoDreamApp extends CrLitElement { min-width: 0; } - .history-item { - display: block; - width: 100%; - height: auto; - min-height: 36px; - margin: 0 0 6px; - padding: 8px 10px; - border-color: transparent; - background: transparent; - text-align: left; - } - - .history-item:hover, - .history-item.selected { - border-color: rgba(70, 120, 190, 0.22); - background: rgba(70, 120, 190, 0.08); - } - - .history-date { - display: block; - font-size: 13px; - font-weight: 650; - line-height: 1.3; - } - - .history-kind { - display: block; - margin-top: 2px; - color: rgba(30, 20, 40, 0.48); - font-size: 11px; - line-height: 1.3; - } - .markdown { font-size: 15px; line-height: 1.72; @@ -784,12 +752,13 @@ export class DaoDreamApp extends CrLitElement { } .history-item:hover { - background: rgba(var(--dream-accent), 0.06); + border-color: rgba(var(--dream-accent), 0.2); + background: rgba(var(--dream-accent), 0.1); } .history-item[data-selected="true"] { - border-color: rgba(var(--dream-accent), 0.22); - background: rgba(var(--dream-accent), 0.1); + border-color: rgba(var(--dream-accent), 0.28); + background: rgba(var(--dream-accent), 0.14); } .history-item:focus-visible { @@ -1380,6 +1349,21 @@ export class DaoDreamApp extends CrLitElement { void this.loadPage_(); } + override updated(changedProperties: Map) { + if (changedProperties.has('loading_') && !this.loading_) { + this.scrollHeatmapToLatest_(); + } + } + + private scrollHeatmapToLatest_() { + const heatmap = + this.shadowRoot?.querySelector('.heatmap-scroll'); + if (!heatmap) { + return; + } + heatmap.scrollLeft = Math.max(0, heatmap.scrollWidth - heatmap.clientWidth); + } + private currentRoute_(): 'today'|'history' { const path = window.location.pathname.replace(/\/+$/, ''); return path === '/today' ? 'today' : 'history'; @@ -1578,6 +1562,9 @@ export class DaoDreamApp extends CrLitElement { }); } } + const recapSummary = typeof recap['summary'] === 'string' ? + recap['summary'].trim() : ''; + const hasStructuredRecap = Boolean(recapSummary || themes.length > 0); if (themes.length === 0) { sections.slice(0, 3).forEach((section, index) => themes.push({ name: section.title || t('dream.page.theme_fallback'), @@ -1590,14 +1577,13 @@ export class DaoDreamApp extends CrLitElement { const sourceDomains = Array.isArray(parsed['source_domains']) ? parsed['source_domains'].filter( (item): item is string => typeof item === 'string') : []; - const recapSummary = typeof recap['summary'] === 'string' ? - recap['summary'].trim() : ''; return { historyDomains: this.boundedNumber_(parsed['history_domains'], 10000), searchQueries: this.boundedNumber_(parsed['search_queries'], 10000), conversationSessions: this.boundedNumber_(parsed['conversation_sessions'], 10000), sourceDomains, + hasStructuredRecap, recap: { summary: recapSummary || sections[0]?.body || reportMarkdown.trim(), timeBuckets: { @@ -2117,8 +2103,24 @@ export class DaoDreamApp extends CrLitElement { `; } - private formatMinutes_(minutes: number) { - return t('dream.page.minutes', {count: minutes}); + private formatDuration_(minutes: number) { + const roundedMinutes = Math.max(0, Math.round(minutes)); + if (roundedMinutes < 60) { + return t('dream.page.minutes', {count: roundedMinutes}); + } + const hours = Math.floor(roundedMinutes / 60); + const remainingMinutes = roundedMinutes % 60; + const formatUnit = (value: number, unit: 'hour'|'minute') => + new Intl.NumberFormat(currentLocale(), { + style: 'unit', + unit, + unitDisplay: 'short', + }).format(value); + const formattedHours = formatUnit(hours, 'hour'); + if (remainingMinutes === 0) { + return formattedHours; + } + return `${formattedHours} ${formatUnit(remainingMinutes, 'minute')}`; } private renderRhythm_(report: DailyDreamReportData) { @@ -2149,7 +2151,7 @@ export class DaoDreamApp extends CrLitElement { style=${`height:${Math.round(value / peak * 100)}%`}>
${label} - ${this.formatMinutes_(value)} + ${this.formatDuration_(value)}
`)} @@ -2206,6 +2208,16 @@ export class DaoDreamApp extends CrLitElement { `; } + private historySummary_(report: DailyDreamReportData) { + if (!report.stats.hasStructuredRecap) { + return report.stats.recap.summary || + this.triggerKindLabel_(report.triggerKind); + } + return report.stats.recap.themes[0]?.name || + report.stats.recap.summary || + this.triggerKindLabel_(report.triggerKind); + } + private renderHistoryList_() { const reports = this.historyReports_(); const selectedKey = this.report_ ? @@ -2242,8 +2254,7 @@ export class DaoDreamApp extends CrLitElement { ${weekly ? report.headline : - report.stats.recap.themes[0]?.name || - this.triggerKindLabel_(report.triggerKind)} + this.historySummary_(report)} ${weekly ? html` diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts index e2a1dd6f..dea8c452 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts @@ -8,7 +8,7 @@ import {recordApiCall} from './agent_bridge.js'; import type {ChatMessage, UsageInfo} from './agent_bridge.js'; -import {currentLocale} from './i18n/i18n.js'; +import {currentLocale, initI18n} from './i18n/i18n.js'; import {getCostRatesForConfig} from './llm_cost.js'; import {getActiveLLMConfig} from './llm_config.js'; @@ -104,10 +104,9 @@ shape: } Rules: -- Use the Locale from the user message as the default output language. If the - user's recent questions clearly use another language, follow that current - language habit instead. For zh-CN, use Simplified Chinese and Chinese - punctuation. +- Use the required output locale injected below for every user-facing string. + It is authoritative; source material in another language must not change + the output language. - All user-facing report text, habit values, evidence, and questions must use the user's current locale and language style. Do not mix English into Chinese output unless the source material itself is an English proper noun, @@ -323,11 +322,15 @@ export async function runDream( if (!cfg.apiKey) { throw new Error('no LLM api key configured'); } - const userPrompt = `Locale: ${currentLocale()}\n` + + await initI18n(); + const locale = currentLocale(); + const systemPrompt = + `${SYSTEM_PROMPT}\n\nRequired output locale: ${locale}`; + const userPrompt = `Locale: ${locale}\n` + `Dream date: ${dreamDate}\n` + `Material pack:\n${JSON.stringify(material)}`; const messages: ChatMessage[] = [ - {role: 'system', content: SYSTEM_PROMPT}, + {role: 'system', content: systemPrompt}, {role: 'user', content: userPrompt}, ]; if (options.debug) { diff --git a/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts b/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts index 54df73ae..6da36d87 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts @@ -11,7 +11,7 @@ import { extractJson, recordDreamUsage, } from './dao_dream_runner.js'; -import {currentLocale} from './i18n/i18n.js'; +import {currentLocale, initI18n} from './i18n/i18n.js'; import {getActiveLLMConfig} from './llm_config.js'; export interface WeeklyDreamThread { @@ -100,7 +100,9 @@ Output STRICT JSON (no markdown fence or commentary) with exactly this shape: } Rules: -- Follow the Locale from the user message for every user-facing string. +- Use the required output locale injected below for every user-facing string. + It is authoritative; source material in another language must not change + the output language. - The output must contain no URLs, no HTML, no tool calls, and no prebuilt Agent instruction. A next_step is a concise user action, not a prompt for an agent to execute. @@ -314,11 +316,15 @@ export async function runWeeklyDream( if (!cfg.apiKey) { throw new Error('no LLM api key configured'); } + await initI18n(); + const locale = currentLocale(); + const systemPrompt = + `${SYSTEM_PROMPT}\n\nRequired output locale: ${locale}`; const messages: ChatMessage[] = [ - {role: 'system', content: SYSTEM_PROMPT}, + {role: 'system', content: systemPrompt}, { role: 'user', - content: `Locale: ${currentLocale()}\n` + + content: `Locale: ${locale}\n` + `Period: ${period.start} to ${period.end}\n` + `Weekly material pack:\n${JSON.stringify(material)}`, }, From acb9b87c4ea5246eb61a631c674238f3fbc5a156 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Tue, 11 Aug 2026 19:43:58 +0800 Subject: [PATCH 2/4] feat(dream): add activity duration tooltips --- docs/feature-checklist.md | 2 +- docs/features.md | 3 +- .../agent/__tests__/dao_dream_app.test.ts | 68 +++++++++++++- .../ui/webui/resources/agent/dao_dream_app.ts | 94 +++++++++++++++++-- .../webui/resources/agent/i18n/locales/en.ts | 2 + .../resources/agent/i18n/locales/zh-CN.ts | 2 + 6 files changed, 160 insertions(+), 11 deletions(-) diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 44f01b79..ced19904 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -125,7 +125,7 @@ Flagship feature. C++ services + `dao://dao-agent` WebUI + vendor runtime. | ☐ | Agent web tools search/fetch tiering | `src/dao/.../resources/agent/web_search/`, Settings Dao page patches, `dao_agent_ui.cc` | — | Provider built-in search is preferred; Auto mode uses configured Jina Search before DuckDuckGo HTML; DuckDuckGo anomaly/verification pages report that accurately instead of `HTML structure changed`; `fetch_url` still falls back from Jina Reader to browser fetch | | ☐ | Dream scheduler and Agent settings controls | `src/dao/.../agent/dao_dream_service.*`, Settings Dao page patches, `dream_bridge.ts` | — | Dream remains off by default; enabling requires memory; nightly/catch-up/manual runs honor idle/time/date gates and show status/history on `dao://dream` | | ☐ | Dream material privacy and excluded-domain filtering | `src/dao/.../agent/dao_dream_material_collector.*`, `dao_dream_domain_utils.*`, `dao_pref_names.*` | — | Excluded domains are normalized and removed before titles/search queries/debug material leave C++; stats do not leak excluded domain names | -| ☐ | Dream one-minute recap, history, rerun, sharing, and habit feedback | `dao_dream_app.ts`, `dao_dream_runner.ts`, `dao_dream_service.cc`, `dao_share_image.ts`, `dao_agent_ui.cc` | — | `dao://dream` loads up to 371 daily reports for the 53-week activity heatmap plus 53 weekly reports for the shared 14-item history rail; structured summaries, measured per-period foreground rhythm (with legacy model-estimate fallback), uncapped aggregate counts, themes, stats, and memory candidates render; candidates affect memory only after confirmation and rejection preserves existing memory; legacy markdown-only reports derive useful recap content; daily/weekly selection, rerun replacement/failure preservation, share image, full-report disclosure, domain exclusion, debug view, and feedback actions work | +| ☐ | Dream one-minute recap, history, rerun, sharing, and habit feedback | `dao_dream_app.ts`, `dao_dream_runner.ts`, `dao_dream_service.cc`, `dao_share_image.ts`, `dao_agent_ui.cc` | — | `dao://dream` loads up to 371 daily reports for the 53-week activity heatmap plus 53 weekly reports for the shared 14-item history rail; report cells show localized date and active-duration tooltips on pointer hover and keyboard focus, legacy reports without duration show the unavailable state, and pointer leave, blur, or heatmap scrolling dismisses the tooltip; structured summaries, measured per-period foreground rhythm (with legacy model-estimate fallback), uncapped aggregate counts, themes, stats, and memory candidates render; candidates affect memory only after confirmation and rejection preserves existing memory; legacy markdown-only reports derive useful recap content; daily/weekly selection, rerun replacement/failure preservation, share image, full-report disclosure, domain exclusion, debug view, and feedback actions work | ## 4. Picture-in-Picture Enhancements diff --git a/docs/features.md b/docs/features.md index 81b6b4a6..62e22d28 100644 --- a/docs/features.md +++ b/docs/features.md @@ -274,7 +274,8 @@ The stack includes: **LLM tool calling**, **long-term memory** (SQLite + FTS5), and an optional debug view of the exact LLM input (`dao.dream_debug`). - **`dao://dream` one-minute recap** — responsive two-column report with a 53-week real-report activity heatmap, compact daily-and-weekly history rail, - summary card, + localized date-and-active-duration tooltips for report-bearing heatmap cells + on pointer hover and keyboard focus, summary card, measured foreground-focus rhythm, topic cards, aggregate counts, memory-candidate actions, and a folded full report. Existing rerun, image sharing, source-domain exclusion, diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts index 972b35b9..8e831ef1 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts @@ -77,6 +77,8 @@ vi.mock('../i18n/i18n.js', () => ({ 'dream.page.activity_less': 'Less', 'dream.page.activity_more': 'More', 'dream.page.activity_label': 'Daily Dream report activity', + 'dream.page.activity_tooltip': '{date} · {duration}', + 'dream.page.activity_duration_unavailable': 'Duration unavailable', 'dream.page.weekly_badge': 'Weekly', 'dream.page.weekly_eyebrow': 'Weekly Dream Recap', 'dream.page.weekly_period': '{start} – {end}', @@ -295,7 +297,7 @@ describe('dao-dream-app routing', () => { expect(bridgeMocks.callNative).toHaveBeenCalledWith( 'getDreamReports', {limit: 371}); expect(el.shadowRoot!.textContent).toContain('2026-06-12'); - expect(el.shadowRoot!.textContent).toContain('Thu, Jun 11'); + expect(el.shadowRoot!.textContent).toContain('6月11日周四'); }); it('opens the activity heatmap at the newest dates', async () => { @@ -311,6 +313,70 @@ describe('dao-dream-app routing', () => { expect(heatmap!.scrollLeft).toBe(540); }); + it('shows and hides localized active duration for a heatmap cell', + async () => { + restoreActivityHeatmap(); + bridgeMocks.callNative.mockResolvedValueOnce([ + report('2026-06-19', '[]', recapMaterialStats()), + ]); + + const el = await mountDreamApp('/'); + const cell = el.shadowRoot!.querySelector( + '.heat-cell[aria-label="6月19日周五"]'); + expect(cell).toBeTruthy(); + + cell!.dispatchEvent(new Event('pointerenter')); + await el.updateComplete; + + const tooltip = el.shadowRoot!.querySelector( + '[role="tooltip"]'); + expect(tooltip?.textContent).toContain('6月19日'); + expect(tooltip?.textContent).toContain('3小时 54分钟'); + expect(tooltip?.closest('.activity-heatmap')).toBeNull(); + expect(el.shadowRoot! + .querySelector( + '.heat-cell[aria-label="6月19日周五"]') + ?.getAttribute('aria-describedby')) + .toBe('dream-activity-tooltip'); + + cell!.dispatchEvent(new Event('pointerleave')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeNull(); + + el.shadowRoot! + .querySelector( + '.heat-cell[aria-label="6月19日周五"]')! + .dispatchEvent(new Event('pointerenter')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeTruthy(); + + el.shadowRoot!.querySelector('.heatmap-scroll')! + .dispatchEvent(new Event('scroll')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeNull(); + }); + + it('shows unavailable duration for a legacy heatmap report on focus', + async () => { + restoreActivityHeatmap(); + bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-19')]); + + const el = await mountDreamApp('/'); + const cell = el.shadowRoot!.querySelector( + '.heat-cell[aria-label="6月19日周五"]'); + expect(cell).toBeTruthy(); + + cell!.dispatchEvent(new Event('focus')); + await el.updateComplete; + + expect(el.shadowRoot!.querySelector('[role="tooltip"]')?.textContent) + .toContain('Duration unavailable'); + + cell!.dispatchEvent(new Event('blur')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeNull(); + }); + it('loads dream history for dao://dream/history', async () => { bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-10')]); diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts index 5905f8b7..3c4ed48e 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts @@ -45,9 +45,17 @@ interface DreamMaterialStats { conversationSessions: number; sourceDomains: string[]; hasStructuredRecap: boolean; + hasDurationData: boolean; recap: DreamRecap; } +interface ActivityTooltipState { + dateKey: string; + text: string; + left: number; + top: number; +} + interface DailyDreamReportData { reportKind: 'daily'; id: number; @@ -119,6 +127,7 @@ export class DaoDreamApp extends CrLitElement { dreamExcludedDomains_: {type: Array, state: true}, dreamExclusionAdding_: {type: Boolean, state: true}, dreamExclusionError_: {type: String, state: true}, + activityTooltip_: {type: Object, state: true}, }; } @@ -134,6 +143,7 @@ export class DaoDreamApp extends CrLitElement { declare private dreamExcludedDomains_: string[]; declare private dreamExclusionAdding_: boolean; declare private dreamExclusionError_: string; + declare private activityTooltip_: ActivityTooltipState|null; constructor() { super(); @@ -149,6 +159,7 @@ export class DaoDreamApp extends CrLitElement { this.dreamExcludedDomains_ = []; this.dreamExclusionAdding_ = false; this.dreamExclusionError_ = ''; + this.activityTooltip_ = null; } static override get styles() { @@ -695,6 +706,23 @@ export class DaoDreamApp extends CrLitElement { outline-offset: 1px; } + .activity-tooltip { + position: fixed; + z-index: 10; + max-width: min(240px, calc(100vw - 24px)); + padding: 6px 9px; + border: 1px solid rgba(var(--dream-accent), 0.22); + border-radius: 7px; + background: rgba(30, 20, 40, 0.92); + box-shadow: 0 6px 18px rgba(var(--dream-ink), 0.16); + color: white; + font-size: 11px; + line-height: 1.35; + pointer-events: none; + transform: translate(-50%, calc(-100% - 7px)); + white-space: nowrap; + } + .heatmap-legend { display: flex; align-items: center; @@ -1528,6 +1556,12 @@ export class DaoDreamApp extends CrLitElement { const hasMeasuredBuckets = measuredBuckets !== null && ['morning', 'afternoon', 'evening', 'night'].some( key => typeof measuredBuckets[key] === 'number'); + const hasRecapBuckets = [ + 'morning_minutes', + 'afternoon_minutes', + 'evening_minutes', + 'night_minutes', + ].some(key => typeof rawBuckets[key] === 'number'); const bucketMinutes = (name: string, recapName: string) => { if (hasMeasuredBuckets) { return this.boundedNumber_( @@ -1584,6 +1618,7 @@ export class DaoDreamApp extends CrLitElement { this.boundedNumber_(parsed['conversation_sessions'], 10000), sourceDomains, hasStructuredRecap, + hasDurationData: hasMeasuredBuckets || hasRecapBuckets, recap: { summary: recapSummary || sections[0]?.body || reportMarkdown.trim(), timeBuckets: { @@ -1980,7 +2015,7 @@ export class DaoDreamApp extends CrLitElement { private formatDreamDate_(value: string): string { const date = this.parseDreamDate_(value); - return date ? new Intl.DateTimeFormat(undefined, { + return date ? new Intl.DateTimeFormat(currentLocale(), { month: 'short', day: 'numeric', weekday: 'short', @@ -2000,6 +2035,30 @@ export class DaoDreamApp extends CrLitElement { return signals >= 16 ? 4 : signals >= 9 ? 3 : signals >= 3 ? 2 : 1; } + private showActivityTooltip_(event: Event, report: DailyDreamReportData) { + const cell = event.currentTarget as HTMLElement; + const rect = cell.getBoundingClientRect(); + const buckets = report.stats.recap.timeBuckets; + const duration = report.stats.hasDurationData ? + this.formatDuration_( + buckets.morning + buckets.afternoon + buckets.evening + + buckets.night) : + t('dream.page.activity_duration_unavailable'); + this.activityTooltip_ = { + dateKey: report.dreamDate, + text: t('dream.page.activity_tooltip', { + date: this.formatDreamDate_(report.dreamDate), + duration, + }), + left: rect.left + rect.width / 2, + top: rect.top, + }; + } + + private hideActivityTooltip_() { + this.activityTooltip_ = null; + } + private renderActivityHeatmap_() { const reportsByDate = new Map( this.reports_.map(report => [report.dreamDate, report])); @@ -2029,7 +2088,7 @@ export class DaoDreamApp extends CrLitElement { const column = Math.floor(index / 7) + 1; if (date.getMonth() !== previousMonth && date.getDate() <= 7) { monthLabels.push({ - label: new Intl.DateTimeFormat(undefined, {month: 'short'}) + label: new Intl.DateTimeFormat(currentLocale(), {month: 'short'}) .format(date), column, }); @@ -2040,11 +2099,22 @@ export class DaoDreamApp extends CrLitElement { `); } return html` @@ -2055,7 +2125,8 @@ export class DaoDreamApp extends CrLitElement { count: this.reports_.length, })} -
+
this.hideActivityTooltip_()}>
${monthLabels.map(month => html` ${month.label}`)} @@ -2071,7 +2142,14 @@ export class DaoDreamApp extends CrLitElement { `)} ${t('dream.page.activity_more')}
- `; + + ${this.activityTooltip_ ? html` + ` : nothing}`; } private renderThemeIcon_(index: number) { diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts index b3317615..6b129a7f 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts @@ -407,6 +407,8 @@ const dict: Dictionary = { 'dream.page.activity_less': 'Less', 'dream.page.activity_more': 'More', 'dream.page.activity_label': 'Daily Dream report activity', + 'dream.page.activity_tooltip': '{date} · {duration}', + 'dream.page.activity_duration_unavailable': 'Duration unavailable', 'dream.page.weekly_badge': 'Weekly', 'dream.page.weekly_eyebrow': 'Weekly Dream Recap', 'dream.page.weekly_period': '{start} – {end}', diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts index 52549a41..78e3151c 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts @@ -307,6 +307,8 @@ const dict: Dictionary = { 'dream.page.activity_less': '少', 'dream.page.activity_more': '多', 'dream.page.activity_label': '过去一年每日梦境报告活跃度', + 'dream.page.activity_tooltip': '{date} · {duration}', + 'dream.page.activity_duration_unavailable': '暂无时长数据', 'dream.page.weekly_badge': '每周', 'dream.page.weekly_eyebrow': '每周梦境回顾', 'dream.page.weekly_period': '{start} 至 {end}', From 60f599cece243a244202260c7f237e75ff5b8f86 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Tue, 11 Aug 2026 22:52:22 +0800 Subject: [PATCH 3/4] test(dream): reduce tooltip test render work --- .../agent/__tests__/dao_dream_app.test.ts | 28 +++++++- .../ui/webui/resources/agent/dao_dream_app.ts | 67 +++++++++++-------- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts index 8e831ef1..f21ced88 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts @@ -128,14 +128,22 @@ const dreamAppCtor = customElements.get('dao-dream-app') as dreamAppCtor.invokeLifecycleCallbacksForTesting = true; type TestDreamApp = HTMLElement & {updateComplete: Promise}; +type TestDreamReport = {dreamDate: string}; type TestDreamAppPrototype = { + reports_: TestDreamReport[]; renderActivityHeatmap_: () => ReturnType; + renderActivityHeatmapCell_: ( + dateKey: string, label: string, report: TestDreamReport|null, + level: number, column: number, row: number) => ReturnType; + renderActivityTooltip_: () => ReturnType; + hideActivityTooltip_: () => void; }; const dreamAppPrototype = (customElements.get('dao-dream-app') as CustomElementConstructor) .prototype as unknown as TestDreamAppPrototype; let restoreActivityHeatmap: () => void; +let useSingleCellActivityHeatmap: (dateKey: string, label: string) => void; function report( dreamDate: string, @@ -278,6 +286,22 @@ describe('dao-dream-app routing', () => {
`); restoreActivityHeatmap = () => activityHeatmapSpy.mockRestore(); + useSingleCellActivityHeatmap = (dateKey: string, label: string) => { + activityHeatmapSpy.mockImplementation(function( + this: TestDreamAppPrototype) { + const report = + this.reports_.find(item => item.dreamDate === dateKey) || null; + return html` +
+
this.hideActivityTooltip_()}> + ${this.renderActivityHeatmapCell_( + dateKey, label, report, report ? 1 : 0, 1, 1)} +
+
+ ${this.renderActivityTooltip_()}`; + }); + }; }); afterEach(() => { @@ -315,7 +339,7 @@ describe('dao-dream-app routing', () => { it('shows and hides localized active duration for a heatmap cell', async () => { - restoreActivityHeatmap(); + useSingleCellActivityHeatmap('2026-06-19', '6月19日周五'); bridgeMocks.callNative.mockResolvedValueOnce([ report('2026-06-19', '[]', recapMaterialStats()), ]); @@ -358,7 +382,7 @@ describe('dao-dream-app routing', () => { it('shows unavailable duration for a legacy heatmap report on focus', async () => { - restoreActivityHeatmap(); + useSingleCellActivityHeatmap('2026-06-19', '6月19日周五'); bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-19')]); const el = await mountDreamApp('/'); diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts index 3c4ed48e..2cf18de2 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts @@ -2059,6 +2059,42 @@ export class DaoDreamApp extends CrLitElement { this.activityTooltip_ = null; } + private renderActivityHeatmapCell_( + dateKey: string, label: string, report: DailyDreamReportData|null, + level: number, column: number, row: number) { + return html` + `; + } + + private renderActivityTooltip_() { + return this.activityTooltip_ ? html` + ` : nothing; + } + private renderActivityHeatmap_() { const reportsByDate = new Map( this.reports_.map(report => [report.dreamDate, report])); @@ -2095,27 +2131,8 @@ export class DaoDreamApp extends CrLitElement { previousMonth = date.getMonth(); } const label = this.formatDreamDate_(dateKey); - cells.push(html` - `); + cells.push(this.renderActivityHeatmapCell_( + dateKey, label, report || null, level, column, index % 7 + 1)); } return html`
@@ -2143,13 +2160,7 @@ export class DaoDreamApp extends CrLitElement { ${t('dream.page.activity_more')}
- ${this.activityTooltip_ ? html` - ` : nothing}`; + ${this.renderActivityTooltip_()}`; } private renderThemeIcon_(index: number) { From f5cf5e9ffb7f58ad3e3cde76bb8327b5971c35f6 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Wed, 12 Aug 2026 10:45:45 +0800 Subject: [PATCH 4/4] test(dream): avoid mounting full heatmap in recap test --- .../agent/__tests__/dao_dream_app.test.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts index f21ced88..3a748596 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts @@ -265,6 +265,24 @@ function expectIconOnlyCopyButton( expect(button!.querySelector('svg[aria-hidden="true"]')).toBeTruthy(); } +function countTemplateMarkers(value: unknown, marker: string): number { + if (Array.isArray(value)) { + return value.reduce( + (count, item) => count + countTemplateMarkers(item, marker), 0); + } + if (typeof value !== 'object' || value === null || + !('strings' in value) || !('values' in value)) { + return 0; + } + const template = value as { + strings: readonly string[]; + values: readonly unknown[]; + }; + const ownMarkers = template.strings.join('').split(marker).length - 1; + return ownMarkers + template.values.reduce( + (count, item) => count + countTemplateMarkers(item, marker), 0); +} + describe('dao-dream-app routing', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -488,7 +506,6 @@ describe('dao-dream-app routing', () => { it('renders the selected one-minute recap design from structured data', async () => { - restoreActivityHeatmap(); bridgeMocks.callNative.mockResolvedValueOnce([ report('2026-06-19', habitCandidates(), recapMaterialStats()), report('2026-06-18'), @@ -498,7 +515,6 @@ describe('dao-dream-app routing', () => { const root = el.shadowRoot!; expect(root.querySelector('.activity-heatmap')).toBeTruthy(); - expect(root.querySelectorAll('.heat-cell').length).toBeGreaterThan(350); expect(root.querySelectorAll('.history-item')).toHaveLength(2); expect(root.querySelector('.recap-summary')?.textContent).toContain( 'Afternoon focus shifted'); @@ -519,6 +535,18 @@ describe('dao-dream-app routing', () => { expect(root.querySelector('.memory-candidates')).toBeTruthy(); }); + it('builds a full year of activity heatmap cells without mounting them', + () => { + restoreActivityHeatmap(); + const el = document.createElement('dao-dream-app') as + TestDreamApp & TestDreamAppPrototype; + + const template = el.renderActivityHeatmap_(); + + expect(countTemplateMarkers(template, 'class="heat-cell"')) + .toBeGreaterThan(350); + }); + it('derives a concise recap fallback from legacy markdown', async () => { bridgeMocks.callNative.mockResolvedValueOnce([{ ...report('2026-06-19'),