mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Desktop sessions can switch provider and model while a CLI subprocess is already alive, so the server now serializes runtime restarts and marks provider-managed launches to prevent stale settings env from overriding the selected provider. Provider settings also write API key env consistently and clear stale managed keys before syncing. This includes the related desktop/docs brand asset refresh and keeps the desktop locale default in Chinese, with tests updated to match the current provider semantics. Constraint: Session-scoped model selection must win over cc-haha/settings.json and inherited ANTHROPIC_* values. Rejected: Store the selected model as a global provider activeModel | chat runtime selection is per session. Confidence: high Scope-risk: moderate Directive: Do not remove CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST without validating Desktop provider switching against stale settings env. Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/conversations.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts Tested: cd desktop && bun run test src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint Tested: git diff --check Not-tested: Full desktop production package/signing.
612 lines
23 KiB
TypeScript
612 lines
23 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { skillsApi } from '../api/skills'
|
|
import { useTranslation } from '../i18n'
|
|
import { useSessionStore } from '../stores/sessionStore'
|
|
import { useChatStore } from '../stores/chatStore'
|
|
import { useProviderStore } from '../stores/providerStore'
|
|
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
|
|
import { useSettingsStore } from '../stores/settingsStore'
|
|
import { useUIStore } from '../stores/uiStore'
|
|
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
|
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
|
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
|
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
|
import { ModelSelector } from '../components/controls/ModelSelector'
|
|
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
|
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
|
|
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
|
|
import {
|
|
FALLBACK_SLASH_COMMANDS,
|
|
findSlashToken,
|
|
insertSlashTrigger,
|
|
mergeSlashCommands,
|
|
replaceSlashCommand,
|
|
resolveSlashUiAction,
|
|
} from '../components/chat/composerUtils'
|
|
import type { AttachmentRef } from '../types/chat'
|
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
|
|
|
type Attachment = {
|
|
id: string
|
|
name: string
|
|
type: 'image' | 'file'
|
|
mimeType?: string
|
|
previewUrl?: string
|
|
data?: string
|
|
}
|
|
|
|
export function EmptySession() {
|
|
const t = useTranslation()
|
|
const [input, setInput] = useState('')
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [workDir, setWorkDir] = useState('')
|
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
|
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
|
const [fileSearchOpen, setFileSearchOpen] = useState(false)
|
|
const [localSlashPanel, setLocalSlashPanel] = useState<LocalSlashCommandName | null>(null)
|
|
const [atFilter, setAtFilter] = useState('')
|
|
const [atCursorPos, setAtCursorPos] = useState(-1)
|
|
const [slashFilter, setSlashFilter] = useState('')
|
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const plusMenuRef = useRef<HTMLDivElement>(null)
|
|
const slashMenuRef = useRef<HTMLDivElement>(null)
|
|
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
|
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
const createSession = useSessionStore((state) => state.createSession)
|
|
const sendMessage = useChatStore((state) => state.sendMessage)
|
|
const setSessionRuntime = useChatStore((state) => state.setSessionRuntime)
|
|
const connectToSession = useChatStore((state) => state.connectToSession)
|
|
const setActiveView = useUIStore((state) => state.setActiveView)
|
|
const addToast = useUIStore((state) => state.addToast)
|
|
|
|
useEffect(() => {
|
|
textareaRef.current?.focus()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!plusMenuOpen) return
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) {
|
|
setPlusMenuOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [plusMenuOpen])
|
|
|
|
useEffect(() => {
|
|
if (!slashMenuOpen) return
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (
|
|
slashMenuRef.current &&
|
|
!slashMenuRef.current.contains(event.target as Node) &&
|
|
textareaRef.current &&
|
|
!textareaRef.current.contains(event.target as Node)
|
|
) {
|
|
setSlashMenuOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [slashMenuOpen])
|
|
|
|
useEffect(() => {
|
|
if (!localSlashPanel) return
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (
|
|
slashMenuRef.current &&
|
|
!slashMenuRef.current.contains(event.target as Node) &&
|
|
textareaRef.current &&
|
|
!textareaRef.current.contains(event.target as Node)
|
|
) {
|
|
setLocalSlashPanel(null)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [localSlashPanel])
|
|
|
|
useEffect(() => {
|
|
if (!fileSearchOpen) return
|
|
const handleClick = (event: MouseEvent) => {
|
|
const menu = document.getElementById('file-search-menu')
|
|
if (
|
|
menu &&
|
|
!menu.contains(event.target as Node) &&
|
|
textareaRef.current &&
|
|
!textareaRef.current.contains(event.target as Node)
|
|
) {
|
|
setFileSearchOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [fileSearchOpen])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
skillsApi.list(workDir || undefined)
|
|
.then(({ skills }) => {
|
|
if (cancelled) return
|
|
setSlashCommands(
|
|
skills
|
|
.filter((skill) => skill.userInvocable)
|
|
.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
})),
|
|
)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setSlashCommands([])
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [workDir])
|
|
|
|
const filteredCommands = useMemo(() => {
|
|
const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS)
|
|
if (!slashFilter) return source
|
|
const lower = slashFilter.toLowerCase()
|
|
return source.filter((command) => (
|
|
command.name.toLowerCase().includes(lower) ||
|
|
command.description.toLowerCase().includes(lower)
|
|
))
|
|
}, [slashCommands, slashFilter])
|
|
|
|
const exactSlashCommand = useMemo(() => {
|
|
const normalized = slashFilter.trim().toLowerCase()
|
|
if (!normalized) return null
|
|
return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null
|
|
}, [filteredCommands, slashFilter])
|
|
|
|
useEffect(() => {
|
|
setSlashSelectedIndex(0)
|
|
}, [slashFilter])
|
|
|
|
useEffect(() => {
|
|
const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null
|
|
if (activeItem && typeof activeItem.scrollIntoView === 'function') {
|
|
activeItem.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
}, [slashMenuOpen, slashSelectedIndex])
|
|
|
|
const handleSubmit = async () => {
|
|
const text = input.trim()
|
|
if ((!text && attachments.length === 0) || isSubmitting) return
|
|
|
|
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
|
if (slashUiAction?.type === 'panel') {
|
|
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
|
|
setInput('')
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setPlusMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
if (slashUiAction?.type === 'settings') {
|
|
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
|
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
|
setInput('')
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setPlusMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
const settings = useSettingsStore.getState()
|
|
let providerState = useProviderStore.getState()
|
|
if (
|
|
settings.activeProviderName &&
|
|
providerState.providers.length === 0 &&
|
|
!providerState.isLoading
|
|
) {
|
|
await providerState.fetchProviders()
|
|
providerState = useProviderStore.getState()
|
|
}
|
|
const inferredProviderId = providerState.activeId ?? (
|
|
settings.activeProviderName
|
|
? providerState.providers.find((provider) => provider.name === settings.activeProviderName)?.id ?? null
|
|
: null
|
|
)
|
|
const draftSelection =
|
|
useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY]
|
|
?? {
|
|
providerId: inferredProviderId,
|
|
modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID,
|
|
}
|
|
const sessionId = await createSession(workDir || undefined)
|
|
setActiveView('code')
|
|
useTabStore.getState().openTab(sessionId, 'New Session')
|
|
connectToSession(sessionId)
|
|
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
|
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
|
setSessionRuntime(sessionId, draftSelection)
|
|
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
|
type: attachment.type,
|
|
name: attachment.name,
|
|
data: attachment.data,
|
|
mimeType: attachment.mimeType,
|
|
}))
|
|
sendMessage(sessionId, text, attachmentPayload)
|
|
setInput('')
|
|
setAttachments([])
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
|
})
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleInputChange = (value: string, cursorPos: number) => {
|
|
setInput(value)
|
|
const token = findSlashToken(value, cursorPos)
|
|
if (!token) {
|
|
setSlashMenuOpen(false)
|
|
} else {
|
|
setSlashFilter(token.filter)
|
|
setSlashMenuOpen(true)
|
|
}
|
|
|
|
// Detect @ trigger for file search
|
|
const textBeforeCursor = value.slice(0, cursorPos)
|
|
let pos = -1
|
|
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
|
|
const ch = textBeforeCursor[i]!
|
|
if (ch === '@') {
|
|
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
|
|
pos = i
|
|
break
|
|
}
|
|
break
|
|
}
|
|
if (/\s/.test(ch)) {
|
|
break
|
|
}
|
|
}
|
|
if (pos < 0) {
|
|
setFileSearchOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
} else {
|
|
setAtFilter(textBeforeCursor.slice(pos + 1))
|
|
setAtCursorPos(cursorPos)
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(true)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
// Ignore key events during IME composition (e.g. Chinese input method)
|
|
if (event.nativeEvent.isComposing) return
|
|
|
|
// Route file search navigation keys to FileSearchMenu
|
|
if (fileSearchOpen) {
|
|
const key = event.key
|
|
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
|
|
event.preventDefault()
|
|
if (key === 'Escape') {
|
|
setFileSearchOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
return
|
|
}
|
|
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
if (slashMenuOpen && filteredCommands.length > 0) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
|
|
return
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
|
|
return
|
|
}
|
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
if (
|
|
event.key === 'Enter' &&
|
|
exactSlashCommand &&
|
|
slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase()
|
|
) {
|
|
event.preventDefault()
|
|
void handleSubmit()
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
const selected = filteredCommands[slashSelectedIndex]
|
|
if (selected) selectSlashCommand(selected.name)
|
|
return
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
setSlashMenuOpen(false)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
handleSubmit()
|
|
}
|
|
}
|
|
|
|
const handlePaste = (event: React.ClipboardEvent) => {
|
|
const items = event.clipboardData?.items
|
|
if (!items) return
|
|
|
|
let hasImage = false
|
|
for (let i = 0; i < items.length; i += 1) {
|
|
const item = items[i]
|
|
if (!item || !item.type.startsWith('image/')) continue
|
|
|
|
hasImage = true
|
|
event.preventDefault()
|
|
const file = item.getAsFile()
|
|
if (!file) continue
|
|
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id,
|
|
name: `pasted-image-${Date.now()}.png`,
|
|
type: 'image',
|
|
mimeType: file.type || undefined,
|
|
previewUrl: reader.result as string,
|
|
data: reader.result as string,
|
|
},
|
|
])
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
|
|
if (!hasImage) return
|
|
}
|
|
|
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = event.target.files
|
|
if (!files) return
|
|
|
|
Array.from(files).forEach((file) => {
|
|
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
const isImage = file.type.startsWith('image/')
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id,
|
|
name: file.name,
|
|
type: isImage ? 'image' : 'file',
|
|
mimeType: file.type || undefined,
|
|
previewUrl: isImage ? (reader.result as string) : undefined,
|
|
data: reader.result as string,
|
|
},
|
|
])
|
|
}
|
|
reader.readAsDataURL(file)
|
|
})
|
|
|
|
event.target.value = ''
|
|
}
|
|
|
|
const handleDrop = (event: React.DragEvent) => {
|
|
event.preventDefault()
|
|
const files = event.dataTransfer.files
|
|
if (files.length > 0) {
|
|
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
|
|
handleFileSelect(fakeEvent)
|
|
}
|
|
}
|
|
|
|
const removeAttachment = (id: string) => {
|
|
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
|
}
|
|
|
|
const selectSlashCommand = (command: string) => {
|
|
const el = textareaRef.current
|
|
if (!el) return
|
|
const cursorPos = el.selectionStart ?? input.length
|
|
const replacement = replaceSlashCommand(input, cursorPos, command)
|
|
if (!replacement) return
|
|
setInput(replacement.value)
|
|
setSlashMenuOpen(false)
|
|
requestAnimationFrame(() => {
|
|
el.focus()
|
|
el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
|
|
})
|
|
}
|
|
|
|
const insertSlashCommand = () => {
|
|
const el = textareaRef.current
|
|
const cursorPos = el?.selectionStart ?? input.length
|
|
const replacement = insertSlashTrigger(input, cursorPos)
|
|
setInput(replacement.value)
|
|
setPlusMenuOpen(false)
|
|
setSlashFilter('')
|
|
setSlashMenuOpen(true)
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.focus()
|
|
textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
|
|
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
|
<div className="flex max-w-md flex-col items-center text-center">
|
|
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
|
|
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
|
{t('empty.title')}
|
|
</h1>
|
|
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
|
|
{t('empty.subtitle')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<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="glass-panel relative flex flex-col gap-3 rounded-xl p-4"
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={handleDrop}
|
|
>
|
|
{fileSearchOpen && (
|
|
<FileSearchMenu
|
|
ref={fileSearchRef}
|
|
cwd={workDir || ''}
|
|
filter={atFilter}
|
|
onSelect={(_path, name) => {
|
|
if (atCursorPos >= 0) {
|
|
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
|
|
const newCursorPos = atCursorPos + name.length
|
|
setInput(newValue)
|
|
setFileSearchOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
void textareaRef.current?.focus()
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{localSlashPanel && (
|
|
<div ref={slashMenuRef}>
|
|
<LocalSlashCommandPanel
|
|
command={localSlashPanel}
|
|
cwd={workDir || undefined}
|
|
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 py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
|
style={{ fontFamily: 'var(--font-body)' }}
|
|
placeholder={t('empty.placeholder')}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3">
|
|
<div className="flex items-center gap-2">
|
|
<div ref={plusMenuRef} className="relative">
|
|
<button
|
|
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
|
aria-label="Open composer tools"
|
|
className="rounded-lg p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
|
</button>
|
|
|
|
{plusMenuOpen && (
|
|
<div className="absolute bottom-full left-0 mb-2 w-[240px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]">
|
|
<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} />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}
|
|
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30"
|
|
>
|
|
{t('common.run')}
|
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<DirectoryPicker value={workDir} onChange={setWorkDir} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
|
</div>
|
|
)
|
|
}
|