From ef6eeab26e4a2ba817b19e8d68b37546f58d293e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:13:05 -0700 Subject: [PATCH 001/126] fix(native-chat): shrink skill pill text (#20254) Co-authored-by: Merge Sim --- .../components/native-chat/NativeChatPromptEditor.test.tsx | 5 ++++- .../src/components/native-chat/NativeChatSkillPill.tsx | 4 ++-- .../components/native-chat/native-chat-prompt-document.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx index 3934cf7be23..78d8da92530 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx @@ -34,7 +34,10 @@ describe('native chat skill editor', () => { it('renders only picker insertions as pills and serializes the exact invocation', () => { const { input, container } = setup('Please $rev') act(() => input.insertSkill!(7, 11, '$review')) - expect(container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review') + const pill = container.querySelector('[data-native-chat-skill]') + expect(pill?.textContent).toBe('Review') + expect(pill?.classList.contains('text-xs')).toBe(true) + expect(pill?.classList.contains('text-sm')).toBe(false) expect(input.value).toBe('Please $review ') expect(input.selectionStart).toBe(15) act(() => { diff --git a/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx b/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx index fee86dd0f98..9bde1a5a1c9 100644 --- a/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx @@ -18,9 +18,9 @@ export function NativeChatSkillPill({ node, selected }: NodeViewProps): React.JS - diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts index 8bfce7ed0b0..7ed5fd8c130 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts @@ -17,7 +17,7 @@ export const NativeChatSkill = Node.create({ 'data-native-chat-skill': node.attrs.token, contenteditable: 'false', class: - 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-1.5 text-sm font-medium text-muted-foreground align-baseline select-none' + 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-1.5 text-xs font-medium text-muted-foreground align-baseline select-none' }, ['span', { 'aria-hidden': 'true' }, 'ϟ'], ['span', {}, String(node.attrs.token).slice(1)] From a05e2139d6529cc3aea3d1fd5f2fc5323d7fa3d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:45:45 -0700 Subject: [PATCH 002/126] fix(claude): refuse a structured model the provider does not list (#19946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): refuse a structured model the provider does not list setClaudeStructuredOption applied a model to a live Claude structured session with no check that the provider lists it, while pre-flighting `effort` against the same catalog a few lines above. Measured on Claude Code 2.1.260: set_model resolves for an unlisted id, list_models never gains a row for it, and every later turn returns is_error with empty modelUsage and zero tokens — a session that looks alive and produces nothing. Nothing undoes the write, so the refusal has to precede it. Two paths reach it: restore replays a stored pick the provider may since have retired, which needs no user error at all, and any caller can send an arbitrary id mid-session. An absent, failed or empty list deliberately refuses nothing, mirroring the null rule the effort guard already applies: no catalog identifies no model, and a CLI predating list_models would otherwise have every model refused under it — silently, since restore swallows the rejection into restoreSkippedOptions. * refactor(claude): keep the model pre-flight's permissive case in the authority claudeCatalogAdmitsModel now answers the question outright instead of handing back a nullable id set the caller had to interpret. The rule that an unidentified catalog refuses nothing lives inside the function, so a second caller cannot get it wrong by omission — and getting it wrong is silent, because restore swallows the rejection into restoreSkippedOptions. The refusal message names the model the user asked for, since it reaches them as the chat error row. --------- Co-authored-by: Merge Sim --- .../claude-structured-model-preflight.test.ts | 130 ++++++++++++++++++ .../claude/claude-structured-options.test.ts | 7 +- src/main/claude/claude-structured-options.ts | 8 ++ ...e-structured-session-adapter-turns.test.ts | 5 +- .../claude-structured-session-options.ts | 22 +++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/main/claude/claude-structured-model-preflight.test.ts diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts new file mode 100644 index 00000000000..e4f5d00b196 --- /dev/null +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + restoreClaudeStructuredSessionOptions, + setClaudeStructuredOption +} from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ +const DEFAULT_ROW = { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' } +const SONNET = { value: 'sonnet', resolvedModel: 'claude-sonnet-5', displayName: 'Sonnet' } +const HAIKU = { + value: 'haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + displayName: 'Haiku' +} + +function sessionWith(catalog: readonly Record[] | 'unavailable') { + const calls: string[] = [] + return { + session: { + options: new Map(), + reportedOptions: {} as { model?: string; effort?: string }, + optionMutationSequence: 0, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + if (catalog === 'unavailable') { + throw new Error('this CLI predates list_models') + } + return [...catalog] + }, + setModel: async (model: string) => { + calls.push(`set_model:${model}`) + } + } + } as unknown as ClaudeSession, + calls + } +} + +describe('Claude model pre-flight against the catalog the CLI listed', () => { + it('refuses a model the provider does not list', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + // Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and + // every later turn returns is_error with zero tokens. Nothing undoes the + // write, so the refusal has to land before it. + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + }) + + it('refuses an unlisted model replayed by restore, and skips it', async () => { + // Needs no user error: a model valid when it was persisted can be retired. + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'claude-opus-4-retired') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + }) + + it('applies a model the provider lists', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) + ).resolves.toEqual({ model: 'haiku' }) + expect(calls).toEqual(['list_models', 'set_model:haiku']) + }) + + it('applies a resolved model id the catalog carries only under its alias', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined) + ).resolves.toEqual({ model: 'claude-sonnet-5' }) + expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5']) + }) + + it('refuses nothing when list_models is unavailable', async () => { + // A CLI predating list_models would otherwise have every model refused, and + // restore swallows the rejection, so the user's pick would vanish silently. + const { session, calls } = sessionWith('unavailable') + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the listed catalog is empty', async () => { + // An empty answer identifies no model, so it is not evidence against one. + const { session, calls } = sessionWith([]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the catalog carries only the synthetic default row', async () => { + // listedModels drops that row, leaving a list that identifies no model. + const { session, calls } = sessionWith([DEFAULT_ROW]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('leaves a restored model the provider lists in place', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'sonnet') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + expect(session.options.get('model')).toBe('sonnet') + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2375df12d93..1bfae10b592 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -6,7 +6,12 @@ import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { return { - connection: { setModel } as ClaudeSession['connection'], + // An empty catalog identifies no model, so the pre-flight refuses nothing and + // this stays a test about fencing. + connection: { + setModel, + supportedModels: async (): Promise => [] + } as ClaudeSession['connection'], providerSessionId: 'provider-session', claudeConfigDir: '/accounts/claude', leafUuid: null, diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 3d1377b12c6..7f607755563 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -5,6 +5,7 @@ import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { + claudeCatalogAdmitsModel, readClaudeCurrentModel, readClaudeModelEffortLevels, readClaudeSettingsEffort @@ -66,6 +67,13 @@ export async function setClaudeStructuredOption( ) } } + // set_model resolves for a model the provider never lists and the session then + // fails every turn with zero tokens, so the acceptance proves nothing and only + // the catalog does. Restore replays a pick the provider may since have retired, + // which reaches here with no user error at all. + if (input.key === 'model' && !(await claudeCatalogAdmitsModel(session, input.value, timeoutMs))) { + throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) + } const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Only a model write can stale the model report — an effort or permission-mode diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index fc5eaa6b66e..00248b09f12 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -80,8 +80,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { await expect( adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) ).resolves.toEqual({ model: 'sonnet' }) - expect(claude.connections[0].calls.slice(-2)).toEqual([ + // The model write pre-flights the catalog first; this CLI lists nothing, which + // identifies no model and so refuses none. + expect(claude.connections[0].calls.slice(-3)).toEqual([ { subtype: 'interrupt', params: {} }, + { subtype: 'list_models' }, { subtype: 'set_model', params: { model: 'sonnet' } } ]) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index afb4fd65076..2385f362c2a 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -149,6 +149,28 @@ export async function readClaudeModelEffortLevels( } } +/** + * Whether the catalog admits the model, matched by alias or resolved id so a pick + * stored as either one is found. The permissive case lives here rather than at the + * call site: every caller must treat an unidentified catalog the same way, and one + * that forgot would refuse every model on a CLI that cannot answer. + */ +export async function claudeCatalogAdmitsModel( + session: ClaudeSession, + modelId: string, + timeoutMs: number | undefined +): Promise { + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const models = listedModels(catalog ? { models: catalog } : null) + // An empty list identifies no model, so it is not evidence against one — a live + // CLI predating `list_models` would otherwise have every model refused under it. + // Do not turn this into a refusal. + return ( + models.length === 0 || + models.some((model) => model.id === modelId || model.resolvedModel === modelId) + ) +} + export async function readClaudeStructuredSessionOptions( session: ClaudeSession, timeoutMs: number | undefined From 719c4341a2de717945c217fe89d60bb35cb44630 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:01:46 +0000 Subject: [PATCH 003/126] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index dce3559fd10..68c938d4238 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 48m + + downloads: 49m @@ -15,7 +15,7 @@ downloads downloads - 48m - 48m + 49m + 49m From 923858e0981667216826788ab7156294dfc5c755 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:52:05 -0700 Subject: [PATCH 004/126] perf: defer closed menus to speed up cold worktree switching (#20311) --- .../tab-bar/tab-bar-static-create-menu.tsx | 2 +- .../components/tab-bar/tab-bar-surface.tsx | 43 +- .../CloseTerminalDialog.test.tsx | 21 + .../terminal-pane/CloseTerminalDialog.tsx | 161 ++++--- .../TerminalContextMenu.test.tsx | 22 +- .../terminal-pane/TerminalContextMenu.tsx | 418 +++++++++--------- 6 files changed, 369 insertions(+), 298 deletions(-) diff --git a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx index 9a7a19a9186..45c39c2da55 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx @@ -13,7 +13,7 @@ import { import type { TabBarProps } from './tab-bar-props' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' -export function renderTabBarStaticCreateMenu({ +export function TabBarStaticCreateMenu({ terminalOnly, mobileEmulatorEnabled, managedBrowserCreationEnabled, diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index dcf22611227..0e9540870e3 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -22,7 +22,7 @@ import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-contr import type { TabBarItemProjection } from './use-tab-bar-item-projection' import type { TabBarItem } from './tab-bar-item-model' import { renderTabBarItems } from './tab-bar-item-surface' -import { renderTabBarStaticCreateMenu } from './tab-bar-static-create-menu' +import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' @@ -99,24 +99,6 @@ export function renderTabBarSurface({ activeClientHostedBrowserRowId, togglePinned }) - const standardCreateMenuItems = renderTabBarStaticCreateMenu({ - props, - terminalOnly, - mobileEmulatorEnabled, - managedBrowserCreationEnabled, - mobileEmulatorCreationEnabled, - workspaceHasSimulatorTab, - showMobileEmulatorIntroCallout, - windowsShellEntries, - defaultWindowsPowerShellImplementation, - pwshAvailable: windowsTerminalCapabilities.pwshAvailable, - newTerminalShortcut, - newBrowserShortcut, - newSimulatorShortcut, - newFileShortcut, - openMarkdownShortcut, - queueNewActiveTerminalFocusAfterNewTabMenuClose - }) return (
: null} ) : null} - {showStaticCreateMenuItems ? standardCreateMenuItems : null} + {showStaticCreateMenuItems ? ( + + ) : null} {showStaticCreateMenuItems && showAgentLaunchItems ? ( <> diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx index d2b8efd3efa..0531720fe47 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx @@ -4,6 +4,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import CloseTerminalDialog from './CloseTerminalDialog' +import { translate } from '@/i18n/i18n' + +vi.mock('@/i18n/i18n', () => ({ + translate: vi.fn((_key: string, fallback: string) => fallback) +})) const mountedRoots: Root[] = [] @@ -49,6 +54,22 @@ describe('CloseTerminalDialog', () => { document.body.innerHTML = '' }) + it('does no dialog-copy work while closed, then builds the opened confirmation', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const props = { onCancel: vi.fn(), onConfirm: vi.fn() } + vi.mocked(translate).mockClear() + + await act(async () => root.render()) + expect(translate).not.toHaveBeenCalled() + + await act(async () => root.render()) + expect(document.body.textContent).toContain('Stop running command?') + expect(translate).toHaveBeenCalled() + }) + it('renders running command copy and confirms without skipping by default', async () => { const onConfirm = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index c4da5244457..9edbca8b22e 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -70,71 +70,104 @@ export default function CloseTerminalDialog({ }} > - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', - 'Stop this agent?' - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', - 'Stop running command?' - )} - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', - "Closing this terminal will stop the agent's current work." - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', - 'Closing this terminal will stop the command running inside it.' - )} - - - {trimmedTabLabel ? ( -

- {trimmedTabLabel} -

- ) : null} -
- setDontAskAgain(checked === true)} - /> - -
- - - - +
) } + +// Keep translation and element construction behind the dialog portal's mount boundary. +function CloseTerminalDialogBody({ + isAgent, + trimmedTabLabel, + checkboxId, + dontAskAgain, + setDontAskAgain, + onCancel, + onConfirm +}: { + isAgent: boolean + trimmedTabLabel: string | undefined + checkboxId: string + dontAskAgain: boolean + setDontAskAgain: (value: boolean) => void + onCancel: () => void + onConfirm: (dontAskAgain: boolean) => void +}): React.JSX.Element { + return ( + <> + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', + 'Stop this agent?' + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', + 'Stop running command?' + )} + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', + "Closing this terminal will stop the agent's current work." + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', + 'Closing this terminal will stop the command running inside it.' + )} + + + {trimmedTabLabel ? ( +

