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,