Files
kamidso-front/src/pages/AssistantPage.tsx
T
javadandCursor b17782eec2 Add Didar connections and a data hub for attaching sources.
The assistant can pick connectors and show subagent steps, and the databases page is replaced by connections, sources, and a dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 19:26:46 +03:30

743 lines
30 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { v4 as uuidv4 } from 'uuid'
import { useFeatureFlags } from '../app/providers/FeatureFlagProvider'
import { useConversations } from '../app/workspace/ConversationsContext'
import { useAssistantRuntime } from '../app/workspace/AssistantRuntimeContext'
import { ensureConversation } from '../features/assistant/api/conversations.api'
import { replyQuestion, rejectQuestion } from '../features/assistant/api/chat.api'
import { listDatasources, listKnowledgeSpaces, listSkills } from '../features/assistant/api/resources.api'
import { listConnectors } from '../features/connectors/api'
import { messagesToTurns } from '../features/assistant/adapters/runToTurn'
import { collectPinnedResources, collectTurnInputFiles, humanForView } from '../features/assistant/adapters/historyToMessages'
import { ExecutionPanel } from '../features/assistant/execution/ExecutionPanel'
import { EnhancedChatInput } from '../features/assistant/input/EnhancedChatInput'
import { ToolsBar } from '../features/assistant/input/ToolsBar'
import { QuestionDock } from '../features/assistant/questions/QuestionDock'
import type { ResourceOption } from '../features/assistant/types/chat'
import {
kamidsoFileToSnapshot,
listWorkspaceFiles,
previewFile,
sessionFilesApi,
snapshotsForSend,
useSessionFiles,
type KamidsoFile,
type SessionFileSnapshot,
} from '../features/assistant/session-files'
import { DATA_FILE_ACCEPT, isDataFilename } from '../features/assistant/session-files/fileKind'
import { FileTitleModal } from '../features/assistant/session-files/FileTitleModal'
import AttachmentPreview, {
AttachmentPreviewCloseButton,
AttachmentPreviewPanelTitle,
} from '../features/assistant/session-files/AttachmentPreview'
import { ChatMessageList } from '../features/assistant/workspace/ChatMessageList'
import { ChatWelcome } from '../features/assistant/workspace/ChatWelcome'
import { ConversationContextBar } from '../features/assistant/workspace/ConversationContextBar'
import { conversationWidthClass } from '../features/assistant/workspace/conversationLayout'
import { Drawer } from '../components/ui/Drawer'
import { t } from '../i18n'
import { skillNeedsSetup } from '../features/settings/types'
import { skillSettingsPath } from '../features/settings/api'
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
export function AssistantPage() {
const { developerMode, isEnabled } = useFeatureFlags()
const connectorsEnabled = isEnabled('connectors')
const location = useLocation()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const convId = searchParams.get('id')
const [conversationId, setConversationId] = useState<string | null>(convId)
const [query, setQuery] = useState('')
const [databases, setDatabases] = useState<ResourceOption[]>([])
const [knowledge, setKnowledge] = useState<ResourceOption[]>([])
const [connectors, setConnectors] = useState<ResourceOption[]>([])
const [skills, setSkills] = useState<ResourceOption[]>([])
const [selectedDbs, setSelectedDbs] = useState<ResourceOption[]>([])
const [selectedConnectors, setSelectedConnectors] = useState<ResourceOption[]>([])
const [selectedKnowledge, setSelectedKnowledge] = useState<ResourceOption | null>(null)
const [selectedSkill, setSelectedSkill] = useState<ResourceOption | null>(null)
const [reasoningMode, setReasoningMode] = useState<'fast' | 'medium' | 'deep'>('fast')
const [reportMode, setReportMode] = useState(false)
const [pendingSkillSetup, setPendingSkillSetup] = useState<ResourceOption | null>(null)
const { refresh: refreshConversations } = useConversations()
const [panelCollapsed, setPanelCollapsed] = useState(false)
const [preview, setPreview] = useState<{
loading: boolean
snapshot: Awaited<ReturnType<typeof previewFile>> | null
error: string | null
}>({ loading: false, snapshot: null, error: null })
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
const followLiveStepRef = useRef(true)
const [mobilePanelOpen, setMobilePanelOpen] = useState(false)
const [libraryFiles, setLibraryFiles] = useState<KamidsoFile[]>([])
const [pendingUpload, setPendingUpload] = useState<File[] | null>(null)
const libraryFileInputRef = useRef<HTMLInputElement>(null)
const sessionFiles = useSessionFiles({ api: sessionFilesApi })
const stream = useAssistantRuntime()
const presetApplied = useRef(false)
const newChatToken = useRef<number | null>(null)
const ignoreConvRestore = useRef(false)
const skipHistoryRestoreFor = useRef<string | null>(null)
const restoredFilesFor = useRef<string | null>(null)
const restoredCatalogFor = useRef<string | null>(null)
const refreshLibrary = useCallback(() => {
void listWorkspaceFiles()
.then(setLibraryFiles)
.catch(() => undefined)
}, [])
useEffect(() => {
void Promise.all([listDatasources(), listKnowledgeSpaces(), listSkills()]).then(([d, k, s]) => {
setDatabases(d)
setKnowledge(k)
setSkills(s)
})
refreshLibrary()
}, [refreshLibrary])
useEffect(() => {
if (!connectorsEnabled) {
setConnectors([])
setSelectedConnectors([])
return
}
void listConnectors()
.then((rows) =>
setConnectors(
rows
.filter((item) => item.status === 'active')
.map((item) => ({
id: item.id,
name: item.display_name,
type: 'didar',
})),
),
)
.catch(() => setConnectors([]))
}, [connectorsEnabled])
useEffect(() => {
const skillId = searchParams.get('skill')
if (!skillId || !skills.length || convId) return
const match = skills.find((item) => item.id === skillId)
if (!match) return
if (searchParams.get('configured') === '1' || !skillNeedsSetup(match)) {
setSelectedSkill(match)
setPendingSkillSetup(null)
return
}
setPendingSkillSetup(match)
}, [convId, searchParams, skills])
useEffect(() => {
const state = location.state as { preset?: string; newChat?: number } | null
if (state?.preset && !presetApplied.current) {
presetApplied.current = true
setQuery(state.preset)
}
if (state?.newChat && state.newChat !== newChatToken.current) {
newChatToken.current = state.newChat
ignoreConvRestore.current = true
stream.resetConversation()
sessionFiles.resetSession()
setConversationId(null)
setSelectedDbs([])
setSelectedConnectors([])
setSelectedKnowledge(null)
setSelectedSkill(null)
setSelectedStepId(null)
followLiveStepRef.current = true
setPanelCollapsed(false)
setQuery('')
skipHistoryRestoreFor.current = null
restoredFilesFor.current = null
restoredCatalogFor.current = null
if (searchParams.get('id') || searchParams.get('run')) {
setSearchParams({}, { replace: true })
}
}
// Reset only when navigation state changes (new chat / preset).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state])
useEffect(() => {
if (ignoreConvRestore.current) {
if (!convId) ignoreConvRestore.current = false
return
}
if (!convId) {
if (stream.conversationId && (stream.loading || stream.messages.length > 0)) {
setConversationId(stream.conversationId)
setSearchParams({ id: stream.conversationId }, { replace: true })
return
}
setConversationId(null)
return
}
if (skipHistoryRestoreFor.current === convId) {
skipHistoryRestoreFor.current = null
setConversationId(convId)
restoredFilesFor.current = convId
restoredCatalogFor.current = convId
return
}
if (convId === stream.conversationId) {
setConversationId(convId)
return
}
setConversationId(convId)
setSelectedStepId(null)
followLiveStepRef.current = true
if (sessionFiles.sessionId !== convId) {
if (sessionFiles.sessionId) sessionFiles.resetSession()
setSelectedDbs([])
setSelectedConnectors([])
setSelectedKnowledge(null)
setSelectedSkill(null)
restoredFilesFor.current = null
restoredCatalogFor.current = null
}
void stream.restoreConversation(convId)
// Restore when the conversation id in the URL changes, not when session file drafts update.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convId, stream.conversationId, stream.restoreConversation])
useEffect(() => {
if (sessionFiles.files.length > 0 || sessionFiles.legacyFile) {
setSelectedSkill(null)
}
}, [sessionFiles.files.length, sessionFiles.legacyFile])
useEffect(() => {
if (!convId || stream.conversationId !== convId || stream.messages.length === 0) return
const pins = collectPinnedResources(stream.messages)
if (restoredFilesFor.current !== convId) {
restoredFilesFor.current = convId
if (pins.files.length) sessionFiles.attachLibraryFiles(pins.files, convId)
}
if (restoredCatalogFor.current === convId) return
if (!databases.length && !knowledge.length && !connectors.length) return
restoredCatalogFor.current = convId
if (pins.databases.length) {
const matches = pins.databases
.map((pin) =>
databases.find((item) => item.id === pin.id || item.name === pin.db_name),
)
.filter((item): item is ResourceOption => Boolean(item))
if (matches.length) setSelectedDbs(matches)
}
if (pins.knowledge) {
const match = knowledge.find(
(item) => item.id === pins.knowledge?.id || item.name === pins.knowledge?.name,
)
if (match) setSelectedKnowledge(match)
}
if (pins.connectors.length) {
const matches = pins.connectors
.map((pin) => connectors.find((item) => item.id === pin.id || item.name === pin.name))
.filter((item): item is ResourceOption => Boolean(item))
if (matches.length) setSelectedConnectors(matches)
}
}, [convId, connectors, databases, knowledge, sessionFiles.attachLibraryFiles, stream.conversationId, stream.messages])
const rounds = useMemo(() => messagesToTurns(stream.messages), [stream.messages])
const isWelcome = rounds.length === 0
const activeExecution = stream.activeViewId ? stream.executionMap[stream.activeViewId] : undefined
const showExecutionPanel = !isWelcome && !panelCollapsed && (activeExecution?.steps.length ?? 0) > 0
const activeView = stream.messages.find((m) => m.id === stream.activeViewId && m.role === 'view')
const activeStepId = selectedStepId || activeExecution?.activeStepId
useEffect(() => {
followLiveStepRef.current = true
setSelectedStepId(null)
}, [stream.activeViewId])
useEffect(() => {
if (!activeExecution?.steps.length) return
if (!followLiveStepRef.current && selectedStepId) return
const running = [...activeExecution.steps].reverse().find((step) => step.status === 'running')
const target = running ?? activeExecution.steps[activeExecution.steps.length - 1]
if (selectedStepId !== target.id) setSelectedStepId(target.id)
}, [activeExecution?.steps, selectedStepId])
const handleStepSelect = useCallback((stepId: string) => {
followLiveStepRef.current = false
setSelectedStepId(stepId)
}, [])
const handleOpenExecution = useCallback((viewId: string, stepId?: string) => {
// Opening the panel from chat keeps live follow unless the run already finished.
if (stepId) {
if (!stream.loading) followLiveStepRef.current = false
setSelectedStepId(stepId)
}
setPanelCollapsed(false)
if (window.innerWidth < 1024) setMobilePanelOpen(true)
if (viewId !== stream.activeViewId) {
const view = stream.messages.find((m) => m.id === viewId && m.role === 'view')
if (view) stream.setActiveViewId(viewId)
}
}, [stream])
const panelInputFiles = useMemo(
() =>
collectTurnInputFiles(
stream.messages,
sessionFiles.files.map((draft) => draft.snapshot),
),
[stream.messages, sessionFiles.files],
)
const activeHuman = useMemo(
() => humanForView(stream.messages, stream.activeViewId),
[stream.messages, stream.activeViewId],
)
const executionPanelProps = {
steps: activeExecution?.steps || [],
outputs: activeExecution?.outputs || {},
activeStepId,
onStepSelect: handleStepSelect,
taskPlan: activeView?.taskPlan?.length ? activeView.taskPlan : stream.taskPlan,
summary: activeView?.context,
artifacts: stream.artifacts,
inputFiles: panelInputFiles,
attachedKnowledge: selectedKnowledge
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
: activeHuman?.attachedKnowledge,
attachedDb: selectedDbs[0]
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
: activeHuman?.attachedDb,
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
loading: stream.loading,
developerMode,
}
const resolveConversationId = useCallback(async () => {
const id = await ensureConversation(conversationId)
if (id !== conversationId) {
skipHistoryRestoreFor.current = id
restoredFilesFor.current = id
restoredCatalogFor.current = id
setConversationId(id)
setSearchParams({ id }, { replace: true })
}
return id
}, [conversationId, setSearchParams])
const handleSend = useCallback(async () => {
const text = query.trim()
if (!text && sessionFiles.files.length === 0 && !sessionFiles.legacyFile) return
setQuery('')
const currentConvId = await resolveConversationId()
let sendSnapshot = null
if (sessionFiles.files.length > 0 || sessionFiles.legacyFile) {
if (sessionFiles.hasHardFailures) {
setQuery(text)
return
}
sendSnapshot = await sessionFiles.prepare(currentConvId)
}
const resources: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge' | 'connector'; name?: string }> = []
if (sendSnapshot) {
for (const file of snapshotsForSend(sendSnapshot)) {
resources.push({ id: file.file_id, type: 'file', name: file.name })
}
}
for (const item of selectedDbs) {
resources.push({ id: item.id, type: 'datasource', name: item.name })
}
for (const item of selectedConnectors) {
resources.push({ id: item.id, type: 'connector', name: item.name })
}
if (selectedKnowledge) resources.push({ id: selectedKnowledge.id, type: 'knowledge', name: selectedKnowledge.name })
const humanId = uuidv4()
const viewId = uuidv4()
const order = Math.floor(stream.messages.length / 2) + 1
const attached = sendSnapshot ? snapshotsForSend(sendSnapshot) : []
const hasAttachedFiles = attached.length > 0
void stream
.send({
conversationId: currentConvId,
query: text || 'فایل را تحلیل کن.',
skillId: selectedSkill?.id,
reasoningMode,
reportMode,
resources,
humanMsg: {
id: humanId,
role: 'human',
context: text,
order,
attachedFiles: hasAttachedFiles ? attached : undefined,
attachedDb: selectedDbs[0]
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
: undefined,
attachedDatabases: selectedDbs.map((item) => ({
id: item.id,
db_name: item.name,
db_type: item.type || '',
})),
attachedSkill: selectedSkill
? { name: selectedSkill.name, id: selectedSkill.id }
: undefined,
attachedKnowledge: selectedKnowledge
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
: undefined,
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
},
viewMsg: { id: viewId, role: 'view', context: '', order, thinking: true, reportMode },
})
.finally(() => {
void refreshConversations()
})
window.setTimeout(() => {
void refreshConversations()
}, 700)
}, [query, sessionFiles, selectedDbs, selectedKnowledge, selectedSkill, reasoningMode, reportMode, stream, resolveConversationId, refreshConversations])
const handlePreviewFile = useCallback(
async (file: SessionFileSnapshot) => {
if (!conversationId || file.file_id.startsWith('legacy:')) return
setPreview({ loading: true, snapshot: null, error: null })
try {
const snapshot = await previewFile(conversationId, file.file_id)
setPreview({ loading: false, snapshot, error: null })
} catch (err) {
setPreview({ loading: false, snapshot: null, error: err instanceof Error ? err.message : 'خطا' })
}
},
[conversationId],
)
const attachedFileIds = useMemo(
() =>
sessionFiles.files
.map((draft) => draft.snapshot?.file_id)
.filter((id): id is string => Boolean(id)),
[sessionFiles.files],
)
const pinnedFiles = useMemo(
() =>
sessionFiles.files
.map((draft) => draft.snapshot)
.filter((file): file is SessionFileSnapshot => Boolean(file?.file_id)),
[sessionFiles.files],
)
const hasConversationPins = Boolean(
pinnedFiles.length || selectedDbs.length || selectedKnowledge || selectedConnectors.length,
)
const dockDrafts = sessionFiles.files.filter((draft) => draft.upload.status !== 'done')
const handleRemovePinnedFile = useCallback(
(fileId: string) => {
const draft = sessionFiles.files.find((item) => item.snapshot?.file_id === fileId)
if (draft) sessionFiles.remove(draft.clientId)
},
[sessionFiles],
)
const handlePickLocalFiles = useCallback((files: FileList | File[]) => {
const next = Array.from(files).filter((file) => isDataFilename(file.name))
if (next.length) setPendingUpload(next)
}, [])
const handleConfirmUpload = useCallback(
async (files: File[], titles: string[]) => {
setPendingUpload(null)
const id = await resolveConversationId()
await sessionFiles.addFiles(files, id, { titles })
refreshLibrary()
},
[resolveConversationId, sessionFiles, refreshLibrary],
)
const handleToggleLibraryFile = useCallback(
(file: KamidsoFile) => {
const snapshot = kamidsoFileToSnapshot(file)
if (!snapshot.file_id) return
const existing = sessionFiles.files.find((draft) => draft.snapshot?.file_id === snapshot.file_id)
if (existing) {
sessionFiles.remove(existing.clientId)
return
}
sessionFiles.attachLibraryFiles(
[snapshot],
conversationId || stream.conversationId || sessionFiles.sessionId,
)
if (!conversationId && !stream.conversationId) {
void resolveConversationId().then((id) => {
sessionFiles.attachLibraryFiles([snapshot], id)
})
}
},
[conversationId, resolveConversationId, sessionFiles, stream.conversationId],
)
const toolsBar = (
<ToolsBar
databases={databases}
knowledge={knowledge}
skills={skills}
selectedDbs={selectedDbs}
selectedKnowledge={selectedKnowledge}
selectedSkill={selectedSkill}
onSelectDbs={setSelectedDbs}
onSelectKnowledge={setSelectedKnowledge}
onSelectSkill={setSelectedSkill}
reportMode={reportMode}
onReportModeChange={setReportMode}
reasoningMode={reasoningMode}
onReasoningModeChange={setReasoningMode}
libraryFiles={libraryFiles}
attachedFileIds={attachedFileIds}
onToggleLibraryFile={handleToggleLibraryFile}
onUploadNew={() => libraryFileInputRef.current?.click()}
connectors={connectors}
selectedConnectors={selectedConnectors}
onSelectConnectors={connectorsEnabled ? setSelectedConnectors : undefined}
/>
)
return (
<div className="flex h-full min-h-0 min-w-0 overflow-x-hidden" dir="ltr">
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden" dir="rtl">
{isWelcome ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="lp-root relative flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center overflow-x-hidden overflow-y-auto px-3">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="lp-grid-bg absolute inset-0 [mask-image:radial-gradient(ellipse_70%_55%_at_50%_40%,#000_28%,transparent_100%)]" />
<div className="absolute -top-24 right-[-6rem] h-[22rem] w-[22rem] rounded-full bg-accent/15 blur-3xl" />
<div className="absolute top-10 left-[-8rem] h-[20rem] w-[20rem] rounded-full bg-violet-500/14 blur-3xl" />
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-surface to-transparent" />
</div>
<ChatWelcome onSuggestion={setQuery} />
<div className="relative z-10 w-full min-w-0 max-w-3xl">
<EnhancedChatInput
variant="hero"
query={query}
onQueryChange={setQuery}
onSend={() => void handleSend()}
onStop={() => void stream.cancel()}
loading={stream.loading}
drafts={sessionFiles.files}
legacyFiles={sessionFiles.legacyFile ? [sessionFiles.legacyFile] : []}
onRemoveFile={sessionFiles.remove}
onRetryFile={() => sessionFiles.retryFailed()}
onAddFiles={handlePickLocalFiles}
pinnedDatabases={selectedDbs}
pinnedKnowledge={selectedKnowledge}
pinnedConnectors={selectedConnectors}
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
onClearKnowledge={() => setSelectedKnowledge(null)}
onRemoveConnector={(id: string) =>
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
}
toolbar={toolsBar}
/>
</div>
</div>
</div>
) : (
<>
<div
className={`grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] ${
showExecutionPanel
? 'gap-2 p-2 md:gap-3 md:p-3 grid-cols-1 lg:grid-cols-[minmax(280px,0.85fr)_minmax(0,1.7fr)]'
: 'grid-cols-1'
}`}
dir="ltr"
>
{showExecutionPanel ? (
<div className="assistant-panel-enter hidden h-full min-h-0 lg:flex">
<ExecutionPanel
{...executionPanelProps}
className="h-full min-h-0 w-full"
onCollapse={() => setPanelCollapsed(true)}
/>
</div>
) : null}
<div className="flex h-full min-h-0 min-w-0 flex-col" dir="rtl">
{stream.runStatus === 'waiting_for_user' ? (
<div className="mb-2 rounded-lg bg-warn-soft px-3 py-2 text-xs text-warn chat-enter">
{t('assistant.execution.waitingForUser')}
</div>
) : null}
{hasConversationPins ? (
<div className={`${conversationWidthClass} shrink-0 pb-1 ${showExecutionPanel ? 'pt-0' : 'pt-3'}`}>
<ConversationContextBar
files={pinnedFiles}
knowledge={
selectedKnowledge
? { id: selectedKnowledge.id, name: selectedKnowledge.name }
: undefined
}
databases={selectedDbs.map((item) => ({
id: item.id,
db_name: item.name,
db_type: item.type || '',
}))}
connectors={selectedConnectors.map((item) => ({ id: item.id, name: item.name }))}
onPreviewFile={(file) => void handlePreviewFile(file)}
onRemoveFile={handleRemovePinnedFile}
onRemoveDatabase={(key) =>
setSelectedDbs((prev) => prev.filter((item) => item.id !== key && item.name !== key))
}
onRemoveConnector={(id: string) =>
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
}
onClearKnowledge={() => setSelectedKnowledge(null)}
/>
</div>
) : null}
<ChatMessageList
rounds={rounds}
loading={stream.loading}
executionMap={stream.executionMap}
activeViewId={stream.activeViewId}
selectedStepId={selectedStepId}
htmlByViewId={stream.htmlByViewId}
thinkingByViewId={stream.thinkingByViewId}
liveTaskPlan={stream.taskPlan}
hideTurnAttachments={hasConversationPins}
onOpenExecution={handleOpenExecution}
onRetryStopped={() => void stream.retryStopped()}
onPreviewFile={(file) => void handlePreviewFile(file)}
/>
<EnhancedChatInput
variant="dock"
query={query}
onQueryChange={setQuery}
onSend={() => void handleSend()}
onStop={() => void stream.cancel()}
loading={stream.loading}
drafts={dockDrafts}
legacyFiles={sessionFiles.legacyFile ? [sessionFiles.legacyFile] : []}
onRemoveFile={sessionFiles.remove}
onRetryFile={() => sessionFiles.retryFailed()}
onAddFiles={handlePickLocalFiles}
pinnedDatabases={selectedDbs}
pinnedKnowledge={selectedKnowledge}
pinnedConnectors={selectedConnectors}
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
onClearKnowledge={() => setSelectedKnowledge(null)}
onRemoveConnector={(id: string) =>
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
}
toolbar={toolsBar}
/>
</div>
</div>
</>
)}
</div>
{!isWelcome && panelCollapsed ? (
<button
type="button"
className="fixed inset-y-0 left-0 z-20 hidden w-8 flex-col justify-center rounded-r-lg border border-line bg-panel/95 text-ink-muted shadow hover:bg-surface-subtle hover:text-accent lg:flex"
onClick={() => setPanelCollapsed(false)}
aria-label={t('assistant.panel.showExecution')}
>
<span className="rotate-90 whitespace-nowrap text-[10px] font-medium tracking-wide">
{t('assistant.panel.showExecution')}
</span>
</button>
) : null}
<Drawer
open={mobilePanelOpen}
title={t('assistant.execution.title')}
onClose={() => setMobilePanelOpen(false)}
width="w-full max-w-lg"
side="start"
>
<div className="min-h-0 flex-1 overflow-hidden p-3">
<ExecutionPanel {...executionPanelProps} className="h-[calc(100dvh-8rem)]" />
</div>
</Drawer>
{stream.pendingQuestion && stream.runStatus === 'waiting_for_user' ? (
<QuestionDock
question={stream.pendingQuestion}
onReply={async (text) => {
const q = stream.pendingQuestion!
if (!q.runId || !q.id) return
try {
await replyQuestion(q.runId, q.id, text)
stream.setPendingQuestion(null)
} catch {
/* keep dock open so the user can retry */
}
}}
onReject={async () => {
const q = stream.pendingQuestion!
if (!q.runId || !q.id) return
try {
await rejectQuestion(q.runId, q.id)
stream.setPendingQuestion(null)
} catch {
/* keep dock open */
}
}}
/>
) : null}
<input
ref={libraryFileInputRef}
type="file"
multiple
accept={DATA_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
if (event.target.files?.length) handlePickLocalFiles(event.target.files)
event.target.value = ''
}}
/>
<FileTitleModal
files={pendingUpload}
onClose={() => setPendingUpload(null)}
onConfirm={(files, titles) => void handleConfirmUpload(files, titles)}
/>
{preview.loading || preview.snapshot || preview.error ? (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-ink/40 p-4 md:items-center">
<div className="max-h-[80vh] w-full max-w-3xl overflow-hidden rounded-2xl border border-line bg-panel shadow-xl">
<div className="flex items-center justify-between border-b border-line px-4 py-3">
<AttachmentPreviewPanelTitle name={preview.snapshot?.name || t('assistant.files.preview')} />
<AttachmentPreviewCloseButton
onClick={() => setPreview({ loading: false, snapshot: null, error: null })}
/>
</div>
<div className="overflow-y-auto p-4">
<AttachmentPreview snapshot={preview.snapshot} loading={preview.loading} error={preview.error} />
</div>
</div>
</div>
) : null}
<ConfirmDialog
open={Boolean(pendingSkillSetup)}
title="تنظیمات این مهارت لازم است"
message={`برای انتخاب «${pendingSkillSetup?.name || 'این مهارت'}» باید تنظیمات کسب‌وکار سازمان را تکمیل کنید. قوانین ایران توسط کامیدسو مدیریت می‌شود و قابل تغییر نیست. آیا الان تنظیمات را انجام می‌دهید؟`}
confirmLabel="بله، تنظیم می‌کنم"
cancelLabel="فعلاً نه"
onConfirm={() => {
if (!pendingSkillSetup) return
navigate(skillSettingsPath(pendingSkillSetup.id, '/assistant'))
setPendingSkillSetup(null)
}}
onCancel={() => setPendingSkillSetup(null)}
/>
</div>
)
}