Merge commit 'fc6519a3ab8b4a330a76770f69d290ac542ec103' into HEAD

This commit is contained in:
Merge Sim
2026-08-29 22:29:49 -07:00
21 changed files with 692 additions and 426 deletions
@@ -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()
+1
View File
@@ -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(),
+2 -2
View File
@@ -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
}
+3 -2
View File
@@ -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,
+3 -2
View File
@@ -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
)
})
+1
View File
@@ -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
+7 -3
View File
@@ -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)
},