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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and the versioning follows [Semantic Versioning](https://semver.org/).

- A failed review now shows the provider's actual error instead of only "all fallback models failed" — the real cause (an unavailable model, an expired login, an exhausted quota) was previously swallowed and never reached the run card.
- A local CLI provider that exits successfully but returns an empty reply is now reported as a failure naming that cause, rather than as an unexplained LLM failure.
- A merged PR now leaves the list on its own shortly after you merge it, instead of lingering until the next periodic sync — the remote takes a few seconds to actually mark it merged, and the app now waits for that rather than refreshing too early and finding nothing changed.
- Approving a PR now updates whether it can be merged, so the merge button appears as soon as your approval satisfies the last requirement — previously it stayed hidden until the next periodic sync, because the remote recomputes mergeability only after the approval returns.
- The window no longer goes permanently black when the interface fails: a crash now shows what went wrong with a retry / reload, a failure during startup shows a recovery screen, and a renderer that dies outright is reloaded automatically. Crash details are also written to the application log, which they previously never were.
- Switching to another PR while a merge is still in flight no longer clears the PR you just opened.

## [0.11.2] - 2026-07-28

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@

- 评审失败时现在会展示供应商返回的真实错误,而不再只有一句「所有备选模型均调用失败」——真正的原因(模型不可用、登录过期、额度耗尽)此前被吞掉,从未出现在运行卡片上。
- 本地 CLI 供应商正常退出却返回空回复时,现在会作为失败上报并指明该原因,而不再表现为一次无从解释的 LLM 调用失败。
- 合并 PR 后,该 PR 会在稍后自动从列表中消失,而不再滞留到下一次周期同步——远端需要几秒才真正标记为已合并,应用现在会等待这一刻,而不是过早刷新、结果什么都没变。
- 批准 PR 后会重新判断其是否可合并,当你的批准满足最后一项要求时合并按钮即刻出现——此前它会一直隐藏到下一次周期同步,因为远端要在批准返回之后才重新计算可合并性。
- 界面出错时窗口不再永久黑屏:渲染崩溃会显示出错内容并提供重试/重新加载,启动阶段失败会显示恢复界面,渲染进程整个崩溃则自动重新加载。崩溃详情同时写入应用日志——此前从不记录。
- 合并请求仍在进行时切换到其他 PR,不再清空你刚打开的那个 PR。

## [0.11.2] - 2026-07-28

Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/main/bootstrap/window-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_SIZE = { width: 1280, height: 800 };
const MIN_SIZE = { width: 960, height: 600 };

/**
* How many times a window may auto-reload after a renderer crash / load failure before giving up. Bounded so a page
* that dies on load can't spin in a reload loop; the counter resets on any successful load (see installCrashRecovery).
*/
const MAX_CRASH_RELOADS = 3;

// Colors of the system window-control buttons on the right of the self-drawn title bar (Windows
// titleBarOverlay): same color as the .app-titlebar background (--bg-app), so the seam is invisible;
// the symbol takes the primary text color. Dark/light sets, following the effective theme
Expand Down Expand Up @@ -204,12 +210,67 @@ export class WindowManager {
return { action: 'deny' };
});

this.installCrashRecovery(win);

if (process.env.ELECTRON_RENDERER_URL) {
void win.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
void win.loadFile(path.join(__dirname, '../renderer/index.html'));
}
}

/**
* Recover the window from renderer-level failures that the React error boundary cannot see.
*
* A boundary only catches errors thrown while rendering; if the renderer **process** dies (OOM, GPU fault, a native
* crash inside Monaco) or the document fails to load, no JavaScript survives to report it — the window is simply left
* showing its background colour, permanently, since nothing was watching. These listeners are that watcher: log what
* happened (the only record there would otherwise be) and reload the window so the user gets a working UI back
* instead of a dead frame.
*
* Reloads are capped: a page that crashes on load would otherwise reload forever, burning CPU and hiding the fault.
* After the cap the window is left as-is with an error in the log, and the user can restart the app.
*/
private installCrashRecovery(win: BrowserWindow): void {
let reloads = 0;
const reload = (reason: string): void => {
if (win.isDestroyed()) return;
if (reloads >= MAX_CRASH_RELOADS) {
this.logger.error(
{ reason, reloads },
'renderer failed repeatedly; not reloading again (restart the app)',
);
return;
}
reloads += 1;
this.logger.warn({ reason, attempt: reloads }, 'reloading the renderer to recover');
win.webContents.reload();
};
// A successful load means the previous failure (if any) is behind us; reset the budget so an unrelated crash
// hours later still gets its full set of retries.
win.webContents.on('did-finish-load', () => {
reloads = 0;
});
win.webContents.on('render-process-gone', (_evt, details) => {
this.logger.error({ details }, 'renderer process gone');
// 'clean-exit' is a normal teardown (window closing), not a crash to recover from.
if (details.reason !== 'clean-exit') reload(`render-process-gone:${details.reason}`);
});
win.webContents.on('did-fail-load', (_evt, errorCode, errorDescription, validatedURL) => {
// -3 is ERR_ABORTED, which a superseded navigation raises normally; nothing failed for the user.
if (errorCode === -3) return;
this.logger.error({ errorCode, errorDescription, validatedURL }, 'renderer failed to load');
reload(`did-fail-load:${errorCode}`);
});
// Not recovered automatically: an unresponsive renderer is usually a long synchronous task that finishes on its
// own, and reloading would throw away the user's in-flight state. Logged so a hang leaves a trace.
win.webContents.on('unresponsive', () => {
this.logger.warn('renderer became unresponsive');
});
win.webContents.on('responsive', () => {
this.logger.info('renderer became responsive again');
});
}
}

