refactor: improve ai workspace code health

This commit is contained in:
lucaronin
2026-05-30 21:10:41 +02:00
parent 0cdf758f7d
commit 83cd7bc20d
7 changed files with 1005 additions and 815 deletions

View File

@@ -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`.

View File

@@ -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. |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
import { Archive, ArrowSquareIn, ArrowSquareOut, GearSix, WarningCircle, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { useDragRegion } from '../hooks/useDragRegion'
import { getVaultAiGuidanceSummary, vaultAiGuidanceNeedsRestore, type VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { translate, type AppLocale } from '../lib/i18n'
import type { AiConversation } from './aiWorkspaceConversations'
import type { AiWorkspaceMode } from './aiWorkspaceSizing'
export function GuidanceWarning({
locale,
onRestore,
status,
}: {
locale: AppLocale
onRestore?: () => void
status?: VaultAiGuidanceStatus
}) {
if (!status || !vaultAiGuidanceNeedsRestore(status)) return null
return (
<div className="flex shrink-0 items-center gap-2 border-y border-border bg-muted/50 px-3 py-2 text-[12px] text-muted-foreground">
<WarningCircle size={15} className="shrink-0 text-amber-600" />
<span className="min-w-0 flex-1">
{translate(locale, 'ai.workspace.guidanceWarning', { summary: getVaultAiGuidanceSummary(status) })}
</span>
{status.canRestore && onRestore && (
<Button type="button" variant="outline" size="xs" onClick={onRestore}>
{translate(locale, 'ai.workspace.restoreGuidance')}
</Button>
)}
</div>
)
}
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<HTMLDivElement>()
return (
<div
ref={dragRegionRef}
className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-3"
data-testid="ai-workspace-chat-header"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="min-w-0 max-w-[260px]">
<div className="truncate text-[13px] font-semibold text-foreground">{conversation.title}</div>
</div>
</div>
<div className="flex items-center gap-1">
{onOpenAiSettings && (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.settings')} title={translate(locale, 'ai.workspace.settings')} onClick={onOpenAiSettings}>
<GearSix size={16} />
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.archive')} title={translate(locale, 'ai.workspace.archive')} disabled={archiveDisabled} onClick={onArchive}>
<Archive size={16} />
</Button>
{mode === 'docked' ? (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.popOut')} title={translate(locale, 'ai.workspace.popOut')} onClick={() => onPopOut?.({ activeConversationId: conversation.id })}>
<ArrowSquareOut size={16} />
</Button>
) : (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.dock')} title={translate(locale, 'ai.workspace.dock')} onClick={onDock}>
<ArrowSquareIn size={16} />
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.close')} title={translate(locale, 'ai.workspace.close')} onClick={onClose}>
<X size={16} />
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<>
<div
className="absolute inset-y-0 left-0 z-30 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-border"
data-testid="ai-workspace-left-resize"
onMouseDown={(event) => startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))}
/>
{mode === 'docked' && (
<div
className="absolute top-0 right-0 left-0 z-30 h-1 cursor-row-resize bg-transparent transition-colors hover:bg-border"
data-testid="ai-workspace-top-resize"
onMouseDown={(event) => startResizeDrag(event, 'row-resize', (_deltaX, deltaY) => onResize(0, -deltaY))}
/>
)}
</>
)
}

View File

@@ -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<SetStateAction<AiConversation[]>>
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<AiConversation[]>(() => (
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,
}
}

View File

@@ -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 }
}