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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-23 00:09:51 +08:00
parent c2355205dc
commit 90cbe9f62c
6 changed files with 185 additions and 4 deletions

View File

@ -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')
}

View File

@ -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,

View File

@ -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,

View File

@ -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(
<ToolCallBlock
toolName="EnterPlanMode"
input={{}}
result={{
isError: false,
content: [
'Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.',
'',
'In plan mode, you should:',
'1. Thoroughly explore the codebase',
'2. Ask clarifying questions if needed',
'',
'Remember: DO NOT write or edit files until the user approves your plan.',
].join('\n'),
}}
/>,
)
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')
})
})

View File

@ -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,

View File

@ -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<string, unknown>) : {}
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 (
<EnterPlanModeToolCallBlock
result={result}
compact={compact}
isPending={isPending}
/>
)
}
if (isExitPlanTool) {
return (
<PlanToolCallBlock
input={input}
@ -175,6 +191,47 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
)
})
function EnterPlanModeToolCallBlock({
result,
compact,
isPending,
}: {
result?: { content: unknown; isError: boolean } | null
compact: boolean
isPending: boolean
}) {
const t = useTranslation()
const errorText = result?.isError ? extractTextContent(result.content) : null
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-brand)]/30 bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`}>
<div className="flex w-full items-center gap-2 px-3 py-2 text-left">
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">architecture</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-semibold text-[var(--color-text-primary)]">
{t('settings.permissions.plan')}
</span>
{isPending ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
{t('tool.preparingTool')}
</span>
) : null}
{result?.isError ? (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
) : null}
</div>
{result?.isError && errorText ? (
<div className="border-t border-[var(--color-border)]/60 px-3 py-3">
{renderResultOutput(result, errorText, t)}
</div>
) : null}
</div>
)
}
function PlanToolCallBlock({
input,
result,