/** Loads the window state (missing/corrupt → empty object, falling back to default size) and constructs the WindowManager. */
Expand Down
74 changes: 22 additions & 52 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import {
} from '@meebox/shared';
import { annotateOwnership } from '../services/comments.js';
import { getContext } from '../services/context.js';
import {
confirmMergeSettled,
confirmMergeabilityAfterReview,
refreshSinglePr,
} from '../services/pr-post-action.js';
import type { IpcController } from './types.js';

/*
Expand Down Expand Up @@ -322,56 +327,8 @@ export const refreshPrs: IpcController<'prs:refresh'> = () => getContext().polle
* the mirror has the new commits so the diff renders the new code. Returns the updated PR; the renderer then reloads the
* list locally (no network poll of other PRs).
*/
export const refreshOnePr: IpcController<'prs:refreshOne'> = async (_event, req) => {
const ctx = getContext();
const existing = await ctx.pr.findPrOrThrow(req.localId);
const adapter = ctx.pr.adapterForOrThrow(existing);
// Remote fetch of just this PR. 403/404 normalize to error codes (matching openPrByUrl); other errors bubble up.
let fresh;
try {
fresh = await adapter.prs.getSinglePullRequest(
{ projectKey: existing.repo.projectKey, repoSlug: existing.repo.repoSlug },
existing.remoteId,
);
} catch (err) {
const status = (err as { status?: number } | null)?.status;
if (status === 403) throw new AppError(ERROR_CODES.PR_FORBIDDEN, undefined, 'forbidden');
if (status === 404) throw new AppError(ERROR_CODES.PR_NOT_FOUND, undefined, 'not found');
throw err;
}
// localStatus mirrors the remote current user's reviewer status (remote authoritative, same mapping as the poll);
// when the current user is unknown (ping incomplete) keep the recorded status rather than downgrading to pending.
const me = adapter.connection.getCurrentUser();
const mineStatus = me ? fresh.reviewers.find((r) => r.name === me.name)?.status : undefined;
const localStatus = !me
? existing.localStatus
: mineStatus === 'approved'
? 'approved'
: mineStatus === 'needsWork'
? 'needs_work'
: 'pending';
const stored: StoredPullRequest = {
...fresh,
localId: existing.localId,
platform: existing.platform,
connectionId: existing.connectionId,
localStatus,
// Preserve local-only bookkeeping (a single-PR refresh isn't a discovery pass).
discoveryFilters: existing.discoveryFilters,
discoveredAt: existing.discoveredAt,
lastSeenAt: new Date().toISOString(),
};
await writePrMeta(await ctx.pr.storeForPr(req.localId), req.localId, stored);
await ctx.pr.invalidateCommentsCache(req.localId);
if (fresh.sourceRef.sha !== existing.sourceRef.sha) {
try {
await ctx.pr.ensureMirrorReadyForPr(stored);
} catch {
/* non-fatal: the diff view self-heals / surfaces a readable error if the mirror still lacks the sha */
}
}
return stored;
};
export const refreshOnePr: IpcController<'prs:refreshOne'> = (_event, req) =>
refreshSinglePr(getContext(), req.localId, { invalidateComments: true });

/**
* The Poller's most recent completion time (used for startup initialization).
Expand All @@ -382,6 +339,11 @@ export const getLastSync: IpcController<'prs:lastSync'> = () => ({

/**
* Set review status: write remote first (on failure the frontend is unchanged), and persist locally after the remote is OK.
*
* A verdict can change the remote's mergeability verdict (an approval satisfying the last required rule makes the PR
* mergeable), but the remote recomputes that asynchronously — the value returned here is still the pre-verdict one. So
* kick off a background re-check that lands the new canMerge, rather than leaving the merge button a poll interval
* behind reality (see services/pr-post-action.ts).
*/
export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, req) => {
const ctx = getContext();
Expand All @@ -398,7 +360,9 @@ export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, r
pr.remoteId,
remoteStatus,
);
return setLocalStatus(ctx.stateStore, req.localId, req.status);
const updated = await setLocalStatus(ctx.stateStore, req.localId, req.status);
confirmMergeabilityAfterReview(ctx, req.localId, pr.mergeStatus.canMerge);
return updated;
};

/**
Expand All @@ -408,7 +372,12 @@ export const markRead: IpcController<'prs:markRead'> = (_event, req) =>
markPrRead(getContext().stateStore, req.localId);

/**
* Merge a PR; do not persist locally here, relying on renderer refresh → poll soft-delete to finish, to avoid local and remote disagreeing.
* Merge a PR; do not persist locally here, relying on refresh → poll soft-delete to finish, to avoid local and remote disagreeing.
*
* The remote does not settle synchronously: for a moment after the merge is accepted the PR still reports open and still
* comes back in the discovery list, so an immediate refresh would show it sitting in the list untouched. A background
* re-check confirms it actually left the open state and then archives it through a poll tick (see
* services/pr-post-action.ts); it is deliberately not awaited, so the caller isn't blocked on the remote settling.
*/
export const mergePr: IpcController<'prs:merge'> = async (_event, req) => {
const ctx = getContext();
Expand All @@ -418,6 +387,7 @@ export const mergePr: IpcController<'prs:merge'> = async (_event, req) => {
{ projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug },
pr.remoteId,
);
confirmMergeSettled(ctx, req.localId);
};

/**
Expand Down
Loading
Loading