mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
c2355205dc
commit
90cbe9f62c
@ -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')
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user