mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Keep desktop launch context visually attached to the composer
The empty-session desktop composer rendered repository launch context as a separate footer, which created a visual gap between the prompt surface and the project/branch/worktree controls. This keeps the PC layout in one panel while preserving the existing mobile bottom layout. Constraint: Mobile H5 composer layout must remain unchanged. Rejected: Restyle the shared launch bar globally | existing standalone and mobile placements rely on the old chrome. Confidence: high Scope-risk: narrow Directive: Keep the composer placement desktop-only unless mobile layout is intentionally redesigned. Tested: cd desktop && bun run test src/pages/EmptySession.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx src/components/shared/DirectoryPicker.test.tsx Tested: bun run check:desktop Tested: agent-browser screenshots for PC and mobile empty-session composer. Not-tested: Packaged Tauri runtime smoke.
This commit is contained in:
parent
ca6d0c40a1
commit
cb2978b222
@ -122,6 +122,16 @@ describe('RepositoryLaunchControls', () => {
|
|||||||
expect(listbox.parentElement?.className).toContain('w-[390px]')
|
expect(listbox.parentElement?.className).toContain('w-[390px]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the flatter desktop bar when embedded in a composer', async () => {
|
||||||
|
renderControls({ placement: 'composer' })
|
||||||
|
|
||||||
|
const trigger = await screen.findByRole('button', { name: 'Select branch: main' })
|
||||||
|
const bar = trigger.parentElement
|
||||||
|
expect(bar).toHaveClass('min-h-[44px]', 'bg-transparent')
|
||||||
|
expect(bar).not.toHaveClass('rounded-b-xl')
|
||||||
|
expect(bar).not.toHaveClass('bg-[var(--color-surface-container-low)]')
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the full-width mobile bottom sheet in H5 mobile browser mode', async () => {
|
it('uses the full-width mobile bottom sheet in H5 mobile browser mode', async () => {
|
||||||
viewportMocks.isMobile = true
|
viewportMocks.isMobile = true
|
||||||
viewportMocks.isTauri = false
|
viewportMocks.isTauri = false
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type Props = {
|
|||||||
onUseWorktreeChange: (enabled: boolean) => void
|
onUseWorktreeChange: (enabled: boolean) => void
|
||||||
onLaunchReadyChange?: (ready: boolean) => void
|
onLaunchReadyChange?: (ready: boolean) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
placement?: 'standalone' | 'composer'
|
||||||
}
|
}
|
||||||
|
|
||||||
const BRANCH_MENU_HEIGHT = 360
|
const BRANCH_MENU_HEIGHT = 360
|
||||||
@ -55,9 +56,11 @@ export function RepositoryLaunchControls({
|
|||||||
onUseWorktreeChange,
|
onUseWorktreeChange,
|
||||||
onLaunchReadyChange,
|
onLaunchReadyChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
placement = 'standalone',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
|
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
|
||||||
|
const isComposerPlacement = placement === 'composer' && !isMobileBrowser
|
||||||
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -331,10 +334,12 @@ export function RepositoryLaunchControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={`flex min-w-0 flex-col ${isMobileBrowser ? 'gap-0' : 'gap-2'}`}>
|
<div ref={rootRef} className={`flex min-w-0 flex-col ${isMobileBrowser ? 'gap-0' : isComposerPlacement ? 'gap-1' : 'gap-2'}`}>
|
||||||
<div className={`flex min-w-0 items-center justify-start gap-x-1.5 gap-y-1 overflow-hidden border-t border-[var(--color-border-separator)] ${
|
<div className={`flex min-w-0 items-center justify-start gap-x-1.5 gap-y-1 overflow-hidden border-t border-[var(--color-border-separator)] ${
|
||||||
isMobileBrowser
|
isMobileBrowser
|
||||||
? 'min-h-[52px] flex-wrap rounded-none bg-[var(--color-surface-container-lowest)] px-3 py-2 shadow-none'
|
? 'min-h-[52px] flex-wrap rounded-none bg-[var(--color-surface-container-lowest)] px-3 py-2 shadow-none'
|
||||||
|
: isComposerPlacement
|
||||||
|
? 'min-h-[44px] flex-nowrap bg-transparent px-4 py-2'
|
||||||
: 'min-h-[48px] flex-nowrap rounded-b-xl bg-[var(--color-surface-container-low)] px-4 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]'
|
: 'min-h-[48px] flex-nowrap rounded-b-xl 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} variant="workbar" isGitProject={isGitReady} />
|
<DirectoryPicker value={workDir} onChange={onWorkDirChange} variant="workbar" isGitProject={isGitReady} />
|
||||||
|
|||||||
@ -198,6 +198,23 @@ describe('EmptySession', () => {
|
|||||||
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-2xl')
|
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-2xl')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('integrates repository launch controls into the desktop composer panel', async () => {
|
||||||
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
const panel = screen.getByTestId('empty-session-composer-panel')
|
||||||
|
expect(panel).toHaveClass('rounded-xl', 'p-0')
|
||||||
|
expect(panel).not.toHaveClass('rounded-b-none')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||||
|
|
||||||
|
const branchButton = await screen.findByRole('button', { name: 'Select branch: main' })
|
||||||
|
const launchBar = branchButton.parentElement
|
||||||
|
expect(launchBar).toBeTruthy()
|
||||||
|
expect(launchBar).toHaveClass('bg-transparent')
|
||||||
|
expect(launchBar).not.toHaveClass('rounded-b-xl')
|
||||||
|
expect(panel).toContainElement(launchBar)
|
||||||
|
})
|
||||||
|
|
||||||
it('creates a session with the selected project and branch when submitted', async () => {
|
it('creates a session with the selected project and branch when submitted', async () => {
|
||||||
render(<EmptySession />)
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
|||||||
@ -553,194 +553,212 @@ export function EmptySession() {
|
|||||||
<div
|
<div
|
||||||
data-testid="empty-session-composer-panel"
|
data-testid="empty-session-composer-panel"
|
||||||
className={`glass-panel relative flex flex-col gap-3 ${
|
className={`glass-panel relative flex flex-col gap-3 ${
|
||||||
isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-t-xl rounded-b-none p-4'
|
isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-0'
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{fileSearchOpen && (
|
<div className={isMobileComposer ? 'contents' : 'flex flex-col gap-3 p-4'}>
|
||||||
<FileSearchMenu
|
{fileSearchOpen && (
|
||||||
ref={fileSearchRef}
|
<FileSearchMenu
|
||||||
cwd={workDir || ''}
|
ref={fileSearchRef}
|
||||||
filter={atFilter}
|
cwd={workDir || ''}
|
||||||
onNavigate={(relativePath) => {
|
filter={atFilter}
|
||||||
if (atCursorPos < 0) return
|
onNavigate={(relativePath) => {
|
||||||
const replacement = `@${relativePath}`
|
if (atCursorPos < 0) return
|
||||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
const replacement = `@${relativePath}`
|
||||||
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
|
||||||
const newCursorPos = atCursorPos + replacement.length
|
|
||||||
setInput(newValue)
|
|
||||||
setAtFilter(relativePath)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
textareaRef.current?.focus()
|
|
||||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onSelect={(path, name) => {
|
|
||||||
if (atCursorPos >= 0) {
|
|
||||||
const attachmentName = name.split('/').filter(Boolean).pop() ?? name
|
|
||||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||||
const beforeToken = input.slice(0, atCursorPos)
|
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
||||||
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
|
const newCursorPos = atCursorPos + replacement.length
|
||||||
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
|
|
||||||
const newValue = `${beforeToken}${spacer}${afterToken}`
|
|
||||||
const newCursorPos = atCursorPos + spacer.length
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
name: attachmentName,
|
|
||||||
type: 'file',
|
|
||||||
path,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setInput(newValue)
|
setInput(newValue)
|
||||||
setFileSearchOpen(false)
|
setAtFilter(relativePath)
|
||||||
setAtFilter('')
|
|
||||||
setAtCursorPos(-1)
|
|
||||||
void textareaRef.current?.focus()
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.focus()
|
||||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
})
|
})
|
||||||
}
|
}}
|
||||||
}}
|
onSelect={(path, name) => {
|
||||||
/>
|
if (atCursorPos >= 0) {
|
||||||
)}
|
const attachmentName = name.split('/').filter(Boolean).pop() ?? name
|
||||||
|
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||||
|
const beforeToken = input.slice(0, atCursorPos)
|
||||||
|
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
|
||||||
|
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
|
||||||
|
const newValue = `${beforeToken}${spacer}${afterToken}`
|
||||||
|
const newCursorPos = atCursorPos + spacer.length
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
name: attachmentName,
|
||||||
|
type: 'file',
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setInput(newValue)
|
||||||
|
setFileSearchOpen(false)
|
||||||
|
setAtFilter('')
|
||||||
|
setAtCursorPos(-1)
|
||||||
|
void textareaRef.current?.focus()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{localSlashPanel && (
|
{localSlashPanel && (
|
||||||
<div ref={slashMenuRef}>
|
<div ref={slashMenuRef}>
|
||||||
<LocalSlashCommandPanel
|
<LocalSlashCommandPanel
|
||||||
command={localSlashPanel}
|
command={localSlashPanel}
|
||||||
cwd={workDir || undefined}
|
cwd={workDir || undefined}
|
||||||
commands={allSlashCommands}
|
commands={allSlashCommands}
|
||||||
onClose={() => setLocalSlashPanel(null)}
|
onClose={() => setLocalSlashPanel(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slashMenuOpen && filteredCommands.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={slashMenuRef}
|
||||||
|
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
||||||
|
>
|
||||||
|
<div className="max-h-[260px] overflow-y-auto py-1">
|
||||||
|
{filteredCommands.map((command, index) => (
|
||||||
|
<button
|
||||||
|
key={command.name}
|
||||||
|
ref={(el) => { slashItemRefs.current[index] = el }}
|
||||||
|
onClick={() => selectSlashCommand(command.name)}
|
||||||
|
onMouseEnter={() => setSlashSelectedIndex(index)}
|
||||||
|
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
|
index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className={`flex-1 resize-none border-none bg-transparent leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${
|
||||||
|
isMobileComposer ? 'max-h-[132px] min-h-[72px] py-1.5 text-base' : 'py-2'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: 'var(--font-body)' }}
|
||||||
|
placeholder={t('empty.placeholder')}
|
||||||
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{slashMenuOpen && filteredCommands.length > 0 && (
|
<div className={`border-t border-[var(--color-border-separator)] pt-3 ${
|
||||||
<div
|
isMobileComposer ? 'flex flex-wrap items-center gap-2' : 'flex items-center justify-between'
|
||||||
ref={slashMenuRef}
|
}`}>
|
||||||
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
>
|
<div ref={plusMenuRef} className="relative">
|
||||||
<div className="max-h-[260px] overflow-y-auto py-1">
|
|
||||||
{filteredCommands.map((command, index) => (
|
|
||||||
<button
|
<button
|
||||||
key={command.name}
|
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
||||||
ref={(el) => { slashItemRefs.current[index] = el }}
|
aria-label="Open composer tools"
|
||||||
onClick={() => selectSlashCommand(command.name)}
|
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
|
||||||
onMouseEnter={() => setSlashSelectedIndex(index)}
|
isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-lg p-1.5'
|
||||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
|
||||||
index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
|
{plusMenuOpen && (
|
||||||
|
<div className={`absolute bottom-full left-0 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${
|
||||||
|
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
|
||||||
|
}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
setPlusMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
|
||||||
|
{t('empty.addFiles')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={insertSlashCommand}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||||
|
>
|
||||||
|
<span className="w-5 text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
|
||||||
|
{t('empty.slashCommands')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PermissionModeSelector workDir={workDir} compact={isMobileComposer} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
<div className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-2' : 'flex items-center gap-3'}`}>
|
||||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
<ContextUsageIndicator
|
||||||
)}
|
chatState="idle"
|
||||||
|
messageCount={0}
|
||||||
<div className="flex items-start gap-3">
|
runtimeSelectionKey={draftRuntimeSelectionKey}
|
||||||
<textarea
|
fallbackModelLabel={draftModelLabel}
|
||||||
ref={textareaRef}
|
draft
|
||||||
value={input}
|
compact={isMobileComposer}
|
||||||
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
|
/>
|
||||||
onKeyDown={handleKeyDown}
|
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
|
||||||
onPaste={handlePaste}
|
|
||||||
className={`flex-1 resize-none border-none bg-transparent leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${
|
|
||||||
isMobileComposer ? 'max-h-[132px] min-h-[72px] py-1.5 text-base' : 'py-2'
|
|
||||||
}`}
|
|
||||||
style={{ fontFamily: 'var(--font-body)' }}
|
|
||||||
placeholder={t('empty.placeholder')}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`border-t border-[var(--color-border-separator)] pt-3 ${
|
|
||||||
isMobileComposer ? 'flex flex-wrap items-center gap-2' : 'flex items-center justify-between'
|
|
||||||
}`}>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<div ref={plusMenuRef} className="relative">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
onClick={handleSubmit}
|
||||||
aria-label="Open composer tools"
|
disabled={!canSubmit}
|
||||||
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
|
aria-label={t('common.run')}
|
||||||
isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-lg p-1.5'
|
title={isMobileComposer ? t('common.run') : undefined}
|
||||||
|
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30 ${
|
||||||
|
isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'w-[112px] px-3 py-1.5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
{!isMobileComposer && t('common.run')}
|
||||||
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{plusMenuOpen && (
|
|
||||||
<div className={`absolute bottom-full left-0 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${
|
|
||||||
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
|
|
||||||
}`}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
setPlusMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
|
|
||||||
{t('empty.addFiles')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={insertSlashCommand}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
||||||
>
|
|
||||||
<span className="w-5 text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
|
|
||||||
{t('empty.slashCommands')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PermissionModeSelector workDir={workDir} compact={isMobileComposer} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-2' : 'flex items-center gap-3'}`}>
|
|
||||||
<ContextUsageIndicator
|
|
||||||
chatState="idle"
|
|
||||||
messageCount={0}
|
|
||||||
runtimeSelectionKey={draftRuntimeSelectionKey}
|
|
||||||
fallbackModelLabel={draftModelLabel}
|
|
||||||
draft
|
|
||||||
compact={isMobileComposer}
|
|
||||||
/>
|
|
||||||
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
aria-label={t('common.run')}
|
|
||||||
title={isMobileComposer ? t('common.run') : undefined}
|
|
||||||
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30 ${
|
|
||||||
isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'w-[112px] px-3 py-1.5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{!isMobileComposer && t('common.run')}
|
|
||||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobileComposer && (
|
||||||
|
<RepositoryLaunchControls
|
||||||
|
workDir={workDir}
|
||||||
|
onWorkDirChange={handleWorkDirChange}
|
||||||
|
branch={selectedBranch}
|
||||||
|
onBranchChange={setSelectedBranch}
|
||||||
|
useWorktree={useWorktree}
|
||||||
|
onUseWorktreeChange={setUseWorktree}
|
||||||
|
onLaunchReadyChange={setRepositoryLaunchReady}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
placement="composer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RepositoryLaunchControls
|
{isMobileComposer && (
|
||||||
workDir={workDir}
|
<RepositoryLaunchControls
|
||||||
onWorkDirChange={handleWorkDirChange}
|
workDir={workDir}
|
||||||
branch={selectedBranch}
|
onWorkDirChange={handleWorkDirChange}
|
||||||
onBranchChange={setSelectedBranch}
|
branch={selectedBranch}
|
||||||
useWorktree={useWorktree}
|
onBranchChange={setSelectedBranch}
|
||||||
onUseWorktreeChange={setUseWorktree}
|
useWorktree={useWorktree}
|
||||||
onLaunchReadyChange={setRepositoryLaunchReady}
|
onUseWorktreeChange={setUseWorktree}
|
||||||
disabled={isSubmitting}
|
onLaunchReadyChange={setRepositoryLaunchReady}
|
||||||
/>
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user