mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Merge repository launch context controls into main
The detached worktree contains the session composer repository controls work: preserving launch context across empty chat tabs, exposing branch and worktree selection under the composer, tightening long-label truncation, and validating the launch paths with desktop tests. Main had moved forward independently, so this records the integration as a merge commit instead of rewriting either side. Constraint: Merge into local main only; do not push or alter remote state. Rejected: Rebase the detached worktree onto main | it would rewrite the already reviewed local commit chain and was unnecessary after a clean merge. Confidence: high Scope-risk: moderate Directive: Keep repository context controls under the composer as a single-line rail; long repo or branch labels must truncate inside their own controls. Tested: bun run check:desktop Not-tested: Native Tauri packaged window visual check
This commit is contained in:
commit
f01ba4eda6
@ -3,7 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
getMessages: vi.fn(),
|
||||||
getGitInfo: vi.fn(),
|
getGitInfo: vi.fn(),
|
||||||
|
getSlashCommands: vi.fn(),
|
||||||
|
getRepositoryContext: vi.fn(),
|
||||||
|
getRecentProjects: vi.fn(),
|
||||||
search: vi.fn(),
|
search: vi.fn(),
|
||||||
browse: vi.fn(),
|
browse: vi.fn(),
|
||||||
wsSend: vi.fn(),
|
wsSend: vi.fn(),
|
||||||
@ -11,7 +18,14 @@ const mocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('../../api/sessions', () => ({
|
vi.mock('../../api/sessions', () => ({
|
||||||
sessionsApi: {
|
sessionsApi: {
|
||||||
|
create: mocks.create,
|
||||||
|
delete: mocks.delete,
|
||||||
|
list: mocks.list,
|
||||||
|
getMessages: mocks.getMessages,
|
||||||
getGitInfo: mocks.getGitInfo,
|
getGitInfo: mocks.getGitInfo,
|
||||||
|
getSlashCommands: mocks.getSlashCommands,
|
||||||
|
getRepositoryContext: mocks.getRepositoryContext,
|
||||||
|
getRecentProjects: mocks.getRecentProjects,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -47,6 +61,40 @@ import { useSettingsStore } from '../../stores/settingsStore'
|
|||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
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', () => {
|
describe('ChatInput file mentions', () => {
|
||||||
const sessionId = 'session-file-mention'
|
const sessionId = 'session-file-mention'
|
||||||
const initialChatState = useChatStore.getInitialState()
|
const initialChatState = useChatStore.getInitialState()
|
||||||
@ -102,6 +150,231 @@ describe('ChatInput file mentions', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
||||||
|
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||||
|
mocks.getRecentProjects.mockResolvedValue({ projects: [] })
|
||||||
|
mocks.create.mockResolvedValue({ sessionId: 'created-session', workDir: '/repo' })
|
||||||
|
mocks.delete.mockResolvedValue({ ok: true })
|
||||||
|
mocks.list.mockResolvedValue({ sessions: [], total: 0 })
|
||||||
|
mocks.getMessages.mockResolvedValue({ messages: [] })
|
||||||
|
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
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('uses the persisted message count to keep reopened sessions in context mode while history loads', async () => {
|
||||||
|
useSessionStore.setState({
|
||||||
|
sessions: [{
|
||||||
|
id: sessionId,
|
||||||
|
title: 'Project',
|
||||||
|
createdAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
messageCount: 2,
|
||||||
|
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.findByText('repo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('main')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /Select branch:/ })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Current worktree')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts an empty active session on the selected branch without an isolated worktree', async () => {
|
||||||
|
mocks.create.mockResolvedValueOnce({ sessionId: 'created-direct', workDir: '/repo' })
|
||||||
|
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" />)
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ }))
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
|
||||||
|
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||||
|
fireEvent.change(input, { target: { value: 'run on feature branch', selectionStart: 21 } })
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.create).toHaveBeenCalledWith({
|
||||||
|
workDir: '/repo',
|
||||||
|
repository: { branch: 'feature/a', worktree: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
|
||||||
|
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
|
||||||
|
type: 'user_message',
|
||||||
|
content: 'run on feature branch',
|
||||||
|
attachments: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts an empty active session on the selected branch inside an isolated worktree', async () => {
|
||||||
|
mocks.create.mockResolvedValueOnce({
|
||||||
|
sessionId: 'created-worktree',
|
||||||
|
workDir: '/repo/.claude/worktrees/desktop-feature-a-12345678',
|
||||||
|
})
|
||||||
|
mocks.list.mockImplementationOnce(() => new Promise(() => {}))
|
||||||
|
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" />)
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ }))
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Current worktree/ }))
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: 'Isolated worktree' }))
|
||||||
|
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
|
||||||
|
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||||
|
fireEvent.change(input, { target: { value: 'run in a worktree', selectionStart: 17 } })
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.create).toHaveBeenCalledWith({
|
||||||
|
workDir: '/repo',
|
||||||
|
repository: { branch: 'feature/a', worktree: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
|
||||||
|
expect(mocks.wsSend).toHaveBeenCalledWith('created-worktree', {
|
||||||
|
type: 'user_message',
|
||||||
|
content: 'run in a worktree',
|
||||||
|
attachments: [],
|
||||||
|
})
|
||||||
|
expect(useSessionStore.getState().sessions[0]?.workDir)
|
||||||
|
.toBe('/repo/.claude/worktrees/desktop-feature-a-12345678')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
|
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 type { AttachmentRef } from '../../types/chat'
|
||||||
import { AttachmentGallery } from './AttachmentGallery'
|
import { AttachmentGallery } from './AttachmentGallery'
|
||||||
import { ProjectContextChip } from '../shared/ProjectContextChip'
|
import { ProjectContextChip } from '../shared/ProjectContextChip'
|
||||||
import { DirectoryPicker } from '../shared/DirectoryPicker'
|
import { RepositoryLaunchControls } from '../shared/RepositoryLaunchControls'
|
||||||
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
|
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
|
||||||
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
|
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
|
||||||
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
||||||
@ -78,6 +78,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const [atCursorPos, setAtCursorPos] = useState(-1)
|
const [atCursorPos, setAtCursorPos] = useState(-1)
|
||||||
const [slashFilter, setSlashFilter] = useState('')
|
const [slashFilter, setSlashFilter] = useState('')
|
||||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
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 composingRef = useRef(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@ -91,7 +96,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const chatState = sessionState?.chatState ?? 'idle'
|
const chatState = sessionState?.chatState ?? 'idle'
|
||||||
const slashCommands = sessionState?.slashCommands ?? []
|
const slashCommands = sessionState?.slashCommands ?? []
|
||||||
const composerPrefill = sessionState?.composerPrefill ?? null
|
const composerPrefill = sessionState?.composerPrefill ?? null
|
||||||
const messageCount = sessionState?.messages?.length ?? 0
|
|
||||||
const runtimeSelection = useSessionRuntimeStore((state) =>
|
const runtimeSelection = useSessionRuntimeStore((state) =>
|
||||||
activeTabId ? state.selections[activeTabId] : undefined,
|
activeTabId ? state.selections[activeTabId] : undefined,
|
||||||
)
|
)
|
||||||
@ -101,9 +105,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
: undefined
|
: undefined
|
||||||
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
||||||
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
||||||
|
const loadedMessageCount = sessionState?.messages?.length ?? 0
|
||||||
|
const messageCount = Math.max(loadedMessageCount, activeSession?.messageCount ?? 0)
|
||||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
||||||
const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
|
|
||||||
const workspaceReferences = useWorkspaceChatContextStore(
|
const workspaceReferences = useWorkspaceChatContextStore(
|
||||||
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
|
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
|
||||||
)
|
)
|
||||||
@ -115,9 +120,17 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
const isActive = chatState !== 'idle'
|
const isActive = chatState !== 'idle'
|
||||||
const isWorkspaceMissing = activeSession?.workDirExists === false
|
const isWorkspaceMissing = activeSession?.workDirExists === false
|
||||||
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
|
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 isHeroComposer = variant === 'hero' && !isMemberSession && !compact
|
||||||
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
||||||
|
const showLaunchControls = !isMemberSession && messageCount === 0
|
||||||
|
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(
|
const composerAttachments = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...attachments,
|
...attachments,
|
||||||
@ -181,6 +194,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
}, [isMemberSession, activeTabId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@ -351,13 +376,53 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
})
|
})
|
||||||
}, [input])
|
}, [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()
|
const text = input.trim()
|
||||||
if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return
|
if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return
|
||||||
|
|
||||||
const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
if (pendingSlashUiAction?.type === 'panel') {
|
||||||
if (slashUiAction?.type === 'panel') {
|
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
|
||||||
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
|
|
||||||
setInput('')
|
setInput('')
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
@ -365,8 +430,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slashUiAction?.type === 'settings') {
|
if (pendingSlashUiAction?.type === 'settings') {
|
||||||
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
|
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
|
||||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
||||||
setInput('')
|
setInput('')
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
@ -375,6 +440,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showLaunchControls && (!launchReady || launchTransitioning)) return
|
||||||
|
|
||||||
const workspaceReferencePrompt = !isMemberSession
|
const workspaceReferencePrompt = !isMemberSession
|
||||||
? formatWorkspaceReferencePrompt(workspaceReferences)
|
? 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,
|
displayContent,
|
||||||
displayAttachments: visibleAttachmentPayload,
|
displayAttachments: visibleAttachmentPayload,
|
||||||
})
|
})
|
||||||
setInput('')
|
setInput('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
if (!isMemberSession) clearWorkspaceReferences(activeTabId!)
|
if (!isMemberSession) {
|
||||||
|
clearWorkspaceReferences(activeTabId!)
|
||||||
|
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)
|
||||||
|
}
|
||||||
setPlusMenuOpen(false)
|
setPlusMenuOpen(false)
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
@ -620,7 +716,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isHeroComposer
|
isHeroComposer
|
||||||
? 'mx-auto flex w-full max-w-3xl flex-col gap-2'
|
? 'mx-auto flex w-full max-w-3xl flex-col'
|
||||||
: compact
|
: compact
|
||||||
? 'mx-auto max-w-full'
|
? 'mx-auto max-w-full'
|
||||||
: 'mx-auto max-w-[860px]'
|
: 'mx-auto max-w-[860px]'
|
||||||
@ -628,7 +724,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={isHeroComposer
|
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
|
: compact
|
||||||
? 'glass-panel relative rounded-xl p-3 transition-colors'
|
? 'glass-panel relative rounded-xl p-3 transition-colors'
|
||||||
: 'glass-panel relative rounded-xl p-4 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 && (
|
{!isMemberSession && fileSearchOpen && (
|
||||||
<FileSearchMenu
|
<FileSearchMenu
|
||||||
ref={fileSearchRef}
|
ref={fileSearchRef}
|
||||||
cwd={resolvedWorkDir || ''}
|
cwd={activeLaunchWorkDir || resolvedWorkDir || ''}
|
||||||
filter={atFilter}
|
filter={atFilter}
|
||||||
onNavigate={(relativePath) => {
|
onNavigate={(relativePath) => {
|
||||||
if (atCursorPos < 0) return
|
if (atCursorPos < 0) return
|
||||||
@ -688,7 +784,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
<LocalSlashCommandPanel
|
<LocalSlashCommandPanel
|
||||||
command={localSlashPanel}
|
command={localSlashPanel}
|
||||||
sessionId={activeTabId ?? undefined}
|
sessionId={activeTabId ?? undefined}
|
||||||
cwd={resolvedWorkDir}
|
cwd={activeLaunchWorkDir || resolvedWorkDir}
|
||||||
commands={allSlashCommands}
|
commands={allSlashCommands}
|
||||||
onClose={() => setLocalSlashPanel(null)}
|
onClose={() => setLocalSlashPanel(null)}
|
||||||
/>
|
/>
|
||||||
@ -869,7 +965,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
|
|
||||||
{!isMemberSession && (
|
{!isMemberSession && (
|
||||||
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
|
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
|
||||||
{hasMessages ? (
|
{messageCount > 0 ? (
|
||||||
<ProjectContextChip
|
<ProjectContextChip
|
||||||
workDir={resolvedWorkDir}
|
workDir={resolvedWorkDir}
|
||||||
repoName={gitInfo?.repoName || null}
|
repoName={gitInfo?.repoName || null}
|
||||||
@ -877,21 +973,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DirectoryPicker
|
<RepositoryLaunchControls
|
||||||
value={resolvedWorkDir || ''}
|
workDir={activeLaunchWorkDir}
|
||||||
onChange={async (newWorkDir) => {
|
onWorkDirChange={handleLaunchWorkDirChange}
|
||||||
if (!activeTabId) return
|
branch={launchBranch}
|
||||||
const oldId = activeTabId
|
onBranchChange={setLaunchBranch}
|
||||||
const { deleteSession, createSession } = useSessionStore.getState()
|
useWorktree={launchUseWorktree}
|
||||||
const { replaceTabSession } = useTabStore.getState()
|
onUseWorktreeChange={setLaunchUseWorktree}
|
||||||
const { disconnectSession, connectToSession } = useChatStore.getState()
|
onLaunchReadyChange={setLaunchReady}
|
||||||
const newId = await createSession(newWorkDir)
|
disabled={isActive || launchTransitioning}
|
||||||
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
|
|
||||||
disconnectSession(oldId)
|
|
||||||
replaceTabSession(oldId, newId)
|
|
||||||
connectToSession(newId)
|
|
||||||
deleteSession(oldId).catch(() => {})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({
|
|||||||
|
|
||||||
import { DirectoryPicker } from './DirectoryPicker'
|
import { DirectoryPicker } from './DirectoryPicker'
|
||||||
import { sessionsApi } from '../../api/sessions'
|
import { sessionsApi } from '../../api/sessions'
|
||||||
|
import { filesystemApi } from '../../api/filesystem'
|
||||||
|
|
||||||
describe('DirectoryPicker', () => {
|
describe('DirectoryPicker', () => {
|
||||||
it('uses the source repository name as the fallback label for desktop worktree paths', () => {
|
it('uses the source repository name as the fallback label for desktop worktree paths', () => {
|
||||||
@ -57,4 +58,74 @@ describe('DirectoryPicker', () => {
|
|||||||
expect(trigger).toHaveTextContent('NanmiCoder/OpenCutSkill')
|
expect(trigger).toHaveTextContent('NanmiCoder/OpenCutSkill')
|
||||||
expect(trigger).not.toHaveTextContent('main')
|
expect(trigger).not.toHaveTextContent('main')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports the flat workbar trigger variant without changing the selected label', () => {
|
||||||
|
render(
|
||||||
|
<DirectoryPicker
|
||||||
|
value="/workspace/project"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
variant="workbar"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = screen.getByRole('button')
|
||||||
|
expect(trigger).toHaveTextContent('project')
|
||||||
|
expect(trigger.className).toContain('rounded-[7px]')
|
||||||
|
expect(trigger.className).not.toContain('rounded-full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('constrains long workbar project names without hiding the full path from hover users', () => {
|
||||||
|
const longProjectName = 'project-with-a-very-long-directory-name-that-should-not-stretch-the-launch-bar'
|
||||||
|
const longPath = `/workspace/${longProjectName}`
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DirectoryPicker
|
||||||
|
value={longPath}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
variant="workbar"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = screen.getByRole('button')
|
||||||
|
const label = screen.getByText(longProjectName)
|
||||||
|
const triggerClasses = trigger.className.split(/\s+/)
|
||||||
|
expect(trigger).toHaveAttribute('title', longPath)
|
||||||
|
expect(triggerClasses).toContain('max-w-full')
|
||||||
|
expect(triggerClasses).not.toContain('w-full')
|
||||||
|
expect(trigger.parentElement?.className).toContain('max-w-[320px]')
|
||||||
|
expect(label.className).toContain('truncate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can show a Git icon for workbar projects before the recent-project cache is loaded', () => {
|
||||||
|
render(
|
||||||
|
<DirectoryPicker
|
||||||
|
value="/workspace/project"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
variant="workbar"
|
||||||
|
isGitProject
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders browse entries without nesting interactive buttons', async () => {
|
||||||
|
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] })
|
||||||
|
vi.mocked(filesystemApi.browse).mockResolvedValue({
|
||||||
|
currentPath: '/workspace',
|
||||||
|
parentPath: '/Users/nanmi',
|
||||||
|
entries: [{ name: 'project', path: '/workspace/project', isDirectory: true }],
|
||||||
|
})
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
render(<DirectoryPicker value="" onChange={vi.fn()} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /选择项目|Select a project/ }))
|
||||||
|
fireEvent.click(await screen.findByText(/选择其他文件夹|Choose a different folder/))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /project/ })).toBeInTheDocument()
|
||||||
|
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('validateDOMNesting'))
|
||||||
|
|
||||||
|
errorSpy.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { useTranslation } from '../../i18n'
|
|||||||
type Props = {
|
type Props = {
|
||||||
value: string
|
value: string
|
||||||
onChange: (path: string) => void
|
onChange: (path: string) => void
|
||||||
|
variant?: 'chip' | 'workbar'
|
||||||
|
isGitProject?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type DirEntry = { name: string; path: string; isDirectory: boolean }
|
type DirEntry = { name: string; path: string; isDirectory: boolean }
|
||||||
@ -28,7 +30,7 @@ function projectNameFromPath(filePath: string) {
|
|||||||
return displayRoot.split('/').filter(Boolean).pop() || filePath
|
return displayRoot.split('/').filter(Boolean).pop() || filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectoryPicker({ value, onChange }: Props) {
|
export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProject = false }: Props) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [mode, setMode] = useState<'recent' | 'browse'>('recent')
|
const [mode, setMode] = useState<'recent' | 'browse'>('recent')
|
||||||
@ -144,36 +146,47 @@ export function DirectoryPicker({ value, onChange }: Props) {
|
|||||||
|
|
||||||
// Find selected project info
|
// Find selected project info
|
||||||
const selectedProject = projects.find((p) => p.realPath === value)
|
const selectedProject = projects.find((p) => p.realPath === value)
|
||||||
|
const isWorkbar = variant === 'workbar'
|
||||||
|
const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
|
||||||
|
const showGitIcon = selectedProject?.isGit || isGitProject
|
||||||
|
const triggerClassName = isWorkbar
|
||||||
|
? 'inline-flex h-9 max-w-full min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
|
||||||
|
: 'flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]'
|
||||||
|
const emptyTriggerClassName = isWorkbar
|
||||||
|
? 'flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
|
||||||
|
: 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className={isWorkbar ? 'relative min-w-0 max-w-[320px] shrink' : 'relative'}>
|
||||||
{/* Trigger — shows selected project chip or placeholder */}
|
{/* Trigger — shows selected project chip or placeholder */}
|
||||||
{value ? (
|
{value ? (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
|
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]"
|
className={triggerClassName}
|
||||||
|
title={value}
|
||||||
>
|
>
|
||||||
{selectedProject?.isGit ? (
|
{showGitIcon ? (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="text-[var(--color-text-secondary)]">
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-secondary)]">folder</span>
|
<span className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'} text-[var(--color-text-secondary)]`}>folder</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-[var(--color-text-primary)]">
|
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
|
||||||
{selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)}
|
{selectedLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">expand_more</span>
|
<span className={`${isWorkbar ? 'text-[15px]' : 'text-[12px]'} material-symbols-outlined shrink-0 text-[var(--color-text-tertiary)]`}>expand_more</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
|
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
|
||||||
className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
className={emptyTriggerClassName}
|
||||||
|
title={t('dirPicker.selectProject')}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px]">folder_open</span>
|
<span className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'}`}>folder_open</span>
|
||||||
{t('dirPicker.selectProject')}
|
<span className="min-w-0 truncate">{t('dirPicker.selectProject')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -283,16 +296,22 @@ export function DirectoryPicker({ value, onChange }: Props) {
|
|||||||
{browseEntries.length === 0 ? (
|
{browseEntries.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noSubdirs')}</div>
|
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noSubdirs')}</div>
|
||||||
) : browseEntries.map((entry) => (
|
) : browseEntries.map((entry) => (
|
||||||
<button
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]"
|
className="flex w-full items-center gap-2 px-3 py-2 hover:bg-[var(--color-surface-hover)]"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]" onClick={() => loadBrowseDir(entry.path)}>folder</span>
|
<button
|
||||||
<span className="text-xs text-[var(--color-text-primary)] flex-1" onClick={() => loadBrowseDir(entry.path)}>{entry.name}</span>
|
type="button"
|
||||||
<button onClick={() => handleSelect(entry.path)} className="px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] hover:bg-[var(--color-primary-fixed)] rounded transition-colors">
|
onClick={() => loadBrowseDir(entry.path)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">folder</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-primary)]">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleSelect(entry.path)} className="rounded px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] transition-colors hover:bg-[var(--color-primary-fixed)]">
|
||||||
{t('common.select')}
|
{t('common.select')}
|
||||||
</button>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ type Props = {
|
|||||||
|
|
||||||
const BRANCH_MENU_HEIGHT = 360
|
const BRANCH_MENU_HEIGHT = 360
|
||||||
const BRANCH_MENU_WIDTH = 390
|
const BRANCH_MENU_WIDTH = 390
|
||||||
|
const WORKTREE_MENU_HEIGHT = 126
|
||||||
|
const WORKTREE_MENU_WIDTH = 226
|
||||||
const VIEWPORT_GUTTER = 12
|
const VIEWPORT_GUTTER = 12
|
||||||
|
|
||||||
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
|
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
|
||||||
@ -59,13 +61,18 @@ export function RepositoryLaunchControls({
|
|||||||
const [branchFilter, setBranchFilter] = useState('')
|
const [branchFilter, setBranchFilter] = useState('')
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||||
|
const [worktreeMenuOpen, setWorktreeMenuOpen] = useState(false)
|
||||||
|
const [worktreeMenuPos, setWorktreeMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const branchButtonRef = useRef<HTMLButtonElement>(null)
|
const branchButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const worktreeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const worktreeMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const searchInputId = useId()
|
const searchInputId = useId()
|
||||||
const listboxId = useId()
|
const listboxId = useId()
|
||||||
|
const worktreeListboxId = useId()
|
||||||
|
|
||||||
const updateMenuPos = useCallback(() => {
|
const updateMenuPos = useCallback(() => {
|
||||||
if (!branchButtonRef.current) return
|
if (!branchButtonRef.current) return
|
||||||
@ -81,6 +88,20 @@ export function RepositoryLaunchControls({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const updateWorktreeMenuPos = useCallback(() => {
|
||||||
|
if (!worktreeButtonRef.current) return
|
||||||
|
const rect = worktreeButtonRef.current.getBoundingClientRect()
|
||||||
|
const spaceAbove = rect.top
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom
|
||||||
|
const direction = spaceBelow >= WORKTREE_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
|
||||||
|
const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - WORKTREE_MENU_WIDTH - VIEWPORT_GUTTER)
|
||||||
|
setWorktreeMenuPos({
|
||||||
|
top: direction === 'down' ? rect.bottom + 6 : rect.top - 6,
|
||||||
|
left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft),
|
||||||
|
direction,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workDir) {
|
if (!workDir) {
|
||||||
setContext(null)
|
setContext(null)
|
||||||
@ -131,17 +152,20 @@ export function RepositoryLaunchControls({
|
|||||||
}, [branch, context, onBranchChange])
|
}, [branch, context, onBranchChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!branchMenuOpen) return
|
if (!branchMenuOpen && !worktreeMenuOpen) return
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
const target = event.target as Node
|
const target = event.target as Node
|
||||||
if (rootRef.current?.contains(target)) return
|
if (rootRef.current?.contains(target)) return
|
||||||
if (menuRef.current?.contains(target)) return
|
if (menuRef.current?.contains(target)) return
|
||||||
|
if (worktreeMenuRef.current?.contains(target)) return
|
||||||
setBranchMenuOpen(false)
|
setBranchMenuOpen(false)
|
||||||
|
setWorktreeMenuOpen(false)
|
||||||
}
|
}
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Escape') return
|
if (event.key !== 'Escape') return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setBranchMenuOpen(false)
|
setBranchMenuOpen(false)
|
||||||
|
setWorktreeMenuOpen(false)
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClick)
|
document.addEventListener('mousedown', handleClick)
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
@ -149,7 +173,7 @@ export function RepositoryLaunchControls({
|
|||||||
document.removeEventListener('mousedown', handleClick)
|
document.removeEventListener('mousedown', handleClick)
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [branchMenuOpen])
|
}, [branchMenuOpen, worktreeMenuOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!branchMenuOpen) return
|
if (!branchMenuOpen) return
|
||||||
@ -163,6 +187,17 @@ export function RepositoryLaunchControls({
|
|||||||
}
|
}
|
||||||
}, [branchMenuOpen, updateMenuPos])
|
}, [branchMenuOpen, updateMenuPos])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!worktreeMenuOpen) return
|
||||||
|
updateWorktreeMenuPos()
|
||||||
|
window.addEventListener('scroll', updateWorktreeMenuPos, true)
|
||||||
|
window.addEventListener('resize', updateWorktreeMenuPos)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updateWorktreeMenuPos, true)
|
||||||
|
window.removeEventListener('resize', updateWorktreeMenuPos)
|
||||||
|
}
|
||||||
|
}, [worktreeMenuOpen, updateWorktreeMenuPos])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
}, [branchFilter])
|
}, [branchFilter])
|
||||||
@ -199,12 +234,30 @@ export function RepositoryLaunchControls({
|
|||||||
return null
|
return null
|
||||||
}, [context, selectedBranch, t, useWorktree])
|
}, [context, selectedBranch, t, useWorktree])
|
||||||
|
|
||||||
|
const requiresIsolation = useMemo(() => {
|
||||||
|
if (context?.state !== 'ok' || !selectedBranch) return false
|
||||||
|
if (selectedBranch.name === context.currentBranch) return false
|
||||||
|
return context.dirty || selectedBranch.checkedOut
|
||||||
|
}, [context, selectedBranch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requiresIsolation && !useWorktree) {
|
||||||
|
onUseWorktreeChange(true)
|
||||||
|
}
|
||||||
|
}, [onUseWorktreeChange, requiresIsolation, useWorktree])
|
||||||
|
|
||||||
const selectBranch = (candidate: RepositoryBranchInfo) => {
|
const selectBranch = (candidate: RepositoryBranchInfo) => {
|
||||||
onBranchChange(candidate.name)
|
onBranchChange(candidate.name)
|
||||||
setBranchMenuOpen(false)
|
setBranchMenuOpen(false)
|
||||||
setBranchFilter('')
|
setBranchFilter('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectWorktreeMode = (enabled: boolean) => {
|
||||||
|
if (!enabled && requiresIsolation) return
|
||||||
|
onUseWorktreeChange(enabled)
|
||||||
|
setWorktreeMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleBranchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleBranchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -244,13 +297,16 @@ export function RepositoryLaunchControls({
|
|||||||
onLaunchReadyChange?.(isLaunchReady)
|
onLaunchReadyChange?.(isLaunchReady)
|
||||||
}, [isLaunchReady, onLaunchReadyChange])
|
}, [isLaunchReady, onLaunchReadyChange])
|
||||||
|
|
||||||
|
const worktreeLabel = useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')
|
||||||
|
const workbarButtonClassName = 'group inline-flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className="flex min-w-0 flex-col gap-2">
|
<div ref={rootRef} className="flex min-w-0 flex-col gap-2">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex min-h-[48px] min-w-0 flex-nowrap items-center justify-start gap-x-1.5 gap-y-1 overflow-hidden rounded-b-xl border-t border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] px-4 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
|
||||||
<DirectoryPicker value={workDir} onChange={onWorkDirChange} />
|
<DirectoryPicker value={workDir} onChange={onWorkDirChange} variant="workbar" isGitProject={isGitReady} />
|
||||||
|
|
||||||
{loading && workDir && (
|
{loading && workDir && (
|
||||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)]">
|
<div className="inline-flex h-9 items-center gap-1.5 rounded-[7px] px-2.5 text-[13px] text-[var(--color-text-secondary)]">
|
||||||
<Loader2 size={14} className="shrink-0 animate-spin" />
|
<Loader2 size={14} className="shrink-0 animate-spin" />
|
||||||
<span>{t('common.loading')}</span>
|
<span>{t('common.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -258,42 +314,53 @@ export function RepositoryLaunchControls({
|
|||||||
|
|
||||||
{isGitReady && (
|
{isGitReady && (
|
||||||
<>
|
<>
|
||||||
|
<span className="hidden h-4 w-px shrink-0 bg-[var(--color-border-separator)] opacity-70 sm:block" aria-hidden="true" />
|
||||||
<button
|
<button
|
||||||
ref={branchButtonRef}
|
ref={branchButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || loading || context.branches.length === 0}
|
disabled={disabled || loading || context.branches.length === 0}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={branchMenuOpen}
|
aria-expanded={branchMenuOpen}
|
||||||
aria-label={t('repoLaunch.selectBranch')}
|
aria-label={`${t('repoLaunch.selectBranch')}: ${selectedBranch?.name || t('repoLaunch.noBranch')}`}
|
||||||
|
title={selectedBranch?.name || t('repoLaunch.noBranch')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBranchMenuOpen((prev) => !prev)
|
setBranchMenuOpen((prev) => !prev)
|
||||||
|
setWorktreeMenuOpen(false)
|
||||||
setBranchFilter('')
|
setBranchFilter('')
|
||||||
}}
|
}}
|
||||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
|
className={`${workbarButtonClassName} max-w-[260px] shrink`}
|
||||||
>
|
>
|
||||||
<GitBranch size={15} className="shrink-0" />
|
<GitBranch size={17} className="shrink-0 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]" />
|
||||||
<span className="truncate font-medium text-[var(--color-text-primary)]">
|
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
|
||||||
{selectedBranch?.name || t('repoLaunch.noBranch')}
|
{selectedBranch?.name || t('repoLaunch.noBranch')}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
ref={worktreeButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-pressed={useWorktree}
|
aria-haspopup="listbox"
|
||||||
aria-label={t('repoLaunch.toggleWorktree')}
|
aria-expanded={worktreeMenuOpen}
|
||||||
onClick={() => onUseWorktreeChange(!useWorktree)}
|
aria-controls={worktreeMenuOpen ? worktreeListboxId : undefined}
|
||||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50 ${
|
aria-label={`${t('repoLaunch.selectWorktree')}: ${worktreeLabel}`}
|
||||||
|
title={worktreeLabel}
|
||||||
|
onClick={() => {
|
||||||
|
setWorktreeMenuOpen((prev) => !prev)
|
||||||
|
setBranchMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`${workbarButtonClassName} shrink-0 ${
|
||||||
useWorktree
|
useWorktree
|
||||||
? 'border-[var(--color-brand)]/40 bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
|
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
|
||||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GitFork size={15} className="shrink-0" />
|
<GitFork size={17} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
<span className="font-medium">
|
<span className="min-w-0 truncate">
|
||||||
{useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')}
|
{worktreeLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -397,6 +464,62 @@ export function RepositoryLaunchControls({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{worktreeMenuOpen && worktreeMenuPos && createPortal(
|
||||||
|
<div
|
||||||
|
ref={worktreeMenuRef}
|
||||||
|
className="w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: worktreeMenuPos.left,
|
||||||
|
...(worktreeMenuPos.direction === 'down'
|
||||||
|
? { top: worktreeMenuPos.top }
|
||||||
|
: { bottom: window.innerHeight - worktreeMenuPos.top }),
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={!useWorktree}
|
||||||
|
aria-disabled={requiresIsolation}
|
||||||
|
disabled={requiresIsolation}
|
||||||
|
onClick={() => selectWorktreeMode(false)}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||||
|
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||||
|
{t('repoLaunch.worktreeCurrent')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={useWorktree}
|
||||||
|
onClick={() => selectWorktreeMode(true)}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
|
||||||
|
useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||||
|
{t('repoLaunch.worktreeIsolated')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -705,7 +705,7 @@ export const en = {
|
|||||||
'repoLaunch.checkedOut': 'Checked out in another worktree',
|
'repoLaunch.checkedOut': 'Checked out in another worktree',
|
||||||
'repoLaunch.worktreeCurrent': 'Current worktree',
|
'repoLaunch.worktreeCurrent': 'Current worktree',
|
||||||
'repoLaunch.worktreeIsolated': 'Isolated worktree',
|
'repoLaunch.worktreeIsolated': 'Isolated worktree',
|
||||||
'repoLaunch.toggleWorktree': 'Toggle worktree isolation',
|
'repoLaunch.selectWorktree': 'Select worktree mode',
|
||||||
'repoLaunch.notGit': 'Current project is not a Git repository.',
|
'repoLaunch.notGit': 'Current project is not a Git repository.',
|
||||||
'repoLaunch.missingWorkdir': 'Working directory is missing.',
|
'repoLaunch.missingWorkdir': 'Working directory is missing.',
|
||||||
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',
|
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',
|
||||||
|
|||||||
@ -707,7 +707,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'repoLaunch.checkedOut': '已在其他工作树中检出',
|
'repoLaunch.checkedOut': '已在其他工作树中检出',
|
||||||
'repoLaunch.worktreeCurrent': '当前工作树',
|
'repoLaunch.worktreeCurrent': '当前工作树',
|
||||||
'repoLaunch.worktreeIsolated': '独立工作树',
|
'repoLaunch.worktreeIsolated': '独立工作树',
|
||||||
'repoLaunch.toggleWorktree': '切换工作树隔离',
|
'repoLaunch.selectWorktree': '选择工作树模式',
|
||||||
'repoLaunch.notGit': '当前项目不是 Git 仓库。',
|
'repoLaunch.notGit': '当前项目不是 Git 仓库。',
|
||||||
'repoLaunch.missingWorkdir': '工作目录不存在。',
|
'repoLaunch.missingWorkdir': '工作目录不存在。',
|
||||||
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',
|
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',
|
||||||
|
|||||||
@ -75,6 +75,57 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ActiveSession task polling', () => {
|
describe('ActiveSession task polling', () => {
|
||||||
|
it('treats a persisted historical session as non-empty before messages finish loading', () => {
|
||||||
|
const sessionId = 'history-loading-session'
|
||||||
|
|
||||||
|
useSessionStore.setState({
|
||||||
|
sessions: [{
|
||||||
|
id: sessionId,
|
||||||
|
title: 'History Loading Session',
|
||||||
|
createdAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
messageCount: 2,
|
||||||
|
projectPath: '/workspace/project',
|
||||||
|
workDir: '/workspace/project',
|
||||||
|
workDirExists: true,
|
||||||
|
}],
|
||||||
|
activeSessionId: sessionId,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
useTabStore.setState({
|
||||||
|
tabs: [{ sessionId, title: 'History Loading Session', type: 'session', status: 'idle' }],
|
||||||
|
activeTabId: 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(<ActiveSession />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('message-list')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
|
||||||
|
})
|
||||||
|
|
||||||
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -260,7 +260,7 @@ export function ActiveSession() {
|
|||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const messages = sessionState?.messages ?? []
|
const messages = sessionState?.messages ?? []
|
||||||
const streamingText = sessionState?.streamingText ?? ''
|
const streamingText = sessionState?.streamingText ?? ''
|
||||||
const isEmpty = messages.length === 0 && !streamingText
|
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
|
||||||
|
|
||||||
const isActive = chatState !== 'idle'
|
const isActive = chatState !== 'idle'
|
||||||
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
||||||
|
|||||||
@ -303,4 +303,93 @@ describe('EmptySession', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the repository launch context on one row and truncates long branch names', async () => {
|
||||||
|
const longBranch = 'feature/super-long-branch-name-for-repository-launch-controls-e2e'
|
||||||
|
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
|
||||||
|
currentBranch: longBranch,
|
||||||
|
defaultBranch: 'main',
|
||||||
|
branches: [{
|
||||||
|
name: longBranch,
|
||||||
|
current: true,
|
||||||
|
local: true,
|
||||||
|
remote: false,
|
||||||
|
checkedOut: true,
|
||||||
|
worktreePath: '/workspace/project',
|
||||||
|
}],
|
||||||
|
worktrees: [{
|
||||||
|
path: '/workspace/project',
|
||||||
|
branch: longBranch,
|
||||||
|
current: true,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: { value: 'draft question', selectionStart: 14 },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||||
|
|
||||||
|
const branchButton = await screen.findByRole('button', { name: new RegExp(`Select branch: ${longBranch}`) })
|
||||||
|
const branchClasses = branchButton.className.split(/\s+/)
|
||||||
|
expect(branchButton.parentElement?.className).toContain('flex-nowrap')
|
||||||
|
expect(branchButton.parentElement?.className).not.toContain('flex-wrap')
|
||||||
|
expect(branchClasses).toContain('max-w-[260px]')
|
||||||
|
expect(branchClasses).not.toContain('max-w-full')
|
||||||
|
expect(branchButton.querySelector('span')?.className).toContain('truncate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to isolated worktree when the fallback branch is checked out elsewhere', async () => {
|
||||||
|
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
|
||||||
|
currentBranch: null,
|
||||||
|
defaultBranch: 'main',
|
||||||
|
branches: [
|
||||||
|
{
|
||||||
|
name: 'main',
|
||||||
|
current: false,
|
||||||
|
local: true,
|
||||||
|
remote: false,
|
||||||
|
checkedOut: true,
|
||||||
|
worktreePath: '/workspace/project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature/a',
|
||||||
|
current: false,
|
||||||
|
local: true,
|
||||||
|
remote: false,
|
||||||
|
checkedOut: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
worktrees: [{
|
||||||
|
path: '/workspace/project/.codex/worktrees/detached/project',
|
||||||
|
branch: null,
|
||||||
|
current: true,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: { value: 'draft question', selectionStart: 14 },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('main')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Isolated worktree/ }))
|
||||||
|
expect(await screen.findByRole('option', { name: 'Current worktree' })).toBeDisabled()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||||
|
workDir: '/workspace/project',
|
||||||
|
repository: { branch: 'main', worktree: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -546,9 +546,9 @@ export function EmptySession() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
|
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
|
||||||
<div className="flex w-full max-w-3xl flex-col gap-2">
|
<div className="flex w-full max-w-3xl flex-col">
|
||||||
<div
|
<div
|
||||||
className="glass-panel relative flex flex-col gap-3 rounded-xl p-4"
|
className="glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4"
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user