+ {trimmedTabLabel} +

+ ) : null} +
+ setDontAskAgain(checked === true)} + /> + +
+ + + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 58a39f28a50..bd25abcce74 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TerminalContextMenu from './TerminalContextMenu' +import { translate } from '@/i18n/i18n' import type { KeybindingOverrides } from '../../../../shared/keybindings' type ItemProps = { onSelect?: () => void; children?: React.ReactNode } @@ -13,9 +14,12 @@ vi.mock('@/components/ui/dropdown-menu', async () => { const React_ = await import('react') const passthrough = ({ children }: { children?: React.ReactNode }) => React_.createElement(React_.Fragment, null, children) + const OpenContext = React_.createContext(false) return { - DropdownMenu: passthrough, - DropdownMenuContent: passthrough, + DropdownMenu: ({ open, children }: { open: boolean; children?: React.ReactNode }) => + React_.createElement(OpenContext.Provider, { value: open }, children), + DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => + React_.useContext(OpenContext) ? passthrough({ children }) : null, DropdownMenuLabel: passthrough, DropdownMenuSeparator: () => null, DropdownMenuShortcut: ({ children }: { children?: React.ReactNode }) => { @@ -36,7 +40,7 @@ vi.mock('@/components/ui/dropdown-menu', async () => { } } }) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ translate: vi.fn((_key: string, fallback: string) => fallback) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) vi.mock('./terminal-context-menu-dismiss', () => ({ shouldIgnoreTerminalMenuPointerDownOutside: () => false @@ -104,6 +108,7 @@ function renderMenu(overrides: Record = {}): string { describe('TerminalContextMenu', () => { beforeEach(() => { + vi.mocked(translate).mockClear() items.list = [] shortcuts.list = [] vi.stubGlobal('navigator', { userAgent: 'Linux' }) @@ -113,6 +118,16 @@ describe('TerminalContextMenu', () => { vi.unstubAllGlobals() }) + it('does no menu-copy work while closed, then builds the opened menu', () => { + renderMenu({ open: false }) + expect(translate).not.toHaveBeenCalled() + expect(items.list).toHaveLength(0) + + renderMenu() + expect(translate).toHaveBeenCalled() + expect(items.list.length).toBeGreaterThan(0) + }) + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { const onCopyAgentSessionContext = vi.fn() const onForkAgentSession = vi.fn() @@ -167,6 +182,7 @@ describe('TerminalContextMenu', () => { item?.onSelect?.() expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + vi.mocked(translate).mockClear() items.list = [] renderMenu({ canCopyAgentSessionId: false }) expect( diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 2cc76cd7164..5236d61d1a6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -78,11 +78,59 @@ type TerminalContextMenuProps = { onCopyAgentSessionId: () => void } -export default function TerminalContextMenu({ - open, +export default function TerminalContextMenu(props: TerminalContextMenuProps): React.JSX.Element { + const { open, onOpenChange, menuPoint, menuOpenedAtRef } = props + return ( + { + if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) { + return + } + onOpenChange(nextOpen) + }} + modal={false} + > + + {state === 'starred' && menuOpen && ( -
+