mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
Merge commit 'fc6519a3ab8b4a330a76770f69d290ac542ec103' into HEAD
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installHappyDomOffscreenCanvasCompatibility } from './happy-dom-offscreen-canvas'
|
||||
|
||||
type TestOffscreenCanvas = new (
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
getContext: (contextType: string, contextAttributes?: unknown) => unknown
|
||||
}
|
||||
|
||||
const openTerminals: Terminal[] = []
|
||||
|
||||
describe('happy-dom OffscreenCanvas compatibility', () => {
|
||||
afterEach(() => {
|
||||
while (openTerminals.length > 0) {
|
||||
openTerminals.pop()?.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('delegates adapter-less 2D contexts to the existing HTML canvas double', () => {
|
||||
const offscreenCanvas = (globalThis as { OffscreenCanvas?: TestOffscreenCanvas })
|
||||
.OffscreenCanvas
|
||||
if (!offscreenCanvas) {
|
||||
expect(installHappyDomOffscreenCanvasCompatibility()).toBe(false)
|
||||
return
|
||||
}
|
||||
|
||||
const context = { measureText: () => ({ width: 10 }) }
|
||||
const getContext = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
.mockReturnValue(context as unknown as CanvasRenderingContext2D)
|
||||
|
||||
expect(installHappyDomOffscreenCanvasCompatibility()).toBe(true)
|
||||
expect(new offscreenCanvas(1, 1).getContext('2d')).toBe(context)
|
||||
expect(getContext).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not turn unsupported non-2D contexts into test doubles', () => {
|
||||
const offscreenCanvas = (globalThis as { OffscreenCanvas?: TestOffscreenCanvas })
|
||||
.OffscreenCanvas
|
||||
if (!offscreenCanvas) {
|
||||
return
|
||||
}
|
||||
|
||||
const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
expect(new offscreenCanvas(1, 1).getContext('webgl')).toBeNull()
|
||||
expect(getContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps xterm DOM rendering open with the existing HTML canvas double', () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const terminal = new Terminal()
|
||||
openTerminals.push(terminal)
|
||||
|
||||
terminal.open(host)
|
||||
|
||||
expect(terminal.element).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// Why: happy-dom exposes OffscreenCanvas without a default canvas adapter. xterm prefers that API
|
||||
// and then cannot open in tests that already provide the HTML canvas text-metric stand-in.
|
||||
|
||||
const OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED = Symbol.for(
|
||||
'orca.happyDomOffscreenCanvasCompatibility'
|
||||
)
|
||||
|
||||
type PatchableCanvas = {
|
||||
getContext?: (contextType: string, contextAttributes?: unknown) => unknown
|
||||
} & Record<symbol, unknown>
|
||||
|
||||
type CanvasConstructor = {
|
||||
prototype: PatchableCanvas
|
||||
new (width: number, height: number): PatchableCanvas
|
||||
}
|
||||
|
||||
type HappyDomGlobals = {
|
||||
OffscreenCanvas?: CanvasConstructor
|
||||
HTMLCanvasElement?: { prototype: PatchableCanvas }
|
||||
document?: Pick<Document, 'createElement'>
|
||||
}
|
||||
|
||||
/** Make happy-dom's adapter-less OffscreenCanvas honor the existing HTML canvas test double. */
|
||||
export function installHappyDomOffscreenCanvasCompatibility(): boolean {
|
||||
const globals = globalThis as unknown as HappyDomGlobals
|
||||
const offscreenCanvas = globals.OffscreenCanvas
|
||||
const htmlCanvas = globals.HTMLCanvasElement
|
||||
const document = globals.document
|
||||
if (!offscreenCanvas || !htmlCanvas || !document) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prototype = offscreenCanvas.prototype
|
||||
if (prototype[OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED] === true) {
|
||||
return true
|
||||
}
|
||||
const originalGetContext = prototype.getContext
|
||||
if (!originalGetContext || !htmlCanvas.prototype.getContext) {
|
||||
return false
|
||||
}
|
||||
|
||||
prototype.getContext = function patchedGetContext(
|
||||
this: PatchableCanvas,
|
||||
contextType: string,
|
||||
contextAttributes?: unknown
|
||||
): unknown {
|
||||
const context = originalGetContext.call(this, contextType, contextAttributes)
|
||||
if (contextType !== '2d' || context) {
|
||||
return context
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas') as PatchableCanvas
|
||||
return htmlCanvas.prototype.getContext!.call(canvas, contextType, contextAttributes)
|
||||
}
|
||||
prototype[OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED] = true
|
||||
return true
|
||||
}
|
||||
|
||||
installHappyDomOffscreenCanvasCompatibility()
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [
|
||||
resolve('config/scripts/happy-dom-offscreen-canvas.ts'),
|
||||
resolve('config/scripts/happy-dom-mutation-observer-retention.ts'),
|
||||
resolve('config/scripts/vitest-host-ports-setup.ts')
|
||||
],
|
||||
|
||||
@@ -67,6 +67,22 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR)
|
||||
})
|
||||
|
||||
it('uses the requested display name for repos:add folder repos', async () => {
|
||||
const result = await handlers.get('repos:add')!(null, {
|
||||
path: '/tmp/from-add',
|
||||
kind: 'folder',
|
||||
displayName: 'inf-오케스트레이터'
|
||||
})
|
||||
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/tmp/from-add',
|
||||
displayName: 'inf-오케스트레이터'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.displayName', 'inf-오케스트레이터')
|
||||
})
|
||||
|
||||
it('inherits global non-Orca visibility while retaining the mixed-version safety marker', async () => {
|
||||
const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'git' })
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation
|
||||
export async function addLocalRepoFromPath(
|
||||
store: Store,
|
||||
path: string,
|
||||
kind: 'git' | 'folder' = 'git'
|
||||
kind: 'git' | 'folder' = 'git',
|
||||
displayName?: string
|
||||
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
|
||||
const repoKind = kind === 'folder' ? 'folder' : 'git'
|
||||
if (repoKind === 'git') {
|
||||
@@ -76,7 +77,7 @@ export async function addLocalRepoFromPath(
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: resolvedPath,
|
||||
displayName: getRepoName(resolvedPath),
|
||||
displayName: displayName?.trim() || getRepoName(resolvedPath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
|
||||
@@ -70,9 +70,9 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S
|
||||
'repos:add',
|
||||
async (
|
||||
_event,
|
||||
args: { path: string; kind?: 'git' | 'folder' }
|
||||
args: { path: string; kind?: 'git' | 'folder'; displayName?: string }
|
||||
): Promise<{ repo: Repo } | { error: string }> => {
|
||||
const result = await addLocalRepoFromPath(store, args.path, args.kind)
|
||||
const result = await addLocalRepoFromPath(store, args.path, args.kind, args.displayName)
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -23325,7 +23325,8 @@ export class OrcaRuntimeService {
|
||||
async addRepo(
|
||||
path: string,
|
||||
kind: 'git' | 'folder' = 'git',
|
||||
executionHostId?: ExecutionHostId | null
|
||||
executionHostId?: ExecutionHostId | null,
|
||||
displayName?: string
|
||||
): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -23373,7 +23374,7 @@ export class OrcaRuntimeService {
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path,
|
||||
displayName: getRepoName(path),
|
||||
displayName: displayName?.trim() || getRepoName(path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(executionHostId != null ? { executionHostId } : {}),
|
||||
...detected,
|
||||
|
||||
@@ -15,7 +15,8 @@ const RepoSelector = z.object({
|
||||
|
||||
const RepoPath = z.object({
|
||||
path: requiredString('Missing repo path'),
|
||||
kind: z.enum(['git', 'folder']).optional()
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: OptionalString
|
||||
})
|
||||
|
||||
const RepoCreate = z.object({
|
||||
@@ -190,7 +191,7 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
params: RepoPath,
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(
|
||||
await context.runtime.addRepo(params.path, params.kind),
|
||||
await context.runtime.addRepo(params.path, params.kind, undefined, params.displayName),
|
||||
context
|
||||
)
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ export type RepositoryApi = {
|
||||
add: (args: {
|
||||
path: string
|
||||
kind?: 'git' | 'folder'
|
||||
displayName?: string
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
remove: (args: { repoId: string }) => Promise<void>
|
||||
// Forget a project on one execution host only, leaving the same repo id on other hosts intact.
|
||||
|
||||
@@ -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=')
|
||||
}
|
||||
|
||||
@@ -156,6 +156,55 @@ describe('NonGitFolderDialog', () => {
|
||||
expect(mocks.state.closeModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the runtime folder project after the requested display name', () => {
|
||||
mocks.state.modalData = {
|
||||
folderPath: '/srv/non-git',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
displayName: 'inf-오케스트레이터'
|
||||
}
|
||||
renderToStaticMarkup(<NonGitFolderDialog />)
|
||||
|
||||
const button = mocks.buttons.find((entry) => entry.label.includes('Open as Folder'))
|
||||
button?.onClick?.()
|
||||
|
||||
expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/srv/non-git', {
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
displayName: 'inf-오케스트레이터'
|
||||
})
|
||||
})
|
||||
|
||||
it('names the SSH folder project after the requested display name', async () => {
|
||||
mocks.state.modalData = {
|
||||
folderPath: '/srv/non-git',
|
||||
connectionId: 'ssh-1',
|
||||
displayName: 'inf-오케스트레이터'
|
||||
}
|
||||
mocks.addRemote.mockResolvedValue({
|
||||
repo: {
|
||||
id: 'ssh-repo',
|
||||
path: '/srv/non-git',
|
||||
displayName: 'inf-오케스트레이터',
|
||||
badgeColor: '#111',
|
||||
addedAt: 1,
|
||||
kind: 'folder',
|
||||
connectionId: 'ssh-1'
|
||||
} satisfies Repo
|
||||
})
|
||||
renderToStaticMarkup(<NonGitFolderDialog />)
|
||||
|
||||
const button = mocks.buttons.find((entry) => entry.label.includes('Open as Folder'))
|
||||
button?.onClick?.()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.addRemote).toHaveBeenCalledWith({
|
||||
connectionId: 'ssh-1',
|
||||
remotePath: '/srv/non-git',
|
||||
kind: 'folder',
|
||||
displayName: 'inf-오케스트레이터'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('activates only the selected SSH folder when repo IDs collide', async () => {
|
||||
const repo: Repo = {
|
||||
id: 'shared-repo',
|
||||
|
||||
@@ -30,6 +30,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
|
||||
const connectionId = typeof modalData.connectionId === 'string' ? modalData.connectionId : ''
|
||||
const runtimeEnvironmentId =
|
||||
typeof modalData.runtimeEnvironmentId === 'string' ? modalData.runtimeEnvironmentId : ''
|
||||
const displayName = typeof modalData.displayName === 'string' ? modalData.displayName.trim() : ''
|
||||
const runtimeEnvironmentName =
|
||||
runtimeEnvironmentId &&
|
||||
(runtimeEnvironments.find((environment) => environment.id === runtimeEnvironmentId)?.name ||
|
||||
@@ -58,7 +59,8 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
|
||||
const result = await window.api.repos.addRemote({
|
||||
connectionId,
|
||||
remotePath: folderPath,
|
||||
kind: 'folder'
|
||||
kind: 'folder',
|
||||
...(displayName ? { displayName } : {})
|
||||
})
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
@@ -111,11 +113,12 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
|
||||
})()
|
||||
} else if (folderPath) {
|
||||
void addNonGitFolder(folderPath, {
|
||||
runtimeEnvironmentId: runtimeEnvironmentId || null
|
||||
runtimeEnvironmentId: runtimeEnvironmentId || null,
|
||||
...(displayName ? { displayName } : {})
|
||||
})
|
||||
}
|
||||
closeModal()
|
||||
}, [addNonGitFolder, closeModal, folderPath, connectionId, runtimeEnvironmentId])
|
||||
}, [addNonGitFolder, closeModal, displayName, folderPath, connectionId, runtimeEnvironmentId])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
|
||||
@@ -14,6 +14,8 @@ export async function completeNestedFolderOpen(args: {
|
||||
selectedCount: number
|
||||
getRuntimeKind: Parameters<typeof trackNestedFolderOpen>[0]['getRuntimeKind']
|
||||
owner: CapturedRuntimeOwner
|
||||
/** User-entered project name; falls back to the host's basename naming when absent. */
|
||||
displayName?: string
|
||||
closeModal: () => void
|
||||
setIsAdding: (value: boolean) => void
|
||||
}): Promise<void> {
|
||||
@@ -26,12 +28,14 @@ export async function completeNestedFolderOpen(args: {
|
||||
state.openModal('confirm-non-git-folder', {
|
||||
folderPath: args.scan.selectedPath,
|
||||
connectionId: args.connectionId,
|
||||
runtimeEnvironmentId: args.owner
|
||||
runtimeEnvironmentId: args.owner,
|
||||
...(args.displayName ? { displayName: args.displayName } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
const repo = await state.addNonGitFolder(args.scan.selectedPath, {
|
||||
runtimeEnvironmentId: args.owner ?? null
|
||||
runtimeEnvironmentId: args.owner ?? null,
|
||||
...(args.displayName ? { displayName: args.displayName } : {})
|
||||
})
|
||||
if (args.generation !== args.currentGeneration()) {
|
||||
return
|
||||
|
||||
@@ -126,6 +126,46 @@ describe('useAddRepoNestedImportFlow open folder fallback', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('names the folder project after the edited group name', async () => {
|
||||
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({
|
||||
nestedGroupName: ' inf-오케스트레이터 '
|
||||
})
|
||||
|
||||
await handleOpenNestedRootFolder()
|
||||
|
||||
expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', {
|
||||
runtimeEnvironmentId: null,
|
||||
displayName: 'inf-오케스트레이터'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves host basename naming alone when the group name is untouched', async () => {
|
||||
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({
|
||||
nestedGroupName: ' platform '
|
||||
})
|
||||
|
||||
await handleOpenNestedRootFolder()
|
||||
|
||||
expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', {
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the edited group name into the SSH folder confirmation', async () => {
|
||||
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({
|
||||
nestedConnectionId: 'ssh-builder',
|
||||
nestedRuntimeKind: 'ssh',
|
||||
nestedGroupName: 'inf-오케스트레이터'
|
||||
})
|
||||
|
||||
await handleOpenNestedRootFolder()
|
||||
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith(
|
||||
'confirm-non-git-folder',
|
||||
expect.objectContaining({ displayName: 'inf-오케스트레이터' })
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks the open-as-folder recovery action with zero selection', async () => {
|
||||
const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow()
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { worktreeRefreshOptions, type CapturedRuntimeOwner } from './add-repo-runtime-owner'
|
||||
import { completeNestedFolderOpen } from './complete-nested-folder-open'
|
||||
import { defaultProjectGroupNameForPath } from './add-repo-dialog-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
export function useAddRepoNestedImportFlow({
|
||||
@@ -255,6 +256,13 @@ export function useAddRepoNestedImportFlow({
|
||||
return Promise.resolve()
|
||||
}
|
||||
const generation = ++nestedImportGenRef.current
|
||||
// Why: only an edited name overrides host naming, so an untouched prefill still lets the host
|
||||
// pick (e.g. the SSH target label for `~`).
|
||||
const enteredName = nestedGroupName.trim()
|
||||
const displayName =
|
||||
enteredName && enteredName !== defaultProjectGroupNameForPath(nestedScan.selectedPath)
|
||||
? enteredName
|
||||
: undefined
|
||||
return completeNestedFolderOpen({
|
||||
scan: nestedScan,
|
||||
generation,
|
||||
@@ -265,6 +273,7 @@ export function useAddRepoNestedImportFlow({
|
||||
selectedCount: nestedSelectedPaths.size,
|
||||
getRuntimeKind: getNestedRepoRuntimeKind,
|
||||
owner: nestedRuntimeEnvironmentId,
|
||||
...(displayName ? { displayName } : {}),
|
||||
closeModal,
|
||||
setIsAdding
|
||||
})
|
||||
@@ -273,6 +282,7 @@ export function useAddRepoNestedImportFlow({
|
||||
getNestedRepoRuntimeKind,
|
||||
nestedAttemptId,
|
||||
nestedConnectionId,
|
||||
nestedGroupName,
|
||||
nestedRuntimeKind,
|
||||
nestedRuntimeEnvironmentId,
|
||||
nestedScan,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
parseExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { AddRepoPathRouteOptions } from './repo-state'
|
||||
import type { AddRepoPathOptions } from './repo-state'
|
||||
import { getRuntimeTargetHostId } from '../runtime-target-host'
|
||||
|
||||
export function repoWithFetchedOwner(
|
||||
@@ -60,7 +60,7 @@ export function settingsForRepoOwner(
|
||||
}
|
||||
|
||||
export function getAddRepoPathRouteSettings(
|
||||
options: AddRepoPathRouteOptions | undefined,
|
||||
options: AddRepoPathOptions | undefined,
|
||||
fallbackSettings: GlobalSettings | null
|
||||
): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined {
|
||||
return options && 'runtimeEnvironmentId' in options
|
||||
|
||||
@@ -33,10 +33,15 @@ export function createRepoAddActions(
|
||||
addRepoPath: async (path, kind = 'git', options) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(getAddRepoPathRouteSettings(options, get().settings))
|
||||
const displayName = options?.displayName?.trim() || undefined
|
||||
let repo: Repo
|
||||
try {
|
||||
if (target.kind === 'local') {
|
||||
const result = await window.api.repos.add({ path, kind })
|
||||
const result = await window.api.repos.add({
|
||||
path,
|
||||
kind,
|
||||
...(displayName ? { displayName } : {})
|
||||
})
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
@@ -46,7 +51,7 @@ export function createRepoAddActions(
|
||||
await callRuntimeRpc<{ repo: Repo }>(
|
||||
target,
|
||||
'repo.add',
|
||||
{ path, kind },
|
||||
{ path, kind, ...(displayName ? { displayName } : {}) },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).repo
|
||||
@@ -81,6 +86,7 @@ export function createRepoAddActions(
|
||||
const { openModal } = get()
|
||||
openModal('confirm-non-git-folder', {
|
||||
folderPath: path,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(target.kind === 'environment' ? { runtimeEnvironmentId: target.environmentId } : {})
|
||||
})
|
||||
return null
|
||||
|
||||
@@ -130,7 +130,11 @@ export type DeleteProjectGroupWithContainedProjectsResult =
|
||||
|
||||
export type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId?: string | null }
|
||||
|
||||
export type AddRepoPathRouteOptions = { runtimeEnvironmentId?: string | null }
|
||||
export type AddRepoPathOptions = {
|
||||
runtimeEnvironmentId?: string | null
|
||||
/** Overrides the host's basename naming for the new project. */
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export type RuntimeCatalogFetchOptions = { runtimeEnvironmentId?: string | null }
|
||||
|
||||
@@ -158,7 +162,7 @@ export type RepoSlice = {
|
||||
addRepoPath: (
|
||||
path: string,
|
||||
kind?: 'git' | 'folder',
|
||||
options?: AddRepoPathRouteOptions
|
||||
options?: AddRepoPathOptions
|
||||
) => Promise<Repo | null>
|
||||
setupProjectExistingFolder: (
|
||||
args: ProjectHostSetupExistingFolderArgs
|
||||
@@ -173,7 +177,7 @@ export type RepoSlice = {
|
||||
args: ProjectHostSetupDeleteArgs
|
||||
) => Promise<ProjectHostSetupDeleteResult | null>
|
||||
setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null>
|
||||
addNonGitFolder: (path: string, options?: AddRepoPathRouteOptions) => Promise<Repo | null>
|
||||
addNonGitFolder: (path: string, options?: AddRepoPathOptions) => Promise<Repo | null>
|
||||
scanNestedRepos: (
|
||||
path: string,
|
||||
connectionId?: string,
|
||||
|
||||
@@ -17,11 +17,11 @@ export function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
|
||||
const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list')
|
||||
return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId))
|
||||
},
|
||||
add: async ({ path, kind }) => {
|
||||
add: async ({ path, kind, displayName }) => {
|
||||
invalidateRuntimeWorktreeCaches()
|
||||
const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>(
|
||||
'repo.add',
|
||||
{ path, kind }
|
||||
{ path, kind, displayName }
|
||||
)
|
||||
return withRuntimeRepoMutationOwner(owned.result, owned.hostId)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user