From 90cbe9f62c813ec0ed33b99155c12be0340ec10b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 23 Jun 2026 00:09:51 +0800 Subject: [PATCH] fix(desktop): polish plan mode and window drag behavior Fixes #869, #874. Render EnterPlanMode as a compact desktop status instead of exposing model-facing plan-mode instructions, and refresh Windows frameless window drag hit testing after first show. Tested: cd desktop && bun run test -- src/components/chat/PlanModePermissionDialog.test.tsx --run Tested: cd desktop && bun run test -- electron/services/windows.test.ts --run Tested: bun run check:desktop Tested: bun run check:native Confidence: high Scope-risk: narrow --- desktop/electron/main.ts | 2 + desktop/electron/services/windows.test.ts | 66 +++++++++++++++++++ desktop/electron/services/windows.ts | 25 +++++++ .../chat/PlanModePermissionDialog.test.tsx | 26 ++++++++ .../src/components/chat/PlanModePreview.tsx | 5 ++ desktop/src/components/chat/ToolCallBlock.tsx | 65 ++++++++++++++++-- 6 files changed, 185 insertions(+), 4 deletions(-) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 5be323cb..ec234056 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -36,6 +36,7 @@ import { writeWindowSmokeSnapshot } from './services/windowSmoke' import { installWindowLifecycle, readWindowState, + refreshWindowsDragHitTest, restoreWindowMaximized, saveWindowState, showMainWindow, @@ -385,6 +386,7 @@ async function createMainWindow() { restoreWindowMaximized(mainWindow, restoredState) showMainWindow(mainWindow, app) + refreshWindowsDragHitTest(mainWindow, process.platform) writeWindowSmokeSnapshot(mainWindow, 'after-final-show') } diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index 74d25e5b..ea8fc240 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -11,6 +11,7 @@ import { isPersistableWindowState, isWindowStateVisibleOnAnyDisplay, readWindowState, + refreshWindowsDragHitTest, restoreWindowMaximized, showMainWindow, toggleWindowFullScreen, @@ -143,6 +144,71 @@ describe('Electron window service', () => { }) }) + it('refreshes Windows drag hit testing after the first frameless show', () => { + vi.useFakeTimers() + try { + const bounds = { x: 20, y: 30, width: 1280, height: 820 } + const window = { + isDestroyed: () => false, + isMinimized: () => false, + isMaximized: () => false, + isFullScreen: () => false, + getBounds: vi.fn(() => bounds), + setBounds: vi.fn(), + } + + const cancel = refreshWindowsDragHitTest(window as never, 'win32', 100) + + expect(cancel).toEqual(expect.any(Function)) + expect(window.setBounds).not.toHaveBeenCalled() + + vi.advanceTimersByTime(100) + + expect(window.getBounds).toHaveBeenCalledTimes(1) + expect(window.setBounds).toHaveBeenNthCalledWith(1, { ...bounds, height: bounds.height + 1 }) + expect(window.setBounds).toHaveBeenNthCalledWith(2, bounds) + } finally { + vi.useRealTimers() + } + }) + + it('does not refresh drag hit testing outside Windows', () => { + vi.useFakeTimers() + try { + const window = { + setBounds: vi.fn(), + } + + expect(refreshWindowsDragHitTest(window as never, 'darwin', 100)).toBeUndefined() + vi.advanceTimersByTime(100) + expect(window.setBounds).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('skips the Windows drag hit-test refresh after the window is destroyed', () => { + vi.useFakeTimers() + try { + const window = { + isDestroyed: () => true, + isMinimized: () => false, + isMaximized: () => false, + isFullScreen: () => false, + getBounds: vi.fn(() => ({ x: 20, y: 30, width: 1280, height: 820 })), + setBounds: vi.fn(), + } + + refreshWindowsDragHitTest(window as never, 'win32', 100) + vi.advanceTimersByTime(100) + + expect(window.getBounds).not.toHaveBeenCalled() + expect(window.setBounds).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('shows, restores, and focuses the hidden main window when a tray or notification action reopens it', () => { const window = { isVisible: () => false, diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index 435383ff..4f8996fc 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -214,6 +214,31 @@ export function showMainWindow(window: BrowserWindow | null, app?: MacOsWindowVi window.focus() } +export function refreshWindowsDragHitTest( + window: BrowserWindow, + platform: NodeJS.Platform = process.platform, + delayMs = 100, +): (() => void) | undefined { + if (platform !== 'win32') return undefined + + const timer = setTimeout(() => { + if ( + window.isDestroyed() + || window.isMinimized() + || window.isMaximized() + || window.isFullScreen() + ) { + return + } + + const bounds = window.getBounds() + window.setBounds({ ...bounds, height: bounds.height + 1 }) + window.setBounds(bounds) + }, delayMs) + + return () => clearTimeout(timer) +} + export function installWindowLifecycle({ app, window, diff --git a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx index 458639fd..1ea8781d 100644 --- a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx +++ b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx @@ -188,4 +188,30 @@ describe('plan mode permission UI', () => { expect(container.textContent).toContain('/tmp/claude-plan.md') expect(container.textContent).not.toContain('Tool Output') }) + + it('renders EnterPlanMode as a compact status instead of raw model instructions', () => { + const { container } = render( + , + ) + + expect(container.textContent).toContain('Plan mode') + expect(container.textContent).not.toContain('Tool Output') + expect(container.textContent).not.toContain('Thoroughly explore the codebase') + expect(container.textContent).not.toContain('Remember: DO NOT write or edit files') + }) }) diff --git a/desktop/src/components/chat/PlanModePreview.tsx b/desktop/src/components/chat/PlanModePreview.tsx index 4b1b9299..116bc136 100644 --- a/desktop/src/components/chat/PlanModePreview.tsx +++ b/desktop/src/components/chat/PlanModePreview.tsx @@ -3,6 +3,7 @@ import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import type { PermissionUpdate } from '../../types/chat' export const EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode' +export const ENTER_PLAN_MODE_TOOL_NAME = 'EnterPlanMode' export type AllowedPrompt = { tool: string @@ -28,6 +29,10 @@ export function isExitPlanModeTool(toolName: string): boolean { return toolName === EXIT_PLAN_MODE_TOOL_NAME } +export function isEnterPlanModeTool(toolName: string): boolean { + return toolName === ENTER_PLAN_MODE_TOOL_NAME +} + export function PlanPreviewCard({ title, plan, diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 4a320c8a..33294f00 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -8,7 +8,12 @@ import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' import { InlineImageGallery } from './InlineImageGallery' import type { AgentTaskNotification } from '../../types/chat' -import { PlanPreviewCard, extractPlanPreview, isExitPlanModeTool } from './PlanModePreview' +import { + PlanPreviewCard, + extractPlanPreview, + isEnterPlanModeTool, + isExitPlanModeTool, +} from './PlanModePreview' type Props = { toolName: string @@ -46,8 +51,9 @@ type ContentStats = { } export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) { - const isPlanTool = isExitPlanModeTool(toolName) - const [expanded, setExpanded] = useState(isPlanTool) + const isExitPlanTool = isExitPlanModeTool(toolName) + const isEnterPlanTool = isEnterPlanModeTool(toolName) + const [expanded, setExpanded] = useState(isExitPlanTool) const t = useTranslation() const obj = input && typeof input === 'object' ? (input as Record) : {} const icon = TOOL_ICONS[toolName] || 'build' @@ -78,7 +84,17 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string' const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput) - if (isPlanTool) { + if (isEnterPlanTool) { + return ( + + ) + } + + if (isExitPlanTool) { return ( +
+ architecture + + {t('settings.permissions.plan')} + + {isPending ? ( + + + ) : null} + {result?.isError ? ( + error + ) : null} +
+ + {result?.isError && errorText ? ( +
+ {renderResultOutput(result, errorText, t)} +
+ ) : null} + + ) +} + function PlanToolCallBlock({ input, result,