Initial commit
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user