diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b8019e74ad93..15af04ed63b7 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -296,8 +296,7 @@ export function NativeChatStructuredSession( diff --git a/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.scaling.test.tsx b/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.scaling.test.tsx index 21bcb32b72fc..48ad8bc416f7 100644 --- a/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.scaling.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.scaling.test.tsx @@ -6,6 +6,7 @@ import type { Tab } from '../../../../shared/tab-types' import { getDefaultSettings } from '../../../../shared/constants' import { useAppStore } from '@/store' import { NativeChatUsageLimitActionsProvider } from './NativeChatUsageLimitActions' +import { useNativeChatHandOff } from './use-native-chat-hand-off' const mocks = vi.hoisted(() => ({ renders: vi.fn(), feed: vi.fn() })) vi.mock('@/components/agent-session-continuation/AgentSessionContinuationDialog', () => ({ @@ -27,6 +28,46 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ const initial = useAppStore.getState() const names = new Map() const feed = { subscribe: () => () => {}, activate: () => () => {}, getNamingSnapshot: () => names } +const directory = { worktreeId: 'folder', worktreePath: '/work', runtimeEnvironmentId: 'a' } + +// The notice gets its hand-off state from the shared hook, as NativeChatStructuredSession wires it. +function Notice({ tab }: { tab: Tab }): React.JSX.Element { + const pane = { + tabId: tab.id, + groupId: tab.groupId, + sessionId: tab.entityId, + agent: 'claude' as const, + target: { kind: 'environment' as const, environmentId: 'a' }, + isVisible: false, + isFocusedGroup: false + } + const handOffState = useNativeChatHandOff({ + pane, + directory, + messages: [], + epoch: null, + focusComposer: () => {} + }) + return ( + {}, + usageLimitTurnItemId: null, + continueUsageLimit: () => {} + }} + > + {null} + + ) +} + afterEach(() => { cleanup() useAppStore.setState(initial, true) @@ -71,31 +112,7 @@ it.each([100, 1000])( render( <> {tabs.map((tab) => ( - {}, - usageLimitTurnItemId: null, - continueUsageLimit: () => {} - }} - > - {null} - + ))} ) diff --git a/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.test.tsx index 26c9549105d3..368a2c7bc7f8 100644 --- a/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.test.tsx @@ -1,11 +1,16 @@ // @vitest-environment happy-dom import type * as OriginalModule0 from '@/runtime/runtime-provider-accounts-client' +import { useContext } from 'react' import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useNativeChatHandOff } from './use-native-chat-hand-off' import { NativeChatUsageLimitActionsProvider } from './NativeChatUsageLimitActions' import { NativeChatNoticeRow } from './NativeChatNoticeRow' -import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context' +import { + NativeChatOrcaStopContext, + type NativeChatOrcaStopView +} from './native-chat-orca-stop-context' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { buildAgentSessionContinuationPrompt } from '@/lib/agent-session-continuation' @@ -15,8 +20,10 @@ import type { RuntimeClientTarget } from '../../../../shared/runtime-client-targ const mocks = vi.hoisted(() => ({ readSnapshot: { + status: 'current', canSwitchAccount: true, - transcriptPath: '/work/chat.jsonl' + transcriptPath: '/work/chat.txt', + capturedText: 'Saved context' }, follows: true, watch: vi.fn(), @@ -26,7 +33,8 @@ const mocks = vi.hoisted(() => ({ dialog: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.options + callStructuredAgentSession: mocks.options, + supportsStructuredAgentSessionContinuationContext: async () => true })) vi.mock('@/runtime/structured-conversation-name', () => ({ useStructuredChatTabConversationName: () => 'My chat' @@ -83,16 +91,42 @@ afterEach(() => { mocks.follows = true }) -function row( - target: RuntimeClientTarget = { kind: 'local' }, - offered = true, - agent: AgentType = 'claude', - messages: readonly NativeChatMessage[] = [], - recovery: Parameters[0]['recovery'] = { - readCurrent: true, - canSwitchAccount: mocks.follows +function ContinueNow() { + const view = useContext(NativeChatOrcaStopContext) + return +} +function Row({ + target, + offered, + agent, + messages, + recovery +}: { + target: RuntimeClientTarget + offered: boolean + agent: AgentType + messages: readonly NativeChatMessage[] + recovery: Parameters[0]['recovery'] +}) { + const pane = { + tabId: 'tab', + sessionId: 'chat', + agent, + target, + isVisible: true, + isFocusedGroup: true } -) { + const handOff = useNativeChatHandOff({ + pane, + directory: { + worktreeId: 'folder', + worktreePath: '/work', + runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null + }, + messages, + epoch: 'epoch', + focusComposer: () => {} + }) const view: NativeChatOrcaStopView = { hostLabel: null, remoteHost: target.kind !== 'local', @@ -107,21 +141,10 @@ function row( + + {handOff.dialog} ) } +function row( + target: RuntimeClientTarget = { kind: 'local' }, + offered = true, + agent: AgentType = 'claude', + messages: readonly NativeChatMessage[] = [], + recovery: Parameters[0]['recovery'] = { + readCurrent: true, + canSwitchAccount: mocks.follows + } +) { + return ( + + ) +} function expectInlineActions(accountName: string | null) { const leadIn = screen.getByText('Continue this chat without waiting:') const actions = leadIn.parentElement @@ -201,12 +239,12 @@ it('offers host-owned recovery actions for the current usage-limit cut', async ( }) ) fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' })) - await waitFor(() => expect(mocks.dialog).toHaveBeenCalled()) + await waitFor(() => expect(mocks.dialog.mock.lastCall?.[0].request).toBeTruthy()) expect(mocks.dialog.mock.lastCall?.[0].request).toMatchObject({ worktreeId: 'folder', avoidSourceAgent: true, launchSource: 'native_chat', - source: { transcriptPath: '/work/chat.jsonl', sourceAgent: 'claude', sourceTitle: 'My chat' } + source: { transcriptPath: '/work/chat.txt', sourceAgent: 'claude', sourceTitle: 'My chat' } }) view.rerender(row(target, false)) expect(screen.queryByRole('button', { name: 'Continue Now' })).toBeNull() @@ -249,38 +287,53 @@ it('offers host-owned recovery actions for the current usage-limit cut', async ( } }) -it('admits one preparation and discards it after Continue supersedes the cut', async () => { - let answer: (value: typeof mocks.readSnapshot) => void = () => {} - mocks.options.mockImplementation( - () => - new Promise((resolve) => { - answer = resolve - }) +it('keeps a deferred account check independent of hand-off and retires it on Continue', async () => { + mocks.snapshot.mockResolvedValue(snapshot(2)) + mocks.select.mockResolvedValue(snapshot(2).claude) + const first = Promise.withResolvers() + const second = Promise.withResolvers() + let checks = 0 + mocks.options.mockImplementation((_target, method) => + method === 'agentSession.options' + ? ++checks === 1 + ? first.promise + : second.promise + : Promise.resolve(mocks.readSnapshot) ) - mocks.follows = false - const view = render(row()) - expect( - screen.getByText('Continue this chat without waiting:').parentElement?.querySelector('.w-32') - ).toBeNull() - expectInlineActions(null) - const button = screen.getByRole('button', { name: 'Hand Off to Another Agent' }) - fireEvent.click(button) - fireEvent.click(button) - expect(mocks.options).toHaveBeenCalledTimes(1) - expect(button).toBeDisabled() - view.rerender(row({ kind: 'local' }, false)) - answer(mocks.readSnapshot) - await waitFor(() => expect(mocks.dialog).not.toHaveBeenCalled()) - view.rerender(row()) - mocks.options.mockResolvedValue(mocks.readSnapshot) + render(row({ kind: 'environment', environmentId: 'chat-host' })) + const switchAccount = async () => { + await waitFor(() => + expect(screen.getByRole('button', { name: 'Switch Account' })).toBeEnabled() + ) + fireEvent.pointerDown(screen.getByRole('button', { name: 'Switch Account' }), { + button: 0, + ctrlKey: false, + pointerType: 'mouse' + }) + fireEvent.click(await screen.findByRole('menuitem', { name: 'account1@example.com' })) + await waitFor(() => expect(checks).toBeGreaterThan(0)) + } fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' })) await screen.findByText('Cancel hand-off') + await switchAccount() fireEvent.click(screen.getByText('Cancel hand-off')) - expect(screen.queryByText('Cancel hand-off')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' })) + await screen.findByText('Cancel hand-off') + first.resolve(mocks.readSnapshot) + await waitFor(() => expect(mocks.select).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByText('Cancel hand-off')) + cleanup() + render(row({ kind: 'environment', environmentId: 'chat-host' })) + await switchAccount() + await waitFor(() => expect(checks).toBe(2)) + fireEvent.click(screen.getByText('Continue cut')) + second.resolve(mocks.readSnapshot) + await waitFor(() => expect(screen.getByRole('button', { name: 'Switch Account' })).toBeEnabled()) + expect(mocks.select).toHaveBeenCalledOnce() }) it('hands off database-backed chats using labelled recent messages when no native file exists', async () => { - mocks.options.mockResolvedValue({ canSwitchAccount: false }) + mocks.options.mockResolvedValue({ status: 'current' }) render( row({ kind: 'local' }, true, 'opencode', [ { @@ -293,12 +346,13 @@ it('hands off database-backed chats using labelled recent messages when no nativ ]) ) fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' })) - await waitFor(() => expect(mocks.dialog).toHaveBeenCalled()) + await waitFor(() => expect(mocks.dialog.mock.lastCall?.[0].request).toBeTruthy()) const source = mocks.dialog.mock.lastCall?.[0].request.source - expect(source.transcriptPath).toBeUndefined() + expect(mocks.dialog.mock.lastCall?.[0].request.avoidSourceAgent).toBe(true) + expect(source).toMatchObject({ sourceAgent: 'opencode', transcriptPath: undefined }) + expect(source.capturedText).toContain('user: Keep the recent task context') expect(buildAgentSessionContinuationPrompt(source, 'focused')).toContain( 'A saved session transcript was unavailable, so use these recent chat messages:' ) - expect(source.capturedText).toContain('user: Keep the recent task context') expect(buildAgentSessionContinuationPrompt(source, 'full')).toBeNull() }) diff --git a/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.tsx b/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.tsx index 48d4cee45346..ea302cd93295 100644 --- a/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatUsageLimitActions.tsx @@ -1,24 +1,7 @@ -import { - createContext, - useContext, - useCallback, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode -} from 'react' +import { createContext, useContext, useMemo, useRef, useLayoutEffect, type ReactNode } from 'react' import { MessageSquarePlus } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import { structuredChatTabById } from '@/lib/structured-chat-tab-index' -import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' -import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' -import { - buildAgentSessionContinuationPrompt, - type AgentSessionContinuationRequest -} from '@/lib/agent-session-continuation' import { ClaudeSwitcherMenu } from '@/components/status-bar/ClaudeSwitcherMenu' import { ProviderAccountChatButton } from '@/components/status-bar/ProviderAccountChatButton' import { isTuiAgent } from '../../../../shared/tui-agent-config' @@ -29,10 +12,8 @@ import { NativeChatOrcaStopContext, type NativeChatOrcaStopView } from './native-chat-orca-stop-context' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatStructuredViewProps } from './native-chat-view-types' -import type { NativeChatFileLinkContext } from './native-chat-file-link' -import { nativeChatMessageCopyText } from '../../../../shared/native-chat-message-copy-text' +import type { NativeChatHandOff } from './use-native-chat-hand-off' type UsageLimitActions = { account: ReactNode @@ -43,15 +24,13 @@ const UsageLimitActionsContext = createContext(null) export function NativeChatUsageLimitActionsProvider({ pane, - directory, - messages, + handOffState, view, recovery, children }: { pane: Omit - directory: NativeChatFileLinkContext | null - messages: readonly NativeChatMessage[] + handOffState: NativeChatHandOff view: NativeChatOrcaStopView recovery: { readCurrent: boolean @@ -63,7 +42,7 @@ export function NativeChatUsageLimitActionsProvider({ const loading = !recovery.readCurrent const canSwitchAccount = recovery.canSwitchAccount const active = view.usageLimitTurnItemId !== null - const intentKey = JSON.stringify([ + const accountKey = JSON.stringify([ executionHostIdForStructuredTarget(pane.target), pane.sessionId, pane.agent, @@ -72,37 +51,18 @@ export function NativeChatUsageLimitActionsProvider({ recovery.readCurrent, pane.isVisible ]) - const intent = useRef<{ key: string; pending: boolean } | null>(null) - const [preparing, setPreparing] = useState(null) + const accountIntent = useRef(null) useLayoutEffect(() => { - const current = { key: intentKey, pending: false } - intent.current = current - setPreparing(null) + const current = {} + accountIntent.current = current return () => { - if (intent.current === current) { - intent.current = null + if (accountIntent.current === current) { + accountIntent.current = null } } - }, [intentKey]) - const disabled = loading || preparing === intentKey - const chatTab = useAppStore((state) => - directory - ? structuredChatTabById(state.unifiedTabsByWorktree, directory.worktreeId, pane.tabId) - : undefined - ) - const conversationName = useStructuredChatTabConversationName(chatTab) - const [prepared, setPrepared] = useState<{ - key: string - request: AgentSessionContinuationRequest - } | null>(null) - const request = prepared?.key === intentKey ? prepared.request : null - const canHandOff = - active && - pane.isVisible && - directory && - isTuiAgent(pane.agent) && - directory.runtimeEnvironmentId === - (pane.target.kind === 'environment' ? pane.target.environmentId : null) + }, [accountKey]) + const disabled = loading || handOffState.preparing + const canHandOff = active && handOffState.available let account: ReactNode = null if (pane.agent === 'claude' && active && pane.isVisible) { if (canSwitchAccount === undefined) { @@ -117,7 +77,7 @@ export function NativeChatUsageLimitActionsProvider({ chatAction={{ target: pane.target, canSwitch: async () => { - const current = intent.current + const current = accountIntent.current if (!current || !active) { return false } @@ -126,118 +86,41 @@ export function NativeChatUsageLimitActionsProvider({ 'agentSession.options', { sessionId: pane.sessionId } ) - return intent.current === current && result.canSwitchAccount === true + return accountIntent.current === current && result.canSwitchAccount === true } }} /> ) } } - const openHandOff = useCallback(async (): Promise => { - if (!canHandOff || !directory || !isTuiAgent(pane.agent)) { - return - } - const current = intent.current - if (!current || current.pending) { - return - } - current.pending = true - setPreparing(current.key) - const options = await callStructuredAgentSession( - pane.target, - 'agentSession.options', - { sessionId: pane.sessionId } - ).catch((error: unknown) => { - console.warn('Could not read saved chat context for hand-off', error) - return null - }) - if (intent.current !== current) { - return - } - setPreparing(null) - const transcriptPath = options?.transcriptPath - const source = { - sourceKind: 'chat' as const, - sourceAgent: pane.agent, - sourceTitle: chatTab?.customLabel?.trim() || conversationName || chatTab?.label, - sourceWorkingDirectory: directory.worktreePath, - transcriptPath, - capturedText: transcriptPath - ? '' - : messages - .map((message) => `${message.role}: ${nativeChatMessageCopyText(message)}`) - .join('\n\n') - } - if (buildAgentSessionContinuationPrompt(source, 'focused')) { - setPrepared({ - key: current.key, - request: { - source, - worktreeId: directory.worktreeId, - workspacePath: directory.worktreePath, - initialCwd: directory.worktreePath, - groupId: pane.groupId, - launchSource: 'native_chat', - avoidSourceAgent: true - } - }) - } else { - current.pending = false - } - }, [ - canHandOff, - directory, - pane.agent, - pane.groupId, - pane.sessionId, - pane.target, - chatTab, - conversationName, - messages - ]) + const openHandOff = handOffState.open const actions = useMemo( () => ({ account, disabled, ...(canHandOff || (loading && isTuiAgent(pane.agent)) - ? { - handOff: () => { - void openHandOff() - } - } + ? { handOff: () => openHandOff({ avoidSourceAgent: true }) } : {}) }), [account, canHandOff, disabled, loading, openHandOff, pane.agent] ) + const resetHandOff = handOffState.reset const stopView = useMemo( () => ({ ...view, continueUsageLimit: (hadFocus?: boolean) => { - intent.current = { key: intentKey, pending: false } - setPreparing(null) - setPrepared(null) + accountIntent.current = {} + resetHandOff() view.continueUsageLimit(hadFocus) } }), - [intentKey, view] + [resetHandOff, view] ) return ( {children} - {request ? ( - { - if (!open) { - intent.current = { key: intentKey, pending: false } - setPrepared(null) - } - }} - /> - ) : null} ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-hand-off.tsx b/src/renderer/src/components/native-chat/use-native-chat-hand-off.tsx index 4551d2874b2c..e35f3a676a9a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hand-off.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-hand-off.tsx @@ -26,7 +26,7 @@ export type NativeChatHandOff = { /** This chat can seed another agent from its owning workspace. */ available: boolean preparing: boolean - open: () => void + open: (policy?: { avoidSourceAgent?: boolean }) => void /** Drops a pending or open hand-off, e.g. when the conversation it describes moves on. */ reset: () => void dialog: ReactNode @@ -90,112 +90,119 @@ export function useNativeChatHandOff({ directory.runtimeEnvironmentId === (pane.target.kind === 'environment' ? pane.target.environmentId : null) ) - const prepare = useCallback(async (): Promise => { - if (!available || !directory || !epoch || !isTuiAgent(pane.agent)) { - return - } - if (active.current) { - return - } - const current: Attempt = { - key: intentKey, - operation: ++operationSerial.current, - phase: 'preparing' - } - active.current = current - setAttempt(current) - const context = await withTimeout( - (async () => { - if ( - !(await supportsStructuredAgentSessionContinuationContext(pane.target)) || - active.current !== current || - current.phase !== 'preparing' - ) { - return null - } - return callStructuredAgentSession( - pane.target, - 'agentSession.continuationContext', - { - sessionId: pane.sessionId, - workspaceId: directory.worktreeId, - expectedCursor: { epoch, sequence: 0 } + const prepare = useCallback( + async (avoidSourceAgent = false): Promise => { + if (!available || !directory || !epoch || !isTuiAgent(pane.agent)) { + return + } + if (active.current) { + return + } + const current: Attempt = { + key: intentKey, + operation: ++operationSerial.current, + phase: 'preparing' + } + active.current = current + setAttempt(current) + const context = await withTimeout( + (async () => { + if ( + !(await supportsStructuredAgentSessionContinuationContext(pane.target)) || + active.current !== current || + current.phase !== 'preparing' + ) { + return null } - ) - })(), - 30_000, - null - ) - if (active.current !== current || current.phase !== 'preparing') { - return - } - if (context?.status === 'superseded') { - retire() - return - } - const saved = context?.status === 'current' && typeof context.capturedText === 'string' - const transcriptPath = saved ? context.transcriptPath : undefined - const source = { - sourceKind: 'chat' as const, - sourceAgent: pane.agent, - sourceTitle: chatTab?.customLabel?.trim() || conversationName || chatTab?.label, - sourceWorkingDirectory: directory.worktreePath, - transcriptPath, - capturedText: saved - ? (context.capturedText ?? '') - : messages - .flatMap((message) => { - if (message.agentStart) { - return [] - } - const text = nativeChatMessageCopyText(message).trim() - return text ? [`${message.role}: ${text}`] : [] - }) - .join('\n\n') - } - if (buildAgentSessionContinuationPrompt(source, 'focused')) { - const prepared: Attempt = { - ...current, - phase: 'prepared', - request: { - source, - worktreeId: directory.worktreeId, - workspacePath: directory.worktreePath, - initialCwd: directory.worktreePath, - groupId: pane.groupId, - launchSource: 'native_chat' - } + return callStructuredAgentSession( + pane.target, + 'agentSession.continuationContext', + { + sessionId: pane.sessionId, + workspaceId: directory.worktreeId, + expectedCursor: { epoch, sequence: 0 } + } + ) + })(), + 30_000, + null + ) + if (active.current !== current || current.phase !== 'preparing') { + return } - active.current = prepared - setAttempt(prepared) - } else { - retire() - toast.error( - translate( - 'components.agentSessionContinuation.noContext', - 'No session context is available to continue in a new session.' + if (context?.status === 'superseded') { + retire() + return + } + const saved = context?.status === 'current' && typeof context.capturedText === 'string' + const transcriptPath = saved ? context.transcriptPath : undefined + const source = { + sourceKind: 'chat' as const, + sourceAgent: pane.agent, + sourceTitle: chatTab?.customLabel?.trim() || conversationName || chatTab?.label, + sourceWorkingDirectory: directory.worktreePath, + transcriptPath, + capturedText: saved + ? (context.capturedText ?? '') + : messages + .flatMap((message) => { + if (message.agentStart) { + return [] + } + const text = nativeChatMessageCopyText(message).trim() + return text ? [`${message.role}: ${text}`] : [] + }) + .join('\n\n') + } + if (buildAgentSessionContinuationPrompt(source, 'focused')) { + const prepared: Attempt = { + ...current, + phase: 'prepared', + request: { + source, + worktreeId: directory.worktreeId, + workspacePath: directory.worktreePath, + initialCwd: directory.worktreePath, + groupId: pane.groupId, + launchSource: 'native_chat', + ...(avoidSourceAgent ? { avoidSourceAgent: true } : {}) + } + } + active.current = prepared + setAttempt(prepared) + } else { + retire() + toast.error( + translate( + 'components.agentSessionContinuation.noContext', + 'No session context is available to continue in a new session.' + ) ) - ) - focusComposer() - } - }, [ - available, - intentKey, - retire, - epoch, - focusComposer, - directory, - pane.agent, - pane.groupId, - pane.sessionId, - pane.target, - chatTab, - conversationName, - messages - ]) - const open = useCallback(() => { - void prepare() - }, [prepare]) + focusComposer() + } + }, + [ + available, + intentKey, + retire, + epoch, + focusComposer, + directory, + pane.agent, + pane.groupId, + pane.sessionId, + pane.target, + chatTab, + conversationName, + messages + ] + ) + const open = useCallback( + (policy?: { avoidSourceAgent?: boolean }) => { + void prepare(policy?.avoidSourceAgent) + }, + [prepare] + ) return { available, preparing: currentAttempt?.phase === 'preparing', diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx index d24f9aaa8680..174f50c4874e 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment happy-dom */ -import React, { createRef, type ReactNode } from 'react' +import React, { createRef, useContext, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,11 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-contin import type { NativeChatComposerHandle } from './NativeChatComposer' import { nativeChatPaneHeader } from './NativeChatPaneHeader' import { emptyNativeChatContextMenuActions } from './use-native-chat-context-menu' +import { + NativeChatUsageLimitActions, + NativeChatUsageLimitActionsProvider +} from './NativeChatUsageLimitActions' +import { NativeChatOrcaStopContext } from './native-chat-orca-stop-context' import { useNativeChatHandOff } from './use-native-chat-hand-off' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' @@ -122,7 +127,8 @@ function StructuredChatTab({ visible = true, fallback = false, terminalPane = false, - owner = 'worktree-1' + owner = 'worktree-1', + usageLimit = false }: { inWorkspace?: boolean epoch?: string @@ -130,6 +136,7 @@ function StructuredChatTab({ fallback?: boolean terminalPane?: boolean owner?: string | null + usageLimit?: boolean }): ReactNode { const pane = { tabId: 'chat-tab', @@ -200,14 +207,34 @@ function StructuredChatTab({ : {}) }) return ( - <> + {}, + usageLimitTurnItemId: usageLimit ? 'cut' : null, + continueUsageLimit: () => {} + }} + > {nativeChatPaneHeader(pane, owner, 'dark', handOffState)} {menu} + {usageLimit ? : null} + {handOffState.dialog} - + ) } +function ContinueCut() { + const view = useContext(NativeChatOrcaStopContext) + return +} + describe('a structured chat tab', () => { beforeEach(() => { vi.clearAllMocks() @@ -343,6 +370,39 @@ describe('a structured chat tab', () => { expect(requests[1]).toEqual(requests[0]) }) + it('shares usage-limit and menu preparation, resets on Continue and keeps the entry policy', async () => { + const saved = Promise.withResolvers<{ + status: 'current' + capturedText: string + transcriptPath: string + }>() + handOff.options.mockReturnValue(saved.promise) + render() + const usageAction = () => + screen.getAllByRole('button', { name: 'Hand Off to Another Agent' })[1] + await act(async () => { + fireEvent.click(usageAction()) + openMenuHandOff() + }) + expect(handOff.options).toHaveBeenCalledOnce() + expect(usageAction().hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByText('Continue cut')) + await act(async () => + saved.resolve({ + status: 'current', + capturedText: 'Saved context', + transcriptPath: '/work/saved.txt' + }) + ) + expect(screen.queryByText('Close hand-off')).toBeNull() + handOff.options.mockResolvedValue({ status: 'current', capturedText: 'Saved context' }) + await act(async () => fireEvent.click(usageAction())) + expect(handOff.dialog.mock.lastCall?.[0].request.avoidSourceAgent).toBe(true) + fireEvent.click(screen.getByText('Close hand-off')) + await act(async () => openMenuHandOff()) + expect(handOff.dialog.mock.lastCall?.[0].request.avoidSourceAgent).toBeUndefined() + }) + it('keeps exactly the terminal pane hand-off row when chat shares that pane', () => { renderToStaticMarkup() expect(