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

### 🔧 Fixed

- A part of the interface that loads on demand — the diff editor, a comment's inline code context — no longer takes the whole app down with it when it fails to load; the failure now stays inside that pane. If it failed because the app was updated or rebuilt while the window was open, it says so and offers to reload, which is the only thing that actually helps in that case.
- 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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

### 🔧 修复

- 按需加载的界面部分——diff 编辑器、评论中的内联代码上下文——加载失败时不再拖垮整个应用,失败被限制在该区域内。若失败原因是窗口开着时应用被更新或重新构建,会明确说明并提供重新加载,那也是这种情况下唯一有效的操作。
- 评审失败时现在会展示供应商返回的真实错误,而不再只有一句「所有备选模型均调用失败」——真正的原因(模型不可用、登录过期、额度耗尽)此前被吞掉,从未出现在运行卡片上。
- 本地 CLI 供应商正常退出却返回空回复时,现在会作为失败上报并指明该原因,而不再表现为一次无从解释的 LLM 调用失败。
- 合并 PR 后,该 PR 会在稍后自动从列表中消失,而不再滞留到下一次周期同步——远端需要几秒才真正标记为已合并,应用现在会等待这一刻,而不是过早刷新、结果什么都没变。
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,41 @@
import { useTranslation } from 'react-i18next';
import { isChunkLoadError } from './LazyBoundary';

