From 83cd7bc20ddc579ad391b61e110f755502f2b39c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 30 May 2026 21:10:41 +0200 Subject: [PATCH] refactor: improve ai workspace code health --- docs/ARCHITECTURE.md | 2 +- docs/GETTING-STARTED.md | 12 +- src/components/AiWorkspace.tsx | 1101 +++++-------------- src/components/AiWorkspaceChrome.tsx | 93 ++ src/components/AiWorkspaceResizeHandles.tsx | 62 ++ src/components/aiWorkspaceConversations.ts | 419 +++++++ src/components/aiWorkspaceSizing.ts | 131 +++ 7 files changed, 1005 insertions(+), 815 deletions(-) create mode 100644 src/components/AiWorkspaceChrome.tsx create mode 100644 src/components/AiWorkspaceResizeHandles.tsx create mode 100644 src/components/aiWorkspaceConversations.ts create mode 100644 src/components/aiWorkspaceSizing.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4616044e..ac6cde76 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,7 +233,7 @@ flowchart TD Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and opens the platform print/save-to-PDF dialog through the Tauri `print_current_webview` command, with `window.print()` as the browser fallback. The export reuses rendered BlockNote output so math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. - **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). -- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior. +- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat orchestration, sidebar tabs, installed-only target picker, permission-mode picker, and dock/pop-out controls. Header/guidance chrome lives in `AiWorkspaceChrome`, edge-resize handles in `AiWorkspaceResizeHandles`, conversation metadata/settings persistence in `aiWorkspaceConversations`, and sizing/class/style helpers in `aiWorkspaceSizing`. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior. Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index e46f4c44..4e9a8866 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -107,7 +107,7 @@ tolaria/ │ ├── theme.json # Editor typography theme configuration │ ├── index.css # Semantic app theme variables + Tailwind setup │ │ -│ ├── components/ # UI components (~98 files) +│ ├── components/ # UI components (~100 files) │ │ ├── Sidebar.tsx # Left panel: filters + type groups │ │ ├── SidebarParts.tsx # Sidebar subcomponents │ │ ├── NoteList.tsx # Second panel: filtered note list @@ -120,7 +120,9 @@ tolaria/ │ │ ├── RawEditorView.tsx # CodeMirror raw editor │ │ ├── Inspector.tsx # Fourth panel: metadata + relationships │ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties -│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace (docked or native window) +│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace orchestration (docked or native window) +│ │ ├── AiWorkspaceChrome.tsx # AI workspace header and vault-guidance chrome +│ │ ├── AiWorkspaceResizeHandles.tsx # AI workspace edge resize handles │ │ ├── AiPanel.tsx # AI transcript/composer surface (selected target + per-vault permission mode) │ │ ├── AiMessage.tsx # Agent message display │ │ ├── AiActionCard.tsx # Agent tool action cards @@ -333,7 +335,11 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace — sidebar chats, installed-only target picker, permission picker, and dock/pop-out controls. | +| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace orchestration — chat sessions, sidebar tabs, target/permission controls, and dock/pop-out wiring. | +| `src/components/AiWorkspaceChrome.tsx` | Header and vault-guidance chrome shared by docked and popped-out AI workspace modes. | +| `src/components/AiWorkspaceResizeHandles.tsx` | Edge resize affordances for docked/side AI workspace layouts. | +| `src/components/aiWorkspaceConversations.ts` | Conversation metadata state, settings persistence, default title generation, and target resolution. | +| `src/components/aiWorkspaceSizing.ts` | AI workspace sizing, localStorage persistence, class names, and layout style helpers. | | `src/components/AiPanel.tsx` | Reusable AI transcript/composer surface — selected target with tool execution, reasoning, actions, and per-vault permission mode. | | `src/utils/openAiWorkspaceWindow.ts` | Native Tauri AI workspace window creation, focus, and dock-back traffic-light handling. | | `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. | diff --git a/src/components/AiWorkspace.tsx b/src/components/AiWorkspace.tsx index cdf6ac9f..eea12963 100644 --- a/src/components/AiWorkspace.tsx +++ b/src/components/AiWorkspace.tsx @@ -1,17 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react' -import { - Archive, - ArrowSquareIn, - ArrowSquareOut, - ArrowsInLineHorizontal, - ArrowsOutLineHorizontal, - CaretDown, - GearSix, - Plus, - SidebarSimple, - WarningCircle, - X, -} from '@phosphor-icons/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowsInLineHorizontal, ArrowsOutLineHorizontal, CaretDown, GearSix, Plus, SidebarSimple, X } from '@phosphor-icons/react' import { DndContext, PointerSensor, @@ -22,7 +10,6 @@ import { } from '@dnd-kit/core' import { SortableContext, - arrayMove, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable' @@ -40,7 +27,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -import { useDragRegion } from '../hooks/useDragRegion' import { DEFAULT_AI_AGENT, getAiAgentAvailability, @@ -49,7 +35,6 @@ import { type AiAgentsStatus, } from '../lib/aiAgents' import { - agentTargets, aiTargetReady, targetAgent, type AiModelProvider, @@ -60,8 +45,6 @@ import { type AiAgentPermissionMode, } from '../lib/aiAgentPermissionMode' import { - getVaultAiGuidanceSummary, - vaultAiGuidanceNeedsRestore, type VaultAiGuidanceStatus, } from '../lib/vaultAiGuidance' import { translate, type AppLocale } from '../lib/i18n' @@ -71,27 +54,34 @@ 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 { - generateAiConversationTitleForTarget, - type GenerateAiConversationTitleRequest, -} from '../utils/aiConversationTitle' +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 interface AiConversation { - archived: boolean - hasActivity: boolean - id: string - targetId: string - title: string - usesDefaultTitle: boolean - usesDefaultTarget: boolean -} +export type { AiConversation } from './aiWorkspaceConversations' interface AiWorkspaceProps { activeEntry?: VaultEntry | null @@ -129,78 +119,6 @@ interface AiWorkspaceProps { vaultPaths?: string[] } -let fallbackConversationIdCounter = 0 - -const DEFAULT_DOCKED_WORKSPACE_SIZE = { height: 540, width: 560 } -const MIN_DOCKED_WORKSPACE_SIZE = { height: 360, width: 460 } -const DEFAULT_SIDE_WORKSPACE_WIDTH = 320 -const MIN_SIDE_WORKSPACE_WIDTH = 320 -const SIDE_WORKSPACE_WIDTH_STORAGE_KEY = 'tolaria:ai-workspace-side-width' -const DEFAULT_SIDEBAR_WIDTH = 168 -const MIN_SIDEBAR_WIDTH = 132 -const MAX_SIDEBAR_WIDTH = 240 - -function randomConversationIdPart(): string { - const cryptoApi = globalThis.crypto - if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID().slice(0, 8) - - if (typeof cryptoApi?.getRandomValues === 'function') { - const values = new Uint32Array(2) - cryptoApi.getRandomValues(values) - return Array.from(values, (value) => value.toString(36)).join('').slice(0, 8) - } - - fallbackConversationIdCounter += 1 - return fallbackConversationIdCounter.toString(36).padStart(4, '0') -} - -function nextConversationId(): string { - return `ai-chat-${Date.now()}-${randomConversationIdPart()}` -} - -function isRunningStatus(status: AgentStatus | undefined): boolean { - return status === 'thinking' || status === 'tool-executing' -} - -function clampNumber(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max) -} - -function maxDockedWorkspaceSize(): { height: number; width: number } { - if (typeof window === 'undefined') return { height: 680, width: 880 } - - return { - height: Math.max(MIN_DOCKED_WORKSPACE_SIZE.height, window.innerHeight - 88), - width: Math.max(MIN_DOCKED_WORKSPACE_SIZE.width, window.innerWidth - 32), - } -} - -function readStoredSideWorkspaceWidth(): number { - if (typeof localStorage === 'undefined') return DEFAULT_SIDE_WORKSPACE_WIDTH - - try { - const parsed = Number(localStorage.getItem(SIDE_WORKSPACE_WIDTH_STORAGE_KEY)) - if (!Number.isFinite(parsed)) return DEFAULT_SIDE_WORKSPACE_WIDTH - return clampNumber(parsed, MIN_SIDE_WORKSPACE_WIDTH, maxDockedWorkspaceSize().width) - } catch { - return DEFAULT_SIDE_WORKSPACE_WIDTH - } -} - -function writeStoredSideWorkspaceWidth(width: number): void { - if (typeof localStorage === 'undefined') return - - try { - localStorage.setItem(SIDE_WORKSPACE_WIDTH_STORAGE_KEY, String(width)) - } catch { - // Ignore unavailable or restricted localStorage implementations. - } -} - -function canArchiveConversation(conversation: AiConversation): boolean { - return conversation.archived || conversation.hasActivity -} - function agentReadinessForTarget(target: AiTarget, statuses: AiAgentsStatus): AiAgentReadiness { if (target.kind === 'api_model') return 'ready' const status = getAiAgentAvailability(statuses, target.agent).status @@ -208,356 +126,6 @@ function agentReadinessForTarget(target: AiTarget, statuses: AiAgentsStatus): Ai return status === 'installed' ? 'ready' : 'missing' } -function flatTargets(groups: AiWorkspaceTargetGroups): AiTarget[] { - return [...groups.localAgents, ...groups.localModels, ...groups.apiModels] -} - -function firstTarget(groups: AiWorkspaceTargetGroups, defaultTarget: AiTarget | undefined, defaultAgent: AiAgentId): AiTarget { - const targets = flatTargets(groups) - const selectedDefault = defaultTarget ? targets.find((target) => target.id === defaultTarget.id) : undefined - if (selectedDefault) return selectedDefault - - const selectedAgent = targets.find((target) => target.kind === 'agent' && target.agent === defaultAgent) - return selectedAgent ?? targets[0] ?? defaultTarget ?? agentTargets()[0] -} - -function resolveTarget(conversation: AiConversation, groups: AiWorkspaceTargetGroups, fallback: AiTarget): AiTarget { - return flatTargets(groups).find((target) => target.id === conversation.targetId) ?? fallback -} - -function createConversation(locale: AppLocale, target: AiTarget, index: number): AiConversation { - return { - archived: false, - hasActivity: false, - id: nextConversationId(), - targetId: target.id, - title: defaultConversationTitle(locale, index), - usesDefaultTitle: true, - usesDefaultTarget: true, - } -} - -function defaultConversationTitle(locale: AppLocale, index: number): string { - if (index <= 1) return translate(locale, 'ai.workspace.chatTitle', { index: '' }).trim() - return translate(locale, 'ai.workspace.chatTitle', { index }) -} - -function isDefaultConversationTitle(title: string): boolean { - return /^(AI\s+)?Chat(?:\s+\d+)?$/i.test(title.trim()) -} - -function defaultConversationTitleIndex(title: string): number { - const match = title.trim().match(/\d+$/) - if (!match) return 1 - const parsed = Number(match[0]) - return Number.isFinite(parsed) ? parsed : 1 -} - -function conversationFromSetting(setting: AiWorkspaceConversationSetting, fallbackTarget: AiTarget, locale: AppLocale): AiConversation | null { - const id = setting.id.trim() - const storedTitle = setting.title.trim() - if (!id || !storedTitle) return null - const usesDefaultTitle = isDefaultConversationTitle(storedTitle) - const title = usesDefaultTitle - ? defaultConversationTitle(locale, defaultConversationTitleIndex(storedTitle)) - : storedTitle - - return { - archived: setting.archived === true, - hasActivity: !usesDefaultTitle, - id, - targetId: setting.target_id?.trim() || fallbackTarget.id, - title, - usesDefaultTitle, - usesDefaultTarget: !setting.target_id, - } -} - -function conversationsFromSettings( - settings: AiWorkspaceConversationSetting[] | null | undefined, - fallbackTarget: AiTarget, - locale: AppLocale, -): AiConversation[] { - const stored = (settings ?? []) - .map((setting) => conversationFromSetting(setting, fallbackTarget, locale)) - .filter((conversation): conversation is AiConversation => conversation !== null) - return stored.length > 0 ? stored : [createConversation(locale, fallbackTarget, 1)] -} - -function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceConversationSetting[] { - return conversations.map((conversation) => ({ - archived: conversation.archived, - id: conversation.id, - target_id: conversation.usesDefaultTarget ? null : conversation.targetId, - title: conversation.title, - })) -} - -function activeConversationForState( - conversations: AiConversation[], - activeId: string, - showArchived: boolean, -): AiConversation | undefined { - const selected = conversations.find((conversation) => conversation.id === activeId) - if (selected && selected.archived === showArchived) return selected - - return conversations.find((conversation) => conversation.archived === showArchived) - ?? conversations.find((conversation) => !conversation.archived) - ?? conversations[0] -} - -interface UseConversationsOptions { - fallbackTarget: AiTarget - initialActiveConversationId?: string - locale: AppLocale - onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void - settings?: AiWorkspaceConversationSetting[] | null - settingsReady: boolean -} - -function appendConversationState( - current: AiConversation[], - locale: AppLocale, - target: AiTarget, -): { activeId: string; conversations: AiConversation[] } { - const next = createConversation(locale, target, current.length + 1) - return { - activeId: next.id, - conversations: [...current, next], - } -} - -function forkConversationState( - current: AiConversation[], - locale: AppLocale, - sourceId: string, -): { activeId: string; conversations: AiConversation[] } | null { - const source = current.find((conversation) => conversation.id === sourceId) - if (!source) return null - - const index = current.length + 1 - const next: AiConversation = { - archived: false, - hasActivity: true, - id: nextConversationId(), - targetId: source.targetId, - title: source.usesDefaultTitle ? defaultConversationTitle(locale, index) : source.title, - usesDefaultTitle: false, - usesDefaultTarget: source.usesDefaultTarget, - } - - return { - activeId: next.id, - conversations: [...current, next], - } -} - -function archiveConversationState( - current: AiConversation[], - id: string, -): { activeId?: string; conversations: AiConversation[] } { - const conversations = current.map((conversation) => ( - conversation.id === id ? { ...conversation, archived: true } : conversation - )) - const fallback = conversations.find((conversation) => !conversation.archived && conversation.id !== id) - return { activeId: fallback?.id, conversations } -} - -function closeConversationState( - current: AiConversation[], - id: string, - activeId: string, - fallbackTarget: AiTarget, - locale: AppLocale, -): { activeId: string; conversations: AiConversation[] } { - const closedConversation = current.find((conversation) => conversation.id === id) - if (!closedConversation) return { activeId, conversations: current } - - const conversations = closedConversation.hasActivity - ? current.map((conversation) => ( - conversation.id === id ? { ...conversation, archived: true } : conversation - )) - : current.filter((conversation) => conversation.id !== id) - const activeConversation = conversations.find((conversation) => conversation.id === activeId && !conversation.archived) - if (activeConversation) return { activeId, conversations } - - const fallbackConversation = conversations.find((conversation) => !conversation.archived) - if (fallbackConversation) return { activeId: fallbackConversation.id, conversations } - - const nextConversation = createConversation(locale, fallbackTarget, conversations.length + 1) - return { - activeId: nextConversation.id, - conversations: [...conversations, nextConversation], - } -} - -function restoreConversationState(current: AiConversation[], id: string): AiConversation[] { - return current.map((conversation) => ( - conversation.id === id ? { ...conversation, archived: false } : conversation - )) -} - -function retargetConversationState(current: AiConversation[], id: string, targetId: string): AiConversation[] { - return current.map((conversation) => ( - conversation.id === id ? { ...conversation, targetId, usesDefaultTarget: false } : conversation - )) -} - -function reorderConversationState(current: AiConversation[], activeId: string, overId: string): AiConversation[] { - const oldIndex = current.findIndex((conversation) => conversation.id === activeId) - const newIndex = current.findIndex((conversation) => conversation.id === overId) - if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return current - - return arrayMove(current, oldIndex, newIndex) -} - -function renameConversationState(current: AiConversation[], id: string, title: string): AiConversation[] { - const nextTitle = title.trim() - if (!nextTitle) return current - - return current.map((conversation) => ( - conversation.id === id ? { ...conversation, title: nextTitle, usesDefaultTitle: false } : conversation - )) -} - -function markConversationActivityState(current: AiConversation[], id: string): AiConversation[] { - return current.map((conversation) => ( - conversation.id === id - ? { - ...conversation, - hasActivity: true, - } - : conversation - )) -} - -function applyGeneratedConversationTitleState(current: AiConversation[], id: string, title: string): AiConversation[] { - const nextTitle = title.trim() - if (!nextTitle) return current - - return current.map((conversation) => ( - conversation.id === id && conversation.usesDefaultTitle - ? { ...conversation, hasActivity: true, title: nextTitle, usesDefaultTitle: false } - : conversation - )) -} - -function updateDefaultConversationTargetState(current: AiConversation[], targetId: string): AiConversation[] { - return current.map((conversation) => ( - conversation.usesDefaultTarget && conversation.targetId !== targetId - ? { ...conversation, targetId } - : conversation - )) -} - -function useConversations({ - fallbackTarget, - initialActiveConversationId, - locale, - onSettingsChange, - settings, - settingsReady, -}: UseConversationsOptions) { - const [conversations, setConversations] = useState(() => ( - conversationsFromSettings(settings, fallbackTarget, locale) - )) - const [activeId, setActiveId] = useState(() => ( - conversations.some((conversation) => conversation.id === initialActiveConversationId) - ? initialActiveConversationId ?? '' - : conversations[0]?.id ?? '' - )) - const [showArchived, setShowArchived] = useState(false) - const onSettingsChangeRef = useRef(onSettingsChange) - - const addConversation = useCallback((target: AiTarget) => { - const next = appendConversationState(conversations, locale, target) - setConversations(next.conversations) - setActiveId(next.activeId) - }, [conversations, locale]) - - const forkConversation = useCallback((sourceId: string) => { - const next = forkConversationState(conversations, locale, sourceId) - if (!next) return undefined - - setConversations(next.conversations) - setActiveId(next.activeId) - return next.activeId - }, [conversations, locale]) - - const archiveConversation = useCallback((id: string) => { - const next = archiveConversationState(conversations, id) - setConversations(next.conversations) - if (next.activeId) setActiveId(next.activeId) - }, [conversations]) - - const closeConversation = useCallback((id: string) => { - const next = closeConversationState(conversations, id, activeId, fallbackTarget, locale) - setConversations(next.conversations) - setActiveId(next.activeId) - }, [activeId, conversations, fallbackTarget, locale]) - - const restoreConversation = useCallback((id: string) => { - setConversations((current) => restoreConversationState(current, id)) - setActiveId(id) - setShowArchived(false) - }, []) - - const reorderConversation = useCallback((activeId: string, overId: string) => { - setConversations((current) => reorderConversationState(current, activeId, overId)) - }, []) - - const setConversationTarget = useCallback((id: string, targetId: string) => { - setConversations((current) => retargetConversationState(current, id, targetId)) - }, []) - - const renameConversation = useCallback((id: string, title: string) => { - setConversations((current) => renameConversationState(current, id, title)) - }, []) - - const markConversationActivity = useCallback((id: string) => { - setConversations((current) => markConversationActivityState(current, id)) - }, []) - - const titleConversationFromAnswer = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => { - void generateAiConversationTitleForTarget(request).then((title) => { - if (!title) return - setConversations((current) => applyGeneratedConversationTitleState(current, request.id, title)) - }) - }, []) - - const updateDefaultConversationTargets = useCallback((targetId: string) => { - setConversations((current) => updateDefaultConversationTargetState(current, targetId)) - }, []) - - useEffect(() => { - onSettingsChangeRef.current = onSettingsChange - }, [onSettingsChange]) - - useEffect(() => { - if (!settingsReady) return - onSettingsChangeRef.current?.(conversationsToSettings(conversations)) - }, [conversations, settingsReady]) - - return { - activeId, - addConversation, - archiveConversation, - closeConversation, - conversations, - forkConversation, - renameConversation, - reorderConversation, - restoreConversation, - setActiveId, - setConversationTarget, - setShowArchived, - showArchived, - markConversationActivity, - titleConversationFromAnswer, - updateDefaultConversationTargets, - } -} - function TargetGroup({ label, targets }: { label: string; targets: AiTarget[] }) { if (targets.length === 0) return null @@ -609,8 +177,6 @@ function TargetPickerTrigger({ ) } -type AiWorkspaceMode = 'docked' | 'side' | 'window' - function TargetPickerContent({ groups, hasTargets, @@ -742,92 +308,6 @@ function PermissionPicker({ ) } -function GuidanceWarning({ - locale, - onRestore, - status, -}: { - locale: AppLocale - onRestore?: () => void - status?: VaultAiGuidanceStatus -}) { - if (!status || !vaultAiGuidanceNeedsRestore(status)) return null - - return ( -
- - - {translate(locale, 'ai.workspace.guidanceWarning', { summary: getVaultAiGuidanceSummary(status) })} - - {status.canRestore && onRestore && ( - - )} -
- ) -} - -function WorkspaceHeader({ - conversation, - archiveDisabled, - locale, - mode, - onArchive, - onClose, - onDock, - onOpenAiSettings, - onPopOut, -}: { - conversation: AiConversation - archiveDisabled: boolean - locale: AppLocale - mode: AiWorkspaceMode - onArchive: () => void - onClose: () => void - onDock?: () => void - onOpenAiSettings?: () => void - onPopOut?: (context?: { activeConversationId?: string }) => void -}) { - const { dragRegionRef } = useDragRegion() - - return ( -
-
-
-
{conversation.title}
-
-
-
- {onOpenAiSettings && ( - - )} - - {mode === 'docked' ? ( - - ) : ( - - )} - -
-
- ) -} - type ConversationSessionProps = { active: boolean activeEntry?: VaultEntry | null @@ -930,6 +410,130 @@ function useGeneratedConversationTitle({ ]) } +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, @@ -966,8 +570,15 @@ function ConversationSession({ vaultPath, vaultPaths, }: ConversationSessionProps) { - const contextActiveEntry = active ? activeEntry : null - const contextEntries = active ? entries : undefined + const context = activeContextForSession({ + active, + activeEntry, + activeNoteContent, + entries, + noteList, + noteListFilter, + openTabs, + }) const readiness = agentReadinessForTarget(target, aiAgentsStatus) const controller = useAiPanelController({ vaultPath, @@ -976,12 +587,12 @@ function ConversationSession({ defaultAiTarget: target, defaultAiAgentReady: target.kind === 'api_model' || defaultAiAgentReady, defaultAiAgentReadiness: readiness, - activeEntry: contextActiveEntry, - activeNoteContent: active ? activeNoteContent : null, - entries: contextEntries, - openTabs: active ? openTabs : undefined, - noteList: active ? noteList : undefined, - noteListFilter: active ? noteListFilter : undefined, + activeEntry: context.activeEntry, + activeNoteContent: context.activeNoteContent, + entries: context.entries, + openTabs: context.openTabs, + noteList: context.noteList, + noteListFilter: context.noteListFilter, locale, onOpenNote, onFileCreated, @@ -989,43 +600,20 @@ function ConversationSession({ onVaultChanged, sessionId: conversation.id, }) - const running = isRunningStatus(controller.agent.status) + const running = controller.agent.status === 'thinking' || controller.agent.status === 'tool-executing' const composerMenuSide = mode === 'window' ? 'bottom' : 'top' const composerControls = ( - <> - - - {onOpenAiSettings && ( - - )} - + ) useEffect(() => { @@ -1044,19 +632,16 @@ function ConversationSession({ return (
- {mode !== 'side' && ( - - )} +
void - onWorkspaceResize: (deltaWidth: number, deltaHeight: number) => void - sidebarWidth: number - workspaceSize: { height: number; width: number } -} - -function workspaceClassName(mode: AiWorkspaceMode, expanded = false): string { - if (mode === 'side') { - return cn( - 'z-20 flex h-full min-h-0 overflow-hidden border-l border-sidebar-border bg-sidebar text-sidebar-foreground', - expanded ? 'absolute inset-0 border-l-0' : 'relative shrink-0', - ) - } - - if (mode === 'window') { - return 'flex h-full w-full overflow-hidden bg-background text-foreground' - } - - return 'fixed right-4 bottom-[30px] z-40 flex overflow-hidden rounded-lg border border-border bg-background text-foreground' -} - -function workspaceStyle( - mode: AiWorkspaceMode, - size: AiWorkspaceSizing['workspaceSize'], - expanded = false, -): CSSProperties | undefined { - if (mode === 'window') return undefined - if (mode === 'side') { - if (expanded) return undefined - return { - minWidth: MIN_SIDE_WORKSPACE_WIDTH, - width: size.width, - } - } - - return { - height: size.height, - maxHeight: 'calc(100vh - 62px)', - maxWidth: 'calc(100vw - 32px)', - minHeight: MIN_DOCKED_WORKSPACE_SIZE.height, - minWidth: MIN_DOCKED_WORKSPACE_SIZE.width, - width: size.width, - } -} - -function startResizeDrag( - event: ReactMouseEvent, - cursor: string, - onDrag: (deltaX: number, deltaY: number) => void, -) { - event.preventDefault() - event.stopPropagation() - - let lastX = event.clientX - let lastY = event.clientY - const previousCursor = document.body.style.cursor - const previousUserSelect = document.body.style.userSelect - document.body.style.cursor = cursor - document.body.style.userSelect = 'none' - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = moveEvent.clientX - lastX - const deltaY = moveEvent.clientY - lastY - lastX = moveEvent.clientX - lastY = moveEvent.clientY - onDrag(deltaX, deltaY) - } - const handleMouseUp = () => { - document.body.style.cursor = previousCursor - document.body.style.userSelect = previousUserSelect - window.removeEventListener('mousemove', handleMouseMove) - window.removeEventListener('mouseup', handleMouseUp) - } - - window.addEventListener('mousemove', handleMouseMove) - window.addEventListener('mouseup', handleMouseUp) -} - -function WorkspaceResizeHandles({ - mode, - onResize, -}: { - mode: AiWorkspaceMode - onResize: (deltaWidth: number, deltaHeight: number) => void -}) { - if (mode === 'window') return null - - return ( - <> -
startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))} - /> - {mode === 'docked' && ( -
startResizeDrag(event, 'row-resize', (_deltaX, deltaY) => onResize(0, -deltaY))} - /> - )} - - ) -} - -function useAiWorkspaceSizing(mode: AiWorkspaceMode): AiWorkspaceSizing { - const [workspaceSize, setWorkspaceSize] = useState(() => ( - mode === 'side' - ? { height: DEFAULT_DOCKED_WORKSPACE_SIZE.height, width: readStoredSideWorkspaceWidth() } - : DEFAULT_DOCKED_WORKSPACE_SIZE - )) - const workspaceSizeRef = useRef(workspaceSize) - const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH) - - const onWorkspaceResize = useCallback((deltaWidth: number, deltaHeight: number) => { - if (mode === 'window') return - const current = workspaceSizeRef.current - const max = maxDockedWorkspaceSize() - const minWidth = mode === 'side' ? MIN_SIDE_WORKSPACE_WIDTH : MIN_DOCKED_WORKSPACE_SIZE.width - const next = { - height: clampNumber(current.height + deltaHeight, MIN_DOCKED_WORKSPACE_SIZE.height, max.height), - width: clampNumber(current.width + deltaWidth, minWidth, max.width), - } - workspaceSizeRef.current = next - if (mode === 'side') writeStoredSideWorkspaceWidth(next.width) - setWorkspaceSize(next) - }, [mode]) - const onSidebarResize = useCallback((delta: number) => { - setSidebarWidth((current) => clampNumber(current + delta, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)) - }, []) - - useEffect(() => { - workspaceSizeRef.current = workspaceSize - }, [workspaceSize]) - - useEffect(() => { - if (mode === 'side') writeStoredSideWorkspaceWidth(workspaceSize.width) - }, [mode, workspaceSize.width]) - - return { onSidebarResize, onWorkspaceResize, sidebarWidth, workspaceSize } -} - function SideWorkspaceTitleEditor({ conversation, locale, @@ -1386,7 +828,7 @@ function SideWorkspaceTab({ }} > {conversation.title} - {isRunningStatus(status) && } + {(status === 'thinking' || status === 'tool-executing') && } )} {!editing && ( @@ -1466,6 +908,99 @@ function useHorizontalScrollFades(dependencyKey: string) { } } +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, @@ -1495,22 +1030,7 @@ function SideWorkspaceHeader({ separated: boolean statuses: Record }) { - const [editingId, setEditingId] = useState(null) - const visibleConversations = conversations.filter((conversation) => !conversation.archived) - const visibleConversationIds = visibleConversations.map((conversation) => conversation.id) const expandLabel = translate(locale, expanded ? 'ai.workspace.restorePanel' : 'ai.workspace.expandPanel') - 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 && ( -
- )} -
+ + )} +
+ ) +} + +export function WorkspaceHeader({ + conversation, + archiveDisabled, + locale, + mode, + onArchive, + onClose, + onDock, + onOpenAiSettings, + onPopOut, +}: { + conversation: AiConversation + archiveDisabled: boolean + locale: AppLocale + mode: AiWorkspaceMode + onArchive: () => void + onClose: () => void + onDock?: () => void + onOpenAiSettings?: () => void + onPopOut?: (context?: { activeConversationId?: string }) => void +}) { + const { dragRegionRef } = useDragRegion() + + return ( +
+
+
+
{conversation.title}
+
+
+
+ {onOpenAiSettings && ( + + )} + + {mode === 'docked' ? ( + + ) : ( + + )} + +
+
+ ) +} diff --git a/src/components/AiWorkspaceResizeHandles.tsx b/src/components/AiWorkspaceResizeHandles.tsx new file mode 100644 index 00000000..b0c145c8 --- /dev/null +++ b/src/components/AiWorkspaceResizeHandles.tsx @@ -0,0 +1,62 @@ +import { type MouseEvent as ReactMouseEvent } from 'react' +import type { AiWorkspaceMode } from './aiWorkspaceSizing' + +function startResizeDrag( + event: ReactMouseEvent, + cursor: string, + onDrag: (deltaX: number, deltaY: number) => void, +) { + event.preventDefault() + event.stopPropagation() + + let lastX = event.clientX + let lastY = event.clientY + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + document.body.style.cursor = cursor + document.body.style.userSelect = 'none' + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - lastX + const deltaY = moveEvent.clientY - lastY + lastX = moveEvent.clientX + lastY = moveEvent.clientY + onDrag(deltaX, deltaY) + } + const handleMouseUp = () => { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) +} + +export function WorkspaceResizeHandles({ + mode, + onResize, +}: { + mode: AiWorkspaceMode + onResize: (deltaWidth: number, deltaHeight: number) => void +}) { + if (mode === 'window') return null + + return ( + <> +
startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))} + /> + {mode === 'docked' && ( +
startResizeDrag(event, 'row-resize', (_deltaX, deltaY) => onResize(0, -deltaY))} + /> + )} + + ) +} diff --git a/src/components/aiWorkspaceConversations.ts b/src/components/aiWorkspaceConversations.ts new file mode 100644 index 00000000..94906f98 --- /dev/null +++ b/src/components/aiWorkspaceConversations.ts @@ -0,0 +1,419 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { arrayMove } from '@dnd-kit/sortable' +import { type AiAgentId } from '../lib/aiAgents' +import { agentTargets, type AiTarget } from '../lib/aiTargets' +import { translate, type AppLocale } from '../lib/i18n' +import type { AiWorkspaceConversationSetting } from '../types' +import { + generateAiConversationTitleForTarget, + type GenerateAiConversationTitleRequest, +} from '../utils/aiConversationTitle' +import type { AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups' + +export interface AiConversation { + archived: boolean + hasActivity: boolean + id: string + targetId: string + title: string + usesDefaultTitle: boolean + usesDefaultTarget: boolean +} + +type ConversationId = AiConversation['id'] +type ConversationTitle = AiConversation['title'] +type SetConversations = Dispatch> +type TargetId = AiConversation['targetId'] + +interface UseConversationsOptions { + fallbackTarget: AiTarget + initialActiveConversationId?: ConversationId + locale: AppLocale + onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void + settings?: AiWorkspaceConversationSetting[] | null + settingsReady: boolean +} + +interface CloseConversationOptions { + activeId: ConversationId + fallbackTarget: AiTarget + id: ConversationId + locale: AppLocale +} + +let fallbackConversationIdCounter = 0 + +function randomConversationIdPart(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID().slice(0, 8) + + if (typeof cryptoApi?.getRandomValues === 'function') { + const values = new Uint32Array(2) + cryptoApi.getRandomValues(values) + return Array.from(values, (value) => value.toString(36)).join('').slice(0, 8) + } + + fallbackConversationIdCounter += 1 + return fallbackConversationIdCounter.toString(36).padStart(4, '0') +} + +function nextConversationId(): string { + return `ai-chat-${Date.now()}-${randomConversationIdPart()}` +} + +export function canArchiveConversation(conversation: AiConversation): boolean { + return conversation.archived || conversation.hasActivity +} + +export function flatTargets(groups: AiWorkspaceTargetGroups): AiTarget[] { + return [...groups.localAgents, ...groups.localModels, ...groups.apiModels] +} + +export function firstTarget( + groups: AiWorkspaceTargetGroups, + defaultTarget: AiTarget | undefined, + defaultAgent: AiAgentId, +): AiTarget { + const targets = flatTargets(groups) + const selectedDefault = defaultTarget ? targets.find((target) => target.id === defaultTarget.id) : undefined + if (selectedDefault) return selectedDefault + + const selectedAgent = targets.find((target) => target.kind === 'agent' && target.agent === defaultAgent) + return selectedAgent ?? targets[0] ?? defaultTarget ?? agentTargets()[0] +} + +export function resolveTarget(conversation: AiConversation, groups: AiWorkspaceTargetGroups, fallback: AiTarget): AiTarget { + return flatTargets(groups).find((target) => target.id === conversation.targetId) ?? fallback +} + +function createConversation(locale: AppLocale, target: AiTarget, index: number): AiConversation { + return { + archived: false, + hasActivity: false, + id: nextConversationId(), + targetId: target.id, + title: defaultConversationTitle(locale, index), + usesDefaultTitle: true, + usesDefaultTarget: true, + } +} + +function defaultConversationTitle(locale: AppLocale, index: number): string { + if (index <= 1) return translate(locale, 'ai.workspace.chatTitle', { index: '' }).trim() + return translate(locale, 'ai.workspace.chatTitle', { index }) +} + +function isDefaultConversationTitle(title: string): boolean { + return /^(AI\s+)?Chat(?:\s+\d+)?$/i.test(title.trim()) +} + +function defaultConversationTitleIndex(title: string): number { + const match = title.trim().match(/\d+$/) + if (!match) return 1 + const parsed = Number(match[0]) + return Number.isFinite(parsed) ? parsed : 1 +} + +function conversationFromSetting( + setting: AiWorkspaceConversationSetting, + fallbackTarget: AiTarget, + locale: AppLocale, +): AiConversation | null { + const id = setting.id.trim() + const storedTitle = setting.title.trim() + if (!id || !storedTitle) return null + const usesDefaultTitle = isDefaultConversationTitle(storedTitle) + const title = usesDefaultTitle + ? defaultConversationTitle(locale, defaultConversationTitleIndex(storedTitle)) + : storedTitle + + return { + archived: setting.archived === true, + hasActivity: !usesDefaultTitle, + id, + targetId: setting.target_id?.trim() || fallbackTarget.id, + title, + usesDefaultTitle, + usesDefaultTarget: !setting.target_id, + } +} + +function conversationsFromSettings( + settings: AiWorkspaceConversationSetting[] | null | undefined, + fallbackTarget: AiTarget, + locale: AppLocale, +): AiConversation[] { + const stored = (settings ?? []) + .map((setting) => conversationFromSetting(setting, fallbackTarget, locale)) + .filter((conversation): conversation is AiConversation => conversation !== null) + return stored.length > 0 ? stored : [createConversation(locale, fallbackTarget, 1)] +} + +function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceConversationSetting[] { + return conversations.map((conversation) => ({ + archived: conversation.archived, + id: conversation.id, + target_id: conversation.usesDefaultTarget ? null : conversation.targetId, + title: conversation.title, + })) +} + +export function activeConversationForState( + conversations: AiConversation[], + activeId: ConversationId, + showArchived: boolean, +): AiConversation | undefined { + const selected = conversations.find((conversation) => conversation.id === activeId) + if (selected && selected.archived === showArchived) return selected + + return conversations.find((conversation) => conversation.archived === showArchived) + ?? conversations.find((conversation) => !conversation.archived) + ?? conversations[0] +} + +function appendConversationState( + current: AiConversation[], + locale: AppLocale, + target: AiTarget, +): { activeId: string; conversations: AiConversation[] } { + const next = createConversation(locale, target, current.length + 1) + return { + activeId: next.id, + conversations: [...current, next], + } +} + +function forkConversationState( + current: AiConversation[], + locale: AppLocale, + sourceId: ConversationId, +): { activeId: string; conversations: AiConversation[] } | null { + const source = current.find((conversation) => conversation.id === sourceId) + if (!source) return null + + const index = current.length + 1 + const next: AiConversation = { + archived: false, + hasActivity: true, + id: nextConversationId(), + targetId: source.targetId, + title: source.usesDefaultTitle ? defaultConversationTitle(locale, index) : source.title, + usesDefaultTitle: false, + usesDefaultTarget: source.usesDefaultTarget, + } + + return { + activeId: next.id, + conversations: [...current, next], + } +} + +function archiveConversationState( + current: AiConversation[], + id: ConversationId, +): { activeId?: string; conversations: AiConversation[] } { + const conversations = current.map((conversation) => ( + conversation.id === id ? { ...conversation, archived: true } : conversation + )) + const fallback = conversations.find((conversation) => !conversation.archived && conversation.id !== id) + return { activeId: fallback?.id, conversations } +} + +function closeConversationState( + current: AiConversation[], + { activeId, fallbackTarget, id, locale }: CloseConversationOptions, +): { activeId: string; conversations: AiConversation[] } { + const closedConversation = current.find((conversation) => conversation.id === id) + if (!closedConversation) return { activeId, conversations: current } + + const conversations = closedConversation.hasActivity + ? current.map((conversation) => ( + conversation.id === id ? { ...conversation, archived: true } : conversation + )) + : current.filter((conversation) => conversation.id !== id) + const activeConversation = conversations.find((conversation) => conversation.id === activeId && !conversation.archived) + if (activeConversation) return { activeId, conversations } + + const fallbackConversation = conversations.find((conversation) => !conversation.archived) + if (fallbackConversation) return { activeId: fallbackConversation.id, conversations } + + const nextConversation = createConversation(locale, fallbackTarget, conversations.length + 1) + return { + activeId: nextConversation.id, + conversations: [...conversations, nextConversation], + } +} + +function restoreConversationState(current: AiConversation[], id: ConversationId): AiConversation[] { + return current.map((conversation) => ( + conversation.id === id ? { ...conversation, archived: false } : conversation + )) +} + +function retargetConversationState(current: AiConversation[], id: ConversationId, targetId: TargetId): AiConversation[] { + return current.map((conversation) => ( + conversation.id === id ? { ...conversation, targetId, usesDefaultTarget: false } : conversation + )) +} + +function reorderConversationState(current: AiConversation[], activeId: ConversationId, overId: ConversationId): AiConversation[] { + const oldIndex = current.findIndex((conversation) => conversation.id === activeId) + const newIndex = current.findIndex((conversation) => conversation.id === overId) + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return current + + return arrayMove(current, oldIndex, newIndex) +} + +function renameConversationState(current: AiConversation[], id: ConversationId, title: ConversationTitle): AiConversation[] { + const nextTitle = title.trim() + if (!nextTitle) return current + + return current.map((conversation) => ( + conversation.id === id ? { ...conversation, title: nextTitle, usesDefaultTitle: false } : conversation + )) +} + +function markConversationActivityState(current: AiConversation[], id: ConversationId): AiConversation[] { + return current.map((conversation) => ( + conversation.id === id + ? { + ...conversation, + hasActivity: true, + } + : conversation + )) +} + +function applyGeneratedConversationTitleState(current: AiConversation[], id: ConversationId, title: ConversationTitle): AiConversation[] { + const nextTitle = title.trim() + if (!nextTitle) return current + + return current.map((conversation) => ( + conversation.id === id && conversation.usesDefaultTitle + ? { ...conversation, hasActivity: true, title: nextTitle, usesDefaultTitle: false } + : conversation + )) +} + +function updateDefaultConversationTargetState(current: AiConversation[], targetId: TargetId): AiConversation[] { + return current.map((conversation) => ( + conversation.usesDefaultTarget && conversation.targetId !== targetId + ? { ...conversation, targetId } + : conversation + )) +} + +function initialActiveId(conversations: AiConversation[], requestedId: ConversationId | undefined): ConversationId { + if (conversations.some((conversation) => conversation.id === requestedId)) return requestedId ?? '' + return conversations[0]?.id ?? '' +} + +function useConversationSettingsPersistence({ + conversations, + onSettingsChange, + settingsReady, +}: { + conversations: AiConversation[] + onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void + settingsReady: boolean +}) { + const onSettingsChangeRef = useRef(onSettingsChange) + + useEffect(() => { + onSettingsChangeRef.current = onSettingsChange + }, [onSettingsChange]) + + useEffect(() => { + if (!settingsReady) return + onSettingsChangeRef.current?.(conversationsToSettings(conversations)) + }, [conversations, settingsReady]) +} + +function useTitleConversationFromAnswer(setConversations: SetConversations) { + return useCallback((request: GenerateAiConversationTitleRequest & { id: ConversationId }) => { + void generateAiConversationTitleForTarget(request).then((title) => { + if (!title) return + setConversations((current) => applyGeneratedConversationTitleState(current, request.id, title)) + }) + }, [setConversations]) +} + +function useUpdateDefaultConversationTargets(setConversations: SetConversations) { + return useCallback((targetId: TargetId) => { + setConversations((current) => updateDefaultConversationTargetState(current, targetId)) + }, [setConversations]) +} + +export function useConversations({ + fallbackTarget, + initialActiveConversationId, + locale, + onSettingsChange, + settings, + settingsReady, +}: UseConversationsOptions) { + const [conversations, setConversations] = useState(() => ( + conversationsFromSettings(settings, fallbackTarget, locale) + )) + const [activeId, setActiveId] = useState(() => initialActiveId(conversations, initialActiveConversationId)) + const [showArchived, setShowArchived] = useState(false) + + const addConversation = useCallback((target: AiTarget) => { + const next = appendConversationState(conversations, locale, target) + setConversations(next.conversations) + setActiveId(next.activeId) + }, [conversations, locale]) + + const forkConversation = useCallback((sourceId: ConversationId) => { + const next = forkConversationState(conversations, locale, sourceId) + if (!next) return undefined + + setConversations(next.conversations) + setActiveId(next.activeId) + return next.activeId + }, [conversations, locale]) + + const archiveConversation = useCallback((id: ConversationId) => { + const next = archiveConversationState(conversations, id) + setConversations(next.conversations) + if (next.activeId) setActiveId(next.activeId) + }, [conversations]) + + const closeConversation = useCallback((id: ConversationId) => { + const next = closeConversationState(conversations, { activeId, fallbackTarget, id, locale }) + setConversations(next.conversations) + setActiveId(next.activeId) + }, [activeId, conversations, fallbackTarget, locale]) + + const restoreConversation = useCallback((id: ConversationId) => { + setConversations((current) => restoreConversationState(current, id)) + setActiveId(id) + setShowArchived(false) + }, []) + + const reorderConversation = useCallback((activeId: ConversationId, overId: ConversationId) => { + setConversations((current) => reorderConversationState(current, activeId, overId)) + }, []) + + const setConversationTarget = useCallback((id: ConversationId, targetId: TargetId) => { + setConversations((current) => retargetConversationState(current, id, targetId)) + }, []) + + const renameConversation = useCallback((id: ConversationId, title: ConversationTitle) => { + setConversations((current) => renameConversationState(current, id, title)) + }, []) + + const markConversationActivity = useCallback((id: ConversationId) => { + setConversations((current) => markConversationActivityState(current, id)) + }, []) + + const titleConversationFromAnswer = useTitleConversationFromAnswer(setConversations) + const updateDefaultConversationTargets = useUpdateDefaultConversationTargets(setConversations) + useConversationSettingsPersistence({ conversations, onSettingsChange, settingsReady }) + + return { + activeId, addConversation, archiveConversation, closeConversation, conversations, forkConversation, + markConversationActivity, renameConversation, reorderConversation, restoreConversation, setActiveId, + setConversationTarget, setShowArchived, showArchived, titleConversationFromAnswer, updateDefaultConversationTargets, + } +} diff --git a/src/components/aiWorkspaceSizing.ts b/src/components/aiWorkspaceSizing.ts new file mode 100644 index 00000000..2a181487 --- /dev/null +++ b/src/components/aiWorkspaceSizing.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' +import { cn } from '@/lib/utils' + +export type AiWorkspaceMode = 'docked' | 'side' | 'window' + +export interface AiWorkspaceSizing { + onSidebarResize: (delta: number) => void + onWorkspaceResize: (deltaWidth: number, deltaHeight: number) => void + sidebarWidth: number + workspaceSize: { height: number; width: number } +} + +const DEFAULT_DOCKED_WORKSPACE_SIZE = { height: 540, width: 560 } +const MIN_DOCKED_WORKSPACE_SIZE = { height: 360, width: 460 } +const DEFAULT_SIDE_WORKSPACE_WIDTH = 320 +const MIN_SIDE_WORKSPACE_WIDTH = 320 +const SIDE_WORKSPACE_WIDTH_STORAGE_KEY = 'tolaria:ai-workspace-side-width' +const DEFAULT_SIDEBAR_WIDTH = 168 +const MIN_SIDEBAR_WIDTH = 132 +const MAX_SIDEBAR_WIDTH = 240 + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +function maxDockedWorkspaceSize(): { height: number; width: number } { + if (typeof window === 'undefined') return { height: 680, width: 880 } + + return { + height: Math.max(MIN_DOCKED_WORKSPACE_SIZE.height, window.innerHeight - 88), + width: Math.max(MIN_DOCKED_WORKSPACE_SIZE.width, window.innerWidth - 32), + } +} + +function readStoredSideWorkspaceWidth(): number { + if (typeof localStorage === 'undefined') return DEFAULT_SIDE_WORKSPACE_WIDTH + + try { + const parsed = Number(localStorage.getItem(SIDE_WORKSPACE_WIDTH_STORAGE_KEY)) + if (!Number.isFinite(parsed)) return DEFAULT_SIDE_WORKSPACE_WIDTH + return clampNumber(parsed, MIN_SIDE_WORKSPACE_WIDTH, maxDockedWorkspaceSize().width) + } catch { + return DEFAULT_SIDE_WORKSPACE_WIDTH + } +} + +function writeStoredSideWorkspaceWidth(width: number): void { + if (typeof localStorage === 'undefined') return + + try { + localStorage.setItem(SIDE_WORKSPACE_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Ignore unavailable or restricted localStorage implementations. + } +} + +export function workspaceClassName(mode: AiWorkspaceMode, expanded = false): string { + if (mode === 'side') { + return cn( + 'z-20 flex h-full min-h-0 overflow-hidden border-l border-sidebar-border bg-sidebar text-sidebar-foreground', + expanded ? 'absolute inset-0 border-l-0' : 'relative shrink-0', + ) + } + + if (mode === 'window') { + return 'flex h-full w-full overflow-hidden bg-background text-foreground' + } + + return 'fixed right-4 bottom-[30px] z-40 flex overflow-hidden rounded-lg border border-border bg-background text-foreground' +} + +export function workspaceStyle( + mode: AiWorkspaceMode, + size: AiWorkspaceSizing['workspaceSize'], + expanded = false, +): CSSProperties | undefined { + if (mode === 'window') return undefined + if (mode === 'side') { + if (expanded) return undefined + return { + minWidth: MIN_SIDE_WORKSPACE_WIDTH, + width: size.width, + } + } + + return { + height: size.height, + maxHeight: 'calc(100vh - 62px)', + maxWidth: 'calc(100vw - 32px)', + minHeight: MIN_DOCKED_WORKSPACE_SIZE.height, + minWidth: MIN_DOCKED_WORKSPACE_SIZE.width, + width: size.width, + } +} + +export function useAiWorkspaceSizing(mode: AiWorkspaceMode): AiWorkspaceSizing { + const [workspaceSize, setWorkspaceSize] = useState(() => ( + mode === 'side' + ? { height: DEFAULT_DOCKED_WORKSPACE_SIZE.height, width: readStoredSideWorkspaceWidth() } + : DEFAULT_DOCKED_WORKSPACE_SIZE + )) + const workspaceSizeRef = useRef(workspaceSize) + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH) + + const onWorkspaceResize = useCallback((deltaWidth: number, deltaHeight: number) => { + if (mode === 'window') return + const current = workspaceSizeRef.current + const max = maxDockedWorkspaceSize() + const minWidth = mode === 'side' ? MIN_SIDE_WORKSPACE_WIDTH : MIN_DOCKED_WORKSPACE_SIZE.width + const next = { + height: clampNumber(current.height + deltaHeight, MIN_DOCKED_WORKSPACE_SIZE.height, max.height), + width: clampNumber(current.width + deltaWidth, minWidth, max.width), + } + workspaceSizeRef.current = next + if (mode === 'side') writeStoredSideWorkspaceWidth(next.width) + setWorkspaceSize(next) + }, [mode]) + const onSidebarResize = useCallback((delta: number) => { + setSidebarWidth((current) => clampNumber(current + delta, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)) + }, []) + + useEffect(() => { + workspaceSizeRef.current = workspaceSize + }, [workspaceSize]) + + useEffect(() => { + if (mode === 'side') writeStoredSideWorkspaceWidth(workspaceSize.width) + }, [mode, workspaceSize.width]) + + return { onSidebarResize, onWorkspaceResize, sidebarWidth, workspaceSize } +}