import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ArrowsInLineHorizontal, ArrowsOutLineHorizontal, CaretDown, GearSix, Plus, SidebarSimple, X } from '@phosphor-icons/react' import { DndContext, PointerSensor, closestCenter, type DragEndEvent, useSensor, useSensors, } from '@dnd-kit/core' import { SortableContext, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' import { DEFAULT_AI_AGENT, getAiAgentAvailability, type AiAgentId, type AiAgentReadiness, type AiAgentsStatus, } from '../lib/aiAgents' import { aiTargetReady, targetAgent, type AiModelProvider, type AiTarget, } from '../lib/aiTargets' import { aiAgentPermissionModeLabels, type AiAgentPermissionMode, } from '../lib/aiAgentPermissionMode' import { type VaultAiGuidanceStatus, } from '../lib/vaultAiGuidance' import { translate, type AppLocale } from '../lib/i18n' import { trackAiWorkspaceChatTitled, trackAiWorkspaceSidebarToggled } from '../lib/productAnalytics' import type { AgentStatus, AiAgentMessage } from '../hooks/useCliAiAgent' import type { AiWorkspaceConversationSetting } from '../types' import type { NoteListItem } from '../utils/ai-context' import type { VaultEntry } from '../types' import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge' import { type GenerateAiConversationTitleRequest } from '../utils/aiConversationTitle' import { cloneAiWorkspaceSessionUntilMessage } from '../lib/aiWorkspaceSessionStore' import { AiPanelView } from './AiPanel' import { GuidanceWarning, WorkspaceHeader } from './AiWorkspaceChrome' import { WorkspaceResizeHandles } from './AiWorkspaceResizeHandles' import { AiAgentIcon } from './AiAgentIcon' import { ConversationSidebar } from './AiWorkspaceSidebar' import { ResizeHandle } from './ResizeHandle' import { useAiPanelController } from './useAiPanelController' import { buildAiWorkspaceTargetGroups, type AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups' import { activeConversationForState, canArchiveConversation, firstTarget, flatTargets, resolveTarget, useConversations, type AiConversation, } from './aiWorkspaceConversations' import { useAiWorkspaceSizing, workspaceClassName, workspaceStyle, type AiWorkspaceMode, type AiWorkspaceSizing, } from './aiWorkspaceSizing' export type { AiConversation } from './aiWorkspaceConversations' interface AiWorkspaceProps { activeEntry?: VaultEntry | null activeNoteContent?: string | null aiAgentsStatus: AiAgentsStatus aiModelProviders?: AiModelProvider[] conversationSettings?: AiWorkspaceConversationSetting[] | null conversationSettingsReady?: boolean defaultAiAgent?: AiAgentId defaultAiAgentReadiness?: AiAgentReadiness defaultAiAgentReady?: boolean defaultAiTarget?: AiTarget entries?: VaultEntry[] initialActiveConversationId?: string locale?: AppLocale mode?: 'docked' | 'side' | 'window' noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } onActiveConversationChange?: (id: string) => void onClose: () => void onConversationSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void onDock?: () => void onFileCreated?: (relativePath: string) => void onFileModified?: (relativePath: string) => void onOpenAiSettings?: () => void onOpenNote?: (path: string) => void onPopOut?: (context?: { activeConversationId?: string }) => void onRestoreVaultAiGuidance?: () => void onUnsupportedAiPaste?: (message: string) => void onVaultChanged?: () => void open: boolean openTabs?: VaultEntry[] vaultAiGuidanceStatus?: VaultAiGuidanceStatus vaultPath: string vaultPaths?: string[] } function agentReadinessForTarget(target: AiTarget, statuses: AiAgentsStatus): AiAgentReadiness { if (target.kind === 'api_model') return 'ready' const status = getAiAgentAvailability(statuses, target.agent).status if (status === 'checking') return 'checking' return status === 'installed' ? 'ready' : 'missing' } function TargetGroup({ label, targets }: { label: string; targets: AiTarget[] }) { if (targets.length === 0) return null return ( <> {label} {targets.map((target) => ( {target.kind === 'agent' ? : null} {target.label} ))} ) } function TargetPickerTrigger({ compact, disabled, hasTargets, locale, selectedTarget, }: { compact: boolean disabled: boolean hasTargets: boolean locale: AppLocale selectedTarget: AiTarget }) { return ( ) } function TargetPickerContent({ groups, hasTargets, locale, onSelectTarget, selectedTarget, side, }: { groups: AiWorkspaceTargetGroups hasTargets: boolean locale: AppLocale selectedTarget: AiTarget onSelectTarget: (targetId: string) => void side: 'bottom' | 'top' }) { const hasLocalAgentsSeparator = groups.localAgents.length > 0 && (groups.localModels.length > 0 || groups.apiModels.length > 0) const hasLocalModelsSeparator = groups.localModels.length > 0 && groups.apiModels.length > 0 return ( {hasTargets ? ( {hasLocalAgentsSeparator && } {hasLocalModelsSeparator && } ) : ( {translate(locale, 'ai.workspace.noTargets')} )} ) } function TargetPicker({ compact = false, disabled, groups, locale, selectedTarget, side = 'bottom', onSelectTarget, }: { compact?: boolean disabled: boolean groups: AiWorkspaceTargetGroups locale: AppLocale selectedTarget: AiTarget side?: 'bottom' | 'top' onSelectTarget: (targetId: string) => void }) { const hasTargets = flatTargets(groups).length > 0 return ( ) } function PermissionPicker({ compact = false, disabled, locale, permissionMode, side = 'bottom', targetKind, onChange, }: { compact?: boolean disabled: boolean locale: AppLocale permissionMode: AiAgentPermissionMode side?: 'bottom' | 'top' targetKind: AiTarget['kind'] onChange: (mode: AiAgentPermissionMode) => void }) { if (targetKind === 'api_model') { return ( ) } return ( {(['safe', 'power_user'] as const).map((mode) => ( onChange(mode)}> {aiAgentPermissionModeLabels(mode, locale).control} ))} ) } type ConversationSessionProps = { active: boolean activeEntry?: VaultEntry | null activeNoteContent?: string | null aiAgentsStatus: AiAgentsStatus conversation: AiConversation defaultAiAgentReady: boolean entries?: VaultEntry[] groups: AiWorkspaceTargetGroups locale: AppLocale mode: AiWorkspaceMode noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } onArchive: () => void onClose: () => void onDock?: () => void onFileCreated?: (relativePath: string) => void onFileModified?: (relativePath: string) => void onForkMessage?: (messageId: string) => void onMessageHistoryScrollStateChange?: (scrolled: boolean) => void onOpenAiSettings?: () => void onOpenNote?: (path: string) => void onPopOut?: () => void onRestoreVaultAiGuidance?: () => void onSelectTarget: (targetId: string) => void onStatusChange: (id: string, status: AgentStatus) => void onPromptSubmitted: (id: string) => void onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void onUnsupportedAiPaste?: (message: string) => void onVaultChanged?: () => void openTabs?: VaultEntry[] target: AiTarget vaultAiGuidanceStatus?: VaultAiGuidanceStatus vaultPath: string vaultPaths?: string[] } function firstCompletedAssistantMessage(messages: AiAgentMessage[]): AiAgentMessage | undefined { return messages.find((message) => ( !message.localMarker && !message.isStreaming && !!message.userMessage.trim() && !!message.response?.trim() )) } function useGeneratedConversationTitle({ aiAgentsStatus, conversation, messages, onTitleFromAnswer, permissionMode, target, vaultPath, vaultPaths, }: { aiAgentsStatus: AiAgentsStatus conversation: AiConversation messages: AiAgentMessage[] onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void permissionMode: AiAgentPermissionMode target: AiTarget vaultPath: string vaultPaths?: string[] }) { const requestedTitleKeysRef = useRef(new Set()) useEffect(() => { if (!conversation.usesDefaultTitle) return const firstMessage = firstCompletedAssistantMessage(messages) const prompt = firstMessage?.userMessage.trim() const assistantResponse = firstMessage?.response?.trim() if (!firstMessage || !prompt || !assistantResponse) return const titleKey = `${conversation.id}:${firstMessage.id ?? prompt}` if (requestedTitleKeysRef.current.has(titleKey)) return requestedTitleKeysRef.current.add(titleKey) onTitleFromAnswer({ assistantResponse, id: conversation.id, permissionMode, prompt, target, targetReady: aiTargetReady(target, aiAgentsStatus), vaultPath, vaultPaths, }) }, [ aiAgentsStatus, conversation.id, conversation.usesDefaultTitle, messages, onTitleFromAnswer, permissionMode, target, vaultPath, vaultPaths, ]) } interface ConversationSessionContext { activeEntry: VaultEntry | null activeNoteContent: string | null entries?: VaultEntry[] noteList?: NoteListItem[] noteListFilter?: { type: string | null; query: string } openTabs?: VaultEntry[] } function activeContextForSession({ active, activeEntry, activeNoteContent, entries, noteList, noteListFilter, openTabs, }: Pick): ConversationSessionContext { if (!active) { return { activeEntry: null, activeNoteContent: null, } } return { activeEntry: activeEntry ?? null, activeNoteContent: activeNoteContent ?? null, entries, noteList, noteListFilter, openTabs, } } function ConversationComposerControls({ disabled, groups, locale, onOpenAiSettings, onPermissionModeChange, onSelectTarget, permissionMode, side, target, }: { disabled: boolean groups: AiWorkspaceTargetGroups locale: AppLocale onOpenAiSettings?: () => void onPermissionModeChange: (mode: AiAgentPermissionMode) => void onSelectTarget: (targetId: string) => void permissionMode: AiAgentPermissionMode side: 'bottom' | 'top' target: AiTarget }) { return ( <> {onOpenAiSettings && ( )} ) } function ConversationWorkspaceHeader({ conversation, locale, mode, onArchive, onClose, onDock, onOpenAiSettings, onPopOut, }: Pick< ConversationSessionProps, 'conversation' | 'locale' | 'mode' | 'onArchive' | 'onClose' | 'onDock' | 'onOpenAiSettings' | 'onPopOut' >) { if (mode === 'side') return null return ( ) } function ConversationSession({ active, activeEntry, activeNoteContent, aiAgentsStatus, conversation, defaultAiAgentReady, entries, groups, locale, mode, noteList, noteListFilter, onArchive, onClose, onDock, onFileCreated, onFileModified, onForkMessage, onMessageHistoryScrollStateChange, onOpenAiSettings, onOpenNote, onPopOut, onRestoreVaultAiGuidance, onSelectTarget, onStatusChange, onPromptSubmitted, onTitleFromAnswer, onUnsupportedAiPaste, onVaultChanged, openTabs, target, vaultAiGuidanceStatus, vaultPath, vaultPaths, }: ConversationSessionProps) { const context = activeContextForSession({ active, activeEntry, activeNoteContent, entries, noteList, noteListFilter, openTabs, }) const readiness = agentReadinessForTarget(target, aiAgentsStatus) const controller = useAiPanelController({ vaultPath, vaultPaths, defaultAiAgent: targetAgent(target), defaultAiTarget: target, defaultAiAgentReady: target.kind === 'api_model' || defaultAiAgentReady, defaultAiAgentReadiness: readiness, activeEntry: context.activeEntry, activeNoteContent: context.activeNoteContent, entries: context.entries, openTabs: context.openTabs, noteList: context.noteList, noteListFilter: context.noteListFilter, locale, onOpenNote, onFileCreated, onFileModified, onVaultChanged, sessionId: conversation.id, }) const running = controller.agent.status === 'thinking' || controller.agent.status === 'tool-executing' const composerMenuSide = mode === 'window' ? 'bottom' : 'top' const composerControls = ( ) useEffect(() => { onStatusChange(conversation.id, controller.agent.status) }, [conversation.id, controller.agent.status, onStatusChange]) useGeneratedConversationTitle({ aiAgentsStatus, conversation, messages: controller.agent.messages, onTitleFromAnswer, permissionMode: controller.permissionMode, target, vaultPath, vaultPaths, }) return (
onPromptSubmitted(conversation.id)} onUnsupportedAiPaste={onUnsupportedAiPaste} showHeader={false} showLeftBorder={false} surface={mode === 'side' ? 'sidebar' : 'default'} />
) } type ResolvedAiWorkspaceProps = AiWorkspaceProps & { defaultAiAgent: AiAgentId defaultAiAgentReady: boolean entries: VaultEntry[] locale: AppLocale mode: AiWorkspaceMode } interface AiWorkspaceModel { activeConversation: AiConversation | undefined activeId: string addDefaultConversation: () => void archiveConversationSafely: (id: string) => void canArchiveConversation: (conversation: AiConversation) => boolean closeConversationSafely: (id: string) => void conversations: AiConversation[] fallbackTarget: AiTarget forkConversationUntilMessage: (sourceId: string, messageId: string) => void groups: AiWorkspaceTargetGroups handleStatusChange: (id: string, status: AgentStatus) => void renameConversation: (id: string, title: string) => void reorderConversation: (activeId: string, overId: string) => void restoreConversation: (id: string) => void sidebarCollapsed: boolean setActiveId: (id: string) => void setConversationTarget: (id: string, targetId: string) => void setShowArchived: (show: boolean) => void showArchived: boolean statuses: Record markConversationActivity: (id: string) => void titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void toggleSidebarCollapsed: () => void updateDefaultConversationTargets: (targetId: string) => void } function resolveAiWorkspaceProps(props: AiWorkspaceProps): ResolvedAiWorkspaceProps { return { ...props, defaultAiAgent: props.defaultAiAgent ?? DEFAULT_AI_AGENT, defaultAiAgentReady: props.defaultAiAgentReady ?? true, entries: props.entries ?? [], locale: props.locale ?? 'en', mode: props.mode ?? 'docked', } } function SideWorkspaceTitleEditor({ conversation, locale, onCancel, onRename, }: { conversation: AiConversation locale: AppLocale onCancel: () => void onRename: (title: string) => void }) { const [draft, setDraft] = useState(conversation.title) const finishedRef = useRef(false) const submit = () => { if (finishedRef.current) return const nextTitle = draft.trim() if (!nextTitle) { finishedRef.current = true onCancel() return } finishedRef.current = true onRename(nextTitle) onCancel() } const cancel = () => { finishedRef.current = true onCancel() } return ( setDraft(event.target.value)} onBlur={submit} onKeyDown={(event) => { if (event.key === 'Enter') submit() if (event.key === 'Escape') cancel() }} onPointerDown={(event) => event.stopPropagation()} aria-label={translate(locale, 'ai.workspace.renameChat')} className="h-9 w-[180px] rounded-lg px-3 text-[13px] font-semibold" autoFocus /> ) } function SideWorkspaceTab({ active, conversation, editing, locale, onClose, onCancelRename, onRename, onSelect, onStartRename, status, }: { active: boolean conversation: AiConversation editing: boolean locale: AppLocale onClose: (id: string) => void onCancelRename: () => void onRename: (id: string, title: string) => void onSelect: (id: string) => void onStartRename: (id: string) => void status: AgentStatus | undefined }) { const closeLabel = translate(locale, 'ai.workspace.closeChat', { title: conversation.title }) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: conversation.id, disabled: editing }) return (
{editing ? ( onRename(conversation.id, title)} /> ) : ( )} {!editing && ( <>
)}
) } function useHorizontalScrollFades(dependencyKey: string) { const scrollRef = useRef(null) const [fades, setFades] = useState({ left: false, right: false }) const updateFades = useCallback(() => { const element = scrollRef.current if (!element) return const maxScrollLeft = element.scrollWidth - element.clientWidth setFades({ left: element.scrollLeft > 1, right: maxScrollLeft > 1 && element.scrollLeft < maxScrollLeft - 1, }) }, []) useEffect(() => { const element = scrollRef.current updateFades() if (!element) return element.addEventListener('scroll', updateFades, { passive: true }) const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateFades) resizeObserver?.observe(element) if (element.firstElementChild) resizeObserver?.observe(element.firstElementChild) return () => { element.removeEventListener('scroll', updateFades) resizeObserver?.disconnect() } }, [dependencyKey, updateFades]) return { scrollRef, showLeftFade: fades.left, showRightFade: fades.right, } } function SideWorkspaceTabs({ activeId, conversations, locale, onCloseConversation, onNewChat, onRename, onReorder, onSelect, statuses, }: { activeId: string conversations: AiConversation[] locale: AppLocale onCloseConversation: (id: string) => void onNewChat: () => void onRename: (id: string, title: string) => void onReorder: (activeId: string, overId: string) => void onSelect: (id: string) => void statuses: Record }) { const [editingId, setEditingId] = useState(null) const visibleConversations = conversations.filter((conversation) => !conversation.archived) const visibleConversationIds = visibleConversations.map((conversation) => conversation.id) const tabDependencyKey = visibleConversations .map((conversation) => `${conversation.id}:${conversation.title}`) .join('\0') const { scrollRef, showLeftFade, showRightFade } = useHorizontalScrollFades(tabDependencyKey) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) const handleDragEnd = useCallback((event: DragEndEvent) => { const activeConversationId = String(event.active.id) const overConversationId = event.over ? String(event.over.id) : '' if (!overConversationId || activeConversationId === overConversationId) return onReorder(activeConversationId, overConversationId) }, [onReorder]) return (
{visibleConversations.map((conversation) => ( setEditingId(null)} onClose={onCloseConversation} onRename={onRename} onSelect={onSelect} onStartRename={setEditingId} status={statuses[conversation.id]} /> ))}
{showLeftFade && (
)} {showRightFade && (
)}
) } function SideWorkspaceHeader({ activeId, conversations, expanded, locale, onClose, onCloseConversation, onNewChat, onRename, onReorder, onSelect, onToggleExpanded, separated, statuses, }: { activeId: string conversations: AiConversation[] expanded: boolean locale: AppLocale onClose: () => void onCloseConversation: (id: string) => void onNewChat: () => void onRename: (id: string, title: string) => void onReorder: (activeId: string, overId: string) => void onSelect: (id: string) => void onToggleExpanded: () => void separated: boolean statuses: Record }) { const expandLabel = translate(locale, expanded ? 'ai.workspace.restorePanel' : 'ai.workspace.expandPanel') return (
) } function SideAiWorkspaceLayout({ model, sizing, workspace, }: { model: AiWorkspaceModel sizing: AiWorkspaceSizing workspace: ResolvedAiWorkspaceProps }) { const [expanded, setExpanded] = useState(false) const [headerSeparated, setHeaderSeparated] = useState(false) return (
{!expanded && }
setExpanded((current) => !current)} separated={headerSeparated} statuses={model.statuses} />
) } function useActiveConversationSync( activeConversation: AiConversation | undefined, activeId: string, setActiveId: (id: string) => void, ) { useEffect(() => { if (activeConversation && activeConversation.id !== activeId) setActiveId(activeConversation.id) }, [activeConversation, activeId, setActiveId]) } function useArchiveConversationSafely({ addConversation, archiveConversation, conversations, fallbackTarget, }: { addConversation: (target: AiTarget) => void archiveConversation: (id: string) => void conversations: AiConversation[] fallbackTarget: AiTarget }) { return useCallback((id: string) => { const conversation = conversations.find((candidate) => candidate.id === id) if (!conversation || !canArchiveConversation(conversation)) return const activeCount = conversations.filter((conversation) => !conversation.archived).length archiveConversation(id) if (activeCount <= 1) addConversation(fallbackTarget) }, [addConversation, archiveConversation, conversations, fallbackTarget]) } function useTrackedConversationActions({ conversations, renameConversation, titleConversationFromAnswer, }: { conversations: AiConversation[] renameConversation: (id: string, title: string) => void titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void }) { const trackedRenameConversation = useCallback((id: string, title: string) => { if (!title.trim()) return renameConversation(id, title) trackAiWorkspaceChatTitled('manual') }, [renameConversation]) const trackedTitleConversationFromAnswer = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => { const conversation = conversations.find((candidate) => candidate.id === request.id) titleConversationFromAnswer(request) if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated') }, [conversations, titleConversationFromAnswer]) return { trackedRenameConversation, trackedTitleConversationFromAnswer } } function useAiWorkspaceNewChatEvent(open: boolean, addDefaultConversation: () => void) { useEffect(() => { if (!open) return const handleNewChat = () => addDefaultConversation() window.addEventListener(NEW_AI_CHAT_EVENT, handleNewChat) return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleNewChat) }, [addDefaultConversation, open]) } function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceModel { const groups = useMemo( () => buildAiWorkspaceTargetGroups(workspace.aiAgentsStatus, workspace.aiModelProviders), [workspace.aiAgentsStatus, workspace.aiModelProviders], ) const fallbackTarget = useMemo( () => firstTarget(groups, workspace.defaultAiTarget, workspace.defaultAiAgent), [groups, workspace.defaultAiAgent, workspace.defaultAiTarget], ) const { activeId, addConversation, archiveConversation, closeConversation, conversations, forkConversation, renameConversation, reorderConversation, restoreConversation, setActiveId, setConversationTarget, setShowArchived, showArchived, markConversationActivity, titleConversationFromAnswer, updateDefaultConversationTargets, } = useConversations({ fallbackTarget, initialActiveConversationId: workspace.initialActiveConversationId, locale: workspace.locale, onSettingsChange: workspace.onConversationSettingsChange, settings: workspace.conversationSettings, settingsReady: workspace.conversationSettingsReady ?? true, }) const [statuses, setStatuses] = useState>({}) const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const activeConversation = activeConversationForState(conversations, activeId, showArchived) const addDefaultConversation = useCallback(() => { addConversation(fallbackTarget) }, [addConversation, fallbackTarget]) const archiveConversationSafely = useArchiveConversationSafely({ addConversation, archiveConversation, conversations, fallbackTarget, }) useActiveConversationSync(activeConversation, activeId, setActiveId) const handleStatusChange = useCallback((id: string, status: AgentStatus) => { setStatuses((current) => current[id] === status ? current : { ...current, [id]: status }) }, []) const forkConversationUntilMessage = useCallback((sourceId: string, messageId: string) => { const targetId = forkConversation(sourceId) if (!targetId) return cloneAiWorkspaceSessionUntilMessage(sourceId, targetId, messageId) }, [forkConversation]) const { trackedRenameConversation, trackedTitleConversationFromAnswer } = useTrackedConversationActions({ conversations, renameConversation, titleConversationFromAnswer, }) const toggleSidebarCollapsed = useCallback(() => { setSidebarCollapsed((current) => { const next = !current trackAiWorkspaceSidebarToggled(next, workspace.mode) return next }) }, [workspace.mode]) useAiWorkspaceNewChatEvent(workspace.open, addDefaultConversation) useEffect(() => { updateDefaultConversationTargets(fallbackTarget.id) }, [fallbackTarget.id, updateDefaultConversationTargets]) return { activeConversation, activeId, addDefaultConversation, archiveConversationSafely, canArchiveConversation, closeConversationSafely: closeConversation, conversations, fallbackTarget, forkConversationUntilMessage, groups, handleStatusChange, renameConversation: trackedRenameConversation, reorderConversation, restoreConversation, sidebarCollapsed, setActiveId, setConversationTarget, setShowArchived, showArchived, statuses, markConversationActivity, titleConversationFromAnswer: trackedTitleConversationFromAnswer, toggleSidebarCollapsed, updateDefaultConversationTargets, } } function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; workspace: ResolvedAiWorkspaceProps }) { const sizing = useAiWorkspaceSizing(workspace.mode) if (workspace.mode === 'side') { return } return (
{!model.sidebarCollapsed && ( )}
) } function ConversationSessions({ model, onMessageHistoryScrollStateChange, workspace, }: { model: AiWorkspaceModel onMessageHistoryScrollStateChange?: (scrolled: boolean) => void workspace: ResolvedAiWorkspaceProps }) { return (
{model.conversations.map((conversation) => { const target = resolveTarget(conversation, model.groups, model.fallbackTarget) return ( model.archiveConversationSafely(conversation.id)} onClose={workspace.onClose} onDock={workspace.onDock} onFileCreated={workspace.onFileCreated} onFileModified={workspace.onFileModified} onForkMessage={(messageId) => model.forkConversationUntilMessage(conversation.id, messageId)} onMessageHistoryScrollStateChange={onMessageHistoryScrollStateChange} onOpenAiSettings={workspace.onOpenAiSettings} onOpenNote={workspace.onOpenNote} onPopOut={workspace.onPopOut} onRestoreVaultAiGuidance={workspace.onRestoreVaultAiGuidance} onSelectTarget={(targetId) => model.setConversationTarget(conversation.id, targetId)} onStatusChange={model.handleStatusChange} onPromptSubmitted={model.markConversationActivity} onTitleFromAnswer={model.titleConversationFromAnswer} onUnsupportedAiPaste={workspace.onUnsupportedAiPaste} onVaultChanged={workspace.onVaultChanged} openTabs={workspace.openTabs} target={target} vaultAiGuidanceStatus={workspace.vaultAiGuidanceStatus} vaultPath={workspace.vaultPath} vaultPaths={workspace.vaultPaths} /> ) })}
) } export function AiWorkspace(props: AiWorkspaceProps) { const workspace = resolveAiWorkspaceProps(props) const model = useAiWorkspaceModel(workspace) const { onActiveConversationChange } = workspace useEffect(() => { if (!workspace.open || !model.activeId) return onActiveConversationChange?.(model.activeId) }, [model.activeId, onActiveConversationChange, workspace.open]) if (!workspace.open || !model.activeConversation) return null return }