Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 渲染(代码块 / 列表 / 换行)。
Expand All @@ -33,6 +35,7 @@
- 并排 diff 在窗口较窄自动降为统一布局时,滚动条总览标尺的删除标记丢失、只剩新增的绿色;现按实际布局正确区分红/绿。
- 窗口尺寸与最大化状态现可跨重启记住(此前调整尺寸或最大化后关窗常丢失)。
- 高分屏开启缩放时,默认窗口尺寸可能超出屏幕范围;现按当前显示器可用区域自适应并居中显示。
- 本地状态目录下偶发残留的临时文件会随每次运行持续累积;现于启动时自动清理。

## [0.6.0] - 2026-06-23

Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
listDrafts,
listFindingClosures,
listStoredPullRequests,
markPrRead,
readCommentsCache,
removeFindingClosure,
setLocalStatus,
Expand Down Expand Up @@ -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 软删收尾,避免本地与远端各执一词。
*/
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); // 变更文件列表
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -128,7 +129,10 @@ export default function App() {
<Sidebar
prs={prs}
selectedId={selectedId}
onSelect={(pr) => setSelectedId(pr.localId)}
onSelect={(pr) => {
setSelectedId(pr.localId);
void markRead(pr.localId);
}}
width={sidebarWidth}
onResize={setSidebarWidth}
availableFilters={showDiscoveryFilter ? availableDiscoveryFilters : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function PrItem({ pr, selected, onClick, reviewVerdict, executing }: PrIt
const canMerge = pr.mergeStatus?.canMerge ?? false;
return (
<div
className={`pr-item ${selected ? 'selected' : ''} pr-item-status-${pr.localStatus}`}
className={`pr-item ${selected ? 'selected' : ''} pr-item-status-${pr.localStatus} ${pr.unread ? 'pr-item-unread' : ''}`}
onClick={onClick}
role="button"
tabIndex={0}
Expand All @@ -48,6 +48,9 @@ export function PrItem({ pr, selected, onClick, reviewVerdict, executing }: PrIt
/>
<div className="pr-item-body">
<div className="pr-item-title">
{pr.unread && (
<span className="pr-item-unread-dot" title={t('prItem.unread')} aria-label="unread" />
)}
{pr.hasConflict && (
<span className="conflict-warn" title={t('prItem.hasConflict')} aria-label="conflict">
⚠️
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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(
Expand Down Expand Up @@ -88,5 +102,6 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) =>
triggerRefresh,
setSelectedPrStatus,
mergeSelectedPr,
markRead,
};
}
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,8 @@
"executing": "Agent タスク実行中",
"hasConflict": "マージコンフリクトがあります",
"mergeable": "マージ可能:すべてのマージ条件を満たしています",
"needsWorkCount_other": "{{count}} 人のレビュアーが要修正とマークしました"
"needsWorkCount_other": "{{count}} 人のレビュアーが要修正とマークしました",
"unread": "未読:新しく割り当てられた、新しいコミットがプッシュされた、またはメンション/返信があります"
},
"prStatus": {
"approved": "承認済み",
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,8 @@
"executing": "Agent 任务执行中",
"hasConflict": "存在合并冲突",
"mergeable": "可合并:已满足所有合并条件",
"needsWorkCount_other": "{{count}} 位 reviewer 标记 needs work"
"needsWorkCount_other": "{{count}} 位 reviewer 标记 needs work",
"unread": "未读:新分配给你、有新 commit 推送,或有人 @ 你 / 回复你"
},
"prStatus": {
"approved": "已批准",
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/renderer/src/styles/layout/sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 13 additions & 1 deletion docs/arch/03-state-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,36 @@
└── <hash>/
├── meta.json # 完整 PR 元数据 StoredPullRequest(自带 platform 字段)
├── comments.json # 评论快照 + cache key
├── read-state.json # 用户已读水位(未读标记派生用,仅 markRead 写)
└── runs/<runId>.json # 评审会话(跟 PR 同寿命)
```
另有 `connections.json` / `watched-repos.json` / `posted-comments.json`(横向幂等记录,与 PR 目录解耦)。
- **评论缓存按 PR `updatedAt` 失效**:`comments.json` 存写入时的 `pr_updated_at`;与当前 PR meta 的 `updatedAt`
不一致即 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<T>(key)` / `write<T>(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`。
Expand Down
8 changes: 8 additions & 0 deletions packages/ipc/src/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
1 change: 1 addition & 0 deletions packages/poller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions packages/poller/src/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -307,13 +308,36 @@ 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,
updatedAt: pr.updatedAt,
discoveredAt: prev?.discoveredAt ?? now,
lastSeenAt: now,
archivedAt: null,
lastMentionAt,
});
}
} catch (err) {
Expand Down
Loading
Loading