mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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.
This commit is contained in:
@@ -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<number | null>(null)
|
||||
// True while a question card owns the input region, so the composer is hidden.
|
||||
const [questionActive, setQuestionActive] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<NativeChatComposerHandle>(null)
|
||||
// The question card's free-text row; keeps Paste working while the card
|
||||
// replaces the composer.
|
||||
const questionAnswerInputRef = useRef<HTMLInputElement>(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<NativeChatPendingSend[]>(() =>
|
||||
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<NativeChatCommandMarker[]>(() =>
|
||||
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<typeof session>(() => {
|
||||
if (!launchPromptMessage) {
|
||||
return session
|
||||
}
|
||||
return { ...session, messages: [...session.messages, launchPromptMessage] }
|
||||
}, [launchPromptMessage, session])
|
||||
|
||||
const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
|
||||
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<typeof session>(() => {
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-native-chat-root="true"
|
||||
data-native-chat-working={isWorking ? 'true' : 'false'}
|
||||
tabIndex={-1}
|
||||
onPointerDownCapture={(event) => {
|
||||
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"
|
||||
>
|
||||
<NativeChatOrchestrationPausedNotice dispatchStatus={orchestrationDispatchStatus} />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
<NativeChatEmptyState kind="loading" />
|
||||
) : viewState.kind === 'error' ? (
|
||||
<NativeChatEmptyState kind="error" message={viewState.message} />
|
||||
) : viewState.kind === 'empty' ? (
|
||||
<NativeChatEmptyState kind="empty" agent={agent} />
|
||||
) : (
|
||||
<NativeChatMessageList
|
||||
session={sessionWithPending}
|
||||
isWorking={isWorking}
|
||||
expandSignal={false}
|
||||
fontScale={fontScale.scale}
|
||||
onLinkClick={nativeChatFileLinkClick}
|
||||
allowFileUriLinks={fileLinkContext !== null}
|
||||
failedDeliveryMessageIds={failedLaunchPromptMessageIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 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". */}
|
||||
<NativeChatInteractiveCard
|
||||
paneKey={paneKey}
|
||||
send={interactiveSend}
|
||||
canSend={canSend}
|
||||
messages={sessionAfterCommandBoundaries.messages}
|
||||
transcriptSettled={session.readPhase === 'ready'}
|
||||
onShowingQuestionChange={setQuestionActive}
|
||||
answerInputRef={questionAnswerInputRef}
|
||||
/>
|
||||
{/* 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 : (
|
||||
<NativeChatComposer
|
||||
ref={composerRef}
|
||||
terminalTabId={terminalTabId}
|
||||
paneKey={paneKey}
|
||||
targetPtyId={targetPtyId}
|
||||
agent={agent}
|
||||
canSend={canSend}
|
||||
isWorking={isWorking}
|
||||
onStop={stopAgent}
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onOptimisticSendCanceled={onOptimisticSendCanceled}
|
||||
onSlashCommand={onSlashCommand}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
readTerminalScreen={readTerminalScreen}
|
||||
launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }}
|
||||
/>
|
||||
)}
|
||||
{contextMenu.menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
</NativeChatSessionGate>
|
||||
)
|
||||
}
|
||||
|
||||
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<number | null>(null)
|
||||
// True while a question card owns the input region, so the composer is hidden.
|
||||
const [questionActive, setQuestionActive] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<NativeChatComposerHandle>(null)
|
||||
// The question card's free-text row; keeps Paste working while the card
|
||||
// replaces the composer.
|
||||
const questionAnswerInputRef = useRef<HTMLInputElement>(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<NativeChatPendingSend[]>(() =>
|
||||
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<NativeChatCommandMarker[]>(() =>
|
||||
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<typeof session>(() => {
|
||||
if (!launchPromptMessage) {
|
||||
return session
|
||||
}
|
||||
return { ...session, messages: [...session.messages, launchPromptMessage] }
|
||||
}, [launchPromptMessage, session])
|
||||
|
||||
const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
|
||||
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<typeof session>(() => {
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-native-chat-root="true"
|
||||
data-native-chat-working={isWorking ? 'true' : 'false'}
|
||||
tabIndex={-1}
|
||||
onPointerDownCapture={(event) => {
|
||||
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"
|
||||
>
|
||||
<NativeChatOrchestrationPausedNotice dispatchStatus={orchestrationDispatchStatus} />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
<NativeChatEmptyState kind="loading" />
|
||||
) : viewState.kind === 'error' ? (
|
||||
<NativeChatEmptyState kind="error" message={viewState.message} />
|
||||
) : viewState.kind === 'empty' ? (
|
||||
<NativeChatEmptyState kind="empty" agent={agent} />
|
||||
) : (
|
||||
<NativeChatMessageList
|
||||
session={sessionWithPending}
|
||||
isWorking={isWorking}
|
||||
expandSignal={false}
|
||||
fontScale={fontScale.scale}
|
||||
onLinkClick={nativeChatFileLinkClick}
|
||||
allowFileUriLinks={fileLinkContext !== null}
|
||||
failedDeliveryMessageIds={failedLaunchPromptMessageIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 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". */}
|
||||
<NativeChatInteractiveCard
|
||||
paneKey={paneKey}
|
||||
send={interactiveSend}
|
||||
canSend={canSend}
|
||||
messages={sessionAfterCommandBoundaries.messages}
|
||||
transcriptSettled={session.readPhase === 'ready'}
|
||||
onShowingQuestionChange={setQuestionActive}
|
||||
answerInputRef={questionAnswerInputRef}
|
||||
/>
|
||||
{/* 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 : (
|
||||
<NativeChatComposer
|
||||
ref={composerRef}
|
||||
terminalTabId={terminalTabId}
|
||||
paneKey={paneKey}
|
||||
targetPtyId={targetPtyId}
|
||||
agent={agent}
|
||||
canSend={canSend}
|
||||
isWorking={isWorking}
|
||||
onStop={stopAgent}
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onOptimisticSendCanceled={onOptimisticSendCanceled}
|
||||
onSlashCommand={onSlashCommand}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
readTerminalScreen={readTerminalScreen}
|
||||
launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }}
|
||||
/>
|
||||
)}
|
||||
{contextMenu.menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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=')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user