From c6c2b63c60199417d5eca1378092f7611918b488 Mon Sep 17 00:00:00 2001 From: Dash Date: Fri, 31 Jul 2026 14:29:09 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(desktop):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=8F=AA=E5=9C=A8=E6=9C=89=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E6=89=8D=E4=BA=8C=E6=AC=A1=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=EF=BC=8C=E5=B9=B6=E6=94=B9=E6=88=90=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 侧栏 UpdateBanner 的重启入口原来是无条件「就地两段式」:点「立即重启」先切 确认态,再点「确认重启」才真重启。没有任何任务在跑时,第二步只有一句「应用会 自动重启」——不带信息量,纯粹多要一次点击。 改成与关窗链路(WindowControls.handleCloseClick)同一套判定:点入口先查 anySessionInTurn(),没有任务在跑就直接重启;只有真的有任务时才拦一次。探针 失败(桥不可用 / handler 未注册)按「没有任务」处理,与关窗链路一致。 因此 confirming 态的语义从「泛化二次确认」收窄为「有任务在运行,重启会打断它」 的中断警告:标题点明状态、副标题讲后果(警告色,不再有分支)、主按钮改为 「仍要重启」。退役无信息量的 confirmHint,四语言同步。 - 入口点击加 ref 防重入:探针在飞时的连点不会重复探针或重复重启 - 收起 / rail 态共用同一判定;那里没有文案位置,中断提示落在 ✓ 的 tooltip 上 - 测试随语义更名为 updateBannerRelaunchEntry.test.tsx,覆盖 busy 拦一次、非 busy 一击直达、探针抛错、连点防重入、收起态同判定 验证:pnpm test:unit、desktop typecheck、check:i18n-glossary、check:i18n 全绿。 更新链路改动已与 owner 确认;未触及 updateService.ts 与 cindy-updater。 实机:dev 下更新链路被 isDev() 短路,拿不到真实 ready 态,双模式目检未在真实 ready 态下完成;本次未新增样式与颜色,确认态仍复用既有 --warning-fg / --update-btn-* 语义 token。 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dash --- .../__tests__/updateBannerBusyHint.test.tsx | 108 -------------- .../updateBannerRelaunchEntry.test.tsx | 141 ++++++++++++++++++ .../updateBannerReleaseNotesLink.test.tsx | 25 +++- .../components/sidebar/UpdateBanner.tsx | 106 +++++++------ .../src/renderer/i18n/locales/en/common.json | 11 +- .../src/renderer/i18n/locales/ja/common.json | 11 +- .../src/renderer/i18n/locales/ko/common.json | 11 +- .../renderer/i18n/locales/zh-CN/common.json | 11 +- 8 files changed, 235 insertions(+), 189 deletions(-) delete mode 100644 apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx create mode 100644 apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx diff --git a/apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx deleted file mode 100644 index 2ea2ca9fef3..00000000000 --- a/apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const { anySessionInTurn, relaunchToUpdate } = vi.hoisted(() => ({ - anySessionInTurn: vi.fn<() => Promise>(), - relaunchToUpdate: vi.fn(), -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock('@/hooks/useUpdateStatus', () => ({ - useUpdateStatus: () => ({ - status: 'ready', - version: '1.2.3', - errorCode: null, - }), -})); - -vi.mock('@/hooks/useUpdateBannerDismiss', () => ({ - useUpdateBannerDismiss: () => ({ - dismissed: false, - dismiss: vi.fn(), - restore: vi.fn(), - isNewUpdateAfterDismiss: vi.fn(() => false), - }), -})); - -vi.mock('@/components/ui/tooltip', () => ({ - Tip: ({ children }: { children: React.ReactNode }) => children, -})); - -import { UpdateBanner } from '@/components/sidebar/UpdateBanner'; - -beforeEach(() => { - anySessionInTurn.mockReset(); - relaunchToUpdate.mockReset(); - Object.defineProperty(window, 'electronAPI', { - configurable: true, - value: { - anySessionInTurn, - relaunchToUpdate, - clientEndpoints: { websiteUrl: 'https://cindy.ai' }, - } as unknown as Window['electronAPI'], - }); -}); - -afterEach(cleanup); - -describe('UpdateBanner busy-turn restart hint', () => { - it('shows the warning hint in the semantic warning color while any turn is running', async () => { - anySessionInTurn.mockResolvedValue(true); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - - const hint = await screen.findByText('update.banner.confirmBusyHint'); - expect(anySessionInTurn).toHaveBeenCalledTimes(1); - expect(hint.className).toContain('text-[var(--warning-fg)]'); - }); - - it('keeps the existing neutral hint when no turn is running', async () => { - anySessionInTurn.mockResolvedValue(false); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - - await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); - const hint = screen.getByText('update.banner.confirmHint'); - expect(hint.className).toContain('text-sidebar-muted'); - expect(hint.className).not.toContain('text-[var(--warning-fg)]'); - }); - - it('keeps relaunch behavior unchanged while the text-only snapshot is pending', async () => { - let resolveTurnCheck!: (busy: boolean) => void; - anySessionInTurn.mockImplementation( - () => new Promise((resolve) => { resolveTurnCheck = resolve; }), - ); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); - - const confirmButton = screen.getByRole('button', { name: 'update.banner.confirmAria' }); - expect((confirmButton as HTMLButtonElement).disabled).toBe(false); - fireEvent.click(confirmButton); - expect(relaunchToUpdate).toHaveBeenCalledTimes(1); - - resolveTurnCheck(false); - }); - - it('keeps the neutral hint when the one-shot probe throws synchronously', async () => { - anySessionInTurn.mockImplementation(() => { - throw new Error('electron bridge is not registered'); - }); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - - await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); - expect(screen.getByText('update.banner.confirmHint')).toBeTruthy(); - }); -}); diff --git a/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx new file mode 100644 index 00000000000..50b3dbd2cb1 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +/** + * UpdateBanner 的重启入口判定 —— 与关窗链路(WindowControls.handleCloseClick)同构: + * 点入口先查 anySessionInTurn(),没有任务在跑就直接重启,有才拦一次并说明「会打断 + * 进行中的任务」。探针失败按「没有任务」处理。 + */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { anySessionInTurn, relaunchToUpdate } = vi.hoisted(() => ({ + anySessionInTurn: vi.fn<() => Promise>(), + relaunchToUpdate: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/hooks/useUpdateStatus', () => ({ + useUpdateStatus: () => ({ + status: 'ready', + version: '1.2.3', + errorCode: null, + }), +})); + +vi.mock('@/hooks/useUpdateBannerDismiss', () => ({ + useUpdateBannerDismiss: () => ({ + dismissed: false, + dismiss: vi.fn(), + restore: vi.fn(), + isNewUpdateAfterDismiss: vi.fn(() => false), + }), +})); + +vi.mock('@/components/ui/tooltip', () => ({ + Tip: ({ children }: { children: React.ReactNode }) => children, +})); + +import { UpdateBanner } from '@/components/sidebar/UpdateBanner'; + +beforeEach(() => { + anySessionInTurn.mockReset(); + relaunchToUpdate.mockReset(); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + anySessionInTurn, + relaunchToUpdate, + clientEndpoints: { websiteUrl: 'https://cindy.ai' }, + } as unknown as Window['electronAPI'], + }); +}); + +afterEach(cleanup); + +describe('UpdateBanner relaunch entry', () => { + it('warns about the interruption instead of relaunching while a turn is running', async () => { + anySessionInTurn.mockResolvedValue(true); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + const hint = await screen.findByText('update.banner.confirmBusyHint'); + expect(anySessionInTurn).toHaveBeenCalledTimes(1); + expect(hint.className).toContain('text-[var(--warning-fg)]'); + // 拦住的这一步不能顺手把 app 重启了 —— 是否打断任务由用户拍板。 + expect(relaunchToUpdate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.confirmAria' })); + expect(relaunchToUpdate).toHaveBeenCalledTimes(1); + }); + + it('relaunches on the first click when no turn is running', async () => { + anySessionInTurn.mockResolvedValue(false); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + expect(anySessionInTurn).toHaveBeenCalledTimes(1); + // 没有任务在跑时不该再出现第二步 —— 那句「应用会自动重启」不带任何信息量。 + expect(screen.queryByRole('button', { name: 'update.banner.confirmAria' })).toBeNull(); + expect(screen.queryByText('update.banner.confirmBusyHint')).toBeNull(); + }); + + it('relaunches when the busy probe throws synchronously (bridge unavailable)', async () => { + anySessionInTurn.mockImplementation(() => { + throw new Error('electron bridge is not registered'); + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('update.banner.confirmBusyHint')).toBeNull(); + }); + + it('ignores repeat clicks while the busy probe is still in flight', async () => { + let resolveTurnCheck!: (busy: boolean) => void; + anySessionInTurn.mockImplementation( + () => new Promise((resolve) => { resolveTurnCheck = resolve; }), + ); + render(); + + const entry = screen.getByRole('button', { name: 'update.banner.ariaExpanded' }); + fireEvent.click(entry); + fireEvent.click(entry); + fireEvent.click(entry); + + await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + + resolveTurnCheck(false); + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + }); + + it('applies the same judgement to the collapsed / rail entry', async () => { + anySessionInTurn.mockResolvedValue(false); + const { unmount } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaCollapsed' })); + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('button', { name: 'update.banner.confirmAria' })).toBeNull(); + + unmount(); + relaunchToUpdate.mockClear(); + anySessionInTurn.mockResolvedValue(true); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaCollapsed' })); + // 收起态没有文案位置,拦下来的形态是 ✓ / ✕ 两键。 + await screen.findByRole('button', { name: 'update.banner.confirmAria' }); + expect(screen.getByRole('button', { name: 'update.banner.cancelAria' })).toBeTruthy(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx index 794f2118d85..742ac9fc3c4 100644 --- a/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx +++ b/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx @@ -3,7 +3,7 @@ /** * UpdateBanner「查看更新公告」文字链 —— 方案 A。 * - * 覆盖入口的四条判定:CDN 有公告才显示、点击带的是待装版本号、confirming 两步确认期 + * 覆盖入口的四条判定:CDN 有公告才显示、点击带的是待装版本号、confirming 中断警告期 * 让位、superseding 态不给入口(那时的 version 指向上一个已就绪补丁,不是正在下的新版)。 */ @@ -38,10 +38,28 @@ const NOTES = { version: '1.4.2', date: '2026-07-28', contributors: [], sections const LINK = 'update.banner.viewNotes'; +// 入口按钮现在先查 busy 探针再决定「直接重启 vs 进中断警告态」,所以这个文件也需要 +// 一个 electronAPI 桩;默认没有任务在跑(本文件只关心文字链,不关心重启)。 +const { anySessionInTurn, relaunchToUpdate } = vi.hoisted(() => ({ + anySessionInTurn: vi.fn<() => Promise>(), + relaunchToUpdate: vi.fn(), +})); + beforeEach(() => { updateStatus.current = { status: 'ready', version: '1.4.2' }; fetchReleaseNotes.mockReset(); fetchReleaseNotes.mockResolvedValue(NOTES); + anySessionInTurn.mockReset(); + anySessionInTurn.mockResolvedValue(false); + relaunchToUpdate.mockReset(); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + anySessionInTurn, + relaunchToUpdate, + clientEndpoints: { websiteUrl: 'https://cindy.ai' }, + } as unknown as Window['electronAPI'], + }); }); afterEach(() => { @@ -68,13 +86,14 @@ describe('UpdateBanner release-notes link', () => { expect(screen.queryByText(LINK)).toBeNull(); }); - it('hides the link while the two-step relaunch confirmation is showing', async () => { + it('hides the link while the busy-turn interruption warning is showing', async () => { + anySessionInTurn.mockResolvedValue(true); render(); await screen.findByText(LINK); fireEvent.click(screen.getByText('update.banner.button')); - expect(screen.getByText('update.banner.confirmButton')).toBeTruthy(); + expect(await screen.findByText('update.banner.confirmButton')).toBeTruthy(); expect(screen.queryByText(LINK)).toBeNull(); }); diff --git a/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx b/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx index c3d834052bc..edf9c29960e 100644 --- a/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx +++ b/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx @@ -6,17 +6,22 @@ * of the already-ready patch (status === 'superseding'). Sits between the upper * content slot and UserInfoSection in the Sidebar shell. * - * 更新确认改为「就地两段式」—— 不再弹出屏幕中央的 ConfirmDialog。用户点「立即重启」 - * 后,banner 自身原地切换成确认态:主按钮「确认重启」占据入口按钮原位(鼠标零位移), - * 「取消」置于其下(次级、需刻意移动),从而在保持左下角、减少视线/鼠标移动的同时, - * 用两步显式点击防止误更新。 + * 点「立即重启」不再无条件多要一次确认:先查 anySessionInTurn(),**只有真的有任务在跑 + * 时**才就地切换成确认态,没有任务就直接重启。这与关窗链路(WindowControls 的 + * handleCloseClick)是同一套判定 —— 探针为 false(含桥不可用的 catch 兜底)就直接执行, + * 为 true 才拦一次。原先那句「应用会自动重启」的中性二次确认纯属多一次点击、不带信息, + * 已退役。 + * + * 因此 confirming 态的语义收窄为**「有任务在进行中,重启会打断它」的中断警告**:标题点明 + * 状态、副标题讲后果(警告色)、主按钮「仍要重启」占据入口按钮原位(鼠标零位移),「取消」 + * 置于其下(次级、需刻意移动)。 * * 「查看更新公告」文字链:ready 态在副标题下给一条 ghost 文字链,点开 UpdateNoticeDialog * 预览**待安装版本**的公告,让用户在「现在重启 vs 稍后」之间有据可依(装完后的自动弹窗 * 只解决装完之后的事)。跨版本时会把「已装版本 → 待装版本」之间跳过的每一版聚合进同一个 * 弹窗(useUpdateNotice 的 onOpenVersion),普通单版本升级就只有一块。三条刻意的边界: * - 只在 ready 态出现。superseding 态顶部刻意不显示版本号(新版还在下),没有可信的 - * 版本可查,confirming 态则要保持两步确认的干净,两者都不给入口。 + * 版本可查,confirming 态则要保持中断警告的干净,两者都不给入口。 * - CDN 上没有该版本公告(或内容不可渲染)时不显示入口 —— 用挂载时的 fetch 探测, * 宁可没有入口,也不给一个点了报错的链接。探测结果被 release-notes 双层缓存复用, * 真正点开时不会再打一次网。 @@ -24,10 +29,10 @@ * 也只能看 <= 当前已装版本的历史,看不到待装版本。这是本方案已知的取舍。 * * - Expanded ready: Flame 36px → "Updated to {v}" → "Relaunch to apply" → notes link → Relaunch pill - * - Expanded confirming: Flame 36px → "Restart to update?" → hint → Confirm pill / Cancel (下方,ghost) + * - Expanded confirming: Flame 36px → "A task is still running" → 警告色 hint → "Restart anyway" pill / Cancel (下方,ghost) * - Expanded superseding: Loader2 36px (spin) → "Newer version found" → "Updating…" → disabled pill with spinner - * - Collapsed ready: Flame 20px, click → confirming(就地展开 ✓ / ✕) - * - Collapsed confirming: Check 20px (确认,占原位) 叠 X 20px (取消) + * - Collapsed ready: Flame 20px, click → 有任务才展开 ✓ / ✕,否则直接重启 + * - Collapsed confirming: Check 20px (仍要重启,占原位) 叠 X 20px (取消) * - Collapsed superseding: Loader2 20px (spin), click is a noop * * superseding 状态下 banner 顶部刻意不显示新版本号 —— 新版还在下,显示版本号等于撒谎; @@ -63,14 +68,15 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // status/version 变化时下面 effect 会自动 restore,新一版更新到达时 banner // 重新出现,不会被上一版的 dismiss 状态吞掉。 const { dismissed, dismiss, restore, isNewUpdateAfterDismiss } = useUpdateBannerDismiss(); - // 就地确认态:替代原先的屏幕中央 ConfirmDialog。 + // 就地中断警告态:只在点击入口时探到「有任务在跑」才进入。 const [confirming, setConfirming] = useState(false); - // 复用关闭窗口保护链路的权威 busy 探针:只统计仍在执行的逻辑 turn, - // 不把 keepalive、已结束但仍在渲染收尾的状态误判为运行中任务。 - const [hasSessionInTurn, setHasSessionInTurn] = useState(false); + // 入口点击的 busy 探针在飞标记 —— 防重入。探针是一次 IPC round trip(毫秒级),所以 + // 刻意不给 loading UI(几毫秒的 spinner 只会闪一下),只用 ref 挡住连点导致的 + // 重复探针 / 重复重启。与 WindowControls 的关窗入口保持同样的「无 loading 态」处理。 + const relaunchProbeRef = useRef(false); // 进入确认态后把焦点移到「取消」按钮 —— 键盘用户点入口键后原触发元素会卸载, - // 若不主动聚焦,焦点会丢失、无法继续操作。刻意聚焦「取消」而非「确认」:让默认落在 - // 安全动作上,避免再按一次 Enter/Space 就直接更新的误操作(即 Radix 对破坏性操作 + // 若不主动聚焦,焦点会丢失、无法继续操作。刻意聚焦「取消」而非「仍要重启」:让默认落在 + // 安全动作上,避免再按一次 Enter/Space 就打断进行中的任务(即 Radix 对破坏性操作 // 的默认焦点策略)。展开态与收起态共用同一个 ref(同一时刻只渲染其一)。 const cancelBtnRef = useRef(null); // 入口「立即重启」按钮的 ref:取消确认态后把焦点还给它,避免键盘用户退出两步流程时 @@ -101,38 +107,11 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP }, [isTranslocated]); // 一旦不再是 ready(如被 superseding 顶掉 / 出错),复位确认态,避免残留一个 - // 指向旧补丁的「确认重启」。 + // 指向旧补丁的「仍要重启」。 useEffect(() => { if (status !== 'ready') setConfirming(false); }, [status]); - // 每次进入确认态都重新查询,避免沿用上一次打开弹层时的 busy 结果。 - // handler 在 splash / login 阶段尚未注册时可能 reject;此 banner 只会在 - // ready 主界面出现,但仍与 WindowControls 保持同样的安全兜底语义。 - useEffect(() => { - if (!confirming) { - setHasSessionInTurn(false); - return; - } - - let cancelled = false; - setHasSessionInTurn(false); - // Start the probe in a microtask so a synchronously unavailable IPC bridge - // is converted into a rejected promise and handled by the same fallback. - void Promise.resolve() - .then(() => window.electronAPI.anySessionInTurn()) - .then((busy) => { - if (!cancelled) setHasSessionInTurn(busy); - }) - .catch(() => { - if (!cancelled) setHasSessionInTurn(false); - }); - - return () => { - cancelled = true; - }; - }, [confirming]); - // 新更新到达时自动 restore:isNewUpdateAfterDismiss 先检查当前 status 是否为 // active update 态(ready / superseding),再对比 dismiss 时的快照——两个条件 // 都满足才 restore(),从而避免 remount 时 useUpdateStatus() 经历 idle→ready @@ -208,6 +187,26 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP window.electronAPI.relaunchToUpdate(theme); }; + // 入口「立即重启」/ 收起态火焰按钮的点击。展开态与收起态共用一份判定: + // - 有任务在跑 → 进 confirming 态,把「会打断进行中的任务」这件事说清楚,由用户拍板; + // - 没有 → 直接重启,不再多要一次无信息量的确认。 + // 探针失败(splash / login 阶段 handler 未注册,或桥同步 throw)按 false 处理 —— 与 + // WindowControls.handleCloseClick 完全同构;banner 只在主界面出现,那时 handler 早已注册。 + const handleRelaunchClick = async (): Promise => { + if (relaunchProbeRef.current) return; + relaunchProbeRef.current = true; + let hasInFlight = false; + try { + hasInFlight = await window.electronAPI.anySessionInTurn(); + } catch { + hasInFlight = false; + } finally { + relaunchProbeRef.current = false; + } + if (hasInFlight) setConfirming(true); + else handleRelaunch(); + }; + const handleMoveToApplications = () => { setShowTranslocatedDialog(false); window.electronAPI.moveToApplicationsFolder(); @@ -273,7 +272,8 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // ── Collapsed state: icon only ── if (isCollapsed) { - // 确认态:上方 ✓(确认,占据原 Flame 图标位置,鼠标零位移),下方 ✕(取消)。 + // 确认态(仅在有任务在跑时出现):上方 ✓(仍要重启,占据原 Flame 图标位置,鼠标零位移), + // 下方 ✕(取消)。收起态没有文案位置,「会打断进行中的任务」只能落在 ✓ 的 tooltip 上。 if (confirming && !isPreparing) { return (
@@ -318,7 +318,7 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP >