1404 lines
44 KiB
TypeScript
1404 lines
44 KiB
TypeScript
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 (
|
|
<>
|
|
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
|
{targets.map((target) => (
|
|
<DropdownMenuRadioItem key={target.id} value={target.id} className="gap-2">
|
|
{target.kind === 'agent' ? <AiAgentIcon agent={target.agent} size={16} /> : null}
|
|
<span className="truncate">{target.label}</span>
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function TargetPickerTrigger({
|
|
compact,
|
|
disabled,
|
|
hasTargets,
|
|
locale,
|
|
selectedTarget,
|
|
}: {
|
|
compact: boolean
|
|
disabled: boolean
|
|
hasTargets: boolean
|
|
locale: AppLocale
|
|
selectedTarget: AiTarget
|
|
}) {
|
|
return (
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant={compact ? 'ghost' : 'outline'}
|
|
size={compact ? 'xs' : 'sm'}
|
|
className={cn(
|
|
'justify-between gap-1.5 text-muted-foreground hover:text-foreground',
|
|
compact ? 'max-w-[150px] rounded-full px-2 text-[12px]' : 'max-w-[240px] gap-2',
|
|
)}
|
|
disabled={disabled || !hasTargets}
|
|
aria-label={translate(locale, 'ai.workspace.targetLabel')}
|
|
data-testid="ai-workspace-target-trigger"
|
|
>
|
|
{selectedTarget.kind === 'agent' ? <AiAgentIcon agent={selectedTarget.agent} size={compact ? 14 : 16} /> : null}
|
|
<span className="truncate">{selectedTarget.shortLabel}</span>
|
|
<CaretDown size={compact ? 12 : 13} />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<DropdownMenuContent align="start" side={side} className="min-w-[280px]">
|
|
{hasTargets ? (
|
|
<DropdownMenuRadioGroup value={selectedTarget.id} onValueChange={onSelectTarget}>
|
|
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalAgents')} targets={groups.localAgents} />
|
|
{hasLocalAgentsSeparator && <DropdownMenuSeparator />}
|
|
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalModels')} targets={groups.localModels} />
|
|
{hasLocalModelsSeparator && <DropdownMenuSeparator />}
|
|
<TargetGroup label={translate(locale, 'ai.workspace.targetApiModels')} targets={groups.apiModels} />
|
|
</DropdownMenuRadioGroup>
|
|
) : (
|
|
<DropdownMenuItem disabled>{translate(locale, 'ai.workspace.noTargets')}</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<DropdownMenu>
|
|
<TargetPickerTrigger
|
|
compact={compact}
|
|
disabled={disabled}
|
|
hasTargets={hasTargets}
|
|
locale={locale}
|
|
selectedTarget={selectedTarget}
|
|
/>
|
|
<TargetPickerContent
|
|
groups={groups}
|
|
hasTargets={hasTargets}
|
|
locale={locale}
|
|
selectedTarget={selectedTarget}
|
|
side={side}
|
|
onSelectTarget={onSelectTarget}
|
|
/>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Button type="button" variant={compact ? 'ghost' : 'outline'} size={compact ? 'xs' : 'sm'} disabled className="rounded-full px-2 text-[12px] text-muted-foreground">
|
|
{translate(locale, 'ai.panel.mode.chat')}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant={compact ? 'ghost' : 'outline'}
|
|
size={compact ? 'xs' : 'sm'}
|
|
className={cn(
|
|
'justify-between text-muted-foreground hover:text-foreground',
|
|
compact ? 'rounded-full px-2 text-[12px]' : 'gap-2',
|
|
)}
|
|
disabled={disabled}
|
|
aria-label={translate(locale, 'ai.workspace.permissionMode')}
|
|
data-testid="ai-workspace-permission-trigger"
|
|
>
|
|
{aiAgentPermissionModeLabels(permissionMode, locale).control}
|
|
<CaretDown size={compact ? 12 : 13} />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" side={side} className="min-w-[180px]">
|
|
{(['safe', 'power_user'] as const).map((mode) => (
|
|
<DropdownMenuItem key={mode} onSelect={() => onChange(mode)}>
|
|
{aiAgentPermissionModeLabels(mode, locale).control}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
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<string>())
|
|
|
|
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<ConversationSessionProps, 'active' | 'activeEntry' | 'activeNoteContent' | 'entries' | 'noteList' | 'noteListFilter' | 'openTabs'>): 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 (
|
|
<>
|
|
<TargetPicker
|
|
compact
|
|
disabled={disabled}
|
|
groups={groups}
|
|
locale={locale}
|
|
selectedTarget={target}
|
|
side={side}
|
|
onSelectTarget={onSelectTarget}
|
|
/>
|
|
<PermissionPicker
|
|
compact
|
|
disabled={disabled}
|
|
locale={locale}
|
|
permissionMode={permissionMode}
|
|
side={side}
|
|
targetKind={target.kind}
|
|
onChange={onPermissionModeChange}
|
|
/>
|
|
{onOpenAiSettings && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
|
aria-label={translate(locale, 'ai.workspace.settings')}
|
|
title={translate(locale, 'ai.workspace.settings')}
|
|
onClick={onOpenAiSettings}
|
|
data-testid="ai-workspace-composer-settings"
|
|
>
|
|
<GearSix size={16} weight="regular" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<WorkspaceHeader
|
|
archiveDisabled={!canArchiveConversation(conversation)}
|
|
conversation={conversation}
|
|
locale={locale}
|
|
mode={mode}
|
|
onArchive={onArchive}
|
|
onClose={onClose}
|
|
onDock={onDock}
|
|
onOpenAiSettings={onOpenAiSettings}
|
|
onPopOut={onPopOut}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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 = (
|
|
<ConversationComposerControls
|
|
disabled={running}
|
|
groups={groups}
|
|
locale={locale}
|
|
onOpenAiSettings={onOpenAiSettings}
|
|
onPermissionModeChange={controller.handlePermissionModeChange}
|
|
onSelectTarget={onSelectTarget}
|
|
permissionMode={controller.permissionMode}
|
|
side={composerMenuSide}
|
|
target={target}
|
|
/>
|
|
)
|
|
|
|
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 (
|
|
<div className={active ? 'flex min-h-0 flex-1 flex-col' : 'hidden'} data-testid={`ai-workspace-session-${conversation.id}`}>
|
|
<ConversationWorkspaceHeader
|
|
conversation={conversation}
|
|
locale={locale}
|
|
mode={mode}
|
|
onArchive={onArchive}
|
|
onClose={onClose}
|
|
onDock={onDock}
|
|
onOpenAiSettings={onOpenAiSettings}
|
|
onPopOut={onPopOut}
|
|
/>
|
|
<GuidanceWarning locale={locale} onRestore={onRestoreVaultAiGuidance} status={vaultAiGuidanceStatus} />
|
|
<div className="flex min-h-0 flex-1">
|
|
<AiPanelView
|
|
controller={controller}
|
|
defaultAiAgent={targetAgent(target)}
|
|
defaultAiAgentReadiness={readiness}
|
|
defaultAiAgentReady={aiTargetReady(target, aiAgentsStatus)}
|
|
defaultAiTarget={target}
|
|
entries={context.entries}
|
|
activeEntry={context.activeEntry}
|
|
composerControls={composerControls}
|
|
interactive={active}
|
|
locale={locale}
|
|
onClose={onClose}
|
|
onForkMessage={onForkMessage}
|
|
onMessageHistoryScrollStateChange={active ? onMessageHistoryScrollStateChange : undefined}
|
|
onOpenNote={onOpenNote}
|
|
onSendPrompt={() => onPromptSubmitted(conversation.id)}
|
|
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
|
showHeader={false}
|
|
showLeftBorder={false}
|
|
surface={mode === 'side' ? 'sidebar' : 'default'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<string, AgentStatus>
|
|
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 (
|
|
<Input
|
|
value={draft}
|
|
onChange={(event) => 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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
className="group relative shrink-0"
|
|
style={{
|
|
transform: CSS.Translate.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.55 : 1,
|
|
}}
|
|
>
|
|
{editing ? (
|
|
<SideWorkspaceTitleEditor
|
|
conversation={conversation}
|
|
locale={locale}
|
|
onCancel={onCancelRename}
|
|
onRename={(title) => onRename(conversation.id, title)}
|
|
/>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
'h-10 shrink-0 cursor-grab justify-start rounded-lg px-3 text-[13px] font-semibold active:cursor-grabbing',
|
|
active
|
|
? 'bg-[var(--state-hover)] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[var(--state-hover)] hover:text-foreground',
|
|
)}
|
|
{...attributes}
|
|
{...listeners}
|
|
aria-pressed={active}
|
|
onClick={() => onSelect(conversation.id)}
|
|
onDoubleClick={(event) => {
|
|
event.stopPropagation()
|
|
onStartRename(conversation.id)
|
|
}}
|
|
>
|
|
<span className="whitespace-nowrap">{conversation.title}</span>
|
|
{(status === 'thinking' || status === 'tool-executing') && <span className="ml-2 h-2 w-2 shrink-0 rounded-full bg-primary" aria-hidden />}
|
|
</Button>
|
|
)}
|
|
{!editing && (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute inset-y-1 right-0 w-9 rounded-r-lg opacity-0 transition-opacity',
|
|
active
|
|
? 'bg-gradient-to-l from-[var(--state-hover)] via-[var(--state-hover)] to-transparent'
|
|
: 'bg-gradient-to-l from-sidebar via-sidebar to-transparent group-hover:from-[var(--state-hover)] group-hover:via-[var(--state-hover)]',
|
|
'group-hover:opacity-100 group-focus-within:opacity-100',
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className={cn(
|
|
'pointer-events-none absolute top-1/2 right-1.5 z-10 h-6 w-6 -translate-y-1/2 rounded-md p-0 opacity-0 shadow-none transition-opacity',
|
|
'bg-transparent text-foreground hover:bg-transparent hover:text-foreground',
|
|
'group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100',
|
|
)}
|
|
aria-label={closeLabel}
|
|
title={closeLabel}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onClose(conversation.id)
|
|
}}
|
|
>
|
|
<X size={13} weight="bold" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useHorizontalScrollFades(dependencyKey: string) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(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<string, AgentStatus>
|
|
}) {
|
|
const [editingId, setEditingId] = useState<string | null>(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 (
|
|
<div className="relative min-w-0 flex-1">
|
|
<div
|
|
ref={scrollRef}
|
|
className="overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
data-testid="ai-workspace-side-tabs"
|
|
>
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<div className="flex w-max items-center gap-1 py-1">
|
|
<SortableContext items={visibleConversationIds} strategy={horizontalListSortingStrategy}>
|
|
{visibleConversations.map((conversation) => (
|
|
<SideWorkspaceTab
|
|
key={conversation.id}
|
|
active={conversation.id === activeId}
|
|
conversation={conversation}
|
|
editing={editingId === conversation.id}
|
|
locale={locale}
|
|
onCancelRename={() => setEditingId(null)}
|
|
onClose={onCloseConversation}
|
|
onRename={onRename}
|
|
onSelect={onSelect}
|
|
onStartRename={setEditingId}
|
|
status={statuses[conversation.id]}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
aria-label={translate(locale, 'ai.workspace.newChat')}
|
|
title={translate(locale, 'ai.workspace.newChat')}
|
|
onClick={onNewChat}
|
|
>
|
|
<Plus size={17} />
|
|
</Button>
|
|
</div>
|
|
</DndContext>
|
|
</div>
|
|
{showLeftFade && (
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-sidebar to-transparent"
|
|
data-testid="ai-workspace-side-tabs-left-fade"
|
|
/>
|
|
)}
|
|
{showRightFade && (
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-sidebar to-transparent"
|
|
data-testid="ai-workspace-side-tabs-right-fade"
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<string, AgentStatus>
|
|
}) {
|
|
const expandLabel = translate(locale, expanded ? 'ai.workspace.restorePanel' : 'ai.workspace.expandPanel')
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex h-[52px] shrink-0 items-center gap-2 px-2',
|
|
separated && 'border-b border-border',
|
|
)}
|
|
data-testid="ai-workspace-side-header"
|
|
>
|
|
<SideWorkspaceTabs
|
|
activeId={activeId}
|
|
conversations={conversations}
|
|
locale={locale}
|
|
onCloseConversation={onCloseConversation}
|
|
onNewChat={onNewChat}
|
|
onRename={onRename}
|
|
onReorder={onReorder}
|
|
onSelect={onSelect}
|
|
statuses={statuses}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={expandLabel}
|
|
title={expandLabel}
|
|
onClick={onToggleExpanded}
|
|
>
|
|
{expanded ? <ArrowsInLineHorizontal size={17} /> : <ArrowsOutLineHorizontal size={17} />}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={translate(locale, 'ai.workspace.close')}
|
|
title={translate(locale, 'ai.workspace.close')}
|
|
onClick={onClose}
|
|
>
|
|
<SidebarSimple size={17} weight="regular" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SideAiWorkspaceLayout({
|
|
model,
|
|
sizing,
|
|
workspace,
|
|
}: {
|
|
model: AiWorkspaceModel
|
|
sizing: AiWorkspaceSizing
|
|
workspace: ResolvedAiWorkspaceProps
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [headerSeparated, setHeaderSeparated] = useState(false)
|
|
|
|
return (
|
|
<section
|
|
className={workspaceClassName('side', expanded)}
|
|
style={workspaceStyle('side', sizing.workspaceSize, expanded)}
|
|
data-testid="ai-workspace"
|
|
data-ai-workspace-mode="side"
|
|
data-ai-workspace-expanded={expanded ? 'true' : 'false'}
|
|
role="complementary"
|
|
aria-label={translate(workspace.locale, 'ai.workspace.title')}
|
|
>
|
|
{!expanded && <WorkspaceResizeHandles mode="side" onResize={sizing.onWorkspaceResize} />}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<SideWorkspaceHeader
|
|
activeId={model.activeId}
|
|
conversations={model.conversations}
|
|
expanded={expanded}
|
|
locale={workspace.locale}
|
|
onClose={workspace.onClose}
|
|
onCloseConversation={model.closeConversationSafely}
|
|
onNewChat={model.addDefaultConversation}
|
|
onRename={model.renameConversation}
|
|
onReorder={model.reorderConversation}
|
|
onSelect={model.setActiveId}
|
|
onToggleExpanded={() => setExpanded((current) => !current)}
|
|
separated={headerSeparated}
|
|
statuses={model.statuses}
|
|
/>
|
|
<ConversationSessions model={model} workspace={workspace} onMessageHistoryScrollStateChange={setHeaderSeparated} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
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<Record<string, AgentStatus>>({})
|
|
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 <SideAiWorkspaceLayout model={model} sizing={sizing} workspace={workspace} />
|
|
}
|
|
|
|
return (
|
|
<section
|
|
className={workspaceClassName(workspace.mode)}
|
|
style={workspaceStyle(workspace.mode, sizing.workspaceSize)}
|
|
data-testid="ai-workspace"
|
|
data-ai-workspace-mode={workspace.mode}
|
|
role="dialog"
|
|
aria-label={translate(workspace.locale, 'ai.workspace.title')}
|
|
>
|
|
<WorkspaceResizeHandles mode={workspace.mode} onResize={sizing.onWorkspaceResize} />
|
|
<ConversationSidebar
|
|
activeId={model.activeId}
|
|
collapsed={model.sidebarCollapsed}
|
|
conversations={model.conversations}
|
|
locale={workspace.locale}
|
|
onCanArchive={model.canArchiveConversation}
|
|
onArchive={model.archiveConversationSafely}
|
|
onNewChat={model.addDefaultConversation}
|
|
onRename={model.renameConversation}
|
|
onRestore={model.restoreConversation}
|
|
onSelect={model.setActiveId}
|
|
onToggleCollapsed={model.toggleSidebarCollapsed}
|
|
setShowArchived={model.setShowArchived}
|
|
showArchived={model.showArchived}
|
|
sidebarWidth={sizing.sidebarWidth}
|
|
statuses={model.statuses}
|
|
/>
|
|
{!model.sidebarCollapsed && (
|
|
<ResizeHandle onResize={sizing.onSidebarResize} />
|
|
)}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<ConversationSessions model={model} workspace={workspace} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function ConversationSessions({
|
|
model,
|
|
onMessageHistoryScrollStateChange,
|
|
workspace,
|
|
}: {
|
|
model: AiWorkspaceModel
|
|
onMessageHistoryScrollStateChange?: (scrolled: boolean) => void
|
|
workspace: ResolvedAiWorkspaceProps
|
|
}) {
|
|
return (
|
|
<div className="flex min-h-0 flex-1">
|
|
{model.conversations.map((conversation) => {
|
|
const target = resolveTarget(conversation, model.groups, model.fallbackTarget)
|
|
|
|
return (
|
|
<ConversationSession
|
|
key={conversation.id}
|
|
active={conversation.id === model.activeConversation?.id}
|
|
activeEntry={workspace.activeEntry}
|
|
activeNoteContent={workspace.activeNoteContent}
|
|
aiAgentsStatus={workspace.aiAgentsStatus}
|
|
conversation={conversation}
|
|
defaultAiAgentReady={workspace.defaultAiAgentReady}
|
|
entries={workspace.entries}
|
|
groups={model.groups}
|
|
locale={workspace.locale}
|
|
mode={workspace.mode}
|
|
noteList={workspace.noteList}
|
|
noteListFilter={workspace.noteListFilter}
|
|
onArchive={() => 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}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <AiWorkspaceLayout model={model} workspace={workspace} />
|
|
}
|