diff --git a/CHANGELOG.md b/CHANGELOG.md index 331a0f69..38398640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - 评审任务并发:在设置页「AI」分区调整同时执行的评审任务数(1~8),即时生效、无需重启。 - 上下文长度:在设置页「AI」分区设置裁剪输入内容的上下文长度上限(32k~1M 的习惯档位),让长 PR 完整入 prompt;对本地 CLI 模式不生效。 - Agent 策略:在设置页「智能体」分区新增策略组——「自动追问」(关闭后评审直接总结、不再条件追问,省 token)与「代码建议数量」(统一约束 /review·/improve·/ask 单次生成的代码建议数量,2~8)。 +- **PR 列表** + - 未读标记:PR 新进入待审列表(新分配 / 请求你评审),或自上次查看后有新 commit 推送、有人 @ 你 / 回复你,列表项会标一个未读圆点;打开 PR 即清除。 - **Agent 对话** - 提问引用附带显示:带 Diff 选区代码提问时,引用的代码在消息气泡下方折叠展示(评论建议引用沿用复评卡片上的定位徽标)。 - 思考过程支持预格式化排版:思考 / 判读内容按 markdown 渲染(代码块 / 列表 / 换行)。 @@ -33,6 +35,7 @@ - 并排 diff 在窗口较窄自动降为统一布局时,滚动条总览标尺的删除标记丢失、只剩新增的绿色;现按实际布局正确区分红/绿。 - 窗口尺寸与最大化状态现可跨重启记住(此前调整尺寸或最大化后关窗常丢失)。 - 高分屏开启缩放时,默认窗口尺寸可能超出屏幕范围;现按当前显示器可用区域自适应并居中显示。 +- 本地状态目录下偶发残留的临时文件会随每次运行持续累积;现于启动时自动清理。 ## [0.6.0] - 2026-06-23 diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 427e030a..5da987cd 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -6,6 +6,7 @@ import { listDrafts, listFindingClosures, listStoredPullRequests, + markPrRead, readCommentsCache, removeFindingClosure, setLocalStatus, @@ -151,6 +152,12 @@ export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, r return setLocalStatus(ctx.stateStore, req.localId, req.status); }; +/** + * 标记 PR 已读:推进已读水位 + 清未读标记(纯本地状态,无远端调用)。 + */ +export const markRead: IpcController<'prs:markRead'> = (_event, req) => + markPrRead(getContext().stateStore, req.localId); + /** * 合并 PR;不在此落本地,靠 renderer refresh → poll 软删收尾,避免本地与远端各执一词。 */ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0347a921..86081961 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -62,6 +62,11 @@ class App { try { await this.bootstrapCore(); this.initRuntimes(); + // 清扫上次会话残留的原子写临时文件(进程在 write↔rename 之间退出留下的孤儿 tmp)。 + // 启动早期、任何写入之前清扫,单写者前提下安全(见 JsonFileStateStore.sweepStaleTmpFiles)。 + await this.stateStore + .sweepStaleTmpFiles() + .catch((err: unknown) => this.logger.warn({ err }, 'state-store: tmp sweep failed')); await this.initConnectionsAndIpc(); await this.initWindow(); await this.startPolling(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 80ae5212..dd406734 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -66,6 +66,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('prs:refresh', pr.refreshPrs); // 立即轮询刷新 ipcMain.handle('prs:lastSync', pr.getLastSync); // 最近一次同步时间 ipcMain.handle('prs:setLocalStatus', pr.setPrStatus); // 设置审阅状态(先远端后本地) + ipcMain.handle('prs:markRead', pr.markRead); // 标记 PR 已读(推进未读水位) ipcMain.handle('prs:merge', pr.mergePr); // 合并 PR ipcMain.handle('repo:sync', pr.syncRepo); // 同步 PR 所属 repo 本地镜像 ipcMain.handle('diff:listChangedFiles', pr.listChangedFiles); // 变更文件列表 diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 25391196..1768da36 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -34,6 +34,7 @@ export default function App() { triggerRefresh, setSelectedPrStatus, mergeSelectedPr, + markRead, } = usePullRequests({ notifyError }); // 应用启动 / 全局生命周期(boot 加载、语言、poll / focus 刷新、向导完成、连接热生效) const { boot, fatalError, lastSyncAt, needsOnboarding, completeOnboarding, refreshBootAndPrs, patchConfig } = @@ -128,7 +129,10 @@ export default function App() { setSelectedId(pr.localId)} + onSelect={(pr) => { + setSelectedId(pr.localId); + void markRead(pr.localId); + }} width={sidebarWidth} onResize={setSidebarWidth} availableFilters={showDiscoveryFilter ? availableDiscoveryFilters : undefined} diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx index 3be1afa2..8739cc01 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx @@ -28,7 +28,7 @@ export function PrItem({ pr, selected, onClick, reviewVerdict, executing }: PrIt const canMerge = pr.mergeStatus?.canMerge ?? false; return (
+ {pr.unread && ( + + )} {pr.hasConflict && ( ⚠️ diff --git a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts index 90063934..b68ed20e 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts @@ -35,6 +35,20 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) => } }, [refreshing, reloadPrs]); + // 标记 PR 已读:用户打开 PR 时调用。先乐观清掉本地未读圆点(即时反馈),再持久化已读水位—— + // 下一轮 poll 不会因旧事件把它标回。每次选中都发 IPC:打开 PR 非高频,且推进已读水位本就是对的; + // 不靠 setState 更新器的副作用判断「是否未读」——更新器在渲染阶段才跑,同步读其副作用拿不到结果。 + const markRead = useCallback(async (localId: string): Promise => { + setPrs((prev) => + prev.map((p) => (p.localId === localId && p.unread ? { ...p, unread: false } : p)), + ); + try { + await invoke('prs:markRead', { localId }); + } catch (e) { + console.error('markRead failed', e); + } + }, []); + const selected = prs.find((p) => p.localId === selectedId) ?? null; const setSelectedPrStatus = useCallback( @@ -88,5 +102,6 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) => triggerRefresh, setSelectedPrStatus, mergeSelectedPr, + markRead, }; } diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index 1e21eab6..17925642 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -611,7 +611,8 @@ "hasConflict": "Merge-Konflikt vorhanden", "mergeable": "Mergebar: alle Merge-Bedingungen erfüllt", "needsWorkCount_one": "{{count}} Reviewer hat „Nachbesserung nötig“ markiert", - "needsWorkCount_other": "{{count}} Reviewer haben „Nachbesserung nötig“ markiert" + "needsWorkCount_other": "{{count}} Reviewer haben „Nachbesserung nötig“ markiert", + "unread": "Ungelesen: dir neu zugewiesen, neue Commits gepusht oder jemand hat dich erwähnt/dir geantwortet" }, "prStatus": { "approved": "Genehmigt", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 514b0d3d..d9e71549 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -611,7 +611,8 @@ "hasConflict": "Has merge conflict", "mergeable": "Mergeable: all merge conditions met", "needsWorkCount_one": "{{count}} reviewer marked needs work", - "needsWorkCount_other": "{{count}} reviewers marked needs work" + "needsWorkCount_other": "{{count}} reviewers marked needs work", + "unread": "Unread: newly assigned to you, new commits pushed, or someone mentioned or replied to you" }, "prStatus": { "approved": "Approved", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 495c20da..a40e5bf7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -595,7 +595,8 @@ "executing": "Agent タスク実行中", "hasConflict": "マージコンフリクトがあります", "mergeable": "マージ可能:すべてのマージ条件を満たしています", - "needsWorkCount_other": "{{count}} 人のレビュアーが要修正とマークしました" + "needsWorkCount_other": "{{count}} 人のレビュアーが要修正とマークしました", + "unread": "未読:新しく割り当てられた、新しいコミットがプッシュされた、またはメンション/返信があります" }, "prStatus": { "approved": "承認済み", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index cefa0300..9fde6f17 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -595,7 +595,8 @@ "executing": "Agent 任务执行中", "hasConflict": "存在合并冲突", "mergeable": "可合并:已满足所有合并条件", - "needsWorkCount_other": "{{count}} 位 reviewer 标记 needs work" + "needsWorkCount_other": "{{count}} 位 reviewer 标记 needs work", + "unread": "未读:新分配给你、有新 commit 推送,或有人 @ 你 / 回复你" }, "prStatus": { "approved": "已批准", diff --git a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss index ad447530..cdb5dfee 100644 --- a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss @@ -378,3 +378,19 @@ margin-right: $space-2; font-size: $fs-md; } + +// 未读圆点:自上次查看后该 PR 有「与我相关」的新事件(新到达 / 新 commit 推送 / @我 / 回复我)。 +// 标题前一个亮蓝实心小圆点(沿用未读消息惯例,取较亮的 info 蓝以在列表里醒目),并把标题字重提到 600 更跳。 +.pr-item-unread-dot { + display: inline-block; + width: 7px; + height: 7px; + margin-right: $space-2; + border-radius: 50%; + background: $color-info; + vertical-align: 0.1em; + flex: 0 0 auto; +} +.pr-item-unread .pr-item-title { + font-weight: 600; +} diff --git a/docs/arch/03-state-storage.md b/docs/arch/03-state-storage.md index f6debc90..52253022 100644 --- a/docs/arch/03-state-storage.md +++ b/docs/arch/03-state-storage.md @@ -25,6 +25,7 @@ └── / ├── meta.json # 完整 PR 元数据 StoredPullRequest(自带 platform 字段) ├── comments.json # 评论快照 + cache key + ├── read-state.json # 用户已读水位(未读标记派生用,仅 markRead 写) └── runs/.json # 评审会话(跟 PR 同寿命) ``` 另有 `connections.json` / `watched-repos.json` / `posted-comments.json`(横向幂等记录,与 PR 目录解耦)。 @@ -32,17 +33,28 @@ 不一致即 stale → 重拉。Bitbucket 任何 PR 变更都跳 `updatedAt`,是足够保险的 cache key。 - **软删 + 1 周 grace**:PR 从远端 reviewer 列表消失(merged/declined/不再是 reviewer)→ 标 `archivedAt`, 不立即删盘;窗口内 UI 隐藏但数据保留、远端复现自动复活;过期下轮 poll 整目录清掉。便于事后回看。 +- **未读标记**:`listStoredPullRequests` 派生 `StoredPullRequest.unread`(不持久化)。规则:**从未打开过**(无 read-state)即 + 未读——覆盖新分配 / 请求评审的新到达,以及清空目录 / 全新安装后涌入的 PR;**打开过之后**则看源 head 又变(新 commit)或已读 + 时间后有「@我 / 回复我」评论(`index.lastMentionAt > read-state.lastReadAt`)。两类状态**分文件、分写者**以避开竞态:**已读水位** + `read-state.json`(`lastReadHeadSha`+`lastReadAt`)**仅** `prs:markRead`(用户打开 PR)写、poll 一概不碰;**mention 游标** + `index.lastMentionAt` 由 poll 独占维护(poll 整体重写 index.json,不会覆盖用户水位),仅在 PR `updatedAt` 跳变时拉评论扫描、与 + 历史取较大值,成本与活动量成正比。commit 检测对各平台通用、不依赖 `updatedAt`。早期开发版不做升级兼容(不抑制旧存量泛红,清库 / 重装即可)。 - **安全 invariant**: - **拉取失败不动本地**:某连接 `listPendingPullRequests` 抛错 → 不写其名下 PR、不软删、不剔索引; 全失败则索引零写入、mtime 不变(避免误触 watcher/备份)。poll 是唯一权威,远端是最终 truth。 - **路径越界屏障**:所有 fs 操作过 `subpathInside` 检查(拒 `..` / 绝对路径 / 清空根),因为 key 由调用方 拼接(含 PR localId / runId),必须防未净化输入越界读写。 - **读宽容、写幂等**:状态文件被外部删/坏时,读返回 null/skip、下一轮 poll 重建;不做启动 reconcile / 自动备份。 +- **启动清扫孤儿 tmp**:原子写「tmp → rename」中,进程在两步之间被强杀 / 退出(如关窗瞬间的 in-flight 异步写)会留下 `*.tmp` + 孤儿、跨会话累积。`sweepStaleTmpFiles` 在启动早期、任何写入之前删掉全部 `*.tmp`——单写者前提下此刻无 in-flight 写,凡 tmp 皆为 + 上次会话孤儿,可放心删;**绝不在运行期清扫**,以免误删并发写 / rename 重试正在用的 tmp(冲突场景不误删多余文件)。 ## 数据 / 接口契约 - `StateStore`:`read(key)` / `write(key,data)` / `delete(key)` / `deleteDir(prefix)` / `list(prefix)`。 -- `PrIndexEntry`:`identity`(PrIdentity) / `updatedAt` / `discoveredAt` / `lastSeenAt` / `archivedAt|null`。 +- `PrIndexEntry`:`identity`(PrIdentity) / `updatedAt` / `discoveredAt` / `lastSeenAt` / `archivedAt|null` / + mention 游标 `lastMentionAt?`。 +- `PrReadStateFile`(`read-state.json`):`lastReadHeadSha` / `lastReadAt`(用户已读水位)。 - `StoredPullRequest`:完整 PR 元数据 + `platform`(自描述)。 - `ReviewRun`:见 [05](05-review-workflow.md)(含 `findings` / `tokenUsage` / `model` / 状态机字段)。 - 所有文件含 `schema_version`。 diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index ee7c71a0..9c97d624 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -70,6 +70,14 @@ export interface PrChannels { request: { localId: string; status: LocalPrStatus }; response: StoredPullRequest | null; }; + /** + * 标记 PR 为已读:推进已读水位(当前 head sha + 时间)并清未读标记。用户打开 PR 时调用。 + * 返回带 `unread:false` 的最新 PR(找不到返回 null)。下一轮 poll 不会因旧事件再把它标回未读。 + */ + 'prs:markRead': { + request: { localId: string }; + response: StoredPullRequest | null; + }; /** * 合并 PR 到目标分支(仅对 canMerge=true 的 PR 暴露入口)。成功后远端 PR 转 * MERGED,调用方应自行刷新列表(下一轮 poll 会软删该 PR)。失败抛错冒泡到 renderer。 diff --git a/packages/poller/src/index.ts b/packages/poller/src/index.ts index d2758112..1406aea6 100644 --- a/packages/poller/src/index.ts +++ b/packages/poller/src/index.ts @@ -3,6 +3,7 @@ export * from './poller.js'; export * from './pr-hash-id.js'; export * from './pr-state.js'; export * from './comments-cache.js'; +export * from './unread.js'; export * from './diff-base-cache.js'; export * from './runs.js'; export * from './agent-session.js'; diff --git a/packages/poller/src/poller.ts b/packages/poller/src/poller.ts index 35c40fa3..b5622314 100644 --- a/packages/poller/src/poller.ts +++ b/packages/poller/src/poller.ts @@ -10,6 +10,7 @@ import type { import type { PlatformAdapter } from '@meebox/platform-core'; import type { StateStore } from '@meebox/state-store'; import { prHashId } from './pr-hash-id.js'; +import { latestCommentToMeAt } from './unread.js'; import { PURGE_GRACE_MS, prDirKey, @@ -307,6 +308,28 @@ export class Poller { }); dirty = true; + // 未读 mention 游标(见 pr-state computeUnread):仅当 PR 内容变更(updatedAt 跳变 → 可能有新评论)且 + // me 已知时拉评论扫「@我 / 回复我」,与历史游标取较大值。新到达 / 新 commit 未读无需在此处理(读取时分别按 + // 发现时间 vs 未读纪元、head sha 比对派生)。read-state 仅由 markRead(用户打开 PR)写,poll 一概不碰。 + let lastMentionAt = prev?.lastMentionAt; + if (isChanged && me) { + try { + const comments = await adapter.comments.listPullRequestComments( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); + const latest = latestCommentToMeAt(comments, me); + if (latest && (!lastMentionAt || Date.parse(latest) > Date.parse(lastMentionAt))) { + lastMentionAt = latest; + } + } catch (err) { + this.opts.logger.warn( + { err, connectionId, localId }, + 'unread scan: failed to list comments', + ); + } + } + // 索引条目:仅 lookup/退场判定需要的字段;archivedAt 反向恢复 (远端回来了) indexByLocalId.set(localId, { identity, @@ -314,6 +337,7 @@ export class Poller { discoveredAt: prev?.discoveredAt ?? now, lastSeenAt: now, archivedAt: null, + lastMentionAt, }); } } catch (err) { diff --git a/packages/poller/src/pr-state.ts b/packages/poller/src/pr-state.ts index 260f7440..050cdb0b 100644 --- a/packages/poller/src/pr-state.ts +++ b/packages/poller/src/pr-state.ts @@ -24,6 +24,24 @@ export interface PrIndexEntry { * 都保留 —— 用户万一回头查可以恢复。 */ archivedAt: string | null; + /** + * 「@我 / 回复我」最新评论时间的单调游标(ISO)。poll 在 PR 内容变更(updatedAt 跳变)时拉评论扫描后 + * 取较大值更新;读取时与已读水位 `lastReadAt` 比较得出 mention 未读。由 poll 独占维护(poll 整体重写索引), + * 与用户的已读水位(另存 read-state.json)解耦,避免 poll 重写索引时把用户操作覆盖掉。 + */ + lastMentionAt?: string; +} + +/** + * 用户对单个 PR 的「已读水位」。独立成 `prs//read-state.json` —— **仅** markRead(用户打开 PR)写; + * poll 周期性重写 index.json 时完全不碰它,从而不会把用户刚推进的水位覆盖回去。未写过 = 用户从未打开该 PR。 + */ +export interface PrReadStateFile { + schema_version: 1; + /** 用户上次查看时的源分支 head sha;当前 head 与之不同 = 有新 commit = 未读 */ + lastReadHeadSha: string; + /** 用户上次查看时间(ISO);晚于此的 @我 / 回复我评论 = 未读 */ + lastReadAt: string; } export interface PrIndexFile { @@ -50,6 +68,25 @@ export function prDirKey(localId: string): string { return `prs/${localId}`; } +export function prReadStateKey(localId: string): string { + return `prs/${localId}/read-state`; +} + +export async function readPrReadState( + store: StateStore, + localId: string, +): Promise { + return store.read(prReadStateKey(localId)); +} + +export async function writePrReadState( + store: StateStore, + localId: string, + data: { lastReadHeadSha: string; lastReadAt: string }, +): Promise { + await store.write(prReadStateKey(localId), { schema_version: 1, ...data }); +} + export async function readPrIndex(store: StateStore): Promise { return store.read(PR_INDEX_KEY); } @@ -73,11 +110,32 @@ export async function writePrMeta( await store.write(prMetaKey(localId), { schema_version: 1, pr }); } +/** + * 计算 PR 的「未读」标记(派生,不持久化)。规则: + * - **从未打开过**(无 read-state)→ 未读:覆盖「新分配 / 请求评审给你」的新到达,以及清空目录 / 全新安装后涌入的 PR。 + * - 打开过之后:源 head 又变(新 commit),或已读时间之后出现「@我 / 回复我」评论(`lastMentionAt > lastReadAt`)→ 未读。 + * + * 已读水位(read-state)由用户打开 PR 写入。早期开发版不做升级兼容——不抑制旧存量泛红(清库 / 重装即可)。 + */ +export function computeUnread( + entry: PrIndexEntry, + readState: PrReadStateFile | null, + pr: StoredPullRequest, +): boolean { + if (!readState) return true; + const commitUnread = pr.sourceRef.sha !== readState.lastReadHeadSha; + const mentionUnread = + entry.lastMentionAt != null && Date.parse(entry.lastMentionAt) > Date.parse(readState.lastReadAt); + return commitUnread || mentionUnread; +} + /** * 列出当前**活跃** (非软删) 的 PR。 * - * 实现:先读索引 → 过滤掉 archivedAt 非空的 → 逐个读 meta.json。索引里没有但目录 + * 实现:先读索引 → 过滤掉 archivedAt 非空的 → 逐个读 meta.json + read-state.json。索引里没有但目录 * 还在的 meta 视为孤儿,跳过 (poll 阶段会清掉)。 + * + * 返回时据已读水位派生 `unread` 标记叠加到每条 PR 上(meta.json 本身不存此字段)。 */ export async function listStoredPullRequests( store: StateStore, @@ -88,11 +146,31 @@ export async function listStoredPullRequests( for (const [localId, entry] of Object.entries(index.prs)) { if (entry.archivedAt) continue; const meta = await readPrMeta(store, localId); - if (meta) out.push(meta.pr); + if (!meta) continue; + const readState = await readPrReadState(store, localId); + out.push({ ...meta.pr, unread: computeUnread(entry, readState, meta.pr) }); } return out; } +/** + * 标记 PR 为已读:把已读水位推进到当前 head sha + now。用户打开 PR 时由 IPC 调用。仅写 read-state.json + * (不碰 index.json),故与周期性 poll 的索引重写互不干扰。找不到 meta 返回 null;否则返回带 `unread:false` 的最新 PR。 + */ +export async function markPrRead( + store: StateStore, + localId: string, + now: string = new Date().toISOString(), +): Promise { + const meta = await readPrMeta(store, localId); + if (!meta) return null; + await writePrReadState(store, localId, { + lastReadHeadSha: meta.pr.sourceRef.sha, + lastReadAt: now, + }); + return { ...meta.pr, unread: false }; +} + /** * 覆写指定 PR 的 localStatus。调用方 (IPC) 通常先 PUT 到 Bitbucket 成功后再调本函数, * 让本地立即反映新状态;下一轮 poll 会从 Bitbucket 拿到同样的值,不会产生抖动。 diff --git a/packages/poller/src/unread.ts b/packages/poller/src/unread.ts new file mode 100644 index 00000000..6d9ee128 --- /dev/null +++ b/packages/poller/src/unread.ts @@ -0,0 +1,59 @@ +import type { PlatformUser, PrComment } from '@meebox/shared'; + +/** + * 「未读」检测的纯逻辑:在 PR 评论树里找出**与当前用户相关**的最新一条他人评论的时间戳。 + * 相关 = ① 正文 @我(按 name / slug 任一 handle 匹配),或 ② 回复我(父评论作者是我)。自己写的评论不计。 + * + * 返回最新相关评论的 createdAt(ISO);无则 null。调用方(poll)把它与历史 `lastMentionAt` 取较大值维护成 + * 单调游标;是否「未读」由读取时与已读水位 `lastReadAt` 比较决定(见 pr-state.computeUnread)——故此处不关心水位。 + * + * 仅在 poll 识别到 PR 内容变更(updatedAt 跳变)时调用——避免对每个跟踪 PR 每轮都拉评论,成本与活动量成正比。 + */ + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * 正文是否 @ 了任一 handle。要求 `@` 前不是单词字符(排除邮箱 `a@h` 之类),`@handle` 后不接单词字符 / `.` / `-` + * (排除 `@handle2` 误命中 `@handle`)。大小写不敏感。 + */ +function mentionsAnyHandle(body: string, handles: readonly string[]): boolean { + for (const h of handles) { + if (!h) continue; + const re = new RegExp(`(? !!x); + const lowered = new Set(handles.map((h) => h.toLowerCase())); + const isMe = (u: PlatformUser): boolean => + lowered.has(u.name.toLowerCase()) || (u.slug ? lowered.has(u.slug.toLowerCase()) : false); + + let latest: string | null = null; + const consider = (iso: string): void => { + if (latest === null || Date.parse(iso) > Date.parse(latest)) latest = iso; + }; + const walk = (list: readonly PrComment[], parentIsMe: boolean): void => { + for (const c of list) { + const authoredByMe = isMe(c.author); + if (!authoredByMe && (parentIsMe || mentionsAnyHandle(c.body, handles))) { + consider(c.createdAt); + } + if (c.replies?.length) walk(c.replies, authoredByMe); + } + }; + walk(comments, false); + return latest; +} diff --git a/packages/poller/tests/unread.test.ts b/packages/poller/tests/unread.test.ts new file mode 100644 index 00000000..4cfa3a8b --- /dev/null +++ b/packages/poller/tests/unread.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import type { PlatformUser, PrComment, StoredPullRequest } from '@meebox/shared'; +import { latestCommentToMeAt } from '../src/unread.js'; +import { computeUnread, type PrIndexEntry, type PrReadStateFile } from '../src/pr-state.js'; + +const me: PlatformUser = { name: 'alice', displayName: 'Alice', slug: 'alice-slug' }; +const bob: PlatformUser = { name: 'bob', displayName: 'Bob' }; + +const T1 = '2026-01-01T00:00:00.000Z'; +const T2 = '2026-01-02T00:00:00.000Z'; + +function comment(overrides: Partial & Pick): PrComment { + return { + remoteId: 'c1', + createdAt: T1, + updatedAt: T1, + anchor: null, + replies: [], + ...overrides, + }; +} + +describe('latestCommentToMeAt', () => { + it('detects an @mention by name', () => { + const comments = [comment({ author: bob, body: 'ping @alice please look' })]; + expect(latestCommentToMeAt(comments, me)).toBe(T1); + }); + + it('detects an @mention by slug', () => { + const comments = [comment({ author: bob, body: 'cc @alice-slug' })]; + expect(latestCommentToMeAt(comments, me)).toBe(T1); + }); + + it('does not match a different handle that contains mine as a prefix', () => { + const comments = [comment({ author: bob, body: 'thanks @alicia' })]; + expect(latestCommentToMeAt(comments, me)).toBeNull(); + }); + + it('detects a reply to my comment', () => { + const comments = [ + comment({ + author: me, + body: 'my top-level comment', + replies: [comment({ author: bob, body: 'I disagree' })], + }), + ]; + expect(latestCommentToMeAt(comments, me)).toBe(T1); + }); + + it('ignores comments authored by me (self mention / self reply)', () => { + const comments = [comment({ author: me, body: 'note to self @alice' })]; + expect(latestCommentToMeAt(comments, me)).toBeNull(); + }); + + it('ignores unrelated comments (no mention, not a reply to me)', () => { + const comments = [comment({ author: bob, body: 'general chatter' })]; + expect(latestCommentToMeAt(comments, me)).toBeNull(); + }); + + it('does not flag a reply to someone else', () => { + const comments = [ + comment({ + author: bob, + body: 'bob top-level', + replies: [comment({ author: me, body: 'my reply to bob' })], + }), + ]; + expect(latestCommentToMeAt(comments, me)).toBeNull(); + }); + + it('returns the latest timestamp across multiple related comments', () => { + const comments = [ + comment({ author: bob, body: 'first @alice', createdAt: T1 }), + comment({ author: bob, body: 'later @alice', createdAt: T2 }), + ]; + expect(latestCommentToMeAt(comments, me)).toBe(T2); + }); +}); + +function entry(over: Partial = {}): PrIndexEntry { + return { + identity: {} as PrIndexEntry['identity'], + updatedAt: T1, + discoveredAt: T1, + lastSeenAt: T1, + archivedAt: null, + ...over, + }; +} + +function pr(headSha: string): StoredPullRequest { + return { sourceRef: { displayId: 'feat', sha: headSha } } as StoredPullRequest; +} + +function read(over: Partial): PrReadStateFile { + return { schema_version: 1, lastReadHeadSha: 'h0', lastReadAt: T1, ...over }; +} + +describe('computeUnread', () => { + it('flags a PR that has never been opened (new arrival / cleared dir / fresh install)', () => { + expect(computeUnread(entry(), null, pr('h1'))).toBe(true); + }); + + it('clears unread once opened, when nothing else changed', () => { + const rs = read({ lastReadHeadSha: 'h1', lastReadAt: T2 }); + expect(computeUnread(entry(), rs, pr('h1'))).toBe(false); + }); + + it('flags a new commit pushed after the PR was opened', () => { + const rs = read({ lastReadHeadSha: 'h1', lastReadAt: T2 }); + expect(computeUnread(entry(), rs, pr('h2'))).toBe(true); + }); + + it('flags a mention newer than the read watermark', () => { + const rs = read({ lastReadHeadSha: 'h1', lastReadAt: T1 }); + expect(computeUnread(entry({ lastMentionAt: T2 }), rs, pr('h1'))).toBe(true); + }); + + it('does not flag a mention older than the read watermark', () => { + const rs = read({ lastReadHeadSha: 'h1', lastReadAt: T2 }); + expect(computeUnread(entry({ lastMentionAt: T1 }), rs, pr('h1'))).toBe(false); + }); +}); diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index ded9b84b..f81248b7 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -367,6 +367,12 @@ export interface StoredPullRequest extends PullRequest { discoveredAt: string; /** 最近一次 poll 仍能看到的时间,ISO */ lastSeenAt: string; + /** + * 「未读」标记(派生值,由 `listStoredPullRequests` 据索引里的已读水位计算后填上;持久化的 meta.json + * 不含此字段)。为真表示自用户上次查看该 PR 后发生了**与我相关**的新事件:源分支推了新 commit、或出现 + * 了 @我 / 回复我的新评论。UI 据此在列表项上点一个未读圆点。用户打开 PR 即清除(推进已读水位)。 + */ + unread?: boolean; } export interface PollResult { diff --git a/packages/state-store/src/json-file-state-store.ts b/packages/state-store/src/json-file-state-store.ts index e54500f2..dddd85ff 100644 --- a/packages/state-store/src/json-file-state-store.ts +++ b/packages/state-store/src/json-file-state-store.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; import path from 'node:path'; import type { Logger } from 'pino'; import type { StateStore } from './types.js'; @@ -86,6 +87,43 @@ export class JsonFileStateStore implements StateStore { } } + /** + * 清扫残留的原子写临时文件(`*.tmp`)。正常写成功即 rename 走 tmp、失败(含 rename 重试用尽)也会主动 rm; + * 但进程在「write tmp」与「rename」之间被强杀 / 退出(如关窗瞬间仍有 in-flight 的异步写)会留下孤儿 tmp, + * 跨会话长期累积。 + * + * **仅在启动、任何写入之前调用**才安全:单写者前提(Electron Main 独占)下,此刻不存在 in-flight 写,凡 `*.tmp` + * 皆为上次会话的孤儿,可放心删;**绝不在运行期清扫**——否则会误删并发写 / rename 重试正在用的 tmp(冲突场景下 + * 不生成、也不误删多余文件)。best-effort:单个删除失败仅记日志、不抛。返回清掉的文件数。 + */ + async sweepStaleTmpFiles(): Promise { + let removed = 0; + const walk = async (dir: string): Promise => { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; // 目录不存在 / 不可读:忽略 + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile() && entry.name.endsWith('.tmp')) { + try { + await fs.unlink(full); + removed++; + } catch (e) { + this.logger?.warn({ err: e, file: full }, 'state-store: failed to sweep stale tmp file'); + } + } + } + }; + await walk(this.rootResolved); + if (removed > 0) this.logger?.info({ removed }, 'state-store: swept stale tmp files at startup'); + return removed; + } + async delete(key: string): Promise { const filePath = this.keyToPath(key); try {