/**
* Full-window fallback for a crash in the app's root subtree.
*
* Without a boundary at the root, any error thrown while rendering unmounts the whole tree and leaves an empty `#root`
* — which reads as a permanently black window, with no way back short of restarting the app. This screen is what the
* user gets instead: what broke, and two ways out.
* user gets instead: what broke, and a way out.
*
* `onRetry` re-renders the subtree, which is enough when the crash came from transient state (a stale record read
* during an in-flight update); reloading rebuilds the renderer from scratch and is the way out when it did not.
* The way out depends on the failure. `onRetry` re-renders the subtree, which is enough when the crash came from
* transient state (a stale record read during an in-flight update). A **stale chunk** is the exception: the page holds
* a hashed module URL that no longer exists (the app was rebuilt or updated while this window stayed open), so
* re-rendering re-requests the same dead URL and fails identically — only a reload recovers. Offering "retry" there
* would be offering a button that cannot work, so that case leads with reload and explains why.
*/
export function AppCrashScreen({ err, onRetry }: { err: Error; onRetry: () => void }) {
const { t } = useTranslation();
const stale = isChunkLoadError(err);
return (
<div className="app-crash" role="alert">
<div className="app-crash-card">
<h1 className="app-crash-title">{t('crash.title')}</h1>
<p className="app-crash-hint">{t('crash.hint')}</p>
<h1 className="app-crash-title">{stale ? t('crash.staleTitle') : t('crash.title')}</h1>
<p className="app-crash-hint">{stale ? t('crash.staleHint') : t('crash.hint')}</p>
<pre className="app-crash-detail">{err.message || String(err)}</pre>
<div className="app-crash-actions">
<button type="button" className="btn btn-primary" onClick={onRetry}>
{t('crash.retry')}
</button>
<button type="button" className="btn" onClick={() => window.location.reload()}>
<button
type="button"
className="btn btn-primary"
onClick={() => window.location.reload()}
>
{t('crash.reload')}
</button>
{!stale && (
<button type="button" className="btn" onClick={onRetry}>
{t('crash.retry')}
</button>
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Suspense, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ErrorBoundary } from './ErrorBoundary';

/**
* Whether an error is a dynamic-import failure — the chunk behind a `lazy()` could not be fetched.
*
* Worth distinguishing because the remedy is the opposite of the usual one: the page is holding a hashed chunk URL that
* no longer exists on disk (the app was rebuilt or updated while this window stayed open), so **retrying re-requests the
* same dead URL and fails again**. Only a reload — which re-reads the entry and picks up current hashes — recovers.
*
* The message differs per engine, hence matching several forms rather than one.
*/
export function isChunkLoadError(err: Error): boolean {
const msg = err.message.toLowerCase();
return (
msg.includes('failed to fetch dynamically imported module') || // Chromium (Electron)
msg.includes('error loading dynamically imported module') || // Firefox
msg.includes('importing a module script failed') || // Safari
msg.includes('unable to preload') // Vite's preload helper
);
}

/**
* Suspense + an error boundary around a `lazy()` subtree.
*
* `Suspense` alone only covers the *pending* half of a lazy import: if the chunk fails to load, the promise rejects and
* the error propagates to the nearest boundary. With no boundary in between it reaches the root one and takes the whole
* app down — a Monaco snippet failing to load should not cost the user the comment thread around it. So each lazy
* subtree gets its own boundary, and the failure stays inside the pane that could not load.
*/
export function LazyBoundary({
label,
loading,
children,
}: {
/** Names the failing region in logs (see ErrorBoundary). */
label: string;
/** Rendered while the chunk is in flight. */
loading: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<ErrorBoundary
label={label}
fallback={(err, reset) => (
<div className="lazy-boundary-error">
<p>{t('lazyLoad.failed')}</p>
<p className="muted">
{isChunkLoadError(err) ? t('lazyLoad.staleHint') : t('lazyLoad.genericHint')}
</p>
<div className="lazy-boundary-actions">
{/* Retry is offered only when it can actually work: for a stale chunk it would re-request the same dead
URL, so that case leads with reload instead. */}
{!isChunkLoadError(err) && (
<button type="button" className="btn btn-sm" onClick={reset}>
{t('crash.retry')}
</button>
)}
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() => window.location.reload()}
>
{t('crash.reload')}
</button>
</div>
</div>
)}
>
<Suspense fallback={loading}>{children}</Suspense>
</ErrorBoundary>
);
}
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './Avatar';
export * from './BitbucketImage';
export * from './ConfirmModal';
export * from './ErrorBoundary';
export * from './LazyBoundary';
export * from './LlmProviderIcon';
export * from './Loading';
export * from './MermaidDiagram';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { lazy, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type {
LocalPrStatus,
Expand All @@ -10,7 +10,7 @@ import type {
} from '@meebox/shared';
import { invoke } from '../../../api';
import { useDraftsForPr } from '../../../stores/drafts-store';
import { PaneLoading } from '../../common';
import { LazyBoundary, PaneLoading } from '../../common';
import { ActivityPanel } from './tabs/activity/ActivityPanel';
import { CommitsPanel } from './tabs/CommitsPanel';
// Monaco editor (~10MB) lazy-loaded: the DiffView chunk is fetched only when actually switching to the Diff tab,
Expand Down Expand Up @@ -211,7 +211,10 @@ export function PrPanel({
{/* keep-alive: each tab mounts only on first visit, then stays alive with only CSS show/hide (see KeepAliveTab).
Switching away and back is instant, no refetch, embedded Monaco / scroll position / expanded state all preserved, eliminating switch jitter. */}
<KeepAliveTab active={tab === 'diff'}>
<Suspense fallback={<PaneLoading label={t('mainPane.loadingEditor')} />}>
<LazyBoundary
label="DiffView"
loading={<PaneLoading label={t('mainPane.loadingEditor')} />}
>
<DiffView
pr={pr}
renderSideBySide={renderSideBySide}
Expand All @@ -225,7 +228,7 @@ export function PrPanel({
onCommitViewConsumed={() => setPendingCommitView(null)}
onViewCommitScopeChange={onViewCommitScopeChange}
/>
</Suspense>
</LazyBoundary>
</KeepAliveTab>
<KeepAliveTab active={tab === 'activity'}>
<ActivityPanel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense, useMemo } from 'react';
import { lazy, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { PlatformUser, PrComment, PrCommentAnchor, StoredPullRequest } from '@meebox/shared';
import i18n from '../../../../../i18n';
Expand All @@ -8,6 +8,7 @@ import {
makeBitbucketImageFor,
ChatIcon,
ConfirmModal,
LazyBoundary,
mermaidComponents,
} from '../../../../common';
import { CommentEditEditor } from './CommentEditEditor';
Expand Down Expand Up @@ -146,11 +147,12 @@ export function CommentItem({
// File-level comments (no line) have no single line to show → skip the code context.
const inlineCode =
comment.anchor && comment.anchor.line != null && depth === 0 ? (
<Suspense
fallback={<div className="pane-loading muted">{t('commentsPanel.loadingCodeContext')}</div>}
<LazyBoundary
label="InlineCodeContext"
loading={<div className="pane-loading muted">{t('commentsPanel.loadingCodeContext')}</div>}
>
<InlineCodeContext pr={pr} anchor={comment.anchor} autoExpand={autoExpandCode} />
</Suspense>
</LazyBoundary>
) : null;

// Edit mode: textarea replaces the markdown body in place; non-edit mode: render markdown
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@
"hint": "Ein Neuladen behebt das in der Regel. Tritt es wiederholt auf, benennen die Details unten und das Anwendungsprotokoll (meebox.log) die Ursache.",
"reload": "Neu laden",
"retry": "Erneut versuchen",
"staleHint": "Ein Teil der Oberfläche konnte nicht mehr geladen werden — das passiert, wenn die Anwendung bei geöffnetem Fenster aktualisiert oder neu gebaut wird. Ein Neuladen übernimmt die aktuelle Version.",
"staleTitle": "Die Anwendungsdateien haben sich geändert, seit dieses Fenster geöffnet wurde",
"title": "Beim Rendern der Oberfläche ist ein Fehler aufgetreten"
},
"diffSearchPanel": {
Expand Down Expand Up @@ -502,6 +504,11 @@
"expandTitle": "Code um die verankerte Zeile erweitern",
"loading": "Code-Kontext wird geladen…"
},
"lazyLoad": {
"failed": "Dieser Teil der Oberfläche konnte nicht geladen werden.",
"genericHint": "Ein erneuter Versuch hilft meistens; schlägt es weiterhin fehl, laden Sie das Fenster neu.",
"staleHint": "Die Anwendungsdateien haben sich seit dem Öffnen dieses Fensters geändert — meist durch ein Update oder einen Neubau. Ein Neuladen übernimmt die aktuelle Version."
},
"llmProfileForm": {
"apiKeyOptionalPlaceholder": "Dieser Provider benötigt keinen Schlüssel (leer lassen)",
"cliCommandFallback": "Kommandozeilen-Tool",
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@
"hint": "Reloading usually resolves it. If it keeps happening, the details below and the application log (meebox.log) identify the cause.",
"reload": "Reload",
"retry": "Retry",
"staleHint": "Part of the interface could no longer be loaded, which happens when the app is updated or rebuilt while a window stays open. Reloading picks up the current version.",
"staleTitle": "The app files changed since this window opened",
"title": "Something went wrong rendering the interface"
},
"diffSearchPanel": {
Expand Down Expand Up @@ -502,6 +504,11 @@
"expandTitle": "Expand code around the anchored line",
"loading": "Loading code context…"
},
"lazyLoad": {
"failed": "This part of the interface could not be loaded.",
"genericHint": "Retrying often works; if it keeps failing, reload the window.",
"staleHint": "The app files changed since this window opened — usually an update or a rebuild. Reloading picks up the current version."
},
"llmProfileForm": {
"apiKeyOptionalPlaceholder": "This provider needs no key (leave empty)",
"cliCommandFallback": "command-line tool",
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@
"hint": "再読み込みで通常は復旧します。繰り返し発生する場合は、以下の詳細とアプリケーションログ(meebox.log)で原因を特定できます。",
"reload": "再読み込み",
"retry": "再試行",
"staleHint": "画面の一部を読み込めなくなりました。ウィンドウを開いたままアプリが更新または再ビルドされた場合に発生します。再読み込みすると最新版が読み込まれます。",
"staleTitle": "ウィンドウを開いた後にアプリのファイルが変更されました",
"title": "画面の描画中にエラーが発生しました"
},
"diffSearchPanel": {
Expand Down Expand Up @@ -491,6 +493,11 @@
"expandTitle": "アンカー行の前後のコードを展開",
"loading": "コードコンテキストを読み込み中…"
},
"lazyLoad": {
"failed": "この部分の画面を読み込めませんでした。",
"genericHint": "再試行で復旧することが多く、繰り返し失敗する場合はウィンドウを再読み込みしてください。",
"staleHint": "ウィンドウを開いた後にアプリのファイルが変更されました(通常は更新または再ビルド)。再読み込みすると最新版が読み込まれます。"
},
"llmProfileForm": {
"apiKeyOptionalPlaceholder": "このプロバイダーはキー不要です(空のままに)",
"cliCommandFallback": "コマンドラインツール",
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@
"hint": "重新加载通常即可恢复。若反复出现,下方详情与应用日志(meebox.log)可定位原因。",
"reload": "重新加载",
"retry": "重试",
"staleHint": "部分界面已无法加载——通常是窗口开着时应用被更新或重新构建所致。重新加载即可载入当前版本。",
"staleTitle": "应用文件在窗口打开后发生了变化",
"title": "界面渲染出错"
},
"diffSearchPanel": {
Expand Down Expand Up @@ -491,6 +493,11 @@
"expandTitle": "展开锚定行前后代码",
"loading": "加载代码上下文…"
},
"lazyLoad": {
"failed": "这部分界面加载失败。",
"genericHint": "重试通常可以恢复;若持续失败,请重新加载窗口。",
"staleHint": "应用文件在窗口打开后发生了变化——通常是更新或重新构建所致。重新加载即可载入当前版本。"
},
"llmProfileForm": {
"apiKeyOptionalPlaceholder": "该 provider 无需密钥(留空)",
"cliCommandFallback": "命令行",
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/src/styles/common/crash.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,22 @@
display: flex;
gap: $space-2;
}

// Failure of one lazily-loaded pane (LazyBoundary). Sized to sit inside whatever region failed — a comment's code
// context, the diff pane — rather than taking over the window like .app-crash does.
.lazy-boundary-error {
padding: $space-8;
color: $text-muted;
font-size: $fs-xs;
line-height: 1.5;

p {
margin: 0 0 $space-2;
}
}

.lazy-boundary-actions {
display: flex;
gap: $space-2;
margin-top: $space-3;
}
Loading
Loading