diff --git a/config/scripts/happy-dom-offscreen-canvas.test.ts b/config/scripts/happy-dom-offscreen-canvas.test.ts new file mode 100644 index 00000000000..a857136d563 --- /dev/null +++ b/config/scripts/happy-dom-offscreen-canvas.test.ts @@ -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() + }) +}) diff --git a/config/scripts/happy-dom-offscreen-canvas.ts b/config/scripts/happy-dom-offscreen-canvas.ts new file mode 100644 index 00000000000..1b06e472025 --- /dev/null +++ b/config/scripts/happy-dom-offscreen-canvas.ts @@ -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 + +type CanvasConstructor = { + prototype: PatchableCanvas + new (width: number, height: number): PatchableCanvas +} + +type HappyDomGlobals = { + OffscreenCanvas?: CanvasConstructor + HTMLCanvasElement?: { prototype: PatchableCanvas } + document?: Pick +} + +/** 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() diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 92e0cf146a5..55333230101 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -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') ], diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index aed03724200..305a900f711 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.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' }) diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index b60ed99d1df..4cdaa169597 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -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(), diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 35dfab5d7ee..a2054450155 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -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 } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 750d606c642..de9cc2ba8f2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23325,7 +23325,8 @@ export class OrcaRuntimeService { async addRepo( path: string, kind: 'git' | 'folder' = 'git', - executionHostId?: ExecutionHostId | null + executionHostId?: ExecutionHostId | null, + displayName?: string ): Promise { 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, diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 7bf14b02fb1..7bf42922db8 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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 ) }) diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 3fa28d5145e..fd82697fc74 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -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 // Forget a project on one execution host only, leaving the same repo id on other hosts intact. diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx new file mode 100644 index 00000000000..6806387411b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -0,0 +1,404 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '../../store' +import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' +import { isNativeChatTranscriptUnsettled } from './use-native-chat-live-session' +import { selectNativeChatViewState } from './native-chat-view-state' +import { NativeChatMessageList } from './NativeChatMessageList' +import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' +import { useNativeChatFontScale } from './use-native-chat-font-scale' +import { useNativeChatCanSend } from './use-native-chat-can-send' +import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' +import { NativeChatEmptyState } from './NativeChatEmptyState' +import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' +import { + shouldClearNativeChatWorkingSuppression, + shouldShowNativeChatWorking +} from './native-chat-working-suppression' +import { + appendPendingSendCache, + launchPromptAsMessage, + pendingSendsAsMessages, + nextNativeChatPendingSendId, + prunePendingSends, + readPendingSendCache, + shouldPruneLaunchPrompt, + writePendingSendCache, + type NativeChatPendingSend +} from './native-chat-pending' +import { + appendCommandMarkerCache, + applyCommandMarkerBoundaries, + commandMarkersAsMessages, + readCommandMarkerCache, + type NativeChatCommandMarker +} from './native-chat-command-marker' +import { + deriveNativeChatStreamingText, + nativeChatStreamingMessage +} from '../../../../shared/native-chat-streaming' +import { + shouldFocusNativeChatComposerFromEditingKey, + shouldFocusNativeChatPaneFromPointerTarget, + shouldRedirectNativeChatTyping +} from './native-chat-typing-redirect' +import { + emptyNativeChatContextMenuActions, + useNativeChatContextMenu +} from './use-native-chat-context-menu' +import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' +import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' +import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' +import type { NativeChatResolvedViewProps } from './native-chat-view-types' +import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' + +/** Renders the bridge UI after NativeChatSessionGate resolves its agent session. */ +export function NativeChatResolvedView({ + paneKey, + agent, + sessionId, + transcriptPath, + isVisible, + targetPtyId, + terminalTabId, + ownsTabWideLaunchDraft, + onSwitchToTerminal, + readTerminalScreen, + contextMenuActions, + orchestrationDispatchStatus +}: NativeChatResolvedViewProps): React.JSX.Element { + // Primitive owner selection (no useShallow): routes the pane's read/subscribe to + // the remote runtime host for a runtime-owned pane; null keeps the local path. + const runtimeEnvironmentId = useAppStore((s) => + selectNativeChatRuntimeEnvironmentId(s, terminalTabId) + ) + const session = useNativeChatRetainedSession({ + paneKey, + agent, + sessionId, + transcriptPath, + runtimeEnvironmentId, + enabled: isVisible + }) + const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) + const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) + const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null + // Launch context prefilled into the TUI input as an unsent draft; the + // composer adopts it so the GUI view shows the same context as the TUI. + const launchDraftSignal = useNativeChatLaunchDraftSignal({ + terminalTabId, + agent, + messages: session.messages, + // 'awaiting' counts too: adopting a prefill against a transcript that hasn't + // flushed would re-offer a prompt the user already submitted. + transcriptLoading: isNativeChatTranscriptUnsettled(session.readPhase) + }) + // The live-session merge reconciles hooks with replayable transcript turn + // boundaries; all working consumers must use that one lifecycle decision. + const liveWorking = session.status === 'working' + // The agent's in-progress reply preview (hook), shown as a live streaming + // bubble while it works — before the completed turn flushes to the transcript. + const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage) + // Why: Stop suppression must clear on a newer working epoch even when status + // never leaves 'working' (interrupt + immediate next turn coalesced). + const hookWorkingEpoch = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null + ) + const canSend = useNativeChatCanSend(targetPtyId) + // Reuse the verified composer send path for interactive cards and composer + // stop (Stop sends ESC, the agent-TUI interrupt key). + const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) + const [workingInterrupted, setWorkingInterrupted] = useState(false) + const previousWorkingEpochRef = useRef(null) + // True while a question card owns the input region, so the composer is hidden. + const [questionActive, setQuestionActive] = useState(false) + const rootRef = useRef(null) + const composerRef = useRef(null) + // The question card's free-text row; keeps Paste working while the card + // replaces the composer. + const questionAnswerInputRef = useRef(null) + const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) + const pasteClipboardIntoComposer = useNativeChatPasteBridge({ + rootRef, + composerRef, + questionAnswerInputRef + }) + const contextMenu = useNativeChatContextMenu({ + rootRef, + actions: { + onPaste: pasteClipboardIntoComposer, + ...(contextMenuActions ?? emptyNativeChatContextMenuActions) + } + }) + + // Optimistic "queued" sends (mobile parity): a composer send is echoed + // immediately and pruned once its real user turn lands in the transcript, so + // the message never vanishes between send and transcript catch-up. + const commandMarkerScope = useMemo( + () => ({ paneKey, agent, sessionId }), + [paneKey, agent, sessionId] + ) + const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) + const [pending, setPending] = useState(() => + readPendingSendCache(pendingScope) + ) + // Slash commands aren't chat turns, so they get a small local "Ran /clear" + // system line instead of a user bubble. Capped + cached per conversation. + const [commandMarkers, setCommandMarkers] = useState(() => + readCommandMarkerCache(commandMarkerScope) + ) + // Reset the optimistic queue only when the pane/agent changes. A fresh launch + // often learns its provider session id after the first send; clearing pending + // on that transition briefly flashes the empty state before the transcript + // user turn lands. + useEffect(() => { + setPending(readPendingSendCache(pendingScope)) + setWorkingInterrupted(false) + }, [pendingScope]) + // Command markers are session-scoped because slash commands like /clear are + // local feedback for a specific transcript boundary. + useEffect(() => { + setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) + setWorkingInterrupted(false) + }, [commandMarkerScope]) + // Prune echoes whose real user turn is now in the transcript. + useEffect(() => { + setPending((prev) => + writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) + ) + }, [session.messages, pendingScope]) + useEffect(() => { + if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { + return + } + clearNativeChatLaunchPrompt(terminalTabId) + }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) + const onOptimisticSend = useCallback( + (text: string, imagePaths?: string[]) => { + setWorkingInterrupted(false) + const sentAt = Date.now() + const boundary = session.messages.at(-1) + const entry: NativeChatPendingSend = { + id: nextNativeChatPendingSendId(sentAt), + text, + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, + ...(imagePaths ? { imagePaths } : {}) + } + setPending(appendPendingSendCache(pendingScope, entry)) + return entry.id + }, + [pendingScope, session.messages] + ) + const onOptimisticSendCanceled = useCallback( + (pendingId: string) => { + // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo + // must not come back from the pane cache as a prompt that was delivered. + const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) + setPending(writePendingSendCache(pendingScope, next)) + }, + [pendingScope] + ) + const onSlashCommand = useCallback( + (command: string) => { + setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) + }, + [commandMarkerScope] + ) + + const launchPromptMessage = useMemo( + () => launchPromptAsMessage(paneLaunchPrompt, session.messages), + [paneLaunchPrompt, session.messages] + ) + const sessionWithLaunchPrompt = useMemo(() => { + if (!launchPromptMessage) { + return session + } + return { ...session, messages: [...session.messages, launchPromptMessage] } + }, [launchPromptMessage, session]) + + const sessionAfterCommandBoundaries = useMemo(() => { + const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) + return messages === sessionWithLaunchPrompt.messages + ? sessionWithLaunchPrompt + : { ...sessionWithLaunchPrompt, messages } + }, [sessionWithLaunchPrompt, commandMarkers]) + const failedLaunchPromptMessageIds = useMemo(() => { + const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null + if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { + return undefined + } + return new Set([id]) + }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) + + // The streaming preview bubble (if any) sits after the transcript but before + // the optimistic user echoes — same order mobile uses. + const pendingMessages = useMemo( + () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), + [pending, sessionAfterCommandBoundaries.messages] + ) + const streamingText = useMemo(() => { + return deriveNativeChatStreamingText({ + messages: + pendingMessages.length > 0 + ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] + : sessionAfterCommandBoundaries.messages, + previewText: hookPreview, + working: liveWorking + }) + }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking]) + const sessionWithPending = useMemo(() => { + if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { + return sessionAfterCommandBoundaries + } + return { + ...sessionAfterCommandBoundaries, + messages: [ + ...sessionAfterCommandBoundaries.messages, + ...commandMarkersAsMessages(commandMarkers), + ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), + ...pendingMessages + ] + } + }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) + // Derive the view state from the pending-augmented session so a send into an + // otherwise-empty conversation flips to the list (showing the queued bubble) + // instead of staying on the empty state. + const viewState = selectNativeChatViewState(sessionWithPending) + + const isConversation = viewState.kind === 'ready' + useEffect(() => { + if ( + shouldClearNativeChatWorkingSuppression({ + working: liveWorking, + interrupted: workingInterrupted, + workingEpoch: hookWorkingEpoch, + previousWorkingEpoch: previousWorkingEpochRef.current + }) + ) { + setWorkingInterrupted(false) + } + if (liveWorking && hookWorkingEpoch != null) { + previousWorkingEpochRef.current = hookWorkingEpoch + } + if (!liveWorking) { + previousWorkingEpochRef.current = null + } + }, [liveWorking, workingInterrupted, hookWorkingEpoch]) + const isWorking = shouldShowNativeChatWorking({ + isConversation, + working: liveWorking, + interrupted: workingInterrupted + }) + + const stopAgent = useCallback(() => { + setWorkingInterrupted(true) + // Why: Stop after a submitted turn drops the delayed-write handle once it + // settles, so cancelPendingSends no longer sees the optimistic id. Clear + // the echo cache here so a cancelled prompt cannot stick as a ghost bubble. + setPending(writePendingSendCache(pendingScope, [])) + interactiveSend.cancel() + }, [interactiveSend, pendingScope]) + const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) + + // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so + // the chord is inert on the loading/empty/error states and elsewhere. + const fontScale = useNativeChatFontScale(isConversation) + + return ( +
{ + if (event.button === 2) { + contextMenu.onSelectionCapture() + event.preventDefault() + event.stopPropagation() + return + } + if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { + rootRef.current?.focus({ preventScroll: true }) + } + }} + onKeyDownCapture={(event) => { + // Backspace/Delete outside an input focuses the composer (like typing) + // but inserts nothing — let the now-focused field handle the keystroke. + if (shouldFocusNativeChatComposerFromEditingKey(event)) { + composerRef.current?.focus() + return + } + if (!shouldRedirectNativeChatTyping(event)) { + return + } + if (!composerRef.current?.insertTypedText(event.key)) { + return + } + event.preventDefault() + event.stopPropagation() + }} + onMouseUpCapture={contextMenu.onSelectionCapture} + onKeyUpCapture={contextMenu.onSelectionCapture} + onContextMenuCapture={contextMenu.onContextMenuCapture} + className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" + > + +
+ {viewState.kind === 'loading' ? ( + + ) : viewState.kind === 'error' ? ( + + ) : viewState.kind === 'empty' ? ( + + ) : ( + + )} +
+ {/* Live interactive prompt (question / approval) is the bottom input region + (mobile parity). A question card supplies its own answer input, so it + fully replaces the composer while active — no stray "Send a message". */} + + {/* canSend reflects the mobile presence-lock: when a mobile client holds + the pty, the composer shows its guarded state instead of racing the + mobile driver (R8). */} + {questionActive ? null : ( + + )} + {contextMenu.menu} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index dc19218f8ba..84c397b8d1d 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -1,60 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAppStore } from '../../store' -import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' -import { useNativeChatRetainedSession } from './use-native-chat-retained-session' -import { isNativeChatTranscriptUnsettled } from './use-native-chat-live-session' -import { selectNativeChatViewState } from './native-chat-view-state' -import { NativeChatMessageList } from './NativeChatMessageList' -import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' -import { useNativeChatFontScale } from './use-native-chat-font-scale' -import { useNativeChatCanSend } from './use-native-chat-can-send' -import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' -import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatSessionGate } from './NativeChatSessionGate' -import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { - shouldClearNativeChatWorkingSuppression, - shouldShowNativeChatWorking -} from './native-chat-working-suppression' -import { - appendPendingSendCache, - launchPromptAsMessage, - pendingSendsAsMessages, - nextNativeChatPendingSendId, - prunePendingSends, - readPendingSendCache, - shouldPruneLaunchPrompt, - writePendingSendCache, - type NativeChatPendingSend -} from './native-chat-pending' -import { - appendCommandMarkerCache, - applyCommandMarkerBoundaries, - commandMarkersAsMessages, - readCommandMarkerCache, - type NativeChatCommandMarker -} from './native-chat-command-marker' -import { - deriveNativeChatStreamingText, - nativeChatStreamingMessage -} from '../../../../shared/native-chat-streaming' -import { - shouldFocusNativeChatComposerFromEditingKey, - shouldFocusNativeChatPaneFromPointerTarget, - shouldRedirectNativeChatTyping -} from './native-chat-typing-redirect' -import { - emptyNativeChatContextMenuActions, - useNativeChatContextMenu -} from './use-native-chat-context-menu' -import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' -import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' -import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' -import type { NativeChatResolvedViewProps, NativeChatViewProps } from './native-chat-view-types' import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { NativeChatResolvedView } from './NativeChatResolvedView' import { useNativeChatStatusEntry } from './use-native-chat-status-entry' -import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' -import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' +import type { NativeChatViewProps } from './native-chat-view-types' export type { NativeChatViewProps } from './native-chat-view-types' @@ -110,352 +58,3 @@ function NativeChatBridgeView({ ) } - -function NativeChatResolvedView({ - paneKey, - agent, - sessionId, - transcriptPath, - isVisible, - targetPtyId, - terminalTabId, - ownsTabWideLaunchDraft, - onSwitchToTerminal, - readTerminalScreen, - contextMenuActions, - orchestrationDispatchStatus -}: NativeChatResolvedViewProps): React.JSX.Element { - // Primitive owner selection (no useShallow): routes the pane's read/subscribe to - // the remote runtime host for a runtime-owned pane; null keeps the local path. - const runtimeEnvironmentId = useAppStore((s) => - selectNativeChatRuntimeEnvironmentId(s, terminalTabId) - ) - const session = useNativeChatRetainedSession({ - paneKey, - agent, - sessionId, - transcriptPath, - runtimeEnvironmentId, - enabled: isVisible - }) - const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) - const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) - const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null - // Launch context prefilled into the TUI input as an unsent draft; the - // composer adopts it so the GUI view shows the same context as the TUI. - const launchDraftSignal = useNativeChatLaunchDraftSignal({ - terminalTabId, - agent, - messages: session.messages, - // 'awaiting' counts too: adopting a prefill against a transcript that hasn't - // flushed would re-offer a prompt the user already submitted. - transcriptLoading: isNativeChatTranscriptUnsettled(session.readPhase) - }) - // The live-session merge reconciles hooks with replayable transcript turn - // boundaries; all working consumers must use that one lifecycle decision. - const liveWorking = session.status === 'working' - // The agent's in-progress reply preview (hook), shown as a live streaming - // bubble while it works — before the completed turn flushes to the transcript. - const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage) - // Why: Stop suppression must clear on a newer working epoch even when status - // never leaves 'working' (interrupt + immediate next turn coalesced). - const hookWorkingEpoch = useAppStore( - (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null - ) - const canSend = useNativeChatCanSend(targetPtyId) - // Reuse the verified composer send path for interactive cards and composer - // stop (Stop sends ESC, the agent-TUI interrupt key). - const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) - const [workingInterrupted, setWorkingInterrupted] = useState(false) - const previousWorkingEpochRef = useRef(null) - // True while a question card owns the input region, so the composer is hidden. - const [questionActive, setQuestionActive] = useState(false) - const rootRef = useRef(null) - const composerRef = useRef(null) - // The question card's free-text row; keeps Paste working while the card - // replaces the composer. - const questionAnswerInputRef = useRef(null) - const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) - const pasteClipboardIntoComposer = useNativeChatPasteBridge({ - rootRef, - composerRef, - questionAnswerInputRef - }) - const contextMenu = useNativeChatContextMenu({ - rootRef, - actions: { - onPaste: pasteClipboardIntoComposer, - ...(contextMenuActions ?? emptyNativeChatContextMenuActions) - } - }) - - // Optimistic "queued" sends (mobile parity): a composer send is echoed - // immediately and pruned once its real user turn lands in the transcript, so - // the message never vanishes between send and transcript catch-up. - const commandMarkerScope = useMemo( - () => ({ paneKey, agent, sessionId }), - [paneKey, agent, sessionId] - ) - const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) - const [pending, setPending] = useState(() => - readPendingSendCache(pendingScope) - ) - // Slash commands aren't chat turns, so they get a small local "Ran /clear" - // system line instead of a user bubble. Capped + cached per conversation. - const [commandMarkers, setCommandMarkers] = useState(() => - readCommandMarkerCache(commandMarkerScope) - ) - // Reset the optimistic queue only when the pane/agent changes. A fresh launch - // often learns its provider session id after the first send; clearing pending - // on that transition briefly flashes the empty state before the transcript - // user turn lands. - useEffect(() => { - setPending(readPendingSendCache(pendingScope)) - setWorkingInterrupted(false) - }, [pendingScope]) - // Command markers are session-scoped because slash commands like /clear are - // local feedback for a specific transcript boundary. - useEffect(() => { - setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) - setWorkingInterrupted(false) - }, [commandMarkerScope]) - // Prune echoes whose real user turn is now in the transcript. - useEffect(() => { - setPending((prev) => - writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) - ) - }, [session.messages, pendingScope]) - useEffect(() => { - if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { - return - } - clearNativeChatLaunchPrompt(terminalTabId) - }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) - const onOptimisticSend = useCallback( - (text: string, imagePaths?: string[]) => { - setWorkingInterrupted(false) - const sentAt = Date.now() - const boundary = session.messages.at(-1) - const entry: NativeChatPendingSend = { - id: nextNativeChatPendingSendId(sentAt), - text, - sentAt, - afterMessageId: boundary?.id ?? null, - afterMessageTimestamp: boundary?.timestamp ?? null, - ...(imagePaths ? { imagePaths } : {}) - } - setPending(appendPendingSendCache(pendingScope, entry)) - return entry.id - }, - [pendingScope, session.messages] - ) - const onOptimisticSendCanceled = useCallback( - (pendingId: string) => { - // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo - // must not come back from the pane cache as a prompt that was delivered. - const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) - setPending(writePendingSendCache(pendingScope, next)) - }, - [pendingScope] - ) - const onSlashCommand = useCallback( - (command: string) => { - setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) - }, - [commandMarkerScope] - ) - - const launchPromptMessage = useMemo( - () => launchPromptAsMessage(paneLaunchPrompt, session.messages), - [paneLaunchPrompt, session.messages] - ) - const sessionWithLaunchPrompt = useMemo(() => { - if (!launchPromptMessage) { - return session - } - return { ...session, messages: [...session.messages, launchPromptMessage] } - }, [launchPromptMessage, session]) - - const sessionAfterCommandBoundaries = useMemo(() => { - const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers) - return messages === sessionWithLaunchPrompt.messages - ? sessionWithLaunchPrompt - : { ...sessionWithLaunchPrompt, messages } - }, [sessionWithLaunchPrompt, commandMarkers]) - const failedLaunchPromptMessageIds = useMemo(() => { - const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null - if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { - return undefined - } - return new Set([id]) - }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) - - // The streaming preview bubble (if any) sits after the transcript but before - // the optimistic user echoes — same order mobile uses. - const pendingMessages = useMemo( - () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), - [pending, sessionAfterCommandBoundaries.messages] - ) - const streamingText = useMemo(() => { - return deriveNativeChatStreamingText({ - messages: - pendingMessages.length > 0 - ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] - : sessionAfterCommandBoundaries.messages, - previewText: hookPreview, - working: liveWorking - }) - }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking]) - const sessionWithPending = useMemo(() => { - if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { - return sessionAfterCommandBoundaries - } - return { - ...sessionAfterCommandBoundaries, - messages: [ - ...sessionAfterCommandBoundaries.messages, - ...commandMarkersAsMessages(commandMarkers), - ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingMessages - ] - } - }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) - // Derive the view state from the pending-augmented session so a send into an - // otherwise-empty conversation flips to the list (showing the queued bubble) - // instead of staying on the empty state. - const viewState = selectNativeChatViewState(sessionWithPending) - - const isConversation = viewState.kind === 'ready' - useEffect(() => { - if ( - shouldClearNativeChatWorkingSuppression({ - working: liveWorking, - interrupted: workingInterrupted, - workingEpoch: hookWorkingEpoch, - previousWorkingEpoch: previousWorkingEpochRef.current - }) - ) { - setWorkingInterrupted(false) - } - if (liveWorking && hookWorkingEpoch != null) { - previousWorkingEpochRef.current = hookWorkingEpoch - } - if (!liveWorking) { - previousWorkingEpochRef.current = null - } - }, [liveWorking, workingInterrupted, hookWorkingEpoch]) - const isWorking = shouldShowNativeChatWorking({ - isConversation, - working: liveWorking, - interrupted: workingInterrupted - }) - - const stopAgent = useCallback(() => { - setWorkingInterrupted(true) - // Why: Stop after a submitted turn drops the delayed-write handle once it - // settles, so cancelPendingSends no longer sees the optimistic id. Clear - // the echo cache here so a cancelled prompt cannot stick as a ghost bubble. - setPending(writePendingSendCache(pendingScope, [])) - interactiveSend.cancel() - }, [interactiveSend, pendingScope]) - const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) - - // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so - // the chord is inert on the loading/empty/error states and elsewhere. - const fontScale = useNativeChatFontScale(isConversation) - - return ( -
{ - if (event.button === 2) { - contextMenu.onSelectionCapture() - event.preventDefault() - event.stopPropagation() - return - } - if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { - rootRef.current?.focus({ preventScroll: true }) - } - }} - onKeyDownCapture={(event) => { - // Backspace/Delete outside an input focuses the composer (like typing) - // but inserts nothing — let the now-focused field handle the keystroke. - if (shouldFocusNativeChatComposerFromEditingKey(event)) { - composerRef.current?.focus() - return - } - if (!shouldRedirectNativeChatTyping(event)) { - return - } - if (!composerRef.current?.insertTypedText(event.key)) { - return - } - event.preventDefault() - event.stopPropagation() - }} - onMouseUpCapture={contextMenu.onSelectionCapture} - onKeyUpCapture={contextMenu.onSelectionCapture} - onContextMenuCapture={contextMenu.onContextMenuCapture} - className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" - > - -
- {viewState.kind === 'loading' ? ( - - ) : viewState.kind === 'error' ? ( - - ) : viewState.kind === 'empty' ? ( - - ) : ( - - )} -
- {/* Live interactive prompt (question / approval) is the bottom input region - (mobile parity). A question card supplies its own answer input, so it - fully replaces the composer while active — no stray "Send a message". */} - - {/* canSend reflects the mobile presence-lock: when a mobile client holds - the pty, the composer shows its guarded state instead of racing the - mobile driver (R8). */} - {questionActive ? null : ( - - )} - {contextMenu.menu} -
- ) -} diff --git a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts index 4b18921f221..e47e169126c 100644 --- a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts @@ -21,7 +21,7 @@ describe('native chat Stop layering', () => { it('publishes working state from both structured and bridge chat roots', () => { for (const path of [ 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', - 'src/renderer/src/components/native-chat/NativeChatView.tsx' + 'src/renderer/src/components/native-chat/NativeChatResolvedView.tsx' ]) { expect(source(path)).toContain('data-native-chat-working=') } diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx index 5b78c067d46..90d9dd4a381 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx @@ -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() + + 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() + + 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', diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 20e76e8d2e8..efb99e4cc39 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -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) => { diff --git a/src/renderer/src/components/sidebar/complete-nested-folder-open.ts b/src/renderer/src/components/sidebar/complete-nested-folder-open.ts index f02fff1c428..d69f014e6c7 100644 --- a/src/renderer/src/components/sidebar/complete-nested-folder-open.ts +++ b/src/renderer/src/components/sidebar/complete-nested-folder-open.ts @@ -14,6 +14,8 @@ export async function completeNestedFolderOpen(args: { selectedCount: number getRuntimeKind: Parameters[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 { @@ -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 diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index 4ed1f739f1a..b099e4c6ce8 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -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() diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index d52109bf0d7..2a347d0abb1 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -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, diff --git a/src/renderer/src/store/repos/owner-routing.ts b/src/renderer/src/store/repos/owner-routing.ts index b611eacb41c..631083d4d04 100644 --- a/src/renderer/src/store/repos/owner-routing.ts +++ b/src/renderer/src/store/repos/owner-routing.ts @@ -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 | null | undefined { return options && 'runtimeEnvironmentId' in options diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index 32b7aca4c60..1bf6d6ad789 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -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 diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index cc89bb084be..2d845659ddc 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -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 setupProjectExistingFolder: ( args: ProjectHostSetupExistingFolderArgs @@ -173,7 +177,7 @@ export type RepoSlice = { args: ProjectHostSetupDeleteArgs ) => Promise setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise - addNonGitFolder: (path: string, options?: AddRepoPathRouteOptions) => Promise + addNonGitFolder: (path: string, options?: AddRepoPathOptions) => Promise scanNestedRepos: ( path: string, connectionId?: string, diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index cf3974ac04d..beea1ecf193 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -17,11 +17,11 @@ export function createReposApi(): NonNullable['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) },