fix: polish ai workspace follow-up

This commit is contained in:
lucaronin
2026-05-27 01:03:31 +02:00
parent f5475f7773
commit 759af948b4
16 changed files with 516 additions and 165 deletions

View File

@@ -229,7 +229,7 @@ flowchart TD
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **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 bounded, width/height-resizable desktop tool inside the main app. Pop-out mode opens a dedicated undecorated 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, and the in-workspace close/dock controls emit a dock request back to the main window before destroying the pop-out window. 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** (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.
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`.
@@ -246,7 +246,7 @@ On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the
Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed.
The AI workspace can also open in a separate Tauri window through `openAiWorkspaceWindow()`. That window uses `?window=ai-workspace` to boot the lightweight `AiWorkspaceWindowApp` route, receives the active vault context through URL params, opts out of main-window size constraints and startup AI onboarding, and redocks by emitting `tolaria:ai-workspace-dock-requested` to the main window.
The AI workspace can also open in a separate Tauri window through `openAiWorkspaceWindow()`. That window uses `?window=ai-workspace` to boot the lightweight `AiWorkspaceWindowApp` route, receives the active vault context through URL params, opts out of main-window size constraints and startup AI onboarding, and redocks by emitting `tolaria:ai-workspace-dock-requested` to the main window. Its webview is transparent so the rounded workspace shell defines the visible floating-window corners across desktop platforms.
**Triggers:**
- `Cmd+Shift+Click` on any note in the note list or sidebar

View File

@@ -17,7 +17,7 @@ The AI workspace also needs installation-local chat metadata so user-facing chat
**The AI workspace pop-out uses a lightweight renderer route backed by app settings metadata.**
`openAiWorkspaceWindow()` opens the `ai-workspace` Tauri webview with `?window=ai-workspace` plus active vault context in URL params. `App` routes that window directly to `AiWorkspaceWindowApp`, which loads settings, AI agent status, and vault guidance without mounting the full vault/editor shell. The window is undecorated and relies on `AiWorkspace` headers for drag regions and the workspace close/dock controls.
`openAiWorkspaceWindow()` opens the `ai-workspace` Tauri webview with `?window=ai-workspace` plus active vault context in URL params. `App` routes that window directly to `AiWorkspaceWindowApp`, which loads settings, AI agent status, and vault guidance without mounting the full vault/editor shell. The window is undecorated and transparent, and it relies on `AiWorkspace` headers for drag regions plus separate close and dock controls. Close only closes the pop-out; dock emits the main-window dock request before closing the pop-out.
`settings.ai_workspace_conversations` stores only chat sidebar metadata: conversation id, title, archive state, and explicit target override. Prompt text, transcripts, note content, model credentials, and vault-local configuration stay out of app settings.
@@ -30,6 +30,6 @@ The AI workspace also needs installation-local chat metadata so user-facing chat
## Consequences
- Pop-out startup avoids full note graph loading and should be close to instant after the Tauri webview is created.
- The dedicated AI window has no native traffic lights; users close or redock it through the workspace header controls.
- The dedicated AI window has no native traffic lights; users close or redock it through separate workspace header controls, and the rounded workspace shell defines the visible floating-window corners.
- Chat titles, archived state, and target overrides persist at the installation level in `settings.json`.
- Future transcript persistence must use a separate storage decision; `ai_workspace_conversations` is intentionally metadata-only.

View File

@@ -205,8 +205,8 @@ describe('AiPanel', () => {
editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n')
fireEvent.input(editor)
expect(editor).toHaveClass('max-h-[160px]', 'overflow-y-auto', 'overscroll-contain')
expect(editor).toHaveStyle({ maxHeight: '160px', overflowY: 'auto' })
expect(editor).toHaveClass('max-h-[120px]', 'overflow-y-auto', 'overscroll-contain')
expect(editor).toHaveStyle({ maxHeight: '120px', overflowY: 'auto' })
expect(screen.getByTestId('agent-send')).toBeVisible()
})

View File

@@ -81,8 +81,8 @@ function composerSendButtonStyle(canSend: boolean): CSSProperties {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
borderRadius: 8,
width: 32,
height: 34,
width: 30,
height: 30,
cursor: canSend ? 'pointer' : 'not-allowed',
}
}
@@ -119,10 +119,10 @@ function ComposerInput({
placeholder={placeholder}
inputRef={inputRef}
editorClassName={cn(
'max-h-[160px] overflow-y-auto overscroll-contain',
hasControls && 'min-h-[72px] border-0 px-2 py-2',
'max-h-[120px] overflow-y-auto overscroll-contain',
hasControls && 'min-h-[34px] border-0 px-2 py-1.5 leading-5',
)}
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
editorStyle={{ maxHeight: 120, overflowY: 'auto', overscrollBehavior: 'contain' }}
/>
)
}
@@ -170,7 +170,7 @@ function ComposerControlsRow({
if (!hasControls) return <>{sendButton}</>
return (
<div className="mt-1 flex items-center justify-between gap-2">
<div className="mt-0.5 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
{children}
</div>
@@ -488,10 +488,10 @@ export function AiPanelComposer({
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
style={{ padding: '6px 10px' }}
>
<div className={cn(
hasControls ? 'rounded-xl border border-border bg-background p-2 shadow-xs' : 'flex items-end gap-2',
hasControls ? 'rounded-xl border border-border bg-background px-2 py-1.5 shadow-xs' : 'flex items-end gap-2',
)}>
<div className={cn('min-w-0 flex-1', hasControls && 'w-full')}>
<ComposerInput

View File

@@ -108,9 +108,12 @@ describe('AiWorkspace', () => {
it('creates chats from the sidebar and hides the legacy AI panel header', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
expect(screen.getByTestId('ai-workspace')).toHaveAttribute('data-ai-workspace-mode', 'docked')
const workspace = screen.getByTestId('ai-workspace')
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'docked')
expect(workspace).toHaveStyle({ width: '560px' })
expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false')
expect(screen.queryByText('AI Agent')).toBeNull()
expect(screen.queryByText('Idle')).toBeNull()
fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat'))
@@ -118,6 +121,55 @@ describe('AiWorkspace', () => {
expect(screen.getAllByText('Chat 2').length).toBeGreaterThan(0)
})
it('resizes the docked workspace from the left edge and the sidebar split', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
const workspace = screen.getByTestId('ai-workspace')
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
fireEvent.mouseMove(window, { clientX: 60, clientY: 20 })
fireEvent.mouseUp(window)
expect(workspace).toHaveStyle({ width: '600px' })
const sidebar = screen.getByTestId('ai-workspace-sidebar-header').parentElement
const sidebarHandle = workspace.querySelector('.cursor-col-resize:not([data-testid])')
expect(sidebar).toHaveStyle({ width: '168px' })
fireEvent.mouseDown(sidebarHandle as Element, { clientX: 100, clientY: 20 })
fireEvent.mouseMove(document, { clientX: 120, clientY: 20 })
fireEvent.mouseUp(document)
expect(sidebar).toHaveStyle({ width: '188px' })
})
it('does not archive an empty chat', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
const archiveButtons = screen.getAllByRole('button', { name: 'Archive chat' })
expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true)
fireEvent.click(archiveButtons[0])
expect(screen.getAllByText('Chat 1').length).toBeGreaterThan(0)
expect(screen.queryByText('Chat 2')).toBeNull()
})
it('activates a visible chat when persisted settings start with an archived chat', () => {
render(
<AiWorkspace
open
mode="docked"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[
{ id: 'archived-chat', title: 'Old Chat', target_id: null, archived: true },
{ id: 'visible-chat', title: 'Live Chat', target_id: null, archived: false },
]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
/>,
)
expect(screen.getByTestId('ai-workspace-session-visible-chat')).toHaveClass('flex')
expect(screen.getByTestId('ai-workspace-session-archived-chat')).toHaveClass('hidden')
})
it('shows grouped target choices without missing agents', async () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
@@ -145,6 +197,7 @@ describe('AiWorkspace', () => {
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ title: 'Summarize Quarterly Sponsor Outreach' }),
])

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
import {
Archive,
ArrowSquareIn,
@@ -44,7 +44,7 @@ import {
vaultAiGuidanceNeedsRestore,
type VaultAiGuidanceStatus,
} from '../lib/vaultAiGuidance'
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
import { translate, type AppLocale } from '../lib/i18n'
import { trackAiWorkspaceChatTitled, trackAiWorkspaceSidebarToggled } from '../lib/productAnalytics'
import type { AgentStatus } from '../hooks/useCliAiAgent'
import type { AiWorkspaceConversationSetting } from '../types'
@@ -54,11 +54,13 @@ import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
import { generateAiConversationTitle } from '../utils/aiConversationTitle'
import { AiPanelView } from './AiPanel'
import { ConversationSidebar } from './AiWorkspaceSidebar'
import { ResizeHandle } from './ResizeHandle'
import { useAiPanelController } from './useAiPanelController'
import { buildAiWorkspaceTargetGroups, type AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
export interface AiConversation {
archived: boolean
hasActivity: boolean
id: string
targetId: string
title: string
@@ -102,6 +104,12 @@ interface AiWorkspaceProps {
let fallbackConversationIdCounter = 0
const DEFAULT_DOCKED_WORKSPACE_SIZE = { height: 540, width: 560 }
const MIN_DOCKED_WORKSPACE_SIZE = { height: 360, width: 460 }
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)
@@ -120,16 +128,27 @@ function nextConversationId(): string {
return `ai-chat-${Date.now()}-${randomConversationIdPart()}`
}
function statusLabelKey(status: AgentStatus): TranslationKey {
if (status === 'thinking' || status === 'tool-executing') return 'ai.workspace.status.running'
if (status === 'error') return 'ai.workspace.status.error'
return 'ai.workspace.status.idle'
}
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 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
@@ -157,6 +176,7 @@ function resolveTarget(conversation: AiConversation, groups: AiWorkspaceTargetGr
function createConversation(locale: AppLocale, target: AiTarget, index: number): AiConversation {
return {
archived: false,
hasActivity: false,
id: nextConversationId(),
targetId: target.id,
title: translate(locale, 'ai.workspace.chatTitle', { index }),
@@ -176,6 +196,7 @@ function conversationFromSetting(setting: AiWorkspaceConversationSetting, fallba
return {
archived: setting.archived === true,
hasActivity: !isDefaultConversationTitle(title),
id,
targetId: setting.target_id?.trim() || fallbackTarget.id,
title,
@@ -204,6 +225,19 @@ function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceCo
}))
}
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
locale: AppLocale
@@ -258,11 +292,15 @@ function renameConversationState(current: AiConversation[], id: string, title: s
function titleConversationFromPromptState(current: AiConversation[], id: string, prompt: string): AiConversation[] {
const generatedTitle = generateAiConversationTitle(prompt)
if (!generatedTitle) return current
return current.map((conversation) => (
conversation.id === id && conversation.usesDefaultTitle
? { ...conversation, title: generatedTitle, usesDefaultTitle: false }
conversation.id === id
? {
...conversation,
hasActivity: true,
title: generatedTitle && conversation.usesDefaultTitle ? generatedTitle : conversation.title,
usesDefaultTitle: generatedTitle ? false : conversation.usesDefaultTitle,
}
: conversation
))
}
@@ -290,20 +328,16 @@ function useConversations({
const onSettingsChangeRef = useRef(onSettingsChange)
const addConversation = useCallback((target: AiTarget) => {
setConversations((current) => {
const next = appendConversationState(current, locale, target)
setActiveId(next.activeId)
return next.conversations
})
}, [locale])
const next = appendConversationState(conversations, locale, target)
setConversations(next.conversations)
setActiveId(next.activeId)
}, [conversations, locale])
const archiveConversation = useCallback((id: string) => {
setConversations((current) => {
const next = archiveConversationState(current, id)
if (next.activeId) setActiveId(next.activeId)
return next.conversations
})
}, [])
const next = archiveConversationState(conversations, id)
setConversations(next.conversations)
if (next.activeId) setActiveId(next.activeId)
}, [conversations])
const restoreConversation = useCallback((id: string) => {
setConversations((current) => restoreConversationState(current, id))
@@ -419,7 +453,7 @@ function TargetPickerContent({
const hasLocalModelsSeparator = groups.localModels.length > 0 && groups.apiModels.length > 0
return (
<DropdownMenuContent align="start" className="min-w-[280px]">
<DropdownMenuContent align="start" className="z-[12000] min-w-[280px]">
{hasTargets ? (
<DropdownMenuRadioGroup value={selectedTarget.id} onValueChange={onSelectTarget}>
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalAgents')} targets={groups.localAgents} />
@@ -511,7 +545,7 @@ function PermissionPicker({
<CaretDown size={compact ? 12 : 13} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[180px]">
<DropdownMenuContent align="start" className="z-[12000] min-w-[180px]">
{(['safe', 'power_user'] as const).map((mode) => (
<DropdownMenuItem key={mode} onSelect={() => onChange(mode)}>
{aiAgentPermissionModeLabels(mode, locale).control}
@@ -550,9 +584,9 @@ function GuidanceWarning({
function WorkspaceHeader({
conversation,
archiveDisabled,
locale,
mode,
status,
onArchive,
onClose,
onDock,
@@ -560,9 +594,9 @@ function WorkspaceHeader({
onPopOut,
}: {
conversation: AiConversation
archiveDisabled: boolean
locale: AppLocale
mode: 'docked' | 'window'
status: AgentStatus
onArchive: () => void
onClose: () => void
onDock?: () => void
@@ -580,9 +614,6 @@ function WorkspaceHeader({
<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 className="truncate text-[11px] text-muted-foreground">
{translate(locale, statusLabelKey(status))}
</div>
</div>
</div>
<div className="flex items-center gap-1">
@@ -591,7 +622,7 @@ function WorkspaceHeader({
<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')} onClick={onArchive}>
<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' ? (
@@ -725,10 +756,10 @@ function ConversationSession({
return (
<div className={active ? 'flex min-h-0 flex-1 flex-col' : 'hidden'} data-testid={`ai-workspace-session-${conversation.id}`}>
<WorkspaceHeader
archiveDisabled={!canArchiveConversation(conversation)}
conversation={conversation}
locale={locale}
mode={mode}
status={controller.agent.status}
onArchive={onArchive}
onClose={onClose}
onDock={onDock}
@@ -772,6 +803,7 @@ interface AiWorkspaceModel {
activeId: string
addDefaultConversation: () => void
archiveConversationSafely: (id: string) => void
canArchiveConversation: (conversation: AiConversation) => boolean
conversations: AiConversation[]
fallbackTarget: AiTarget
groups: AiWorkspaceTargetGroups
@@ -800,24 +832,176 @@ function resolveAiWorkspaceProps(props: AiWorkspaceProps): ResolvedAiWorkspacePr
}
}
function workspaceClassName(mode: 'docked' | 'window'): string {
if (mode === 'window') return 'fixed inset-0 z-[90] flex bg-background text-foreground'
return 'fixed bottom-10 right-4 z-[90] flex h-[min(680px,calc(100vh-88px))] w-[min(880px,calc(100vw-32px))] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-2xl'
interface AiWorkspaceSizing {
onSidebarResize: (delta: number) => void
onWorkspaceResize: (deltaWidth: number, deltaHeight: number) => void
sidebarWidth: number
workspaceSize: { height: number; width: number }
}
function workspaceStyle(mode: 'docked' | 'window'): CSSProperties | undefined {
function workspaceClassName(mode: 'docked' | 'window'): string {
if (mode === 'window') {
return 'fixed inset-0 z-[120] flex overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-2xl'
}
return 'fixed bottom-20 right-4 z-[120] flex overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-2xl'
}
function workspaceStyle(mode: 'docked' | 'window', size: AiWorkspaceSizing['workspaceSize']): CSSProperties | undefined {
if (mode === 'window') return undefined
return {
maxHeight: 'calc(100vh - 88px)',
height: size.height,
maxHeight: 'calc(100vh - 112px)',
maxWidth: 'calc(100vw - 32px)',
minHeight: 420,
minWidth: 520,
resize: 'both',
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: 'docked' | 'window'
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))}
/>
<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))}
/>
</>
)
}
function useAiWorkspaceSizing(mode: 'docked' | 'window'): AiWorkspaceSizing {
const [workspaceSize, setWorkspaceSize] = useState(DEFAULT_DOCKED_WORKSPACE_SIZE)
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH)
const onWorkspaceResize = useCallback((deltaWidth: number, deltaHeight: number) => {
if (mode === 'window') return
setWorkspaceSize((current) => {
const max = maxDockedWorkspaceSize()
return {
height: clampNumber(current.height + deltaHeight, MIN_DOCKED_WORKSPACE_SIZE.height, max.height),
width: clampNumber(current.width + deltaWidth, MIN_DOCKED_WORKSPACE_SIZE.width, max.width),
}
})
}, [mode])
const onSidebarResize = useCallback((delta: number) => {
setSidebarWidth((current) => clampNumber(current + delta, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH))
}, [])
return { onSidebarResize, onWorkspaceResize, sidebarWidth, workspaceSize }
}
function useActiveConversationSync(
activeConversation: AiConversation | undefined,
activeId: string,
setActiveId: (id: string) => void,
) {
useEffect(() => {
if (activeConversation && activeConversation.id !== activeId) setActiveId(activeConversation.id)
}, [activeConversation, activeId, setActiveId])
}
function useArchiveConversationSafely({
addConversation,
archiveConversation,
conversations,
fallbackTarget,
}: {
addConversation: (target: AiTarget) => void
archiveConversation: (id: string) => void
conversations: AiConversation[]
fallbackTarget: AiTarget
}) {
return useCallback((id: string) => {
const conversation = conversations.find((candidate) => candidate.id === id)
if (!conversation || !canArchiveConversation(conversation)) return
const activeCount = conversations.filter((conversation) => !conversation.archived).length
archiveConversation(id)
if (activeCount <= 1) addConversation(fallbackTarget)
}, [addConversation, archiveConversation, conversations, fallbackTarget])
}
function useTrackedConversationActions({
conversations,
renameConversation,
titleConversationFromPrompt,
}: {
conversations: AiConversation[]
renameConversation: (id: string, title: string) => void
titleConversationFromPrompt: (id: string, prompt: string) => void
}) {
const trackedRenameConversation = useCallback((id: string, title: string) => {
if (!title.trim()) return
renameConversation(id, title)
trackAiWorkspaceChatTitled('manual')
}, [renameConversation])
const trackedTitleConversationFromPrompt = useCallback((id: string, prompt: string) => {
const conversation = conversations.find((candidate) => candidate.id === id)
titleConversationFromPrompt(id, prompt)
if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated')
}, [conversations, titleConversationFromPrompt])
return { trackedRenameConversation, trackedTitleConversationFromPrompt }
}
function useAiWorkspaceNewChatEvent(open: boolean, addDefaultConversation: () => void) {
useEffect(() => {
if (!open) return
const handleNewChat = () => addDefaultConversation()
window.addEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
}, [addDefaultConversation, open])
}
function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceModel {
const groups = useMemo(
() => buildAiWorkspaceTargetGroups(workspace.aiAgentsStatus, workspace.aiModelProviders),
@@ -849,33 +1033,26 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
})
const [statuses, setStatuses] = useState<Record<string, AgentStatus>>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const activeConversation = conversations.find((conversation) => conversation.id === activeId)
?? conversations.find((conversation) => !conversation.archived)
?? conversations[0]
const activeConversation = activeConversationForState(conversations, activeId, showArchived)
const addDefaultConversation = useCallback(() => {
addConversation(fallbackTarget)
}, [addConversation, fallbackTarget])
const archiveConversationSafely = useCallback((id: string) => {
const activeCount = conversations.filter((conversation) => !conversation.archived).length
archiveConversation(id)
if (activeCount <= 1) addConversation(fallbackTarget)
}, [addConversation, archiveConversation, conversations, fallbackTarget])
const archiveConversationSafely = useArchiveConversationSafely({
addConversation,
archiveConversation,
conversations,
fallbackTarget,
})
useActiveConversationSync(activeConversation, activeId, setActiveId)
const handleStatusChange = useCallback((id: string, status: AgentStatus) => {
setStatuses((current) => current[id] === status ? current : { ...current, [id]: status })
}, [])
const trackedRenameConversation = useCallback((id: string, title: string) => {
if (!title.trim()) return
renameConversation(id, title)
trackAiWorkspaceChatTitled('manual')
}, [renameConversation])
const trackedTitleConversationFromPrompt = useCallback((id: string, prompt: string) => {
const conversation = conversations.find((candidate) => candidate.id === id)
titleConversationFromPrompt(id, prompt)
if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated')
}, [conversations, titleConversationFromPrompt])
const { trackedRenameConversation, trackedTitleConversationFromPrompt } = useTrackedConversationActions({
conversations,
renameConversation,
titleConversationFromPrompt,
})
const toggleSidebarCollapsed = useCallback(() => {
setSidebarCollapsed((current) => {
const next = !current
@@ -884,13 +1061,7 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
})
}, [workspace.mode])
useEffect(() => {
if (!workspace.open) return
const handleNewChat = () => addDefaultConversation()
window.addEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
}, [addDefaultConversation, workspace.open])
useAiWorkspaceNewChatEvent(workspace.open, addDefaultConversation)
useEffect(() => {
updateDefaultConversationTargets(fallbackTarget.id)
}, [fallbackTarget.id, updateDefaultConversationTargets])
@@ -900,6 +1071,7 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
activeId,
addDefaultConversation,
archiveConversationSafely,
canArchiveConversation,
conversations,
fallbackTarget,
groups,
@@ -919,20 +1091,24 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
}
function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; workspace: ResolvedAiWorkspaceProps }) {
const sizing = useAiWorkspaceSizing(workspace.mode)
return (
<section
className={workspaceClassName(workspace.mode)}
style={workspaceStyle(workspace.mode)}
style={workspaceStyle(workspace.mode, sizing.workspaceSize)}
data-testid="ai-workspace"
data-ai-workspace-mode={workspace.mode}
role="dialog"
aria-label={translate(workspace.locale, 'ai.workspace.title')}
>
<WorkspaceResizeHandles mode={workspace.mode} onResize={sizing.onWorkspaceResize} />
<ConversationSidebar
activeId={model.activeId}
collapsed={model.sidebarCollapsed}
conversations={model.conversations}
locale={workspace.locale}
onCanArchive={model.canArchiveConversation}
onArchive={model.archiveConversationSafely}
onNewChat={model.addDefaultConversation}
onRename={model.renameConversation}
@@ -941,8 +1117,12 @@ function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; work
onToggleCollapsed={model.toggleSidebarCollapsed}
setShowArchived={model.setShowArchived}
showArchived={model.showArchived}
sidebarWidth={sizing.sidebarWidth}
statuses={model.statuses}
/>
{!model.sidebarCollapsed && (
<ResizeHandle onResize={sizing.onSidebarResize} />
)}
<div className="flex min-w-0 flex-1 flex-col">
<ConversationSessions model={model} workspace={workspace} />
</div>
@@ -959,7 +1139,7 @@ function ConversationSessions({ model, workspace }: { model: AiWorkspaceModel; w
return (
<ConversationSession
key={conversation.id}
active={conversation.id === model.activeConversation?.id && !conversation.archived}
active={conversation.id === model.activeConversation?.id}
activeEntry={workspace.activeEntry}
activeNoteContent={workspace.activeNoteContent}
aiAgentsStatus={workspace.aiAgentsStatus}

View File

@@ -21,6 +21,7 @@ interface ConversationSidebarProps {
collapsed: boolean
conversations: AiConversation[]
locale: AppLocale
onCanArchive: (conversation: AiConversation) => boolean
onArchive: (id: string) => void
onNewChat: () => void
onRename: (id: string, title: string) => void
@@ -29,6 +30,7 @@ interface ConversationSidebarProps {
onToggleCollapsed: () => void
setShowArchived: (show: boolean) => void
showArchived: boolean
sidebarWidth: number
statuses: Record<string, AgentStatus>
}
@@ -177,12 +179,14 @@ function CollapsedConversationSidebar({ locale, onNewChat }: { locale: AppLocale
}
function ConversationArchiveButton({
disabled,
conversationId,
locale,
onArchive,
onRestore,
showArchived,
}: {
disabled: boolean
conversationId: string
locale: AppLocale
onArchive: (id: string) => void
@@ -196,9 +200,10 @@ function ConversationArchiveButton({
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 disabled:opacity-0"
aria-label={label}
title={label}
disabled={disabled}
onClick={() => showArchived ? onRestore(conversationId) : onArchive(conversationId)}
>
{showArchived ? <ArrowSquareIn size={16} /> : <Archive size={16} />}
@@ -211,6 +216,7 @@ function ConversationRow({
conversation,
editing,
locale,
onCanArchive,
onArchive,
onRename,
onRestore,
@@ -224,6 +230,7 @@ function ConversationRow({
conversation: AiConversation
editing: boolean
locale: AppLocale
onCanArchive: (conversation: AiConversation) => boolean
onArchive: (id: string) => void
onRename: (id: string, title: string) => void
onRestore: (id: string) => void
@@ -262,6 +269,7 @@ function ConversationRow({
</Button>
)}
<ConversationArchiveButton
disabled={!onCanArchive(conversation)}
conversationId={conversation.id}
locale={locale}
onArchive={onArchive}
@@ -277,6 +285,7 @@ function ConversationList({
conversations,
editingId,
locale,
onCanArchive,
onArchive,
onRename,
onRestore,
@@ -285,7 +294,7 @@ function ConversationList({
showArchived,
statuses,
}: Pick<ConversationSidebarProps,
'activeId' | 'conversations' | 'locale' | 'onArchive' | 'onRename' | 'onRestore' | 'onSelect' | 'showArchived' | 'statuses'
'activeId' | 'conversations' | 'locale' | 'onCanArchive' | 'onArchive' | 'onRename' | 'onRestore' | 'onSelect' | 'showArchived' | 'statuses'
> & {
editingId: string | null
setEditingId: (id: string | null) => void
@@ -306,6 +315,7 @@ function ConversationList({
conversation={conversation}
editing={editingId === conversation.id}
locale={locale}
onCanArchive={onCanArchive}
onArchive={onArchive}
onRename={onRename}
onRestore={onRestore}
@@ -343,6 +353,7 @@ function ExpandedConversationSidebar({
activeId,
conversations,
locale,
onCanArchive,
onArchive,
onRename,
onRestore,
@@ -350,7 +361,7 @@ function ExpandedConversationSidebar({
setShowArchived,
showArchived,
statuses,
}: Omit<ConversationSidebarProps, 'collapsed' | 'onNewChat' | 'onToggleCollapsed'>) {
}: Omit<ConversationSidebarProps, 'collapsed' | 'onNewChat' | 'onToggleCollapsed' | 'sidebarWidth'>) {
const [editingId, setEditingId] = useState<string | null>(null)
return (
@@ -361,6 +372,7 @@ function ExpandedConversationSidebar({
conversations={conversations}
editingId={editingId}
locale={locale}
onCanArchive={onCanArchive}
onArchive={onArchive}
onRename={onRename}
onRestore={onRestore}
@@ -384,6 +396,7 @@ export function ConversationSidebar({
collapsed,
conversations,
locale,
onCanArchive,
onArchive,
onNewChat,
onRename,
@@ -392,13 +405,14 @@ export function ConversationSidebar({
onToggleCollapsed,
setShowArchived,
showArchived,
sidebarWidth,
statuses,
}: ConversationSidebarProps) {
return (
<div className={cn(
'flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width]',
collapsed ? 'w-12' : 'w-[220px]',
)}>
<div
className="flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width]"
style={{ width: collapsed ? 48 : sidebarWidth }}
>
<SidebarHeader
collapsed={collapsed}
locale={locale}
@@ -412,6 +426,7 @@ export function ConversationSidebar({
activeId={activeId}
conversations={conversations}
locale={locale}
onCanArchive={onCanArchive}
onArchive={onArchive}
onRename={onRename}
onRestore={onRestore}

View File

@@ -1,11 +1,15 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AppPreferencesProvider, useAppPreferences } from '../hooks/useAppPreferences'
import { useAiAgentsStatus } from '../hooks/useAiAgentsStatus'
import { useSettings } from '../hooks/useSettings'
import { useVaultAiGuidanceStatus } from '../hooks/useVaultAiGuidanceStatus'
import { areAiFeaturesEnabled } from '../lib/aiFeatures'
import type { AiWorkspaceConversationSetting, Settings } from '../types'
import { dockCurrentAiWorkspaceWindow, readAiWorkspaceWindowContext } from '../utils/openAiWorkspaceWindow'
import {
closeCurrentAiWorkspaceWindow,
dockCurrentAiWorkspaceWindow,
readAiWorkspaceWindowContext,
} from '../utils/openAiWorkspaceWindow'
import { AppAiWorkspaceSurface } from './AppAiWorkspaceSurface'
import { Toast } from './Toast'
@@ -13,6 +17,19 @@ function useAiWorkspaceWindowContext() {
return useMemo(() => readAiWorkspaceWindowContext(), [])
}
function useTransparentWindowBackground() {
useEffect(() => {
const previousBodyBackground = document.body.style.background
const previousRootBackground = document.documentElement.style.background
document.body.style.background = 'transparent'
document.documentElement.style.background = 'transparent'
return () => {
document.body.style.background = previousBodyBackground
document.documentElement.style.background = previousRootBackground
}
}, [])
}
function useAiWorkspaceSettingsSaver(
enabled: boolean,
settings: Settings,
@@ -25,6 +42,7 @@ function useAiWorkspaceSettingsSaver(
}
export function AiWorkspaceWindowApp() {
useTransparentWindowBackground()
const [toastMessage, setToastMessage] = useState<string | null>(null)
const context = useAiWorkspaceWindowContext()
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
@@ -49,6 +67,11 @@ export function AiWorkspaceWindowApp() {
console.warn('[ai] Failed to dock workspace window:', err)
})
}, [])
const handleClose = useCallback(() => {
void closeCurrentAiWorkspaceWindow().catch((err) => {
console.warn('[ai] Failed to close workspace window:', err)
})
}, [])
return (
<AppPreferencesProvider dateDisplayFormat={preferences.dateDisplayFormat}>
@@ -68,7 +91,7 @@ export function AiWorkspaceWindowApp() {
openTabs={[]}
noteList={[]}
noteListFilter={{ type: null, query: '' }}
onClose={handleDock}
onClose={handleClose}
onConversationSettingsChange={handleConversationSettingsChange}
onDock={handleDock}
onUnsupportedAiPaste={setToastMessage}

View File

@@ -454,7 +454,8 @@ export function InlineWikilinkInput({
forceRender((current) => current + 1)
}
const flushPendingCompositionInput = () => {
if (isComposingRef.current || !pendingCompositionInputRef.current) return
if (isComposingRef.current) return
const hadPendingInput = pendingCompositionInputRef.current
pendingCompositionInputRef.current = false
const editor = editorRef.current
@@ -466,6 +467,8 @@ export function InlineWikilinkInput({
}
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
if (!hadPendingInput && nextValue === value) return
const nextSelection = readSelectionRange(editor)
const clampedSelection: InlineSelectionRange = {
start: Math.min(nextSelection.start, nextValue.length),

View File

@@ -170,10 +170,21 @@ describe('mockHandlers coverage', () => {
anonymous_id: 'anon-1',
release_channel: 'alpha',
theme_mode: null,
date_display_format: null,
note_width_mode: null,
sidebar_type_pluralization_enabled: null,
initial_h1_auto_rename_enabled: null,
ai_features_enabled: null,
ui_language: 'zh-CN',
default_ai_agent: 'codex',
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: null,
all_notes_show_pdfs: null,
all_notes_show_images: null,
all_notes_show_unsupported: null,
multi_workspace_enabled: null,
})
const list = {

View File

@@ -114,9 +114,20 @@ let mockSettings: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
date_display_format: null,
note_width_mode: null,
sidebar_type_pluralization_enabled: null,
initial_h1_auto_rename_enabled: null,
ai_features_enabled: null,
default_ai_agent: 'claude_code',
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: null,
all_notes_show_pdfs: null,
all_notes_show_images: null,
all_notes_show_unsupported: null,
multi_workspace_enabled: null,
}
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
@@ -512,9 +523,20 @@ export const mockHandlers: Record<string, (args: any) => any> = {
release_channel: s.release_channel,
theme_mode: s.theme_mode ?? null,
ui_language: s.ui_language ?? null,
date_display_format: s.date_display_format ?? null,
note_width_mode: s.note_width_mode ?? null,
sidebar_type_pluralization_enabled: s.sidebar_type_pluralization_enabled ?? null,
initial_h1_auto_rename_enabled: s.initial_h1_auto_rename_enabled ?? null,
ai_features_enabled: s.ai_features_enabled ?? null,
default_ai_agent: s.default_ai_agent ?? null,
default_ai_target: s.default_ai_target ?? null,
ai_model_providers: s.ai_model_providers ?? null,
ai_workspace_conversations: s.ai_workspace_conversations ?? null,
hide_gitignored_files: s.hide_gitignored_files ?? null,
all_notes_show_pdfs: s.all_notes_show_pdfs ?? null,
all_notes_show_images: s.all_notes_show_images ?? null,
all_notes_show_unsupported: s.all_notes_show_unsupported ?? null,
multi_workspace_enabled: s.multi_workspace_enabled ?? null,
}
return null
},

View File

@@ -3,6 +3,7 @@ import {
AI_WORKSPACE_WINDOW_LABEL,
buildAiWorkspaceWindowUrl,
buildRuntimeAiWorkspaceWindowUrl,
closeCurrentAiWorkspaceWindow,
dockCurrentAiWorkspaceWindow,
openAiWorkspaceWindow,
readAiWorkspaceWindowContext,
@@ -15,7 +16,7 @@ const webviewGetByLabel = vi.fn()
const existingUnminimize = vi.fn().mockResolvedValue(undefined)
const existingSetFocus = vi.fn().mockResolvedValue(undefined)
const emitTo = vi.fn().mockResolvedValue(undefined)
const destroy = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
@@ -47,9 +48,16 @@ vi.mock('@tauri-apps/api/event', () => ({
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ destroy }),
getCurrentWindow: () => ({ close }),
}))
async function runCurrentWindowAction(action: () => Promise<void>) {
vi.mocked(isTauri).mockReturnValue(true)
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
await action()
return dispatchSpy
}
describe('openAiWorkspaceWindow', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -111,6 +119,8 @@ describe('openAiWorkspaceWindow', () => {
minHeight: 420,
minimizable: false,
decorations: false,
shadow: true,
transparent: true,
}),
)
expect(localStorage.getItem('tolaria:ai-workspace-window:ai-workspace')).toBe('true')
@@ -130,17 +140,25 @@ describe('openAiWorkspaceWindow', () => {
expect(webviewWindowCalls).not.toHaveBeenCalled()
})
it('requests docking and destroys the current AI workspace window', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
it('closes the current AI workspace window without docking it', async () => {
const dispatchSpy = await runCurrentWindowAction(closeCurrentAiWorkspaceWindow)
await dockCurrentAiWorkspaceWindow()
expect(dispatchSpy).not.toHaveBeenCalledWith(expect.objectContaining({
type: AI_WORKSPACE_DOCK_REQUESTED_EVENT,
}))
expect(emitTo).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
dispatchSpy.mockRestore()
})
it('requests docking and closes the current AI workspace window', async () => {
const dispatchSpy = await runCurrentWindowAction(dockCurrentAiWorkspaceWindow)
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({
type: AI_WORKSPACE_DOCK_REQUESTED_EVENT,
}))
expect(emitTo).toHaveBeenCalledWith('main', AI_WORKSPACE_DOCK_REQUESTED_EVENT)
expect(destroy).toHaveBeenCalledOnce()
expect(close).toHaveBeenCalledOnce()
dispatchSpy.mockRestore()
})
})

View File

@@ -86,11 +86,20 @@ export async function openAiWorkspaceWindow(context: AiWorkspaceWindowContext =
resizable: true,
minimizable: false,
decorations: false,
shadow: true,
transparent: true,
})
return true
}
export async function closeCurrentAiWorkspaceWindow(): Promise<void> {
if (!isTauri()) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close().catch(() => {})
}
export async function dockCurrentAiWorkspaceWindow(): Promise<void> {
requestDockAiWorkspace()
@@ -99,5 +108,5 @@ export async function dockCurrentAiWorkspaceWindow(): Promise<void> {
const { emitTo } = await import('@tauri-apps/api/event')
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await emitTo('main', AI_WORKSPACE_DOCK_REQUESTED_EVENT).catch(() => {})
await getCurrentWindow().destroy().catch(() => {})
await getCurrentWindow().close().catch(() => {})
}

View File

@@ -1,8 +1,21 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
import { test, expect, type Page } from '@playwright/test'
import { installMockAiAgent, sendShortcut } from './helpers'
function visibleAgentInput(page: Page) {
return page.locator('[data-testid="agent-input"]:visible')
}
function visibleAgentSend(page: Page) {
return page.locator('[data-testid="agent-send"]:visible')
}
function visibleAiMessages(page: Page) {
return page.locator('[data-testid="ai-message"]:visible')
}
test.describe('AI chat conversation history', () => {
test.beforeEach(async ({ page }) => {
await installMockAiAgent(page)
// Block vault API so mock entries are used
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
@@ -21,12 +34,12 @@ test.describe('AI chat conversation history', () => {
test('first message renders a mocked AI response', async ({ page }) => {
// Find the input and send a message
const input = page.getByTestId('agent-input')
const input = visibleAgentInput(page)
await input.fill('Hello')
await page.getByTestId('agent-send').click()
await visibleAgentSend(page).click()
// Wait for mock response to appear
const response = page.getByTestId('ai-message').last()
const response = visibleAiMessages(page).last()
await expect(response).toBeVisible({ timeout: 5000 })
await expect(response).toContainText('[mock-claude code]')
@@ -35,20 +48,20 @@ test.describe('AI chat conversation history', () => {
test('second message appends to the current visible conversation', async ({ page }) => {
// Send first message
const input = page.getByTestId('agent-input')
const input = visibleAgentInput(page)
await input.fill('What is 2+2?')
await page.getByTestId('agent-send').click()
await visibleAgentSend(page).click()
// Wait for first response to appear
const firstResponse = page.getByTestId('ai-message').last()
const firstResponse = visibleAiMessages(page).last()
await expect(firstResponse).toBeVisible({ timeout: 5000 })
await expect(firstResponse).toContainText('[mock-claude code]')
// Send second message
await input.fill('What was my previous question?')
await page.getByTestId('agent-send').click()
await visibleAgentSend(page).click()
const messages = page.getByTestId('ai-message')
const messages = visibleAiMessages(page)
await expect(messages).toHaveCount(2)
await expect(messages.first()).toContainText('What is 2+2?')
const secondResponse = page.getByTestId('ai-message').last()
@@ -57,52 +70,51 @@ test.describe('AI chat conversation history', () => {
test('history resets after clearing conversation', async ({ page }) => {
// Send first message
const input = page.getByTestId('agent-input')
const input = visibleAgentInput(page)
await input.fill('Hello')
await page.getByTestId('agent-send').click()
await visibleAgentSend(page).click()
// Wait for response
const firstResponse = page.getByTestId('ai-message').last()
const firstResponse = visibleAiMessages(page).last()
await expect(firstResponse).toBeVisible({ timeout: 5000 })
// Clear conversation (click the + button)
await page.locator('button[title="New AI chat"]').click()
await page.getByTestId('ai-workspace-sidebar-new-chat').click()
await page.waitForTimeout(300)
// Messages should be cleared
await expect(page.getByTestId('ai-message')).toHaveCount(0)
await expect(visibleAiMessages(page)).toHaveCount(0)
// Send new message — should have no history
await input.fill('Fresh start')
await page.getByTestId('agent-send').click()
await visibleAgentInput(page).fill('Fresh start')
await visibleAgentSend(page).click()
const freshResponse = page.getByTestId('ai-message').last()
const freshResponse = visibleAiMessages(page).last()
await expect(freshResponse).toBeVisible({ timeout: 5000 })
await expect(freshResponse).toContainText('[mock-claude code]')
await expect(freshResponse).toContainText('You said: "Fresh start"')
})
test('closing and reopening restores the last chat until a new AI chat is started', async ({ page }) => {
const input = page.getByTestId('agent-input')
test('closing and reopening restores the titled chat and remains usable', async ({ page }) => {
const input = visibleAgentInput(page)
await input.fill('Keep this thread alive')
await page.getByTestId('agent-send').click()
await visibleAgentSend(page).click()
const firstResponse = page.getByTestId('ai-message').last()
const firstResponse = visibleAiMessages(page).last()
await expect(firstResponse).toContainText('[mock-claude code]', { timeout: 5000 })
await page.getByTitle('Close AI panel').click()
await expect(page.getByTestId('ai-panel')).toHaveCount(0)
await page.getByTitle('Close AI workspace').click()
await expect(page.getByTestId('ai-workspace')).toHaveCount(0)
await sendShortcut(page, 'L', ['Meta', 'Shift'])
const panel = page.getByTestId('ai-panel')
await expect(panel).toBeVisible({ timeout: 3_000 })
const restoredMessage = page.getByTestId('ai-message').last()
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await expect(page.getByTestId('ai-workspace')).toContainText('Keep Thread Alive')
await expect(visibleAiMessages(page)).toHaveCount(0)
await page.getByTitle('New AI chat').focus()
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.getByTestId('ai-workspace-sidebar-new-chat').focus()
await expect(page.getByTestId('ai-workspace-sidebar-new-chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)
await expect(visibleAiMessages(page)).toHaveCount(0)
})
})

View File

@@ -1,38 +1,9 @@
import { test, expect } from '@playwright/test'
import { installMockAiAgent } from './helpers'
test.describe('AI chat empty body fix — no regression', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
type Handler = (args?: Record<string, unknown>) => unknown
type BrowserWindow = Window & typeof globalThis & {
__mockHandlers?: Record<string, Handler>
}
const installMockAiAgent = (handlers?: Record<string, Handler> | null) => {
if (!handlers) return handlers ?? null
handlers.get_ai_agents_status = () => ({
claude_code: { installed: true, version: 'mock' },
codex: { installed: false, version: null },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
kiro: { installed: false, version: null },
})
return handlers
}
const browserWindow = window as BrowserWindow
let ref = installMockAiAgent(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
set(value) {
ref = installMockAiAgent(value as Record<string, Handler> | undefined) ?? null
},
get() {
return installMockAiAgent(ref) ?? ref
},
})
})
await installMockAiAgent(page)
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })

View File

@@ -15,6 +15,40 @@ export async function closeCommandPalette(page: Page): Promise<void> {
await expect(page.locator(COMMAND_INPUT)).not.toBeVisible()
}
export async function installMockAiAgent(page: Page): Promise<void> {
await page.addInitScript(() => {
type Handler = (args?: Record<string, unknown>) => unknown
type BrowserWindow = Window & typeof globalThis & {
__mockHandlers?: Record<string, Handler>
}
const installMockAgent = (handlers?: Record<string, Handler> | null) => {
if (!handlers) return handlers ?? null
handlers.get_ai_agents_status = () => ({
claude_code: { installed: true, version: 'mock' },
codex: { installed: false, version: null },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
kiro: { installed: false, version: null },
})
return handlers
}
const browserWindow = window as BrowserWindow
let ref = installMockAgent(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
set(value) {
ref = installMockAgent(value as Record<string, Handler> | undefined) ?? null
},
get() {
return installMockAgent(ref) ?? ref
},
})
})
}
export async function findCommand(
page: Page,
name: string,