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:
程序员阿江(Relakkes) 2026-05-07 18:08:38 +08:00
parent 0f220f4af5
commit 0e705473ff
2 changed files with 203 additions and 29 deletions

View File

@ -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 () => {

View File

@ -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>