Files
2026-07-01 17:56:31 +08:00

816 lines
32 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import {
IconArrowRight,
IconBuilding,
IconCheck,
IconClose,
IconLock,
IconPaperclip,
IconSend,
IconSparkles,
IconStop,
IconUserRound,
} from './SvgIcons'
import type { OutgoingAttachmentPayload } from '../types/chat'
import type { TaskPreferredAgent } from '../types/kanban'
import type { SavedOrgSummary } from '../types/visual'
import { getContextUsageMetrics } from '../lib/contextUsage'
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_TOTAL_SIZE = 20 * 1024 * 1024
const ACCEPTED_TYPES = 'image/*,video/mp4,video/mpeg,video/quicktime,video/webm,.mp4,.mpeg,.mpg,.mov,.webm,.txt,.md,.pdf,.csv,.json,.yaml,.yml,.py,.js,.ts,.tsx,.jsx,.html,.css,.java,.c,.cpp,.go,.rs,.rb,.sh,.xml,.toml,.docx,.xlsx,.pptx'
type AttachmentTransferState = 'reading' | 'ready' | 'error'
type ComposerExecMode = 'task' | 'company' | 'org' | 'custom'
type ComposerCompanyProfile = 'corporate' | 'custom'
type ComposerModeOption = 'task' | 'company'
type CompanyArchitectureOption = '' | 'corporate' | `org:${string}`
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
native: 'OpenOPC Native',
codex: 'Codex',
claude_code: 'Claude Code',
cursor: 'Cursor',
opencode: 'OpenCode',
}
interface PendingAttachment {
id: string
file: File
filename: string
mime_type: string
size_bytes: number
preview_url: string
base64_data?: string
progress_percent: number
transfer_state: AttachmentTransferState
error?: string
}
function readFileAsBase64(file: File, onProgress?: (progress: number) => void): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onprogress = (event) => {
if (!event.lengthComputable) return
onProgress?.(Math.round((event.loaded / event.total) * 100))
}
reader.onload = () => {
const result = reader.result as string
onProgress?.(100)
resolve(result.split(',')[1] || '')
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
function attachmentBadgeLabel(mime: string, filename: string): string {
const extension = filename.includes('.') ? filename.split('.').pop()?.toUpperCase() ?? '' : ''
if (mime.startsWith('image/')) return 'IMG'
if (mime.startsWith('video/')) return 'VID'
if (mime === 'application/pdf') return 'PDF'
if (mime.includes('wordprocessingml')) return 'DOC'
if (mime.includes('spreadsheetml') || extension === 'CSV') return 'XLS'
if (mime.includes('presentationml')) return 'PPT'
if (mime.includes('json')) return 'JSON'
if (mime.includes('yaml') || extension === 'YML' || extension === 'YAML') return 'YAML'
if (mime.startsWith('text/')) return 'TXT'
if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(extension)) return extension
return extension || 'FILE'
}
function attachmentToneClass(mime: string, filename: string): string {
const label = attachmentBadgeLabel(mime, filename)
if (label === 'IMG') return 'image'
if (label === 'VID') return 'video'
if (label === 'PDF') return 'pdf'
if (label === 'DOC' || label === 'XLS' || label === 'PPT') return 'office'
if (label === 'JSON' || label === 'YAML') return 'data'
if (label === 'TXT') return 'text'
if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(label)) return 'code'
return 'generic'
}
function AttachmentProgressRing({
progress,
state,
error,
}: {
progress: number
state: AttachmentTransferState
error?: string
}) {
const radius = 11
const circumference = 2 * Math.PI * radius
const normalized = error ? 100 : state === 'ready' ? 100 : Math.max(0, Math.min(progress, 100))
const dashOffset = circumference * (1 - normalized / 100)
return (
<span
className={`attachment-progress-ring${error ? ' error' : state === 'ready' ? ' ready' : ''}`}
aria-label={error ? 'Attachment preparation failed' : state === 'ready' ? 'Attachment ready to send' : `Preparing attachment ${normalized}%`}
role="img"
>
<svg viewBox="0 0 28 28" aria-hidden="true">
<circle className="attachment-progress-track" cx="14" cy="14" r={radius} />
<circle
className="attachment-progress-value"
cx="14"
cy="14"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="attachment-progress-center">
{error ? '!' : state === 'ready' ? <IconCheck /> : normalized}
</span>
</span>
)
}
function ContextRing({
usedPct,
usedTokens,
windowTokens,
}: {
usedPct: number
usedTokens?: number
windowTokens?: number
}) {
const radius = 11
const circumference = 2 * Math.PI * radius
const clamped = Math.max(0, Math.min(usedPct, 100))
const dashOffset = circumference * (1 - clamped / 100)
const isLow = clamped >= 80
const isCritical = clamped >= 90
const usageLabel = typeof usedTokens === 'number' && typeof windowTokens === 'number'
? `${clamped}% used (${usedTokens.toLocaleString()}/${windowTokens.toLocaleString()})`
: `${clamped}% used`
return (
<span
className={`composer-context-ring${isLow ? ' low' : ''}${isCritical ? ' critical' : ''}`}
title={`Context window: ${usageLabel}`}
aria-label={`Context window ${usageLabel}`}
role="img"
>
<svg viewBox="0 0 28 28" aria-hidden="true">
<circle className="composer-context-ring-track" cx="14" cy="14" r={radius} />
<circle
className="composer-context-ring-value"
cx="14"
cy="14"
r={radius}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="composer-context-ring-label">{clamped}</span>
</span>
)
}
interface MessageComposerProps {
disabled?: boolean
placeholder?: string
channelId?: string
execMode?: string
companyProfile?: string
taskPreferredAgent?: TaskPreferredAgent
agentStatus?: string
currentTool?: string
displayTool?: string
activeAgentCount?: number
runtimeControlState?: 'running' | 'suspending' | 'suspended' | 'resuming' | 'idle'
canStop?: boolean
autoFocus?: boolean
contextTokens?: number
contextWindow?: number
contextRemainingPct?: number
savedOrgs?: SavedOrgSummary[] | null
activeSavedOrg?: string | null
selectedOrgId?: string | null
/**
* When true the mode/agent pickers freeze into a read-only chip: the chat
* has committed to its current execution identity (i.e. messages have been
* sent) and the identity is no longer changeable from this composer. The
* chip exposes a hover hint pointing users at "start a new chat" instead.
*/
lockedMode?: boolean
onSend: (content: string, attachments?: OutgoingAttachmentPayload[]) => void
onModeChange?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void
onTaskAgentChange?: (preferredAgent: TaskPreferredAgent) => void
onSavedOrgsRefresh?: () => void
onSavedOrgLoad?: (name: string) => void
onStop?: () => void
/**
* Spawn a brand-new chat in the requested mode, preserving the user inside
* the same project. Wired from the locked-mode chip popover so users can
* "continue in a different mode" without having to find the global new-chat
* button. When omitted, the popover degrades gracefully to text-only.
*/
onContinueInNewChat?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void
}
interface ModeAlternative {
key: string
mode: ComposerExecMode
profile?: ComposerCompanyProfile
orgId?: string
label: string
description: string
icon: ReactElement
}
function savedOrgLabel(org: SavedOrgSummary): string {
return org.organization_name?.trim() || org.name
}
export function MessageComposer({
disabled,
placeholder,
channelId,
execMode,
companyProfile,
taskPreferredAgent = 'native',
agentStatus,
currentTool,
displayTool,
activeAgentCount,
runtimeControlState,
canStop,
autoFocus = true,
contextTokens,
contextWindow,
contextRemainingPct,
savedOrgs,
activeSavedOrg,
selectedOrgId,
lockedMode = false,
onSend,
onModeChange,
onTaskAgentChange,
onSavedOrgsRefresh,
onSavedOrgLoad,
onStop,
onContinueInNewChat,
}: MessageComposerProps) {
const [text, setText] = useState('')
const [focused, setFocused] = useState(false)
const [pending, setPending] = useState<PendingAttachment[]>([])
const [lightbox, setLightbox] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const isStopping = runtimeControlState === 'suspending'
const isRuntimeActive = runtimeControlState === 'running'
|| runtimeControlState === 'suspending'
|| runtimeControlState === 'resuming'
const isSuspended = runtimeControlState === 'suspended'
const hasRuntimeControlState = runtimeControlState != null
const isWorking = isRuntimeActive || (!hasRuntimeControlState && agentStatus != null && agentStatus !== 'idle')
const stopEnabled = (canStop ?? true) && !isStopping && !isSuspended
const contextUsage = useMemo(
() => getContextUsageMetrics({ contextTokens, contextWindow, contextRemainingPct }),
[contextRemainingPct, contextTokens, contextWindow],
)
useEffect(() => {
const ta = textareaRef.current
if (!ta) return
ta.style.height = 'auto'
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`
}, [text])
useEffect(() => {
setText('')
setPending(prev => {
prev.forEach(attachment => {
if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url)
})
return []
})
if (!disabled && autoFocus) setTimeout(() => textareaRef.current?.focus(), 50)
}, [channelId, disabled, autoFocus])
const updateAttachment = useCallback((id: string, updater: (attachment: PendingAttachment) => PendingAttachment) => {
setPending(prev => prev.map(attachment => attachment.id === id ? updater(attachment) : attachment))
}, [])
const prepareAttachment = useCallback(async (attachmentId: string, file: File) => {
try {
const base64 = await readFileAsBase64(file, (progress) => {
updateAttachment(attachmentId, (attachment) => ({
...attachment,
progress_percent: progress,
transfer_state: 'reading',
}))
})
updateAttachment(attachmentId, (attachment) => ({
...attachment,
base64_data: base64,
progress_percent: 100,
transfer_state: 'ready',
}))
} catch {
updateAttachment(attachmentId, (attachment) => ({
...attachment,
error: 'Failed to prepare file',
progress_percent: 0,
transfer_state: 'error',
}))
}
}, [updateAttachment])
const addFiles = useCallback((files: FileList | File[]) => {
const arr = Array.from(files)
let runningTotal = pending.reduce((sum, attachment) => sum + attachment.size_bytes, 0)
const newPending: PendingAttachment[] = arr.map(file => {
let error: string | undefined
if (file.size > MAX_FILE_SIZE) error = `Too large (${formatSize(file.size)})`
else if (runningTotal + file.size > MAX_TOTAL_SIZE) error = 'Total size exceeds 20MB'
else runningTotal += file.size
return {
id: crypto.randomUUID(),
file,
filename: file.name || 'upload',
mime_type: file.type || 'application/octet-stream',
size_bytes: file.size,
preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
progress_percent: 0,
transfer_state: error ? 'error' : 'reading',
error,
}
})
setPending(prev => [...prev, ...newPending])
newPending
.filter(attachment => !attachment.error)
.forEach(attachment => { void prepareAttachment(attachment.id, attachment.file) })
}, [pending, prepareAttachment])
const removeAttachment = useCallback((id: string) => {
setPending(prev => {
const item = prev.find(attachment => attachment.id === id)
if (item?.preview_url) URL.revokeObjectURL(item.preview_url)
return prev.filter(attachment => attachment.id !== id)
})
}, [])
const handleSend = useCallback(() => {
if (disabled || isWorking) return
const content = text.trim()
const preparing = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading')
const ready = pending.filter(attachment => !attachment.error && !!attachment.base64_data)
if (preparing.length > 0) return
if (!content && ready.length === 0) return
const attachments = ready.map(attachment => ({
filename: attachment.filename,
data: attachment.base64_data!,
mime_type: attachment.mime_type,
}))
onSend(content || 'Sent with attachments', attachments.length ? attachments : undefined)
setText('')
pending.forEach(attachment => {
if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url)
})
setPending([])
}, [disabled, isWorking, text, pending, onSend])
const handlePaste = useCallback((event: React.ClipboardEvent) => {
const files = event.clipboardData?.files
if (files && files.length > 0) {
event.preventDefault()
addFiles(files)
}
}, [addFiles])
const normalizedCompanyProfile = String(companyProfile ?? '').trim().toLowerCase()
const normalizedExecMode: ComposerExecMode = execMode === 'company'
? 'company'
: execMode === 'org' || execMode === 'custom' || normalizedCompanyProfile === 'custom'
? 'org'
: 'task'
const savedOrgOptions = useMemo(
() => (savedOrgs ?? []).filter(org => !!org.name && org.name !== 'corporate'),
[savedOrgs],
)
const activeSavedOrgOption = activeSavedOrg
? savedOrgOptions.find(org => org.name === activeSavedOrg)
: undefined
const selectedOrgOption = selectedOrgId
? savedOrgOptions.find(org => org.name === selectedOrgId)
: undefined
const activeSavedOrgLabel = activeSavedOrgOption
? savedOrgLabel(activeSavedOrgOption)
: activeSavedOrg || ''
const selectedOrgLabel = selectedOrgOption
? savedOrgLabel(selectedOrgOption)
: selectedOrgId || activeSavedOrgLabel
const selectedOrgValue = selectedOrgOption?.name || selectedOrgId || activeSavedOrgOption?.name || ''
const selectedModeOption: ComposerModeOption = normalizedExecMode === 'task' ? 'task' : 'company'
const selectedCompanyArchitecture: CompanyArchitectureOption = normalizedExecMode === 'org'
? (selectedOrgValue ? `org:${selectedOrgValue}` : '')
: 'corporate'
const companyArchitectureLabel = normalizedExecMode === 'org'
? selectedOrgLabel
? `Company / ${selectedOrgLabel}`
: 'Company / Saved org'
: 'Company / Corporate'
const modeLabel = selectedModeOption === 'task' ? 'Task' : companyArchitectureLabel
const showModePicker = !!execMode && !!onModeChange
const showTaskAgentPicker = normalizedExecMode === 'task' && !!onTaskAgentChange
// Build the list of "Continue in a new chat" alternatives, excluding the
// mode the current chat is already locked to. We surface up to three options
// so the popover stays compact; the order is stable so users build muscle
// memory for it.
const continueAlternatives: ModeAlternative[] = useMemo(() => {
const currentKey = normalizedExecMode === 'task'
? 'task'
: normalizedExecMode === 'org'
? `org:${selectedOrgValue || 'selected'}`
: 'company:corporate'
const continueOrgName = selectedOrgValue || activeSavedOrgOption?.name || ''
const continueOrgLabel = selectedOrgLabel || activeSavedOrgLabel || continueOrgName
const all: ModeAlternative[] = [
{
key: 'task',
mode: 'task',
label: 'Task',
description: 'A single agent handles the request',
icon: <IconUserRound />,
},
{
key: 'company:corporate',
mode: 'company',
profile: 'corporate',
label: 'Company / Corporate',
description: 'A team of roles collaborates',
icon: <IconBuilding />,
},
]
if (continueOrgName) {
all.push({
key: `org:${continueOrgName}`,
mode: 'org',
profile: 'custom',
orgId: continueOrgName,
label: `Company / ${continueOrgLabel}`,
description: 'A saved company architecture collaborates',
icon: <IconSparkles />,
})
}
return all.filter(option => option.key !== currentKey)
}, [activeSavedOrgLabel, activeSavedOrgOption?.name, normalizedExecMode, selectedOrgLabel, selectedOrgValue])
const preparingAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading').length
const readyAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'ready').length
const visibleTool = displayTool || currentTool
const statusText = (() => {
if (!isWorking) return null
if (isStopping) return 'Stopping...'
if (activeAgentCount && activeAgentCount > 1) return `${activeAgentCount} agents working`
if (visibleTool) return `Running ${visibleTool}`
if (agentStatus === 'reflecting') return 'Thinking...'
return 'Working...'
})()
return (
<>
<div className={`msg-composer${focused ? ' focused' : ''}${isWorking ? ' working' : ''}`}>
{isWorking && statusText && (
<div className="composer-status">
<div className="composer-status-indicator" />
<span className="composer-status-text">{statusText}</span>
<button className="composer-stop-btn" onClick={onStop} title="Stop" disabled={!stopEnabled || !onStop}>
<IconStop />
<span>{isStopping ? 'Stopping...' : 'Stop'}</span>
</button>
</div>
)}
{pending.length > 0 && (
<div className="composer-attachments">
{pending.map(attachment => (
<div key={attachment.id} className={`attachment-chip${attachment.error ? ' error' : ''}`}>
{attachment.preview_url ? (
<img
className="attachment-thumb"
src={attachment.preview_url}
alt={attachment.filename}
onClick={() => setLightbox(attachment.preview_url)}
/>
) : (
<span className={`attachment-file-icon tone-${attachmentToneClass(attachment.mime_type, attachment.filename)}`}>
{attachmentBadgeLabel(attachment.mime_type, attachment.filename)}
</span>
)}
<span className="attachment-chip-info">
<span className="attachment-chip-name">{attachment.filename}</span>
<span className="attachment-chip-size">
{attachment.error
? attachment.error
: attachment.transfer_state === 'reading'
? `Preparing ${attachment.progress_percent}%`
: `${formatSize(attachment.size_bytes)} - Ready`}
</span>
</span>
<AttachmentProgressRing
progress={attachment.progress_percent}
state={attachment.transfer_state}
error={attachment.error}
/>
<button
className="attachment-chip-remove"
onClick={() => removeAttachment(attachment.id)}
title="Remove"
>
<IconClose />
</button>
</div>
))}
</div>
)}
<div className="composer-input-area">
<textarea
ref={textareaRef}
value={text}
onChange={event => setText(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onPaste={handlePaste}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}}
placeholder={placeholder ?? 'Message...'}
rows={1}
disabled={disabled}
/>
<div className="composer-bottom">
<button
className="composer-attach-btn"
onClick={() => fileInputRef.current?.click()}
title="Attach files"
disabled={disabled}
>
<IconPaperclip />
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_TYPES}
style={{ display: 'none' }}
onChange={event => {
if (event.target.files) addFiles(event.target.files)
event.target.value = ''
}}
/>
{(showModePicker || execMode) && (
<div className="composer-config-group" data-locked={lockedMode ? 'true' : undefined}>
{showModePicker && !lockedMode ? (
<label
className="composer-mode-inline"
data-kind="mode"
title="Execution mode for this chat and new work started from it"
>
<span className="composer-mode-inline-label">Mode</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={selectedModeOption}
onChange={(event) => {
const value = event.target.value as ComposerModeOption
if (value === 'task') {
onModeChange?.('task')
return
}
onModeChange?.('company', 'corporate')
}}
onFocus={() => onSavedOrgsRefresh?.()}
onPointerDown={() => onSavedOrgsRefresh?.()}
disabled={disabled}
aria-label="Execution mode"
>
<option value="task">Task</option>
<option value="company">Company</option>
</select>
</span>
</label>
) : showModePicker && lockedMode ? (
<div
className="composer-mode-chip"
data-kind="mode"
tabIndex={0}
role="group"
aria-label={`Mode locked to ${modeLabel}.${onContinueInNewChat ? ' Use the menu to start a new chat in a different mode.' : ' Start a new chat to use a different mode.'}`}
>
<span className="composer-mode-chip-icon" aria-hidden="true">
<IconLock />
</span>
<span className="composer-mode-chip-label">{modeLabel}</span>
<div className="composer-mode-chip-popover" role="dialog" aria-label="Mode info">
<div className="composer-mode-chip-popover-title">
Mode is fixed for this chat
</div>
<div className="composer-mode-chip-popover-body">
Once the first message is sent, this chat is committed to{' '}
<strong>{modeLabel}</strong>.
</div>
{onContinueInNewChat && continueAlternatives.length > 0 && (
<>
<div className="composer-mode-chip-popover-divider" aria-hidden="true" />
<div className="composer-mode-chip-popover-action-title">
Continue in a new chat
</div>
<div className="composer-mode-chip-popover-actions">
{continueAlternatives.map(alt => (
<button
key={alt.key}
type="button"
className="composer-mode-chip-popover-action"
onClick={() => onContinueInNewChat(alt.mode, alt.profile, alt.orgId)}
aria-label={`Start a new chat in ${alt.label}`}
>
<span className="composer-mode-chip-popover-action-icon" aria-hidden="true">
{alt.icon}
</span>
<span className="composer-mode-chip-popover-action-text">
<span className="composer-mode-chip-popover-action-label">
{alt.label}
</span>
<span className="composer-mode-chip-popover-action-desc">
{alt.description}
</span>
</span>
<span className="composer-mode-chip-popover-action-arrow" aria-hidden="true">
<IconArrowRight />
</span>
</button>
))}
</div>
</>
)}
</div>
</div>
) : (
<span className="composer-mode" data-kind="mode">{modeLabel}</span>
)}
{selectedModeOption === 'company' && showModePicker && !lockedMode && (
<>
<span className="composer-config-divider" aria-hidden="true" />
<label
className="composer-mode-inline"
data-kind="org"
title="Company architecture for this chat"
>
<span className="composer-mode-inline-label">Company</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={selectedCompanyArchitecture}
onChange={(event) => {
const value = event.target.value as CompanyArchitectureOption
if (value === 'corporate') {
onModeChange?.('company', 'corporate')
return
}
if (value.startsWith('org:')) {
const orgName = value.slice(4)
if (orgName) onModeChange?.('org', 'custom', orgName)
}
}}
onFocus={() => onSavedOrgsRefresh?.()}
onPointerDown={() => onSavedOrgsRefresh?.()}
disabled={disabled}
aria-label="Company architecture"
>
<option value="corporate">Corporate</option>
{!selectedCompanyArchitecture && (
<option value="" disabled>Select saved org</option>
)}
{selectedCompanyArchitecture
&& selectedCompanyArchitecture !== 'corporate'
&& !savedOrgOptions.some(org => `org:${org.name}` === selectedCompanyArchitecture) && (
<option value={selectedCompanyArchitecture}>{selectedOrgLabel || selectedOrgValue}</option>
)}
{savedOrgOptions.length === 0 ? (
<option value="" disabled>No saved orgs</option>
) : savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}>
{savedOrgLabel(org)}
</option>
))}
</select>
</span>
</label>
</>
)}
{normalizedExecMode === 'task' && (
<>
<span className="composer-config-divider" aria-hidden="true" />
{showTaskAgentPicker && !lockedMode ? (
<label
className="composer-mode-inline"
data-kind="agent"
title="Execution agent for this task-mode chat"
>
<span className="composer-mode-inline-label">Agent</span>
<span className="composer-mode-select-wrap">
<select
className="composer-mode-select"
value={taskPreferredAgent}
onChange={(event) => onTaskAgentChange?.(event.target.value as TaskPreferredAgent)}
disabled={disabled}
aria-label="Task mode agent"
>
{Object.entries(TASK_AGENT_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</span>
</label>
) : showTaskAgentPicker && lockedMode ? (
<span
className="composer-mode-chip"
data-kind="agent"
tabIndex={0}
role="status"
aria-label={`Agent locked to ${TASK_AGENT_LABELS[taskPreferredAgent]}. Start a new chat to use a different agent.`}
>
<span className="composer-mode-chip-icon" aria-hidden="true">
<IconLock />
</span>
<span className="composer-mode-chip-label">
{TASK_AGENT_LABELS[taskPreferredAgent]}
</span>
<span className="composer-mode-chip-popover" role="tooltip">
<span className="composer-mode-chip-popover-title">
Agent is fixed for this chat
</span>
<span className="composer-mode-chip-popover-body">
The execution agent is committed once the chat starts. Start a new
chat to switch agents.
</span>
</span>
</span>
) : (
<span className="composer-mode" data-kind="agent">{TASK_AGENT_LABELS[taskPreferredAgent]}</span>
)}
</>
)}
</div>
)}
<span className="composer-hint">
{preparingAttachmentCount > 0
? `Preparing ${preparingAttachmentCount} attachment${preparingAttachmentCount > 1 ? 's' : ''}`
: readyAttachmentCount > 0
? `${readyAttachmentCount} attachment${readyAttachmentCount > 1 ? 's' : ''} ready`
: 'Shift+Enter for new line'}
</span>
{typeof contextUsage.usedPct === 'number' && (
<ContextRing
usedPct={contextUsage.usedPct}
usedTokens={contextUsage.usedTokens}
windowTokens={contextUsage.windowTokens}
/>
)}
<button
className="composer-send-btn"
onClick={handleSend}
disabled={disabled || preparingAttachmentCount > 0 || (!text.trim() && readyAttachmentCount === 0) || isWorking}
>
<IconSend />
</button>
</div>
</div>
</div>
{lightbox && (
<div className="lightbox-overlay" onClick={() => setLightbox(null)}>
<img className="lightbox-img" src={lightbox} alt="Preview" />
</div>
)}
</>
)
}