diff --git a/apps/desktop/src/main/core/local-data-service.ts b/apps/desktop/src/main/core/local-data-service.ts index 4b0b8941..dc13ebc3 100644 --- a/apps/desktop/src/main/core/local-data-service.ts +++ b/apps/desktop/src/main/core/local-data-service.ts @@ -164,6 +164,7 @@ export class LocalDataService { this.evidence = new EvidenceService( this.database, (hash) => this.objectPath(hash), + (sourceId, relativePath) => this.resolveLocalItemPath(sourceId, relativePath), (sourceId) => this.notifyChanged(sourceId, true), ) this.highRiskImports?.setAutoResolver((batch, accepted) => this.resolveAutoScanBatch(batch, accepted)) @@ -748,7 +749,7 @@ export class LocalDataService { } async previewFile(dataSourceId: string, fileId: string): Promise { - this.requireSource(dataSourceId) + const source = this.requireSource(dataSourceId) const row = this.database.prepare(` SELECT relative_path, extension, modified_at, content_hash, state FROM source_items WHERE id = ? AND data_source_id = ? @@ -762,7 +763,13 @@ export class LocalDataService { if (!row) throw new Error('文件记录不存在。') if (row.state !== 'present' || !row.content_hash) throw new Error('文件当前不可预览。') if (!['.md', '.mdx', '.markdown'].includes(row.extension.toLowerCase())) throw new Error('仅支持 Markdown 文件预览。') - const content = await readFile(this.objectPath(row.content_hash), 'utf8') + // 本地文件夹来源不落对象库,直接读原文件。 + const contentPath = source.kind === 'local-folder' + ? this.resolveLocalItemPath(dataSourceId, row.relative_path) + : this.objectPath(row.content_hash) + const content = await readFile(contentPath, 'utf8').catch((error: NodeJS.ErrnoException) => { + throw error.code === 'ENOENT' ? new Error('源文件已不存在,请恢复文件后重新扫描数据源。') : error + }) if (Buffer.byteLength(content, 'utf8') > 2 * 1024 * 1024) throw new Error('Markdown 文件过大,无法预览。') return { fileName: basename(row.relative_path), relativePath: row.relative_path, modifiedAt: row.modified_at, content } } @@ -1585,7 +1592,7 @@ export class LocalDataService { deferExport ? 'pending' : 'normal', new Date().toISOString(), ) - if (!shouldExport && !deferExport && isLocalParseableExtension(item.extension)) { + if (!deferExport && isLocalParseableExtension(item.extension)) { this.evidence.enqueueVersion(versionId, item.extension) } if (shouldExport && this.fileExports) { @@ -1603,7 +1610,7 @@ export class LocalDataService { ): Promise { const findVersion = this.database.prepare(` SELECT source_versions.id, source_versions.object_hash, source_versions.source_item_id, - source_items.remote_id, source_items.relative_path, + source_items.remote_id, source_items.relative_path, source_items.extension, ( SELECT COUNT(*) FROM source_versions AS accepted_version @@ -1642,12 +1649,16 @@ export class LocalDataService { source_item_id: string remote_id: string relative_path: string + extension: string accepted_version_count: number } | undefined if (!version) continue if (accepted) { updatePolicy.run('approved', versionId) enqueue.run(versionId, new Date().toISOString()) + if (isLocalParseableExtension(version.extension)) { + this.evidence.enqueueVersion(versionId, version.extension) + } } else { addIgnored.run(batch.sourceId, version.remote_id, version.relative_path, new Date().toISOString()) if (Number(version.accepted_version_count) === 0) { @@ -1836,6 +1847,13 @@ export class LocalDataService { ) } + private resolveLocalItemPath(sourceId: string, relativePath: string): string { + const source = this.requireSource(sourceId) + const connector = this.connectors.get(source.kind) + if (!connector.resolveLocalPath) throw new Error('该数据源没有本机文件可读。') + return connector.resolveLocalPath(this.toConnection(source), relativePath) + } + private requireSource(id: string): SourceRow { const source = this.database .prepare('SELECT * FROM data_sources WHERE id = ?') diff --git a/apps/desktop/src/main/evidence/evidence-service.ts b/apps/desktop/src/main/evidence/evidence-service.ts index 3aa135f0..4475a724 100644 --- a/apps/desktop/src/main/evidence/evidence-service.ts +++ b/apps/desktop/src/main/evidence/evidence-service.ts @@ -18,6 +18,8 @@ interface PendingJobRow { object_hash: string extension: string data_source_id: string + source_kind: string + relative_path: string } interface EvidenceDocumentRow { @@ -83,6 +85,7 @@ export class EvidenceService { constructor( private readonly database: DatabaseSync, private readonly objectPath: (hash: string) => string, + private readonly resolveLocalPath: (dataSourceId: string, relativePath: string) => string, private readonly onUpdated: (dataSourceId: string) => void, ) {} @@ -157,6 +160,12 @@ export class EvidenceService { SET status = 'pending', error_message = '应用在解析完成前退出', started_at = NULL WHERE status = 'running' `).run() + // 失败任务在重启后重试,让修复过的解析路径可以救回历史失败记录。 + this.database.prepare(` + UPDATE evidence_parse_jobs + SET status = 'pending', error_message = NULL, started_at = NULL + WHERE status = 'failed' AND attempt_count < 5 + `).run() this.database.prepare(` INSERT OR IGNORE INTO evidence_parse_jobs ( source_version_id, parser, status, queued_at @@ -327,10 +336,12 @@ export class EvidenceService { while (!this.stopping) { const job = this.database.prepare(` SELECT evidence_parse_jobs.source_version_id, source_versions.object_hash, - source_items.extension, source_items.data_source_id + source_items.extension, source_items.data_source_id, + data_sources.kind AS source_kind, source_items.relative_path FROM evidence_parse_jobs JOIN source_versions ON source_versions.id = evidence_parse_jobs.source_version_id JOIN source_items ON source_items.id = source_versions.source_item_id + JOIN data_sources ON data_sources.id = source_items.data_source_id WHERE evidence_parse_jobs.status = 'pending' ORDER BY evidence_parse_jobs.queued_at LIMIT 1 @@ -349,7 +360,11 @@ export class EvidenceService { `).run(new Date().toISOString(), job.source_version_id) try { - const buffer = await readFile(this.objectPath(job.object_hash)) + // 本地文件夹来源不落对象库,直接读原文件;连接器来源读扫描时存的对象。 + const contentPath = job.source_kind === 'local-folder' + ? this.resolveLocalPath(job.data_source_id, job.relative_path) + : this.objectPath(job.object_hash) + const buffer = await readFile(contentPath) const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer).replace(/^\uFEFF/, '') const blocks = MARKDOWN_EXTENSIONS.has(job.extension.toLowerCase()) ? parseMarkdown(text) @@ -362,7 +377,11 @@ export class EvidenceService { `).run(new Date().toISOString(), job.source_version_id) this.onUpdated(job.data_source_id) } catch (error) { - const message = error instanceof Error ? error.message : '文档解析失败' + const isMissingFile = typeof error === 'object' && error !== null && 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + const message = isMissingFile + ? '源文件已不存在,请恢复文件后重新扫描数据源。' + : error instanceof Error ? error.message : '文档解析失败' this.database.prepare(` UPDATE evidence_parse_jobs SET status = 'failed', error_message = ?, parsed_at = ? diff --git a/apps/desktop/src/main/transcription/private-transcription-sync.ts b/apps/desktop/src/main/transcription/private-transcription-sync.ts index 403c5961..4fc1c656 100644 --- a/apps/desktop/src/main/transcription/private-transcription-sync.ts +++ b/apps/desktop/src/main/transcription/private-transcription-sync.ts @@ -202,7 +202,7 @@ export function toImportedRealityEvent( const normalizedCaptureDevice = captureDevice && typeof captureDevice === 'object' && !Array.isArray(captureDevice) && typeof (captureDevice as Record).id === 'string' && typeof (captureDevice as Record).name === 'string' - && ['desktop', 'iphone', 'watch'].includes(String((captureDevice as Record).kind)) + && ['desktop', 'iphone', 'apple_watch'].includes(String((captureDevice as Record).kind)) ? captureDevice as ImportRealityEventInput['captureDevice'] : { id: 'synced-iphone', name: 'iPhone', kind: 'iphone' as const } const audioSource = sourceMetadata.audioSource === 'system' ? 'system' : 'microphone' @@ -572,7 +572,8 @@ export class PrivateTranscriptionSyncService { const input = toImportedRealityEvent(source, summary) if (!input) continue activeEventIds.add(input.id) - const fingerprint = `${source.revision}:${source.updatedAt}:${summary?.revision ?? 0}:${summary?.updatedAt ?? ''}:${summary ? 'valid' : 'missing'}` + // fingerprint 含 captureDevice:云端下发补上来源后(哪怕 revision 没变)也要重新物化,纠正旧的 iPhone 兜底数据。 + const fingerprint = `${source.revision}:${source.updatedAt}:${summary?.revision ?? 0}:${summary?.updatedAt ?? ''}:${summary ? 'valid' : 'missing'}:${input.captureDevice.id}:${input.captureDevice.kind}` if (materialized[input.id] === fingerprint) continue await this.reality.importEvent(input) materialized[input.id] = fingerprint diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css b/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css index 85903afa..b1b8cc37 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css +++ b/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css @@ -4853,7 +4853,6 @@ } .context-room-dashboard-grid > article, -.context-room-dashboard-timeline, .context-room-dashboard-bottom > article { overflow: hidden; border: 1px solid var(--cr-border); @@ -4862,7 +4861,6 @@ } .context-room-dashboard-grid article > header, -.context-room-dashboard-timeline > header, .context-room-dashboard-bottom article > header { display: flex; align-items: center; @@ -4876,8 +4874,7 @@ font-weight: 600; } -.context-room-dashboard-grid article > header svg, -.context-room-dashboard-timeline > header svg { +.context-room-dashboard-grid article > header svg { width: 14px; height: 14px; } @@ -4970,11 +4967,7 @@ font-size: 11px; } -.context-room-dashboard-timeline { - margin-top: 20px; -} - -.context-room-dashboard-timeline > header span { +.context-room-activity-pane > header span { margin-left: auto; color: var(--cr-tertiary); font-size: 10px; @@ -5015,20 +5008,20 @@ font-size: 11px; } -.context-room-dashboard-timeline ol, +.context-room-activity-pane ol, .context-room-activity-list { margin: 0; padding: 14px; list-style: none; } -.context-room-dashboard-timeline li, +.context-room-activity-pane li, .context-room-activity-list li { display: flex; gap: 12px; } -.context-room-dashboard-timeline li > i, +.context-room-activity-pane li > i, .context-room-activity-list li > i { width: 8px; height: 8px; @@ -5039,7 +5032,7 @@ background: var(--cr-surface); } -.context-room-dashboard-timeline li > div, +.context-room-activity-pane li > div, .context-room-activity-list li > div { min-width: 0; flex: 1; @@ -5047,19 +5040,19 @@ border-bottom: 1px solid var(--cr-border); } -.context-room-dashboard-timeline li:last-child > div, +.context-room-activity-pane li:last-child > div, .context-room-activity-list li:last-child > div { border-bottom: 0; } -.context-room-dashboard-timeline li > div > div:first-child, +.context-room-activity-pane li > div > div:first-child, .context-room-activity-list li > div > div:first-child { display: flex; justify-content: space-between; gap: 8px; } -.context-room-dashboard-timeline li b, +.context-room-activity-pane li b, .context-room-activity-list li b { overflow: hidden; font-size: 12px; @@ -5068,22 +5061,22 @@ white-space: nowrap; } -.context-room-dashboard-timeline li time, +.context-room-activity-pane li time, .context-room-activity-list li time { flex: 0 0 auto; color: var(--cr-tertiary); font-size: 10px; } -.context-room-dashboard-timeline li p, +.context-room-activity-pane li p, .context-room-activity-list li p { margin: 2px 0 0; color: var(--cr-muted); font-size: 11px; } -.context-room-dashboard-timeline li button, -.context-room-dashboard-timeline .context-room-timeline-peer button, +.context-room-activity-pane li button, +.context-room-activity-pane .context-room-timeline-peer button, .context-room-activity-list li button, .context-room-activity-list .context-room-timeline-peer button { display: inline-flex; @@ -5096,23 +5089,23 @@ font-size: 10px; } -.context-room-dashboard-timeline li button:hover, -.context-room-dashboard-timeline .context-room-timeline-peer button:hover, +.context-room-activity-pane li button:hover, +.context-room-activity-pane .context-room-timeline-peer button:hover, .context-room-activity-list li button:hover, .context-room-activity-list .context-room-timeline-peer button:hover { background: var(--cr-surface-hover); } -.context-room-dashboard-timeline li button svg, -.context-room-dashboard-timeline .context-room-timeline-peer button svg, +.context-room-activity-pane li button svg, +.context-room-activity-pane .context-room-timeline-peer button svg, .context-room-activity-list li button svg, .context-room-activity-list .context-room-timeline-peer button svg { width: 11px; height: 11px; } -.context-room-dashboard-timeline li button[aria-expanded='true'] svg, -.context-room-dashboard-timeline .context-room-timeline-peer button[aria-expanded='true'] svg, +.context-room-activity-pane li button[aria-expanded='true'] svg, +.context-room-activity-pane .context-room-timeline-peer button[aria-expanded='true'] svg, .context-room-activity-list li button[aria-expanded='true'] svg, .context-room-activity-list .context-room-timeline-peer button[aria-expanded='true'] svg { transform: rotate(90deg); @@ -5481,14 +5474,17 @@ /* iOS-inspired timeline surface: grouped content, quiet chrome, and a clear vertical reading path for events. Kept scoped so the rest of the Room UI can retain its existing desktop card treatment. */ -.context-room-dashboard-timeline { - overflow: visible; +.context-room-activity-pane { + height: 100%; + min-height: 0; + overflow-y: auto; + padding: 8px 10px 14px; border: 0; border-radius: 16px; background: transparent; } -.context-room-dashboard-timeline > header { +.context-room-activity-pane > header { min-height: 0; padding: 2px 4px 10px; border: 0; @@ -5499,13 +5495,13 @@ letter-spacing: -0.01em; } -.context-room-dashboard-timeline > header svg { +.context-room-activity-pane > header svg { width: 18px; height: 18px; stroke-width: 2.25; } -.context-room-dashboard-timeline > header span { +.context-room-activity-pane > header span { align-self: center; padding: 3px 8px; border-radius: 999px; @@ -5591,7 +5587,7 @@ font-weight: 550; } -.context-room-dashboard-timeline > ol { +.context-room-activity-pane > ol { margin: 0; padding: 2px 18px; border: 1px solid rgba(60, 60, 67, 0.1); @@ -5600,7 +5596,7 @@ box-shadow: 0 6px 20px rgba(31, 31, 31, 0.045); } -.context-room-dashboard-timeline > ol > li { +.context-room-activity-pane > ol > li { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr); @@ -5608,11 +5604,11 @@ padding: 15px 0; } -.context-room-dashboard-timeline > ol > li + li { +.context-room-activity-pane > ol > li + li { border-top: 1px solid var(--cr-border); } -.context-room-dashboard-timeline > ol > li > i { +.context-room-activity-pane > ol > li > i { position: relative; z-index: 1; width: 10px; @@ -5629,7 +5625,7 @@ 圆心(padding-top 15 + margin-top 4 + 半径 5 = 24);bottom 跨过 下一条目 border-top(1) + padding-top(15) + 到圆心的 24。圆点不透明 且 z-index 更高,会盖住穿过它的线段。 */ -.context-room-dashboard-timeline > ol > li::before { +.context-room-activity-pane > ol > li::before { position: absolute; top: 24px; bottom: -25px; @@ -5639,32 +5635,32 @@ content: ''; } -.context-room-dashboard-timeline > ol > li:last-child::before { +.context-room-activity-pane > ol > li:last-child::before { display: none; } -.context-room-dashboard-timeline > ol > li > i[data-kind='done'] { +.context-room-activity-pane > ol > li > i[data-kind='done'] { border-color: #34c759; box-shadow: 0 0 0 3px rgba(52, 199, 89, 0.12); } -.context-room-dashboard-timeline > ol > li > i[data-kind='warn'] { +.context-room-activity-pane > ol > li > i[data-kind='warn'] { border-color: #ff9500; box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.14); } -.context-room-dashboard-timeline > ol > li > div { +.context-room-activity-pane > ol > li > div { min-width: 0; padding: 0; border: 0; } -.context-room-dashboard-timeline li > div > div:first-child { +.context-room-activity-pane li > div > div:first-child { align-items: flex-start; gap: 10px; } -.context-room-dashboard-timeline li b { +.context-room-activity-pane li b { overflow: visible; color: var(--cr-text); font-size: 13px; @@ -5674,7 +5670,7 @@ white-space: normal; } -.context-room-dashboard-timeline li time { +.context-room-activity-pane li time { flex: 0 0 auto; padding: 3px 7px; border-radius: 6px; @@ -5685,14 +5681,14 @@ line-height: 1.2; } -.context-room-dashboard-timeline li p { +.context-room-activity-pane li p { margin: 5px 0 0; color: var(--cr-muted); font-size: 12px; line-height: 1.55; } -.context-room-dashboard-timeline li button { +.context-room-activity-pane li button { min-height: 26px; align-items: center; gap: 5px; @@ -5704,7 +5700,7 @@ font-weight: 500; } -.context-room-dashboard-timeline li button:hover { +.context-room-activity-pane li button:hover { background: rgba(0, 122, 255, 0.08); } @@ -5748,7 +5744,7 @@ } @container context-room-shell (max-width: 759px) { - .context-room-dashboard-timeline > header { + .context-room-activity-pane > header { padding-inline: 0; } @@ -5776,27 +5772,27 @@ flex: 1 1 auto; } - .context-room-dashboard-timeline > ol { + .context-room-activity-pane > ol { padding-inline: 14px; } - .context-room-dashboard-timeline > ol > li { + .context-room-activity-pane > ol > li { gap: 12px; padding-block: 14px; } /* 窄容器下 padding-block 由 15 收窄到 14,轴线端点随之微调。 */ - .context-room-dashboard-timeline > ol > li::before { + .context-room-activity-pane > ol > li::before { top: 23px; bottom: -24px; } - .context-room-dashboard-timeline li > div > div:first-child { + .context-room-activity-pane li > div > div:first-child { flex-direction: column; gap: 5px; } - .context-room-dashboard-timeline li time { + .context-room-activity-pane li time { order: -1; align-self: flex-start; } @@ -9675,7 +9671,7 @@ .context-room-home-card, .context-room-dashboard-hero, .context-room-dashboard-grid > article, -.context-room-dashboard-timeline, +.context-room-activity-pane, .context-room-dashboard-bottom > article { border-radius: 8px; } @@ -9712,7 +9708,7 @@ } .context-room-dashboard-grid article > header[data-icon-tone] svg, -.context-room-dashboard-timeline > header[data-icon-tone] svg, +.context-room-activity-pane > header[data-icon-tone] svg, .context-room-dashboard-bottom article > header[data-icon-tone] svg, .context-room-app .context-room-recommendation-source-list [data-icon-tone] svg, .context-room-recommendation-dialog .context-room-recommendation-source-list [data-icon-tone] svg, @@ -11055,7 +11051,7 @@ } } /* Keep the iOS treatment authoritative after the legacy parity rules above. */ -.context-room-dashboard-timeline { +.context-room-activity-pane { border-radius: 16px; } @@ -13507,81 +13503,6 @@ .context-room-imported-comments-unanchored-chevron[data-collapsed='true'] { transform: rotate(-90deg); } .context-room-imported-comments-unanchored[data-collapsed='true'] { padding-bottom: 0; } -/* ===== 工作 / 动态(原型 renderWorkFeed):极简信息流 ===== */ - -.context-room-activity-feed { - height: 100%; - min-height: 0; - overflow-y: auto; - padding: 6px; -} - -.context-room-activity-feed-list { - display: flex; - flex-direction: column; - gap: 2px; -} - -.context-room-activity-feed-item { - display: flex; - gap: 10px; - padding: 10px 8px; - border-radius: 8px; -} - -.context-room-activity-feed-item:hover { - background: var(--cr-surface-hover); -} - -.context-room-activity-feed-ico { - display: inline-flex; - width: 26px; - height: 26px; - flex: none; - align-items: center; - justify-content: center; - border-radius: 7px; - background: var(--cr-surface-secondary); - color: var(--cr-muted); -} - -.context-room-activity-feed-ico svg { - width: 14px; - height: 14px; -} - -.context-room-activity-feed-item[data-category='meeting'] .context-room-activity-feed-ico { - background: color-mix(in srgb, var(--cr-icon-calendar) 12%, transparent); - color: var(--cr-icon-calendar); -} - -.context-room-activity-feed-item[data-category='mail'] .context-room-activity-feed-ico { - background: color-mix(in srgb, var(--cr-icon-communication) 12%, transparent); - color: var(--cr-icon-communication); -} - -.context-room-activity-feed-item[data-category='task'] .context-room-activity-feed-ico { - background: color-mix(in srgb, var(--cr-icon-task) 12%, transparent); - color: var(--cr-icon-task); -} - -.context-room-activity-feed-item[data-category='material'] .context-room-activity-feed-ico { - background: color-mix(in srgb, var(--cr-icon-document) 12%, transparent); - color: var(--cr-icon-document); -} - -.context-room-activity-feed-main { - flex: 1; - min-width: 0; -} - -.context-room-activity-feed-main > p { - margin: 3px 0 0; - color: var(--cr-muted); - font-size: 12px; - line-height: 1.5; -} - /* 动态行与时间轴行共用:标题在左、时间/版本在右。 */ .context-room-activity-entry-row { display: flex; @@ -13779,12 +13700,31 @@ } .context-room-todo-section > .context-room-schedule-pane, -.context-room-todo-section > .context-room-task-pane { +.context-room-todo-section > .context-room-task-pane, +.context-room-todo-section > .context-room-mail-pane { height: auto; min-height: 0; overflow: visible; } +/* 待办邮件区行:图标列定宽(其余行样式承接 mail-pane 基础组) */ +.context-room-mail-item-icon { + display: inline-flex; + width: 26px; + height: 26px; + flex: 0 0 auto; + align-items: center; + justify-content: center; +} + +.context-room-mail-item-main { + gap: 2px; +} + +.context-room-mail-item.is-unread b { + font-weight: 650; +} + .context-room-task-pane > header { gap: 8px; } @@ -13811,15 +13751,17 @@ height: 12px; } -/* 待办两分区的标题行:图标 + 名称 + 计数靠左(原型 cr-todo-head)。 */ +/* 待办各分区的标题行:图标 + 名称 + 计数靠左(原型 cr-todo-head)。 */ .context-room-schedule-pane > header, -.context-room-task-pane > header { +.context-room-task-pane > header, +.context-room-mail-pane > header { justify-content: flex-start; gap: 6px; } .context-room-schedule-pane > header > svg, -.context-room-task-pane > header > svg { +.context-room-task-pane > header > svg, +.context-room-mail-pane > header > svg { width: 13px; height: 13px; flex: none; diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/PortedDetail.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/PortedDetail.tsx index aafe91a2..89f394a0 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/PortedDetail.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/PortedDetail.tsx @@ -247,11 +247,16 @@ export function PortedDetail({ const openObject = useCallback((target: WorkspaceObjectPreview) => { // 详情展示在归属页签内:不触碰文档选中(右区常驻打开的文档),移动端也不把右区盖上来。 - const subtab = target.kind === 'meeting' || target.kind === 'task' + // 邮件详情由待办邮件区与资料两处承接:已在其中一个页签就不切走,缺省去待办。 + const isMailTarget = target.kind === 'mail' || target.kind === 'connector-mail'; + const subtab = target.kind === 'meeting' || target.kind === 'task' || isMailTarget ? 'todo' - : 'materials' + : 'materials'; + const hostedHere = isMailTarget + && layout.panels.includes('work') + && (layout.subtabs.work === 'todo' || layout.subtabs.work === 'materials'); setSelectedObject(target) - if (!(layout.panels.includes('work') && layout.subtabs.work === subtab)) { + if (!hostedHere && !(layout.panels.includes('work') && layout.subtabs.work === subtab)) { layout.switchBoard('work', subtab) } }, [layout]) diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomIconSidebar.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomIconSidebar.tsx index f3970290..cfb83067 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomIconSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/RoomIconSidebar.tsx @@ -41,8 +41,8 @@ export const BOARD_TABS = [ export const BOARD_SUBTABS: Record = { work: [ { id: 'overview', label: 'contextRoom:boardTab.overview' }, - { id: 'activity', label: 'contextRoom:boardTab.activity' }, { id: 'todo', label: 'contextRoom:boardTab.todo' }, + { id: 'activity', label: 'contextRoom:boardTab.activity' }, { id: 'materials', label: 'contextRoom:boardTab.materials' }, ], thoughts: [], diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ActivityPane.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ActivityPane.tsx index 2ea3fa43..f8285738 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ActivityPane.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ActivityPane.tsx @@ -1,9 +1,10 @@ +import { ChevronLeft, ChevronRight, GitBranch } from 'lucide-react'; import type { RoomDocument } from '@nxcore/agent-contract'; -import { GitBranch } from 'lucide-react'; -import { useLocale } from '../../../../../i18n/LocaleContext'; +import { useCallback, useMemo, useState } from 'react'; +import { useLocale, type Translate } from '../../../../../i18n/LocaleContext'; import type { KnowledgeFileDto } from '../../../../../../../shared/knowledge'; -import { useRoomActivityEntries } from '../../hooks/useRoomActivityEntries'; +import { useRoomActivityEntries, type ActivityCategory } from '../../hooks/useRoomActivityEntries'; import { localizedUiText } from '../../adapters'; import { formatTimelineTime, parseTimelineDate } from '../../roomTimeline'; import type { ContextRoomRecord, ContextRoomResource } from '../../types'; @@ -11,10 +12,53 @@ import { ActivityEntryBody, CATEGORY_ICONS, useActivityEntryInteractions } from import { PanelEmptyState } from './PanelEmptyState'; import type { WorkspaceObjectPreview } from './index'; +type TimelineView = 'day' | 'week' | 'month'; + +/** 同期折叠窗口:发生时间相差 10 分钟内的相邻条目视为同一批,折叠展示。 */ +const TIMELINE_CLUSTER_WINDOW_MS = 10 * 60 * 1000; + +/** 折叠组领头条目的优先级:会议 > 任务 > 邮件/资料 > 其余。 */ +function entryPriority(category: ActivityCategory): number { + if (category === 'meeting') return 0; + if (category === 'task') return 1; + if (category === 'other') return 3; + return 2; +} + +function startOfWeek(value: Date) { + const result = new Date(value.getFullYear(), value.getMonth(), value.getDate()); + result.setDate(result.getDate() + (value.getDay() === 0 ? -6 : 1 - value.getDay())); + return result; +} + +function inTimelineRange(value: Date, view: TimelineView, cursor: Date) { + if (view === 'day') return value.toDateString() === cursor.toDateString(); + if (view === 'week') { + const start = startOfWeek(cursor); + const end = new Date(start); + end.setDate(end.getDate() + 7); + return value >= start && value < end; + } + return value.getFullYear() === cursor.getFullYear() && value.getMonth() === cursor.getMonth(); +} + +function timelineRangeLabel(view: TimelineView, cursor: Date, locale: string, t: Translate) { + if (view === 'day') { + return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }).format(cursor); + } + if (view === 'week') { + const start = startOfWeek(cursor); + const end = new Date(start); + end.setDate(end.getDate() + 6); + const formatter = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }); + return `${formatter.format(start)} ~ ${formatter.format(end)}`; + } + return t('contextRoom:overviewDashboard.monthYear', { year: cursor.getFullYear(), month: cursor.getMonth() + 1 }); +} + /** - * 工作 / 动态(原型 renderWorkFeed):Room 统一信息流—— - * 投影事件、文档版本、资料收录、邮件与会议按时间倒序平铺; - * 时间范围浏览与筛选在概览底部的「Room 时间轴」卡。 + * 工作 / 动态(Room 时间轴):投影事件、文档版本、资料收录、邮件与会议 + * 按发生时间倒序;日/周/月区间翻页 + 类型/人物筛选,同期事件折叠展示。 */ export function ActivityPane({ room, @@ -30,20 +74,33 @@ export function ActivityPane({ onOpenObject: (target: WorkspaceObjectPreview) => void; }) { const { locale, t } = useLocale(); - const { rawEntries, projectedEntries, library, today } = useRoomActivityEntries({ + const { rawEntries, projectedEntries, peoplePool, library, today } = useRoomActivityEntries({ room, backendDocuments, knowledgeFiles, locale, t, }); + const [timelineView, setTimelineView] = useState('month'); + const [timelineCursor, setTimelineCursor] = useState(() => new Date()); + const [expanded, setExpanded] = useState>(() => new Set()); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [personFilter, setPersonFilter] = useState(null); const interactions = useActivityEntryInteractions({ libraryResources: library.resources, onSelectResource, onOpenObject, }); - const entries = [...projectedEntries, ...rawEntries] + const visibleEntries = [...projectedEntries, ...rawEntries] + .filter((entry) => { + if (categoryFilter !== 'all' && entry.category !== categoryFilter) return false; + if (personFilter && !entry.people.includes(personFilter)) return false; + const when = entry.time ? parseTimelineDate(entry.time, today) : null; + // 无日期条目(本地快照邮件/会议)只在月视图沉底展示;人物筛选天然排除无人员条目。 + if (!when) return timelineView === 'month'; + return inTimelineRange(when, timelineView, timelineCursor); + }) .sort((left, right) => { const leftDate = parseTimelineDate(left.time ?? '', today); const rightDate = parseTimelineDate(right.time ?? '', today); @@ -53,33 +110,152 @@ export function ActivityPane({ return 0; }); + /** 相邻条目发生时间相差 ≤ 折叠窗口的收成一组:领头条目按类别优先级挑,其余收进「同期事件」展开区。入参须已按时间倒序。 */ + const clustered = useMemo(() => { + const groups: Array<{ entries: typeof visibleEntries; headTime: number | null }> = []; + for (const entry of visibleEntries) { + const when = entry.time ? parseTimelineDate(entry.time, today) : null; + const time = when ? when.getTime() : null; + const current = groups[groups.length - 1]; + if (current && time !== null && current.headTime !== null && current.headTime - time <= TIMELINE_CLUSTER_WINDOW_MS) { + current.entries.push(entry); + continue; + } + groups.push({ entries: [entry], headTime: time }); + } + return groups.map(({ entries }) => { + const leading = entries.reduce((best, entry) => entryPriority(entry.category) < entryPriority(best.category) ? entry : best); + return { leading, peers: entries.filter((entry) => entry !== leading) }; + }); + }, [today, visibleEntries]); + + const toggleExpanded = useCallback((key: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const moveTimeline = (delta: number) => + setTimelineCursor((current) => { + const next = new Date(current); + if (timelineView === 'month') next.setMonth(next.getMonth() + delta); + else next.setDate(next.getDate() + delta * (timelineView === 'week' ? 7 : 1)); + return next; + }); + + const hasAnyEntries = projectedEntries.length + rawEntries.length > 0; + return ( -
- {entries.length ?
{entries.map((entry) => { - const Icon = CATEGORY_ICONS[entry.category]; - return ( -
- -
+
+
+
+
+
+ {(['day', 'week', 'month'] as const).map((view) => ( + + ))} +
+ +
+
+
+ + {(['meeting', 'mail', 'task', 'material', 'other'] as const).map((category) => ( + + ))} +
+ {peoplePool.length ? ( + + ) : null} +
+ {hasAnyEntries ? ( + clustered.length ?
    {clustered.map(({ leading, peers }) => { + const clusterKey = `cluster:${leading.id}`; + const LeadingIcon = CATEGORY_ICONS[leading.category]; + return
  1. + +
    - - {entry.document && entry.document.version > 0 - ? V{String(entry.document.version)} + {leading.time ? : null} + {leading.document && leading.document.version > 0 + ? V{String(leading.document.version)} : null}
    - + + {peers.length ? <> + + {expanded.has(clusterKey) ?
    {peers.map((peer) => { + const PeerIcon = CATEGORY_ICONS[peer.category]; + return ( +
    + +
    +
    + + {peer.time ? : null} + {peer.document && peer.document.version > 0 + ? V{String(peer.document.version)} + : null} +
    + +
    +
    + ); + })}
    : null} + : null}
    -
- ); - })}
: ( + ; + })} : ( + + ) + ) : ( ); } + +/** 待办邮件区的行:连接器邮件与本地快照邮件统一结构。 */ +interface MailRow { + key: string; + title: string; + subtitle: string; + timeLabel: string; + sortTime: number; + unread?: boolean; + provider?: string; + open: WorkspaceObjectPreview; +} + +/** + * 待办 / 邮件:连接器邮件与本地快照邮件合并平铺(同主题同日去重,保留连接器 + * 版本),按时间倒序;邮件详情在分区内整区替换展示(返回即回列表)。 + */ +export function MailPane({ + room, + onOpen, + detail, + onCloseDetail, + onUpdateRoom, +}: { + room: ContextRoomRecord; + onOpen: (target: WorkspaceObjectPreview) => void; + detail?: WorkspaceObjectPreview | null; + onCloseDetail?: () => void; + onUpdateRoom: (updater: RoomUpdater) => void; +}) { + const { locale, t } = useLocale(); + const { mails: connectorMails } = useRoomMails(room.id); + + const connectorMailKeys = useMemo(() => new Set(connectorMails.flatMap((mail) => { + const when = mail.sentAt ? new Date(mail.sentAt) : null; + if (!when || Number.isNaN(when.getTime())) return []; + return [`${mail.subject.trim().toLocaleLowerCase()}\x00${paddedDateKey(when)}`]; + })), [connectorMails]); + + const rows = useMemo(() => { + const connectorRows: MailRow[] = connectorMails.map((mail) => ({ + key: `mail:${mail.sourceId}`, + title: mail.subject, + subtitle: mail.senderName ?? mail.senderAddress ?? t('contextRoom:objectDetail.defaultSender'), + timeLabel: mail.sentAt && !Number.isNaN(Date.parse(mail.sentAt)) + ? new Date(mail.sentAt).toLocaleDateString(locale) + : '', + sortTime: Date.parse(mail.sentAt ?? '') || 0, + provider: mail.provider ?? undefined, + open: { kind: 'connector-mail', sourceId: mail.sourceId }, + })); + const localRows: MailRow[] = room.materials + .filter((material) => material.type === '邮件') + .filter((mail) => { + const when = parseDisplayDate(mail.time); + if (!when) return true; + return !connectorMailKeys.has(`${mail.title.trim().toLocaleLowerCase()}\x00${paddedDateKey(when)}`); + }) + .map((mail) => ({ + key: `lmail:${mail.id}`, + title: mail.title, + subtitle: mail.sender ?? localizedUiText(mail.summary, t), + timeLabel: mail.time, + sortTime: parseDisplayDate(mail.time)?.getTime() ?? 0, + unread: mail.unread, + open: { kind: 'mail', id: mail.id } as const, + })); + return [...connectorRows, ...localRows] + .sort((left, right) => (left.sortTime !== right.sortTime + ? right.sortTime - left.sortTime + : left.title.localeCompare(right.title, locale))); + }, [connectorMailKeys, connectorMails, locale, room.materials, t]); + + const localMailObject = detail?.kind === 'mail' + ? room.materials.find((material) => material.id === detail.id && material.type === '邮件') ?? null + : null; + if (detail?.kind === 'mail' && localMailObject && onCloseDetail) { + return ( +
+ +
+ ); + } + + const connectorMailDetail = detail?.kind === 'connector-mail' ? detail : null; + const mailDetailState = useConnectorMailDetail(room.id, connectorMailDetail?.sourceId ?? null); + if (connectorMailDetail && onCloseDetail) { + return ; + } + + return ( +
+
+
+
+ {rows.map((row) => ( + + ))} + {!rows.length ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ConnectorMailDetail.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ConnectorMailDetail.tsx new file mode 100644 index 00000000..97b9cd69 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/ConnectorMailDetail.tsx @@ -0,0 +1,108 @@ +import type { RoomMailDetail } from '@nxcore/agent-contract'; +import { useEffect, useRef, useState } from 'react'; +import { useLocale } from '../../../../../i18n/LocaleContext'; + +import { MailProviderIcon } from '../MailProviderIcon'; +import { X } from 'lucide-react'; +import { MarkdownBody } from './MarkdownBody'; + +/** 连接器邮件详情拉取(会话内缓存,Room 切换即失效):待办邮件区与资料面板共用。 */ +export function useConnectorMailDetail(roomId: string, sourceId: string | null) { + const [state, setState] = useState<{ loading: boolean; detail: RoomMailDetail | null; error: boolean }>({ + loading: false, + detail: null, + error: false, + }); + const cache = useRef(new Map()); + const seq = useRef(0); + + useEffect(() => { + cache.current.clear(); + seq.current += 1; + }, [roomId]); + + useEffect(() => { + if (!sourceId) { + setState({ loading: false, detail: null, error: false }); + return; + } + const cached = cache.current.get(sourceId); + if (cached) { + setState({ loading: false, detail: cached, error: false }); + return; + } + const ticket = seq.current + 1; + seq.current = ticket; + setState({ loading: true, detail: null, error: false }); + void (async () => { + try { + const fetched = await window.nxcore?.contextRooms?.readMail(roomId, sourceId); + if (!fetched) throw new Error('mail_detail_unavailable'); + cache.current.set(sourceId, fetched); + if (seq.current === ticket) { + setState({ loading: false, detail: fetched, error: false }); + } + } catch { + if (seq.current === ticket) { + setState({ loading: false, detail: null, error: true }); + } + } + })(); + }, [roomId, sourceId]); + + return state; +} + +/** 连接器邮件详情面板:身份头 + 元信息 + 正文滚动区。 */ +export function ConnectorMailDetailPanel({ + state, + locale, + onClose, +}: { + state: { loading: boolean; detail: RoomMailDetail | null; error: boolean }; + locale: string; + onClose: () => void; +}) { + const { t } = useLocale(); + if (state.loading) { + return ( + + ); + } + if (state.error || !state.detail) { + return ( + + ); + } + const detail = state.detail; + const when = detail.sentAt && !Number.isNaN(Date.parse(detail.sentAt)) + ? new Date(detail.sentAt).toLocaleString(locale) + : null; + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/IdeasBoardPane.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/IdeasBoardPane.tsx index b40890d1..da34051e 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/IdeasBoardPane.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/IdeasBoardPane.tsx @@ -105,8 +105,8 @@ export function IdeasBoardPane({ // 续生一层(已有子级时服务端只挪 selectionPath)。回退是纯本地操作,生成中 // (expanding)也放行——否则生成中点上级只剩镜头挪过去、分岔露不出来; // 续生只在 active 态且未到末梢层(全图最多四层,末梢点选只选中); - // 已拍板=只读浏览(同普通导图):点父级露出全部分岔、点有子级的选项下钻, - // 只改本地视图路径,不触发续生、不改服务端已选路线。 + // 已拍板=只读浏览(同普通导图):点父级露出全部分岔、点选项沿链下钻, + // 点末端叶子则收缩成路径链;只改本地视图路径,不触发续生、不改服务端已选路线。 const onRouteSelect = (nodeRef: string | null) => { if (nodeRef) setSelectedNodeRef(nodeRef); if (!nodeRef || !routeView || !projection) return; @@ -117,9 +117,9 @@ export function IdeasBoardPane({ return; } const chain = routeView.graph ? routePathTo(routeView.graph.root, nodeRef) : null; - if (chain && (chain[chain.length - 1]?.children?.length ?? 0) > 0) { - setFinalPath(chain.map((node) => node.ref)); - } + // 点选项(含末端叶子)都换层:点有子级的露出其全部分岔,点叶子则视图收缩成 + // 路径链——前面父级的分岔全部收起,画面聚焦到这条链。 + if (chain) setFinalPath(chain.map((node) => node.ref)); return; } if (depth >= 0) { diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/MaterialsPane.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/MaterialsPane.tsx index 099d761e..6ea222df 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/MaterialsPane.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/MaterialsPane.tsx @@ -9,10 +9,9 @@ import { RotateCcw, SearchX, Trash2, - X, } from 'lucide-react'; -import type { RoomDocument, RoomMailDetail } from '@nxcore/agent-contract'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { RoomDocument } from '@nxcore/agent-contract'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocale } from '../../../../../i18n/LocaleContext'; import type { KnowledgeFileDto } from '../../../../../../../shared/knowledge'; @@ -23,7 +22,7 @@ import { useRoomMails } from '../../hooks/useRoomMails'; import { MailProviderIcon } from '../MailProviderIcon'; import { ObjectDetailView } from '../ObjectDetailView'; import { ResourceCorrectionMenu } from '../ResourceCorrection'; -import { MarkdownBody } from './MarkdownBody'; +import { ConnectorMailDetailPanel, useConnectorMailDetail } from './ConnectorMailDetail'; import { PanelEmptyState } from './PanelEmptyState'; import type { WorkspaceObjectPreview } from './index'; @@ -83,7 +82,7 @@ function saveMaterialsView(roomId: string, next: MaterialsViewMemory): void { } /** 本地快照时间的宽松解析("昨天 16:40"/"07-21 10:20"),解析不到返回 null。 */ -function parseDisplayDate(value: string): Date | null { +export function parseDisplayDate(value: string): Date | null { if (!value) return null; if (/^(今天|today)(?:\s|$)/iu.test(value)) return new Date(); if (/^(昨天|yesterday)(?:\s|$)/iu.test(value)) { @@ -100,7 +99,7 @@ function parseDisplayDate(value: string): Date | null { } /** 本地日期键(补零):与连接器邮件的同日判断共用。 */ -function paddedDateKey(when: Date): string { +export function paddedDateKey(when: Date): string { return `${String(when.getFullYear())}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')}`; } @@ -129,59 +128,7 @@ interface MaterialRow { connectorSource?: string; } -/** 连接器邮件详情(资料面板下半区):身份头 + 元信息 + 正文滚动区。 */ -function ConnectorMailDetailPanel({ - state, - locale, - onClose, -}: { - state: { loading: boolean; detail: RoomMailDetail | null; error: boolean }; - locale: string; - onClose: () => void; -}) { - const { t } = useLocale(); - if (state.loading) { - return ( - - ); - } - if (state.error || !state.detail) { - return ( - - ); - } - const detail = state.detail; - const when = detail.sentAt && !Number.isNaN(Date.parse(detail.sentAt)) - ? new Date(detail.sentAt).toLocaleString(locale) - : null; - return ( - - ); -} +/** 连接器邮件详情(受控 detail 驱动):点击行 → onOpenObject(connector-mail)。 */ /** * 工作 / 资料(PRD L3.2.6 + L3.2.5):按来源对象平铺管理(不按文件格式分夹), @@ -233,14 +180,6 @@ export function MaterialsPane({ const [deletingDocumentId, setDeletingDocumentId] = useState(null); const [deleteError, setDeleteError] = useState(null); const [actionError, setActionError] = useState(null); - // 连接器邮件详情(受控 detail 驱动):点击行 → onOpenObject(connector-mail)。 - const [mailDetailState, setMailDetailState] = useState<{ loading: boolean; detail: RoomMailDetail | null; error: boolean }>({ - loading: false, - detail: null, - error: false, - }); - const mailDetailCache = useRef(new Map()); - const mailDetailSeq = useRef(0); useEffect(() => { setMemory(loadMaterialsView(room.id)); @@ -352,39 +291,7 @@ export function MaterialsPane({ // 连接器邮件详情:受控 detail 变化时拉取全文(会话内缓存,Room 切换即失效)。 const connectorMailDetail = detail?.kind === 'connector-mail' ? detail : null; - useEffect(() => { - mailDetailCache.current.clear(); - mailDetailSeq.current += 1; - }, [room.id]); - useEffect(() => { - if (!connectorMailDetail) { - setMailDetailState({ loading: false, detail: null, error: false }); - return; - } - const sourceId = connectorMailDetail.sourceId; - const cached = mailDetailCache.current.get(sourceId); - if (cached) { - setMailDetailState({ loading: false, detail: cached, error: false }); - return; - } - const seq = mailDetailSeq.current + 1; - mailDetailSeq.current = seq; - setMailDetailState({ loading: true, detail: null, error: false }); - void (async () => { - try { - const fetched = await window.nxcore?.contextRooms?.readMail(room.id, sourceId); - if (!fetched) throw new Error('mail_detail_unavailable'); - mailDetailCache.current.set(sourceId, fetched); - if (mailDetailSeq.current === seq) { - setMailDetailState({ loading: false, detail: fetched, error: false }); - } - } catch { - if (mailDetailSeq.current === seq) { - setMailDetailState({ loading: false, detail: null, error: true }); - } - } - })(); - }, [connectorMailDetail, room.id]); + const mailDetailState = useConnectorMailDetail(room.id, connectorMailDetail?.sourceId ?? null); // 本地邮件详情:资料面板内嵌 ObjectDetailView。 diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewDashboard.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewDashboard.tsx index c1c28874..f32d2888 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewDashboard.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewDashboard.tsx @@ -30,7 +30,6 @@ import { useRoomUpdatedTime } from '../../roomUpdatedTime'; import { roomKindIcon, roomKindTone } from '../utils'; import { CalendarProviderIcon } from '../CalendarProviderIcon'; import { PanelEmptyState } from './PanelEmptyState'; -import { OverviewTimelineCard } from './OverviewTimelineCard'; import type { WorkspaceObjectPreview } from './index'; // 逐 Room 的 AI 状态文案覆盖表(原演示 Room 词条已移除);缺省走下方真实数据派生。 @@ -377,14 +376,6 @@ export function OverviewDashboard({ {!openTasks.length && !projectionTasks.length ? : null} - - document.origin !== 'native')} - knowledgeFiles={knowledgeFiles} - onSelectResource={onSelectResource} - onOpenObject={onOpenObject} - />
); } diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewTimelineCard.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewTimelineCard.tsx deleted file mode 100644 index 89d2ceeb..00000000 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/OverviewTimelineCard.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { ChevronLeft, ChevronRight, GitBranch } from 'lucide-react'; -import type { RoomDocument } from '@nxcore/agent-contract'; -import { useCallback, useMemo, useState } from 'react'; -import { useLocale, type Translate } from '../../../../../i18n/LocaleContext'; - -import type { KnowledgeFileDto } from '../../../../../../../shared/knowledge'; -import { useRoomActivityEntries, type ActivityCategory } from '../../hooks/useRoomActivityEntries'; -import { localizedUiText } from '../../adapters'; -import { formatTimelineTime, parseTimelineDate } from '../../roomTimeline'; -import type { ContextRoomRecord, ContextRoomResource } from '../../types'; -import { ActivityEntryBody, CATEGORY_ICONS, useActivityEntryInteractions } from './ActivityEntryParts'; -import { PanelEmptyState } from './PanelEmptyState'; -import type { WorkspaceObjectPreview } from './index'; - -type TimelineView = 'day' | 'week' | 'month'; - -/** 同期折叠窗口:发生时间相差 10 分钟内的相邻条目视为同一批,折叠展示。 */ -const TIMELINE_CLUSTER_WINDOW_MS = 10 * 60 * 1000; - -/** 折叠组领头条目的优先级:会议 > 任务 > 邮件/资料 > 其余。 */ -function entryPriority(category: ActivityCategory): number { - if (category === 'meeting') return 0; - if (category === 'task') return 1; - if (category === 'other') return 3; - return 2; -} - -function startOfWeek(value: Date) { - const result = new Date(value.getFullYear(), value.getMonth(), value.getDate()); - result.setDate(result.getDate() + (value.getDay() === 0 ? -6 : 1 - value.getDay())); - return result; -} - -function inTimelineRange(value: Date, view: TimelineView, cursor: Date) { - if (view === 'day') return value.toDateString() === cursor.toDateString(); - if (view === 'week') { - const start = startOfWeek(cursor); - const end = new Date(start); - end.setDate(end.getDate() + 7); - return value >= start && value < end; - } - return value.getFullYear() === cursor.getFullYear() && value.getMonth() === cursor.getMonth(); -} - -function timelineRangeLabel(view: TimelineView, cursor: Date, locale: string, t: Translate) { - if (view === 'day') { - return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }).format(cursor); - } - if (view === 'week') { - const start = startOfWeek(cursor); - const end = new Date(start); - end.setDate(end.getDate() + 6); - const formatter = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }); - return `${formatter.format(start)} ~ ${formatter.format(end)}`; - } - return t('contextRoom:overviewDashboard.monthYear', { year: cursor.getFullYear(), month: cursor.getMonth() + 1 }); -} - -/** - * 概览底部的 Room 时间轴卡(原型 rd-dash-timeline-card): - * 日/周/月切换 + 区间翻页 + 类型/人物筛选,条目带折叠的相关资料。 - */ -export function OverviewTimelineCard({ - room, - backendDocuments, - knowledgeFiles, - onSelectResource, - onOpenObject, -}: { - room: ContextRoomRecord; - backendDocuments: RoomDocument[]; - knowledgeFiles: KnowledgeFileDto[]; - onSelectResource: (resource: ContextRoomResource) => void; - onOpenObject: (target: WorkspaceObjectPreview) => void; -}) { - const { locale, t } = useLocale(); - const { rawEntries, projectedEntries, peoplePool, library, today } = useRoomActivityEntries({ - room, - backendDocuments, - knowledgeFiles, - locale, - t, - }); - const [timelineView, setTimelineView] = useState('month'); - const [timelineCursor, setTimelineCursor] = useState(() => new Date()); - const [expanded, setExpanded] = useState>(() => new Set()); - const [categoryFilter, setCategoryFilter] = useState('all'); - const [personFilter, setPersonFilter] = useState(null); - const interactions = useActivityEntryInteractions({ - libraryResources: library.resources, - onSelectResource, - onOpenObject, - }); - - const visibleEntries = [...projectedEntries, ...rawEntries] - .filter((entry) => { - if (categoryFilter !== 'all' && entry.category !== categoryFilter) return false; - if (personFilter && !entry.people.includes(personFilter)) return false; - const when = entry.time ? parseTimelineDate(entry.time, today) : null; - // 无日期条目(本地快照邮件/会议)只在月视图沉底展示;人物筛选天然排除无人员条目。 - if (!when) return timelineView === 'month'; - return inTimelineRange(when, timelineView, timelineCursor); - }) - .sort((left, right) => { - const leftDate = parseTimelineDate(left.time ?? '', today); - const rightDate = parseTimelineDate(right.time ?? '', today); - if (leftDate && rightDate) return rightDate.getTime() - leftDate.getTime(); - if (leftDate) return -1; - if (rightDate) return 1; - return 0; - }); - - /** 相邻条目发生时间相差 ≤ 折叠窗口的收成一组:领头条目按类别优先级挑,其余收进「同期事件」展开区。入参须已按时间倒序。 */ - const clustered = useMemo(() => { - const groups: Array<{ entries: typeof visibleEntries; headTime: number | null }> = []; - for (const entry of visibleEntries) { - const when = entry.time ? parseTimelineDate(entry.time, today) : null; - const time = when ? when.getTime() : null; - const current = groups[groups.length - 1]; - if (current && time !== null && current.headTime !== null && current.headTime - time <= TIMELINE_CLUSTER_WINDOW_MS) { - current.entries.push(entry); - continue; - } - groups.push({ entries: [entry], headTime: time }); - } - return groups.map(({ entries }) => { - const leading = entries.reduce((best, entry) => entryPriority(entry.category) < entryPriority(best.category) ? entry : best); - return { leading, peers: entries.filter((entry) => entry !== leading) }; - }); - }, [today, visibleEntries]); - - const toggleExpanded = useCallback((key: string) => { - setExpanded((current) => { - const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }, []); - - const moveTimeline = (delta: number) => - setTimelineCursor((current) => { - const next = new Date(current); - if (timelineView === 'month') next.setMonth(next.getMonth() + delta); - else next.setDate(next.getDate() + delta * (timelineView === 'week' ? 7 : 1)); - return next; - }); - - const hasAnyEntries = projectedEntries.length + rawEntries.length > 0; - - return ( -
-
-
-
-
- {(['day', 'week', 'month'] as const).map((view) => ( - - ))} -
- -
-
-
- - {(['meeting', 'mail', 'task', 'material', 'other'] as const).map((category) => ( - - ))} -
- {peoplePool.length ? ( - - ) : null} -
- {hasAnyEntries ? ( - clustered.length ?
    {clustered.map(({ leading, peers }) => { - const clusterKey = `cluster:${leading.id}`; - const LeadingIcon = CATEGORY_ICONS[leading.category]; - return
  1. - -
    -
    - - {leading.time ? : null} - {leading.document && leading.document.version > 0 - ? V{String(leading.document.version)} - : null} -
    - - {peers.length ? <> - - {expanded.has(clusterKey) ?
    {peers.map((peer) => { - const PeerIcon = CATEGORY_ICONS[peer.category]; - return ( -
    - -
    -
    - - {peer.time ? : null} - {peer.document && peer.document.version > 0 - ? V{String(peer.document.version)} - : null} -
    - -
    -
    - ); - })}
    : null} - : null} -
    -
  2. ; - })}
: ( - - ) - ) : ( - - )} -
- ); -} diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/TodoPane.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/TodoPane.tsx index 5b40f9b3..5de13974 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/TodoPane.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-panels/TodoPane.tsx @@ -1,14 +1,14 @@ import { useLocale } from '../../../../../i18n/LocaleContext'; import type { ContextRoomRecord } from '../../types'; -import { SchedulePane, TasksPane } from './ActivityPanes'; +import { MailPane, SchedulePane, TasksPane } from './ActivityPanes'; import type { WorkspaceObjectPreview } from './index'; type RoomUpdater = (room: ContextRoomRecord) => ContextRoomRecord; /** - * 工作 / 待办(PRD L3.2.3 + L3.2.4):日程与会议(时间视图)和任务聚合在同一个 - * 扫描视图里,从邮件/会议提取的行动项随任务列表展示;会议/任务详情仍在本视图内打开。 + * 工作 / 待办:日历、邮件、任务三个分区纵向聚合在同一个扫描视图里; + * 会议/任务/邮件详情仍在本视图内打开(按归属分区承接)。 */ export function TodoPane({ room, @@ -20,10 +20,10 @@ export function TodoPane({ onUpdateRoom, }: { room: ContextRoomRecord; - onOpen: (target: { kind: 'meeting' | 'task'; id: string }) => void; + onOpen: (target: WorkspaceObjectPreview) => void; onSelect: (taskId: string) => void; onToggle: (taskId: string) => void; - /** 受控详情态:会议详情归日程区,任务详情归任务区。 */ + /** 受控详情态:会议归日历区,邮件归邮件区,任务归任务区。 */ detail?: WorkspaceObjectPreview | null; onCloseDetail?: () => void; onUpdateRoom: (updater: RoomUpdater) => void; @@ -31,21 +31,30 @@ export function TodoPane({ const { t } = useLocale(); return (
-
- +
-
- + +
+
+ diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-workspace/WorkspaceLayout.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-workspace/WorkspaceLayout.tsx index b0e49a02..a3bf1cdb 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-workspace/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-workspace/WorkspaceLayout.tsx @@ -245,8 +245,13 @@ export function WorkspaceLayout({ {overview ? ( - // 原型概览态:整中栏独占、无二级页签行(rd-overview)。 + // 原型概览态:整中栏独占,顶部仍保留工作页签条(rd-overview)。
+ setBoardSubtab('work', nextSubtab)} + /> void; }) { - // 详情归属页签与 PortedDetail.openObject 的映射保持一致:会议/任务归待办,邮件归资料。 - const objectOwnerSubtab = (target: WorkspaceObjectPreview): BoardSubtab => - target.kind === 'meeting' || target.kind === 'task' ? 'todo' : 'materials'; - const ownedDetail = selectedObject && board === 'work' && objectOwnerSubtab(selectedObject) === subtab + // 详情归属页签与 PortedDetail.openObject 的映射保持一致:会议/任务归待办, + // 邮件归待办邮件区与资料(两处都列邮件,在哪个页签打开就在哪个页签承接),其余归资料。 + const detailOwnerSubtabs = (target: WorkspaceObjectPreview): BoardSubtab[] => { + if (target.kind === 'meeting' || target.kind === 'task') return ['todo']; + if (target.kind === 'mail' || target.kind === 'connector-mail') return ['todo', 'materials']; + return ['materials']; + }; + const ownedDetail = selectedObject && board === 'work' && subtab !== null && detailOwnerSubtabs(selectedObject).includes(subtab) ? selectedObject : null; diff --git a/apps/desktop/src/renderer/src/components/pages/SourcesPage.tsx b/apps/desktop/src/renderer/src/components/pages/SourcesPage.tsx index 8e198a02..046eea93 100644 --- a/apps/desktop/src/renderer/src/components/pages/SourcesPage.tsx +++ b/apps/desktop/src/renderer/src/components/pages/SourcesPage.tsx @@ -276,10 +276,16 @@ export function SourcesPage() { // connected 时若是该 provider 类型第一次连接——弹过滤偏好引导。 // "第一次"以 localStorage 记录为准(不依赖"当前有没有该类连接":用户可能 // 在别的设备/早前连过,也可能授权期间切走页面错过了轮询瞬间)。 - const maybeGuide = useCallback((provider: string) => { + // 已保存过过滤偏好的不再弹:偏好全局只有一份,换个来源类型再弹同一表单 + // 只是重复打扰(issue #232)。 + const maybeGuide = useCallback(async (provider: string) => { if (!provider || guided.current.has(provider)) return guided.current.add(provider) markProviderGuided(provider) + try { + const rules = await window.nxcore?.ingest.getFilterRules() + if (rules?.preference.trim()) return + } catch { /* 偏好读不到时按未设置处理,照常弹 */ } setGuideProvider(provider) }, []) useEffect(() => { @@ -308,14 +314,20 @@ export function SourcesPage() { // 存量连接补引导:页面挂载时扫一遍已有连接,某 provider 类型已连接但从未 // 引导过(旧版本连接的、或授权期间切走页面错过的)——补弹一次。 + // 至多弹一个,其余未引导类型当场全部记账:偏好全局只有一份,"每进一次 + // 页面补弹一个"是重复打扰(issue #232);被静默记账的连接首同步由网关 + // 轮询周期兜底(默认 5 分钟)。 useEffect(() => { void window.nxcore?.nangoConnector.status().then((status) => { if (!status.enabled) return - for (const connection of status.connections) { - if (guided.current.has(connection.provider)) continue - maybeGuide(connection.provider) - break // 一次只弹一个,下一个来源页挂载时再补 + const [first, ...rest] = [...new Set(status.connections.map((connection) => connection.provider))] + .filter((provider) => !guided.current.has(provider)) + if (!first) return + for (const provider of rest) { + guided.current.add(provider) + markProviderGuided(provider) } + void maybeGuide(first) }).catch(() => undefined) }, [maybeGuide]) diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json b/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json index 63423f98..aae6f521 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json @@ -1462,7 +1462,10 @@ "tiptapTableControls.toggleHeaderCell": "Toggle header cell", "tiptapTableControls.unsetHeaderColumn": "Unset header column", "tiptapTableControls.unsetHeaderRow": "Unset header row", - "todoPane.scheduleSection": "Schedule & meetings", + "todoPane.mailSection": "Mail", + "todoPane.mailsInThisRoomWillAppearHere": "Mails in this Room will appear here", + "todoPane.noMailsYet": "No mails yet", + "todoPane.scheduleSection": "Calendar", "todoPane.tasksSection": "Tasks", "useDocumentEditorOperations.agentIsRevisingTheContinuation": "Agent is revising the continuation", "useDocumentEditorOperations.continuationUnavailable": "Agent continuation is unavailable.", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json index 9903f459..2e2b14a5 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json @@ -1462,7 +1462,10 @@ "tiptapTableControls.toggleHeaderCell": "切换表头单元格", "tiptapTableControls.unsetHeaderColumn": "取消表头列", "tiptapTableControls.unsetHeaderRow": "取消表头行", - "todoPane.scheduleSection": "日程与会议", + "todoPane.mailSection": "邮件", + "todoPane.mailsInThisRoomWillAppearHere": "Room 内的邮件会显示在这里", + "todoPane.noMailsYet": "还没有邮件", + "todoPane.scheduleSection": "日历", "todoPane.tasksSection": "任务", "useDocumentEditorOperations.agentIsRevisingTheContinuation": "Agent 正在重新续写", "useDocumentEditorOperations.continuationUnavailable": "Agent 续写服务不可用。", diff --git a/apps/desktop/tests/context-room-overview-timeline.test.tsx b/apps/desktop/tests/context-room-activity-timeline.test.tsx similarity index 97% rename from apps/desktop/tests/context-room-overview-timeline.test.tsx rename to apps/desktop/tests/context-room-activity-timeline.test.tsx index 27e50933..0f6f34f0 100644 --- a/apps/desktop/tests/context-room-overview-timeline.test.tsx +++ b/apps/desktop/tests/context-room-activity-timeline.test.tsx @@ -19,7 +19,7 @@ vi.mock('../src/renderer/src/components/context-room/ContextRoomStateProvider', import type { RoomDocument, RoomOverviewProjection } from '@nxcore/agent-contract' import { createContextRoomFixture } from './context-room-fixture' -import { OverviewTimelineCard } from '../src/renderer/src/components/context-room/ported/components/detail-panels/OverviewTimelineCard' +import { ActivityPane } from '../src/renderer/src/components/context-room/ported/components/detail-panels/ActivityPane' /** 本月内锚定的相对时间:跨月漂移时钳到 1 号,保证条目始终落在当前月视图里; * 各条目小时错开,钳制同日后排序仍然确定。 */ @@ -130,11 +130,11 @@ const knowledgeFiles = [{ bytes: 1024, uploadedAt: monthDay(7, 12), status: 'ready', -}] as unknown as Parameters[0]['knowledgeFiles'] +}] as unknown as Parameters[0]['knowledgeFiles'] function renderPane() { return TestRenderer.create( - { +describe('动态时间轴:排序与真实对象条目', () => { afterEach(() => { vi.unstubAllGlobals() }) @@ -222,7 +222,7 @@ describe('Room 时间轴卡:排序与真实对象条目', () => { let renderer: TestRenderer.ReactTestRenderer | null = null await act(async () => { renderer = TestRenderer.create( - { }) }) -describe('Room 时间轴卡:同期事件折叠', () => { +describe('动态时间轴:同期事件折叠', () => { afterEach(() => { vi.unstubAllGlobals() }) diff --git a/apps/desktop/tests/context-room-todo-mail-pane.test.tsx b/apps/desktop/tests/context-room-todo-mail-pane.test.tsx new file mode 100644 index 00000000..c02de1b8 --- /dev/null +++ b/apps/desktop/tests/context-room-todo-mail-pane.test.tsx @@ -0,0 +1,181 @@ +import TestRenderer, { act } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../src/renderer/src/i18n/LocaleContext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useLocale: () => ({ + locale: 'zh-CN', + t: (message: string, values?: Record) => actual.translate('zh-CN', message, values), + }), + } +}) + +import type { RoomMail, RoomMailDetail } from '@nxcore/agent-contract' +import type { ReactNode } from 'react' + +// 无 DOM 环境:本地邮件详情(ObjectDetailView)内的归入纠正菜单替换为透传。 +vi.mock('@radix-ui/react-dropdown-menu', () => { + const passthrough = ({ children }: { children?: ReactNode }) => children ?? null + return { + Root: passthrough, + Trigger: ({ children }: { children?: ReactNode }) => children ?? null, + Portal: passthrough, + Content: passthrough, + Item: ({ children, onSelect }: { children?: ReactNode; onSelect?: () => void }) => ( + + ), + } +}) + +import { createContextRoomFixture } from './context-room-fixture' +import type { ContextRoomRecord } from '../src/renderer/src/components/context-room/ported/types' +import type { WorkspaceObjectPreview } from '../src/renderer/src/components/context-room/ported/components/detail-panels' +import { MailPane } from '../src/renderer/src/components/context-room/ported/components/detail-panels/ActivityPanes' + +function mailFixture(overrides: Partial & { sourceId: string; subject: string }): RoomMail { + return { + senderName: null, + senderAddress: null, + sentAt: null, + snippet: null, + hasAttachments: false, + ...overrides, + } +} + +function localMail(id: string, title: string, time: string, extra: Partial = {}) { + return { id, type: '邮件' as const, title, time, summary: `${title}摘要`, ...extra } +} + +/** 本地“今天”的日期串与 ISO 串(同主题 + 同日触发去重)。 */ +function localDateString(): string { + const now = new Date() + return `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}` +} + +function todayAtLocal(hour: number): string { + const now = new Date() + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, 0, 0, 0).toISOString() +} + +function roomWithLocalMails(): ContextRoomRecord { + const room = createContextRoomFixture('room-todo-mail', '待办邮件 Room') + room.materials = [ + // 与连接器邮件同主题同日:去重,保留连接器版本 + localMail('lm-dup', '设计周报', `${localDateString()} 10:20`), + localMail('lm-keep', '预算确认', `${localDateString()} 09:00`, { sender: '财务' }), + localMail('lm-old', '上周纪要', '2026-01-06 15:00'), + ] + return room +} + +async function renderMailPane( + room: ContextRoomRecord, + mails: RoomMail[] = [], + mailDetails: Record = {}, + detail: WorkspaceObjectPreview | null = null, +) { + const listMails = vi.fn().mockResolvedValue({ items: mails }) + const readMail = vi.fn(async (_roomId: string, sourceId: string) => { + const mailDetail = mailDetails[sourceId] + if (!mailDetail) throw new Error('mail_not_found') + return mailDetail + }) + const onOpen = vi.fn() + const onCloseDetail = vi.fn() + vi.stubGlobal('window', { + ...globalThis, + nxcore: { contextRooms: { listMails, readMail } }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = TestRenderer.create( + {}} + />, + ) + }) + return { renderer: renderer!, onOpen, onCloseDetail, listMails, readMail } +} + +function rowTitles(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root + .findAll((node) => typeof node.props?.className === 'string' + && node.props.className.split(' ').includes('context-room-mail-item')) + .map((node) => node.findByType('b').children[0]) +} + +describe('待办 / 邮件分区', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('连接器邮件与本地快照合并按时间倒序;同主题同日的本地邮件去重', async () => { + const { renderer } = await renderMailPane(roomWithLocalMails(), [ + mailFixture({ sourceId: 'cm-1', subject: '设计周报', senderName: '林薇', sentAt: todayAtLocal(10) }), + ]) + expect(rowTitles(renderer)).toEqual(['设计周报', '预算确认', '上周纪要']) + }) + + it('点击连接器邮件行派发 connector-mail 对象;本地邮件行派发 mail 对象', async () => { + const room = roomWithLocalMails() + const { renderer, onOpen } = await renderMailPane(room, [ + mailFixture({ sourceId: 'cm-1', subject: '设计周报', sentAt: todayAtLocal(10) }), + ]) + const rows = renderer.root.findAll((node) => typeof node.props?.className === 'string' + && node.props.className.split(' ').includes('context-room-mail-item')) + await act(async () => { + rows[0].props.onClick() + rows[1].props.onClick() + }) + expect(onOpen).toHaveBeenNthCalledWith(1, { kind: 'connector-mail', sourceId: 'cm-1' }) + expect(onOpen).toHaveBeenNthCalledWith(2, { kind: 'mail', id: 'lm-keep' }) + }) + + it('连接器邮件详情整区替换:拉取正文后显示主题,关闭回调可用', async () => { + const mailDetail = { + sourceId: 'cm-1', + provider: null, + subject: '设计周报', + senderName: '林薇', + senderAddress: 'linwei@example.com', + sentAt: todayAtLocal(10), + body: '本周完成了视觉走查', + hasAttachments: false, + } as unknown as RoomMailDetail + const { renderer, readMail, onCloseDetail } = await renderMailPane( + roomWithLocalMails(), + [mailFixture({ sourceId: 'cm-1', subject: '设计周报', sentAt: todayAtLocal(10) })], + { 'cm-1': mailDetail }, + { kind: 'connector-mail', sourceId: 'cm-1' }, + ) + expect(readMail).toHaveBeenCalledWith('room-todo-mail', 'cm-1') + const detail = renderer.root.findAll((node) => + typeof node.props?.['data-testid'] === 'string' + && node.props['data-testid'] === 'context-room-mail-detail') + expect(detail).toHaveLength(1) + expect(renderer.root.findByProps({ title: '设计周报' }).children.join('')).toContain('设计周报') + const close = renderer.root.findAllByType('button') + .find((button) => button.props['aria-label'] === '关闭邮件详情') + expect(close).toBeTruthy() + await act(async () => { + close!.props.onClick() + }) + expect(onCloseDetail).toHaveBeenCalledTimes(1) + }) + + it('无邮件时空态不渲染行', async () => { + const { renderer } = await renderMailPane(createContextRoomFixture('room-todo-mail', '待办邮件 Room')) + expect(rowTitles(renderer)).toEqual([]) + expect(renderer.root.findAll((node) => typeof node.props?.className === 'string' + && node.props.className.split(' ').includes('context-room-mail-item'))).toHaveLength(0) + }) +}) diff --git a/apps/desktop/tests/local-folder-evidence-parse.test.ts b/apps/desktop/tests/local-folder-evidence-parse.test.ts new file mode 100644 index 00000000..e62a90d4 --- /dev/null +++ b/apps/desktop/tests/local-folder-evidence-parse.test.ts @@ -0,0 +1,117 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ConnectorRegistry } from '../src/main/connectors/connector-registry' +import { LocalFolderConnector } from '../src/main/connectors/local-folder-connector' +import { LocalDataService } from '../src/main/core/local-data-service' +import type { LocalFileExportTarget } from '../src/main/core/local-data-service' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }))) +}) + +function exportStub(): LocalFileExportTarget { + return { + capabilities: async () => ({ items: [] }), + importLocalFile: vi.fn(async () => ({ + fileEntryId: 'file-entry-1', fileVersionId: 'file-version-1', jobId: 'job-1', + contentHash: 'a'.repeat(64), blobDeduped: false, versionDeduped: false, + })), + importConnectorFile: vi.fn(), + } +} + +async function waitForParse( + service: LocalDataService, + dataSourceId: string, +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const file = service.listFiles(dataSourceId).at(0) + if (file && file.parseStatus !== 'pending' && file.parseStatus !== 'running') { + return file.parseStatus + } + await new Promise((resolveWait) => setTimeout(resolveWait, 5)) + } + return service.listFiles(dataSourceId).at(0)?.parseStatus +} + +describe('local-folder evidence parse', () => { + // 回归背景:本地文件夹来源不往对象库写副本(导出走原路径),但解析器 + // 只认对象库路径,导致每个 md 文件必报 ENOENT“解析失败”。 + it('parses markdown evidence from the original folder path without storing objects', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'everroom-local-evidence-')) + temporaryDirectories.push(fixtureRoot) + const dataDirectory = join(fixtureRoot, 'data') + const documents = join(fixtureRoot, 'Documents') + await mkdir(documents) + await writeFile(join(documents, 'notes.md'), '# 标题\n\n正文段落') + + const service = new LocalDataService( + dataDirectory, + new ConnectorRegistry().register(new LocalFolderConnector()), + exportStub(), + ) + await service.initialize() + + try { + await service.connectLocalFolders([documents]) + const dataSourceId = service.listSources()[0]!.id + expect(await waitForParse(service, dataSourceId)).toBe('success') + const file = service.listFiles(dataSourceId).at(0)! + expect(file.parseStatus).toBe('success') + expect(file.evidenceCount).toBeGreaterThan(0) + expect(await readdir(join(dataDirectory, 'objects', 'sha256'))).toEqual([]) + expect(service.listEvidence(dataSourceId, file.id).blocks.length).toBeGreaterThan(0) + const preview = await service.previewFile(dataSourceId, file.id) + expect(preview.content).toContain('正文段落') + } finally { + await service.shutdown() + } + }) + + it('retries a previously failed parse job on restart', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'everroom-local-evidence-retry-')) + temporaryDirectories.push(fixtureRoot) + const dataDirectory = join(fixtureRoot, 'data') + const documents = join(fixtureRoot, 'Documents') + await mkdir(documents) + await writeFile(join(documents, 'notes.md'), '# Notes') + + const first = new LocalDataService( + dataDirectory, + new ConnectorRegistry().register(new LocalFolderConnector()), + exportStub(), + ) + await first.initialize() + let dataSourceId = '' + try { + await first.connectLocalFolders([documents]) + dataSourceId = first.listSources()[0]!.id + expect(await waitForParse(first, dataSourceId)).toBe('success') + } finally { + await first.shutdown() + } + + const seed = new DatabaseSync(join(dataDirectory, 'database', 'nxcore.db')) + seed.exec("UPDATE evidence_parse_jobs SET status = 'failed', attempt_count = 1, error_message = 'ENOENT: no such file or directory'") + seed.close() + + const second = new LocalDataService( + dataDirectory, + new ConnectorRegistry().register(new LocalFolderConnector()), + exportStub(), + ) + try { + await second.initialize() + expect(await waitForParse(second, dataSourceId)).toBe('success') + } finally { + await second.shutdown() + } + }) +}) diff --git a/apps/desktop/vite.browser.config.mts b/apps/desktop/vite.browser.config.mts index c4c65495..dcdbbbaf 100644 --- a/apps/desktop/vite.browser.config.mts +++ b/apps/desktop/vite.browser.config.mts @@ -428,7 +428,7 @@ const base = { }) return { items: all.slice(offset, offset + limit), total: all.length } }, - getFilterRules: async () => ({ preference: '', insight: '', updatedAt: null }), + getFilterRules: async () => ({ preference: sessionStorage.getItem('mockFilterPref') || '', insight: '', updatedAt: null }), updateFilterPreference: async (content) => ({ preference: content, insight: '', updatedAt: null }) }, migrations: { sources: async () => [], runs: async () => [], onProgress: () => () => {}, conversations: async () => ({ items: [ { id: 'thread-1', provider: 'claude', sourceId: 's1', title: '历史会话示例', agentId: 'claude', externalSessionId: 'x', messageCount: 2, lastMessageAt: '2026-09-08T00:00:00.000Z', lastMessageExcerpt: '上次的结论…', available: true },