refactor: improve ai workspace code health
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
93
src/components/AiWorkspaceChrome.tsx
Normal file
93
src/components/AiWorkspaceChrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/components/AiWorkspaceResizeHandles.tsx
Normal file
62
src/components/AiWorkspaceResizeHandles.tsx
Normal 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))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
419
src/components/aiWorkspaceConversations.ts
Normal file
419
src/components/aiWorkspaceConversations.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
131
src/components/aiWorkspaceSizing.ts
Normal file
131
src/components/aiWorkspaceSizing.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user