From 3031c2330cd7ea3772fe4ccf4328e60d173fe33b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:05:07 -0700 Subject: [PATCH] refactor(native-chat): isolate resolved bridge view Keep NativeChatView focused on mode routing and session-gate ownership by moving the resolved bridge UI into its own component. This behavior-preserving split clears the pre-existing max-lines baseline without disabling the lint rule. --- .../native-chat/NativeChatResolvedView.tsx | 404 +++++++++++++++++ .../components/native-chat/NativeChatView.tsx | 405 +----------------- .../native-chat-stop-layering.test.ts | 2 +- 3 files changed, 407 insertions(+), 404 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx new file mode 100644 index 00000000000..6806387411b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -0,0 +1,404 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '../../store' +import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' +import { isNativeChatTranscriptUnsettled } from './use-native-chat-live-session' +import { selectNativeChatViewState } from './native-chat-view-state' +import { NativeChatMessageList } from './NativeChatMessageList' +import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' +import { useNativeChatFontScale } from './use-native-chat-font-scale' +import { useNativeChatCanSend } from './use-native-chat-can-send' +import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' +import { NativeChatEmptyState } from './NativeChatEmptyState' +import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' +import { + shouldClearNativeChatWorkingSuppression, + shouldShowNativeChatWorking +} from './native-chat-working-suppression' +import { + appendPendingSendCache, + launchPromptAsMessage, + pendingSendsAsMessages, + nextNativeChatPendingSendId, + prunePendingSends, + readPendingSendCache, + shouldPruneLaunchPrompt, + writePendingSendCache, + type NativeChatPendingSend +} from './native-chat-pending' +import { + appendCommandMarkerCache, + applyCommandMarkerBoundaries, + commandMarkersAsMessages, + readCommandMarkerCache, + type NativeChatCommandMarker +} from './native-chat-command-marker' +import { + deriveNativeChatStreamingText, + nativeChatStreamingMessage +} from '../../../../shared/native-chat-streaming' +import { + shouldFocusNativeChatComposerFromEditingKey, + shouldFocusNativeChatPaneFromPointerTarget, + shouldRedirectNativeChatTyping +} from './native-chat-typing-redirect' +import { + emptyNativeChatContextMenuActions, + useNativeChatContextMenu +} from './use-native-chat-context-menu' +import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' +import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' +import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' +import type { NativeChatResolvedViewProps } from './native-chat-view-types' +import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' + +/** Renders the bridge UI after NativeChatSessionGate resolves its agent session. */ +export function NativeChatResolvedView({ + paneKey, + agent, + sessionId, + transcriptPath, + isVisible, + targetPtyId, + terminalTabId, + ownsTabWideLaunchDraft, + onSwitchToTerminal, + readTerminalScreen, + contextMenuActions, + orchestrationDispatchStatus +}: NativeChatResolvedViewProps): React.JSX.Element { + // Primitive owner selection (no useShallow): routes the pane's read/subscribe to + // the remote runtime host for a runtime-owned pane; null keeps the local path. + const runtimeEnvironmentId = useAppStore((s) => + selectNativeChatRuntimeEnvironmentId(s, terminalTabId) + ) + const session = useNativeChatRetainedSession({ + paneKey, + agent, + sessionId, + transcriptPath, + runtimeEnvironmentId, + enabled: isVisible + }) + const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) + const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) + const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null + // Launch context prefilled into the TUI input as an unsent draft; the + // composer adopts it so the GUI view shows the same context as the TUI. + const launchDraftSignal = useNativeChatLaunchDraftSignal({ + terminalTabId, + agent, + messages: session.messages, + // 'awaiting' counts too: adopting a prefill against a transcript that hasn't + // flushed would re-offer a prompt the user already submitted. + transcriptLoading: isNativeChatTranscriptUnsettled(session.readPhase) + }) + // The live-session merge reconciles hooks with replayable transcript turn + // boundaries; all working consumers must use that one lifecycle decision. + const liveWorking = session.status === 'working' + // The agent's in-progress reply preview (hook), shown as a live streaming + // bubble while it works — before the completed turn flushes to the transcript. + const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage) + // Why: Stop suppression must clear on a newer working epoch even when status + // never leaves 'working' (interrupt + immediate next turn coalesced). + const hookWorkingEpoch = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null + ) + const canSend = useNativeChatCanSend(targetPtyId) + // Reuse the verified composer send path for interactive cards and composer + // stop (Stop sends ESC, the agent-TUI interrupt key). + const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) + const [workingInterrupted, setWorkingInterrupted] = useState(false) + const previousWorkingEpochRef = useRef(null) + // True while a question card owns the input region, so the composer is hidden. + const [questionActive, setQuestionActive] = useState(false) + const rootRef = useRef(null) + const composerRef = useRef(null) + // The question card's free-text row; keeps Paste working while the card + // replaces the composer. + const questionAnswerInputRef = useRef(null) + const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) + const pasteClipboardIntoComposer = useNativeChatPasteBridge({ + rootRef, + composerRef, + questionAnswerInputRef + }) + const contextMenu = useNativeChatContextMenu({ + rootRef, + actions: { + onPaste: pasteClipboardIntoComposer, + ...(contextMenuActions ?? emptyNativeChatContextMenuActions) + } + }) + + // Optimistic "queued" sends (mobile parity): a composer send is echoed + // immediately and pruned once its real user turn lands in the transcript, so + // the message never vanishes between send and transcript catch-up. + const commandMarkerScope = useMemo( + () => ({ paneKey, agent, sessionId }), + [paneKey, agent, sessionId] + ) + const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) + const [pending, setPending] = useState(() => + readPendingSendCache(pendingScope) + ) + // Slash commands aren't chat turns, so they get a small local "Ran /clear" + // system line instead of a user bubble. Capped + cached per conversation. + const [commandMarkers, setCommandMarkers] = useState(() => + readCommandMarkerCache(commandMarkerScope) + ) + // Reset the optimistic queue only when the pane/agent changes. A fresh launch + // often learns its provider session id after the first send; clearing pending + // on that transition briefly flashes the empty state before the transcript + // user turn lands. + useEffect(() => { + setPending(readPendingSendCache(pendingScope)) + setWorkingInterrupted(false) + }, [pendingScope]) + // Command markers are session-scoped because slash commands like /clear are + // local feedback for a specific transcript boundary. + useEffect(() => { + setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) + setWorkingInterrupted(false) + }, [commandMarkerScope]) + // Prune echoes whose real user turn is now in the transcript. + useEffect(() => { + setPending((prev) => + writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) + ) + }, [session.messages, pendingScope]) + useEffect(() => { + if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { + return + } + clearNativeChatLaunchPrompt(terminalTabId) + }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) + const onOptimisticSend = useCallback( + (text: string, imagePaths?: string[]) => { + setWorkingInterrupted(false) + const sentAt = Date.now() + const boundary = session.messages.at(-1) + const entry: NativeChatPendingSend = { + id: nextNativeChatPendingSendId(sentAt), + text, + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, + ...(imagePaths ? { imagePaths } : {}) + } + setPending(appendPendingSendCache(pendingScope, entry)) + return entry.id + }, + [pendingScope, session.messages] + ) + const onOptimisticSendCanceled = useCallback( + (pendingId: string) => { + // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo + // must not come back from the pane cache as a prompt that was delivered. + const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) + setPending(writePendingSendCache(pendingScope, next)) + }, + [pendingScope] + ) + const onSlashCommand = useCallback( + (command: string) => { + setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) + }, + [commandMarkerScope] + ) + + const launchPromptMessage = useMemo( + () => launchPromptAsMessage(paneLaunchPrompt, session.messages), + [paneLaunchPrompt, session.messages] + ) + const sessionWithLaunchPrompt = useMemo(() => { + if (!launchPromptMessage) { + return session + } + return { ...session, messages: [...session.messages, launchPromptMessage] } + }, [launchPromptMessage, session]) + + const sessionAfterCommandBoundaries = useMemo(() => { + const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) + return messages === sessionWithLaunchPrompt.messages + ? sessionWithLaunchPrompt + : { ...sessionWithLaunchPrompt, messages } + }, [sessionWithLaunchPrompt, commandMarkers]) + const failedLaunchPromptMessageIds = useMemo(() => { + const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null + if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { + return undefined + } + return new Set([id]) + }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) + + // The streaming preview bubble (if any) sits after the transcript but before + // the optimistic user echoes — same order mobile uses. + const pendingMessages = useMemo( + () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), + [pending, sessionAfterCommandBoundaries.messages] + ) + const streamingText = useMemo(() => { + return deriveNativeChatStreamingText({ + messages: + pendingMessages.length > 0 + ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] + : sessionAfterCommandBoundaries.messages, + previewText: hookPreview, + working: liveWorking + }) + }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking]) + const sessionWithPending = useMemo(() => { + if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { + return sessionAfterCommandBoundaries + } + return { + ...sessionAfterCommandBoundaries, + messages: [ + ...sessionAfterCommandBoundaries.messages, + ...commandMarkersAsMessages(commandMarkers), + ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), + ...pendingMessages + ] + } + }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) + // Derive the view state from the pending-augmented session so a send into an + // otherwise-empty conversation flips to the list (showing the queued bubble) + // instead of staying on the empty state. + const viewState = selectNativeChatViewState(sessionWithPending) + + const isConversation = viewState.kind === 'ready' + useEffect(() => { + if ( + shouldClearNativeChatWorkingSuppression({ + working: liveWorking, + interrupted: workingInterrupted, + workingEpoch: hookWorkingEpoch, + previousWorkingEpoch: previousWorkingEpochRef.current + }) + ) { + setWorkingInterrupted(false) + } + if (liveWorking && hookWorkingEpoch != null) { + previousWorkingEpochRef.current = hookWorkingEpoch + } + if (!liveWorking) { + previousWorkingEpochRef.current = null + } + }, [liveWorking, workingInterrupted, hookWorkingEpoch]) + const isWorking = shouldShowNativeChatWorking({ + isConversation, + working: liveWorking, + interrupted: workingInterrupted + }) + + const stopAgent = useCallback(() => { + setWorkingInterrupted(true) + // Why: Stop after a submitted turn drops the delayed-write handle once it + // settles, so cancelPendingSends no longer sees the optimistic id. Clear + // the echo cache here so a cancelled prompt cannot stick as a ghost bubble. + setPending(writePendingSendCache(pendingScope, [])) + interactiveSend.cancel() + }, [interactiveSend, pendingScope]) + const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) + + // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so + // the chord is inert on the loading/empty/error states and elsewhere. + const fontScale = useNativeChatFontScale(isConversation) + + return ( +
{ + if (event.button === 2) { + contextMenu.onSelectionCapture() + event.preventDefault() + event.stopPropagation() + return + } + if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { + rootRef.current?.focus({ preventScroll: true }) + } + }} + onKeyDownCapture={(event) => { + // Backspace/Delete outside an input focuses the composer (like typing) + // but inserts nothing — let the now-focused field handle the keystroke. + if (shouldFocusNativeChatComposerFromEditingKey(event)) { + composerRef.current?.focus() + return + } + if (!shouldRedirectNativeChatTyping(event)) { + return + } + if (!composerRef.current?.insertTypedText(event.key)) { + return + } + event.preventDefault() + event.stopPropagation() + }} + onMouseUpCapture={contextMenu.onSelectionCapture} + onKeyUpCapture={contextMenu.onSelectionCapture} + onContextMenuCapture={contextMenu.onContextMenuCapture} + className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" + > + +
+ {viewState.kind === 'loading' ? ( + + ) : viewState.kind === 'error' ? ( + + ) : viewState.kind === 'empty' ? ( + + ) : ( + + )} +
+ {/* Live interactive prompt (question / approval) is the bottom input region + (mobile parity). A question card supplies its own answer input, so it + fully replaces the composer while active — no stray "Send a message". */} + + {/* canSend reflects the mobile presence-lock: when a mobile client holds + the pty, the composer shows its guarded state instead of racing the + mobile driver (R8). */} + {questionActive ? null : ( + + )} + {contextMenu.menu} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index dc19218f8ba..84c397b8d1d 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -1,60 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAppStore } from '../../store' -import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' -import { useNativeChatRetainedSession } from './use-native-chat-retained-session' -import { isNativeChatTranscriptUnsettled } from './use-native-chat-live-session' -import { selectNativeChatViewState } from './native-chat-view-state' -import { NativeChatMessageList } from './NativeChatMessageList' -import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' -import { useNativeChatFontScale } from './use-native-chat-font-scale' -import { useNativeChatCanSend } from './use-native-chat-can-send' -import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' -import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatSessionGate } from './NativeChatSessionGate' -import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { - shouldClearNativeChatWorkingSuppression, - shouldShowNativeChatWorking -} from './native-chat-working-suppression' -import { - appendPendingSendCache, - launchPromptAsMessage, - pendingSendsAsMessages, - nextNativeChatPendingSendId, - prunePendingSends, - readPendingSendCache, - shouldPruneLaunchPrompt, - writePendingSendCache, - type NativeChatPendingSend -} from './native-chat-pending' -import { - appendCommandMarkerCache, - applyCommandMarkerBoundaries, - commandMarkersAsMessages, - readCommandMarkerCache, - type NativeChatCommandMarker -} from './native-chat-command-marker' -import { - deriveNativeChatStreamingText, - nativeChatStreamingMessage -} from '../../../../shared/native-chat-streaming' -import { - shouldFocusNativeChatComposerFromEditingKey, - shouldFocusNativeChatPaneFromPointerTarget, - shouldRedirectNativeChatTyping -} from './native-chat-typing-redirect' -import { - emptyNativeChatContextMenuActions, - useNativeChatContextMenu -} from './use-native-chat-context-menu' -import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' -import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' -import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' -import type { NativeChatResolvedViewProps, NativeChatViewProps } from './native-chat-view-types' import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { NativeChatResolvedView } from './NativeChatResolvedView' import { useNativeChatStatusEntry } from './use-native-chat-status-entry' -import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' -import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' +import type { NativeChatViewProps } from './native-chat-view-types' export type { NativeChatViewProps } from './native-chat-view-types' @@ -110,352 +58,3 @@ function NativeChatBridgeView({ ) } - -function NativeChatResolvedView({ - paneKey, - agent, - sessionId, - transcriptPath, - isVisible, - targetPtyId, - terminalTabId, - ownsTabWideLaunchDraft, - onSwitchToTerminal, - readTerminalScreen, - contextMenuActions, - orchestrationDispatchStatus -}: NativeChatResolvedViewProps): React.JSX.Element { - // Primitive owner selection (no useShallow): routes the pane's read/subscribe to - // the remote runtime host for a runtime-owned pane; null keeps the local path. - const runtimeEnvironmentId = useAppStore((s) => - selectNativeChatRuntimeEnvironmentId(s, terminalTabId) - ) - const session = useNativeChatRetainedSession({ - paneKey, - agent, - sessionId, - transcriptPath, - runtimeEnvironmentId, - enabled: isVisible - }) - const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) - const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) - const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null - // Launch context prefilled into the TUI input as an unsent draft; the - // composer adopts it so the GUI view shows the same context as the TUI. - const launchDraftSignal = useNativeChatLaunchDraftSignal({ - terminalTabId, - agent, - messages: session.messages, - // 'awaiting' counts too: adopting a prefill against a transcript that hasn't - // flushed would re-offer a prompt the user already submitted. - transcriptLoading: isNativeChatTranscriptUnsettled(session.readPhase) - }) - // The live-session merge reconciles hooks with replayable transcript turn - // boundaries; all working consumers must use that one lifecycle decision. - const liveWorking = session.status === 'working' - // The agent's in-progress reply preview (hook), shown as a live streaming - // bubble while it works — before the completed turn flushes to the transcript. - const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage) - // Why: Stop suppression must clear on a newer working epoch even when status - // never leaves 'working' (interrupt + immediate next turn coalesced). - const hookWorkingEpoch = useAppStore( - (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null - ) - const canSend = useNativeChatCanSend(targetPtyId) - // Reuse the verified composer send path for interactive cards and composer - // stop (Stop sends ESC, the agent-TUI interrupt key). - const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) - const [workingInterrupted, setWorkingInterrupted] = useState(false) - const previousWorkingEpochRef = useRef(null) - // True while a question card owns the input region, so the composer is hidden. - const [questionActive, setQuestionActive] = useState(false) - const rootRef = useRef(null) - const composerRef = useRef(null) - // The question card's free-text row; keeps Paste working while the card - // replaces the composer. - const questionAnswerInputRef = useRef(null) - const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) - const pasteClipboardIntoComposer = useNativeChatPasteBridge({ - rootRef, - composerRef, - questionAnswerInputRef - }) - const contextMenu = useNativeChatContextMenu({ - rootRef, - actions: { - onPaste: pasteClipboardIntoComposer, - ...(contextMenuActions ?? emptyNativeChatContextMenuActions) - } - }) - - // Optimistic "queued" sends (mobile parity): a composer send is echoed - // immediately and pruned once its real user turn lands in the transcript, so - // the message never vanishes between send and transcript catch-up. - const commandMarkerScope = useMemo( - () => ({ paneKey, agent, sessionId }), - [paneKey, agent, sessionId] - ) - const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) - const [pending, setPending] = useState(() => - readPendingSendCache(pendingScope) - ) - // Slash commands aren't chat turns, so they get a small local "Ran /clear" - // system line instead of a user bubble. Capped + cached per conversation. - const [commandMarkers, setCommandMarkers] = useState(() => - readCommandMarkerCache(commandMarkerScope) - ) - // Reset the optimistic queue only when the pane/agent changes. A fresh launch - // often learns its provider session id after the first send; clearing pending - // on that transition briefly flashes the empty state before the transcript - // user turn lands. - useEffect(() => { - setPending(readPendingSendCache(pendingScope)) - setWorkingInterrupted(false) - }, [pendingScope]) - // Command markers are session-scoped because slash commands like /clear are - // local feedback for a specific transcript boundary. - useEffect(() => { - setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) - setWorkingInterrupted(false) - }, [commandMarkerScope]) - // Prune echoes whose real user turn is now in the transcript. - useEffect(() => { - setPending((prev) => - writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) - ) - }, [session.messages, pendingScope]) - useEffect(() => { - if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { - return - } - clearNativeChatLaunchPrompt(terminalTabId) - }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) - const onOptimisticSend = useCallback( - (text: string, imagePaths?: string[]) => { - setWorkingInterrupted(false) - const sentAt = Date.now() - const boundary = session.messages.at(-1) - const entry: NativeChatPendingSend = { - id: nextNativeChatPendingSendId(sentAt), - text, - sentAt, - afterMessageId: boundary?.id ?? null, - afterMessageTimestamp: boundary?.timestamp ?? null, - ...(imagePaths ? { imagePaths } : {}) - } - setPending(appendPendingSendCache(pendingScope, entry)) - return entry.id - }, - [pendingScope, session.messages] - ) - const onOptimisticSendCanceled = useCallback( - (pendingId: string) => { - // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo - // must not come back from the pane cache as a prompt that was delivered. - const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) - setPending(writePendingSendCache(pendingScope, next)) - }, - [pendingScope] - ) - const onSlashCommand = useCallback( - (command: string) => { - setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) - }, - [commandMarkerScope] - ) - - const launchPromptMessage = useMemo( - () => launchPromptAsMessage(paneLaunchPrompt, session.messages), - [paneLaunchPrompt, session.messages] - ) - const sessionWithLaunchPrompt = useMemo(() => { - if (!launchPromptMessage) { - return session - } - return { ...session, messages: [...session.messages, launchPromptMessage] } - }, [launchPromptMessage, session]) - - const sessionAfterCommandBoundaries = useMemo(() => { - const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) - return messages === sessionWithLaunchPrompt.messages - ? sessionWithLaunchPrompt - : { ...sessionWithLaunchPrompt, messages } - }, [sessionWithLaunchPrompt, commandMarkers]) - const failedLaunchPromptMessageIds = useMemo(() => { - const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null - if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { - return undefined - } - return new Set([id]) - }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) - - // The streaming preview bubble (if any) sits after the transcript but before - // the optimistic user echoes — same order mobile uses. - const pendingMessages = useMemo( - () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), - [pending, sessionAfterCommandBoundaries.messages] - ) - const streamingText = useMemo(() => { - return deriveNativeChatStreamingText({ - messages: - pendingMessages.length > 0 - ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] - : sessionAfterCommandBoundaries.messages, - previewText: hookPreview, - working: liveWorking - }) - }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking]) - const sessionWithPending = useMemo(() => { - if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { - return sessionAfterCommandBoundaries - } - return { - ...sessionAfterCommandBoundaries, - messages: [ - ...sessionAfterCommandBoundaries.messages, - ...commandMarkersAsMessages(commandMarkers), - ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingMessages - ] - } - }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) - // Derive the view state from the pending-augmented session so a send into an - // otherwise-empty conversation flips to the list (showing the queued bubble) - // instead of staying on the empty state. - const viewState = selectNativeChatViewState(sessionWithPending) - - const isConversation = viewState.kind === 'ready' - useEffect(() => { - if ( - shouldClearNativeChatWorkingSuppression({ - working: liveWorking, - interrupted: workingInterrupted, - workingEpoch: hookWorkingEpoch, - previousWorkingEpoch: previousWorkingEpochRef.current - }) - ) { - setWorkingInterrupted(false) - } - if (liveWorking && hookWorkingEpoch != null) { - previousWorkingEpochRef.current = hookWorkingEpoch - } - if (!liveWorking) { - previousWorkingEpochRef.current = null - } - }, [liveWorking, workingInterrupted, hookWorkingEpoch]) - const isWorking = shouldShowNativeChatWorking({ - isConversation, - working: liveWorking, - interrupted: workingInterrupted - }) - - const stopAgent = useCallback(() => { - setWorkingInterrupted(true) - // Why: Stop after a submitted turn drops the delayed-write handle once it - // settles, so cancelPendingSends no longer sees the optimistic id. Clear - // the echo cache here so a cancelled prompt cannot stick as a ghost bubble. - setPending(writePendingSendCache(pendingScope, [])) - interactiveSend.cancel() - }, [interactiveSend, pendingScope]) - const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) - - // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so - // the chord is inert on the loading/empty/error states and elsewhere. - const fontScale = useNativeChatFontScale(isConversation) - - return ( -
{ - if (event.button === 2) { - contextMenu.onSelectionCapture() - event.preventDefault() - event.stopPropagation() - return - } - if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { - rootRef.current?.focus({ preventScroll: true }) - } - }} - onKeyDownCapture={(event) => { - // Backspace/Delete outside an input focuses the composer (like typing) - // but inserts nothing — let the now-focused field handle the keystroke. - if (shouldFocusNativeChatComposerFromEditingKey(event)) { - composerRef.current?.focus() - return - } - if (!shouldRedirectNativeChatTyping(event)) { - return - } - if (!composerRef.current?.insertTypedText(event.key)) { - return - } - event.preventDefault() - event.stopPropagation() - }} - onMouseUpCapture={contextMenu.onSelectionCapture} - onKeyUpCapture={contextMenu.onSelectionCapture} - onContextMenuCapture={contextMenu.onContextMenuCapture} - className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" - > - -
- {viewState.kind === 'loading' ? ( - - ) : viewState.kind === 'error' ? ( - - ) : viewState.kind === 'empty' ? ( - - ) : ( - - )} -
- {/* Live interactive prompt (question / approval) is the bottom input region - (mobile parity). A question card supplies its own answer input, so it - fully replaces the composer while active — no stray "Send a message". */} - - {/* canSend reflects the mobile presence-lock: when a mobile client holds - the pty, the composer shows its guarded state instead of racing the - mobile driver (R8). */} - {questionActive ? null : ( - - )} - {contextMenu.menu} -
- ) -} diff --git a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts index 4b18921f221..e47e169126c 100644 --- a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts @@ -21,7 +21,7 @@ describe('native chat Stop layering', () => { it('publishes working state from both structured and bridge chat roots', () => { for (const path of [ 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', - 'src/renderer/src/components/native-chat/NativeChatView.tsx' + 'src/renderer/src/components/native-chat/NativeChatResolvedView.tsx' ]) { expect(source(path)).toContain('data-native-chat-working=') }