diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 37202fd9..ceb85e12 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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
diff --git a/docs/adr/0128-lightweight-ai-workspace-window.md b/docs/adr/0128-lightweight-ai-workspace-window.md
index 0c0f93d5..1c842d53 100644
--- a/docs/adr/0128-lightweight-ai-workspace-window.md
+++ b/docs/adr/0128-lightweight-ai-workspace-window.md
@@ -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.
diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx
index 985bc2cc..bce226f8 100644
--- a/src/components/AiPanel.test.tsx
+++ b/src/components/AiPanel.test.tsx
@@ -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()
})
diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx
index ebd9b566..d4148d8e 100644
--- a/src/components/AiPanelChrome.tsx
+++ b/src/components/AiPanelChrome.tsx
@@ -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 (
-
+
{children}
@@ -488,10 +488,10 @@ export function AiPanelComposer({
return (
{
it('creates chats from the sidebar and hides the legacy AI panel header', () => {
render( )
- 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( )
+
+ 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( )
+
+ 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(
+ ,
+ )
+
+ 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( )
@@ -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' }),
])
diff --git a/src/components/AiWorkspace.tsx b/src/components/AiWorkspace.tsx
index 0ccfc9a6..0f8de1a7 100644
--- a/src/components/AiWorkspace.tsx
+++ b/src/components/AiWorkspace.tsx
@@ -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 (
-
+
{hasTargets ? (
@@ -511,7 +545,7 @@ function PermissionPicker({
-
+
{(['safe', 'power_user'] as const).map((mode) => (
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({
{conversation.title}
-
- {translate(locale, statusLabelKey(status))}
-
@@ -591,7 +622,7 @@ function WorkspaceHeader({
)}
-
+
{mode === 'docked' ? (
@@ -725,10 +756,10 @@ function ConversationSession({
return (
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 (
+ <>
+ startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))}
+ />
+
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
>({})
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 (
+
+ {!model.sidebarCollapsed && (
+
+ )}
@@ -959,7 +1139,7 @@ function ConversationSessions({ model, workspace }: { model: AiWorkspaceModel; w
return (
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
}
@@ -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 ? : }
@@ -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({
)}
& {
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) {
+}: Omit) {
const [editingId, setEditingId] = useState(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 (
-
+
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(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 (
@@ -68,7 +91,7 @@ export function AiWorkspaceWindowApp() {
openTabs={[]}
noteList={[]}
noteListFilter={{ type: null, query: '' }}
- onClose={handleDock}
+ onClose={handleClose}
onConversationSettingsChange={handleConversationSettingsChange}
onDock={handleDock}
onUnsupportedAiPaste={setToastMessage}
diff --git a/src/components/InlineWikilinkInput.tsx b/src/components/InlineWikilinkInput.tsx
index 7712d9d2..0b53cff6 100644
--- a/src/components/InlineWikilinkInput.tsx
+++ b/src/components/InlineWikilinkInput.tsx
@@ -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),
diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts
index f05a9c07..4b7c326f 100644
--- a/src/mock-tauri/mock-handlers.coverage.test.ts
+++ b/src/mock-tauri/mock-handlers.coverage.test.ts
@@ -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 = {
diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts
index 6f1f1d59..0fb69d34 100644
--- a/src/mock-tauri/mock-handlers.ts
+++ b/src/mock-tauri/mock-handlers.ts
@@ -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 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
},
diff --git a/src/utils/openAiWorkspaceWindow.test.ts b/src/utils/openAiWorkspaceWindow.test.ts
index 0a4460a4..5a2699f6 100644
--- a/src/utils/openAiWorkspaceWindow.test.ts
+++ b/src/utils/openAiWorkspaceWindow.test.ts
@@ -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 = {}
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) {
+ 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()
})
})
diff --git a/src/utils/openAiWorkspaceWindow.ts b/src/utils/openAiWorkspaceWindow.ts
index 60ad4ec1..9824cb85 100644
--- a/src/utils/openAiWorkspaceWindow.ts
+++ b/src/utils/openAiWorkspaceWindow.ts
@@ -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 {
+ if (!isTauri()) return
+
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
+ await getCurrentWindow().close().catch(() => {})
+}
+
export async function dockCurrentAiWorkspaceWindow(): Promise {
requestDockAiWorkspace()
@@ -99,5 +108,5 @@ export async function dockCurrentAiWorkspaceWindow(): Promise {
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(() => {})
}
diff --git a/tests/smoke/ai-chat-history.spec.ts b/tests/smoke/ai-chat-history.spec.ts
index c0b45a8d..dfa51c87 100644
--- a/tests/smoke/ai-chat-history.spec.ts
+++ b/tests/smoke/ai-chat-history.spec.ts
@@ -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)
})
})
diff --git a/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts b/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts
index 26cfba87..99ce5750 100644
--- a/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts
+++ b/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts
@@ -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) => unknown
- type BrowserWindow = Window & typeof globalThis & {
- __mockHandlers?: Record
- }
-
- const installMockAiAgent = (handlers?: Record | 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 | 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 })
diff --git a/tests/smoke/helpers.ts b/tests/smoke/helpers.ts
index b477a415..188742ed 100644
--- a/tests/smoke/helpers.ts
+++ b/tests/smoke/helpers.ts
@@ -15,6 +15,40 @@ export async function closeCommandPalette(page: Page): Promise {
await expect(page.locator(COMMAND_INPUT)).not.toBeVisible()
}
+export async function installMockAiAgent(page: Page): Promise {
+ await page.addInitScript(() => {
+ type Handler = (args?: Record) => unknown
+ type BrowserWindow = Window & typeof globalThis & {
+ __mockHandlers?: Record
+ }
+
+ const installMockAgent = (handlers?: Record | 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 | undefined) ?? null
+ },
+ get() {
+ return installMockAgent(ref) ?? ref
+ },
+ })
+ })
+}
+
export async function findCommand(
page: Page,
name: string,