mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: show repository controls in empty chat tabs
Empty active session tabs were still using the old directory-only picker, so sessions created from the sidebar lost the branch and worktree controls even when their workDir was a Git repository. Reuse the launch controls below the existing composer and replace only the empty session when the user changes repository launch options. Constraint: Sidebar-created sessions land in ActiveSession, not the EmptySession landing view Rejected: Reshape the whole session layout | the intended surface is only the controls below the composer Confidence: high Scope-risk: narrow Directive: Keep repository launch controls shared between EmptySession and empty ActiveSession composer flows Tested: bun run test -- --run src/__tests__/pages.test.tsx src/components/chat/ChatInput.test.tsx Tested: bun run check:desktop Tested: agent-browser sidebar new-session smoke at http://127.0.0.1:4790/ with screenshot /tmp/cc-haha-after-sidebar-new-session-controls.png Not-tested: root bun run verify remains blocked by pre-existing root coverage gate outside desktop
This commit is contained in:
parent
0f220f4af5
commit
0e705473ff
@ -4,6 +4,8 @@ import '@testing-library/jest-dom'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getGitInfo: vi.fn(),
|
||||
getRepositoryContext: vi.fn(),
|
||||
getRecentProjects: vi.fn(),
|
||||
search: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
wsSend: vi.fn(),
|
||||
@ -12,6 +14,8 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
getGitInfo: mocks.getGitInfo,
|
||||
getRepositoryContext: mocks.getRepositoryContext,
|
||||
getRecentProjects: mocks.getRecentProjects,
|
||||
},
|
||||
}))
|
||||
|
||||
@ -47,6 +51,40 @@ import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
|
||||
function okRepositoryContext() {
|
||||
return {
|
||||
state: 'ok' as const,
|
||||
workDir: '/repo',
|
||||
repoRoot: '/repo',
|
||||
repoName: 'repo',
|
||||
currentBranch: 'main',
|
||||
defaultBranch: 'main',
|
||||
dirty: false,
|
||||
branches: [
|
||||
{
|
||||
name: 'main',
|
||||
current: true,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: true,
|
||||
worktreePath: '/repo',
|
||||
},
|
||||
{
|
||||
name: 'feature/a',
|
||||
current: false,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: false,
|
||||
},
|
||||
],
|
||||
worktrees: [{
|
||||
path: '/repo',
|
||||
branch: 'main',
|
||||
current: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('ChatInput file mentions', () => {
|
||||
const sessionId = 'session-file-mention'
|
||||
const initialChatState = useChatStore.getInitialState()
|
||||
@ -102,6 +140,52 @@ describe('ChatInput file mentions', () => {
|
||||
},
|
||||
})
|
||||
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
||||
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||
mocks.getRecentProjects.mockResolvedValue({ projects: [] })
|
||||
})
|
||||
|
||||
it('shows branch and worktree launch controls for an empty active Git session', async () => {
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Project',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-01T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/repo',
|
||||
workDir: '/repo',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<ChatInput variant="hero" />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Select branch: main/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Current worktree')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Select a project...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
|
||||
|
||||
@ -18,7 +18,7 @@ import { ModelSelector } from '../controls/ModelSelector'
|
||||
import type { AttachmentRef } from '../../types/chat'
|
||||
import { AttachmentGallery } from './AttachmentGallery'
|
||||
import { ProjectContextChip } from '../shared/ProjectContextChip'
|
||||
import { DirectoryPicker } from '../shared/DirectoryPicker'
|
||||
import { RepositoryLaunchControls } from '../shared/RepositoryLaunchControls'
|
||||
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
|
||||
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
|
||||
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
||||
@ -78,6 +78,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const [atCursorPos, setAtCursorPos] = useState(-1)
|
||||
const [slashFilter, setSlashFilter] = useState('')
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const [launchWorkDir, setLaunchWorkDir] = useState('')
|
||||
const [launchBranch, setLaunchBranch] = useState<string | null>(null)
|
||||
const [launchUseWorktree, setLaunchUseWorktree] = useState(false)
|
||||
const [launchReady, setLaunchReady] = useState(true)
|
||||
const [launchTransitioning, setLaunchTransitioning] = useState(false)
|
||||
const composingRef = useRef(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@ -115,9 +120,17 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const isActive = chatState !== 'idle'
|
||||
const isWorkspaceMissing = activeSession?.workDirExists === false
|
||||
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
|
||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
|
||||
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
|
||||
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
||||
const showLaunchControls = !isMemberSession && !hasMessages
|
||||
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
|
||||
const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/')
|
||||
? resolveSlashUiAction(input.trim().slice(1))
|
||||
: null
|
||||
const canSubmit = !isWorkspaceMissing &&
|
||||
!launchTransitioning &&
|
||||
(!showLaunchControls || launchReady || !!pendingSlashUiAction) &&
|
||||
(input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
|
||||
const composerAttachments = useMemo(
|
||||
() => [
|
||||
...attachments,
|
||||
@ -181,6 +194,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
setFileSearchOpen(false)
|
||||
}, [isMemberSession, activeTabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLaunchControls) return
|
||||
const nextWorkDir = activeSession?.workDir || gitInfo?.workDir || ''
|
||||
setLaunchWorkDir((current) => {
|
||||
if (current === nextWorkDir) return current
|
||||
setLaunchBranch(null)
|
||||
setLaunchUseWorktree(false)
|
||||
setLaunchReady(!nextWorkDir)
|
||||
return nextWorkDir
|
||||
})
|
||||
}, [activeSession?.workDir, activeTabId, gitInfo?.workDir, showLaunchControls])
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
@ -351,13 +376,53 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
})
|
||||
}, [input])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const replaceEmptySession = useCallback(async (
|
||||
workDir: string,
|
||||
repository?: { branch?: string | null; worktree?: boolean },
|
||||
) => {
|
||||
if (!activeTabId) return null
|
||||
const oldId = activeTabId
|
||||
const { createSession, deleteSession } = useSessionStore.getState()
|
||||
const { replaceTabSession } = useTabStore.getState()
|
||||
const { disconnectSession, connectToSession } = useChatStore.getState()
|
||||
const newId = await createSession(
|
||||
workDir || undefined,
|
||||
repository ? { repository } : undefined,
|
||||
)
|
||||
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
|
||||
disconnectSession(oldId)
|
||||
replaceTabSession(oldId, newId)
|
||||
connectToSession(newId)
|
||||
deleteSession(oldId).catch(() => {})
|
||||
return newId
|
||||
}, [activeTabId])
|
||||
|
||||
const handleLaunchWorkDirChange = useCallback(async (newWorkDir: string) => {
|
||||
setLaunchWorkDir(newWorkDir)
|
||||
setLaunchBranch(null)
|
||||
setLaunchUseWorktree(false)
|
||||
setLaunchReady(!newWorkDir)
|
||||
if (!activeTabId) return
|
||||
|
||||
setLaunchTransitioning(true)
|
||||
try {
|
||||
await replaceEmptySession(newWorkDir)
|
||||
} catch (error) {
|
||||
useUIStore.getState().addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
})
|
||||
} finally {
|
||||
setLaunchTransitioning(false)
|
||||
}
|
||||
}, [activeTabId, replaceEmptySession, t])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim()
|
||||
if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return
|
||||
|
||||
const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
||||
if (slashUiAction?.type === 'panel') {
|
||||
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
|
||||
if (pendingSlashUiAction?.type === 'panel') {
|
||||
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
|
||||
setInput('')
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(false)
|
||||
@ -365,8 +430,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
return
|
||||
}
|
||||
|
||||
if (slashUiAction?.type === 'settings') {
|
||||
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
|
||||
if (pendingSlashUiAction?.type === 'settings') {
|
||||
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
|
||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||
setInput('')
|
||||
setSlashMenuOpen(false)
|
||||
@ -375,6 +440,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
return
|
||||
}
|
||||
|
||||
if (showLaunchControls && (!launchReady || launchTransitioning)) return
|
||||
|
||||
const workspaceReferencePrompt = !isMemberSession
|
||||
? formatWorkspaceReferencePrompt(workspaceReferences)
|
||||
: ''
|
||||
@ -417,13 +484,42 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
})),
|
||||
]
|
||||
|
||||
sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
|
||||
let targetSessionId = activeTabId!
|
||||
if (showLaunchControls && activeLaunchWorkDir && launchBranch) {
|
||||
const shouldReplaceForRepositoryLaunch =
|
||||
launchUseWorktree ||
|
||||
(gitInfo?.branch ? launchBranch !== gitInfo.branch : true)
|
||||
if (shouldReplaceForRepositoryLaunch) {
|
||||
setLaunchTransitioning(true)
|
||||
try {
|
||||
const newSessionId = await replaceEmptySession(activeLaunchWorkDir, {
|
||||
branch: launchBranch,
|
||||
worktree: launchUseWorktree,
|
||||
})
|
||||
if (!newSessionId) return
|
||||
targetSessionId = newSessionId
|
||||
} catch (error) {
|
||||
useUIStore.getState().addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
})
|
||||
return
|
||||
} finally {
|
||||
setLaunchTransitioning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(targetSessionId, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
|
||||
displayContent,
|
||||
displayAttachments: visibleAttachmentPayload,
|
||||
})
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
if (!isMemberSession) clearWorkspaceReferences(activeTabId!)
|
||||
if (!isMemberSession) {
|
||||
clearWorkspaceReferences(activeTabId!)
|
||||
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)
|
||||
}
|
||||
setPlusMenuOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(false)
|
||||
@ -620,7 +716,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
<div
|
||||
className={
|
||||
isHeroComposer
|
||||
? 'mx-auto flex w-full max-w-3xl flex-col gap-2'
|
||||
? 'mx-auto flex w-full max-w-3xl flex-col'
|
||||
: compact
|
||||
? 'mx-auto max-w-full'
|
||||
: 'mx-auto max-w-[860px]'
|
||||
@ -628,7 +724,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
>
|
||||
<div
|
||||
className={isHeroComposer
|
||||
? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
|
||||
? 'glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4 transition-colors'
|
||||
: compact
|
||||
? 'glass-panel relative rounded-xl p-3 transition-colors'
|
||||
: 'glass-panel relative rounded-xl p-4 transition-colors'}
|
||||
@ -638,7 +734,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
{!isMemberSession && fileSearchOpen && (
|
||||
<FileSearchMenu
|
||||
ref={fileSearchRef}
|
||||
cwd={resolvedWorkDir || ''}
|
||||
cwd={activeLaunchWorkDir || resolvedWorkDir || ''}
|
||||
filter={atFilter}
|
||||
onNavigate={(relativePath) => {
|
||||
if (atCursorPos < 0) return
|
||||
@ -688,7 +784,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
<LocalSlashCommandPanel
|
||||
command={localSlashPanel}
|
||||
sessionId={activeTabId ?? undefined}
|
||||
cwd={resolvedWorkDir}
|
||||
cwd={activeLaunchWorkDir || resolvedWorkDir}
|
||||
commands={allSlashCommands}
|
||||
onClose={() => setLocalSlashPanel(null)}
|
||||
/>
|
||||
@ -877,21 +973,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
compact={compact}
|
||||
/>
|
||||
) : (
|
||||
<DirectoryPicker
|
||||
value={resolvedWorkDir || ''}
|
||||
onChange={async (newWorkDir) => {
|
||||
if (!activeTabId) return
|
||||
const oldId = activeTabId
|
||||
const { deleteSession, createSession } = useSessionStore.getState()
|
||||
const { replaceTabSession } = useTabStore.getState()
|
||||
const { disconnectSession, connectToSession } = useChatStore.getState()
|
||||
const newId = await createSession(newWorkDir)
|
||||
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
|
||||
disconnectSession(oldId)
|
||||
replaceTabSession(oldId, newId)
|
||||
connectToSession(newId)
|
||||
deleteSession(oldId).catch(() => {})
|
||||
}}
|
||||
<RepositoryLaunchControls
|
||||
workDir={activeLaunchWorkDir}
|
||||
onWorkDirChange={handleLaunchWorkDirChange}
|
||||
branch={launchBranch}
|
||||
onBranchChange={setLaunchBranch}
|
||||
useWorktree={launchUseWorktree}
|
||||
onUseWorktreeChange={setLaunchUseWorktree}
|
||||
onLaunchReadyChange={setLaunchReady}
|
||||
disabled={isActive || launchTransitioning}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user