fix: polish ai workspace follow-up
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ function composerSendButtonStyle(canSend: boolean): CSSProperties {
|
||||
background: canSend ? 'var(--primary)' : 'var(--muted)',
|
||||
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
|
||||
borderRadius: 8,
|
||||
width: 32,
|
||||
height: 34,
|
||||
width: 30,
|
||||
height: 30,
|
||||
cursor: canSend ? 'pointer' : 'not-allowed',
|
||||
}
|
||||
}
|
||||
@@ -119,10 +119,10 @@ function ComposerInput({
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
editorClassName={cn(
|
||||
'max-h-[160px] overflow-y-auto overscroll-contain',
|
||||
hasControls && 'min-h-[72px] border-0 px-2 py-2',
|
||||
'max-h-[120px] overflow-y-auto overscroll-contain',
|
||||
hasControls && 'min-h-[34px] border-0 px-2 py-1.5 leading-5',
|
||||
)}
|
||||
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
|
||||
editorStyle={{ maxHeight: 120, overflowY: 'auto', overscrollBehavior: 'contain' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -170,7 +170,7 @@ function ComposerControlsRow({
|
||||
if (!hasControls) return <>{sendButton}</>
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{children}
|
||||
</div>
|
||||
@@ -488,10 +488,10 @@ export function AiPanelComposer({
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
style={{ padding: '6px 10px' }}
|
||||
>
|
||||
<div className={cn(
|
||||
hasControls ? 'rounded-xl border border-border bg-background p-2 shadow-xs' : 'flex items-end gap-2',
|
||||
hasControls ? 'rounded-xl border border-border bg-background px-2 py-1.5 shadow-xs' : 'flex items-end gap-2',
|
||||
)}>
|
||||
<div className={cn('min-w-0 flex-1', hasControls && 'w-full')}>
|
||||
<ComposerInput
|
||||
|
||||
@@ -108,9 +108,12 @@ describe('AiWorkspace', () => {
|
||||
it('creates chats from the sidebar and hides the legacy AI panel header', () => {
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('ai-workspace')).toHaveAttribute('data-ai-workspace-mode', 'docked')
|
||||
const workspace = screen.getByTestId('ai-workspace')
|
||||
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'docked')
|
||||
expect(workspace).toHaveStyle({ width: '560px' })
|
||||
expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false')
|
||||
expect(screen.queryByText('AI Agent')).toBeNull()
|
||||
expect(screen.queryByText('Idle')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat'))
|
||||
|
||||
@@ -118,6 +121,55 @@ describe('AiWorkspace', () => {
|
||||
expect(screen.getAllByText('Chat 2').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('resizes the docked workspace from the left edge and the sidebar split', () => {
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
||||
|
||||
const workspace = screen.getByTestId('ai-workspace')
|
||||
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
|
||||
fireEvent.mouseMove(window, { clientX: 60, clientY: 20 })
|
||||
fireEvent.mouseUp(window)
|
||||
expect(workspace).toHaveStyle({ width: '600px' })
|
||||
|
||||
const sidebar = screen.getByTestId('ai-workspace-sidebar-header').parentElement
|
||||
const sidebarHandle = workspace.querySelector('.cursor-col-resize:not([data-testid])')
|
||||
expect(sidebar).toHaveStyle({ width: '168px' })
|
||||
fireEvent.mouseDown(sidebarHandle as Element, { clientX: 100, clientY: 20 })
|
||||
fireEvent.mouseMove(document, { clientX: 120, clientY: 20 })
|
||||
fireEvent.mouseUp(document)
|
||||
expect(sidebar).toHaveStyle({ width: '188px' })
|
||||
})
|
||||
|
||||
it('does not archive an empty chat', () => {
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
||||
|
||||
const archiveButtons = screen.getAllByRole('button', { name: 'Archive chat' })
|
||||
expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true)
|
||||
fireEvent.click(archiveButtons[0])
|
||||
|
||||
expect(screen.getAllByText('Chat 1').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Chat 2')).toBeNull()
|
||||
})
|
||||
|
||||
it('activates a visible chat when persisted settings start with an archived chat', () => {
|
||||
render(
|
||||
<AiWorkspace
|
||||
open
|
||||
mode="docked"
|
||||
aiAgentsStatus={installedStatuses()}
|
||||
aiModelProviders={providers}
|
||||
conversationSettings={[
|
||||
{ id: 'archived-chat', title: 'Old Chat', target_id: null, archived: true },
|
||||
{ id: 'visible-chat', title: 'Live Chat', target_id: null, archived: false },
|
||||
]}
|
||||
vaultPath="/tmp/vault"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ai-workspace-session-visible-chat')).toHaveClass('flex')
|
||||
expect(screen.getByTestId('ai-workspace-session-archived-chat')).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('shows grouped target choices without missing agents', async () => {
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
||||
|
||||
@@ -145,6 +197,7 @@ describe('AiWorkspace', () => {
|
||||
fireEvent.click(screen.getByText('Send mocked prompt'))
|
||||
|
||||
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
|
||||
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ title: 'Summarize Quarterly Sponsor Outreach' }),
|
||||
])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import {
|
||||
Archive,
|
||||
ArrowSquareIn,
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
vaultAiGuidanceNeedsRestore,
|
||||
type VaultAiGuidanceStatus,
|
||||
} from '../lib/vaultAiGuidance'
|
||||
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { trackAiWorkspaceChatTitled, trackAiWorkspaceSidebarToggled } from '../lib/productAnalytics'
|
||||
import type { AgentStatus } from '../hooks/useCliAiAgent'
|
||||
import type { AiWorkspaceConversationSetting } from '../types'
|
||||
@@ -54,11 +54,13 @@ import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
import { generateAiConversationTitle } from '../utils/aiConversationTitle'
|
||||
import { AiPanelView } from './AiPanel'
|
||||
import { ConversationSidebar } from './AiWorkspaceSidebar'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { buildAiWorkspaceTargetGroups, type AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
|
||||
|
||||
export interface AiConversation {
|
||||
archived: boolean
|
||||
hasActivity: boolean
|
||||
id: string
|
||||
targetId: string
|
||||
title: string
|
||||
@@ -102,6 +104,12 @@ interface AiWorkspaceProps {
|
||||
|
||||
let fallbackConversationIdCounter = 0
|
||||
|
||||
const DEFAULT_DOCKED_WORKSPACE_SIZE = { height: 540, width: 560 }
|
||||
const MIN_DOCKED_WORKSPACE_SIZE = { height: 360, width: 460 }
|
||||
const DEFAULT_SIDEBAR_WIDTH = 168
|
||||
const MIN_SIDEBAR_WIDTH = 132
|
||||
const MAX_SIDEBAR_WIDTH = 240
|
||||
|
||||
function randomConversationIdPart(): string {
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID().slice(0, 8)
|
||||
@@ -120,16 +128,27 @@ function nextConversationId(): string {
|
||||
return `ai-chat-${Date.now()}-${randomConversationIdPart()}`
|
||||
}
|
||||
|
||||
function statusLabelKey(status: AgentStatus): TranslationKey {
|
||||
if (status === 'thinking' || status === 'tool-executing') return 'ai.workspace.status.running'
|
||||
if (status === 'error') return 'ai.workspace.status.error'
|
||||
return 'ai.workspace.status.idle'
|
||||
}
|
||||
|
||||
function isRunningStatus(status: AgentStatus | undefined): boolean {
|
||||
return status === 'thinking' || status === 'tool-executing'
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function maxDockedWorkspaceSize(): { height: number; width: number } {
|
||||
if (typeof window === 'undefined') return { height: 680, width: 880 }
|
||||
|
||||
return {
|
||||
height: Math.max(MIN_DOCKED_WORKSPACE_SIZE.height, window.innerHeight - 88),
|
||||
width: Math.max(MIN_DOCKED_WORKSPACE_SIZE.width, window.innerWidth - 32),
|
||||
}
|
||||
}
|
||||
|
||||
function canArchiveConversation(conversation: AiConversation): boolean {
|
||||
return conversation.archived || conversation.hasActivity
|
||||
}
|
||||
|
||||
function agentReadinessForTarget(target: AiTarget, statuses: AiAgentsStatus): AiAgentReadiness {
|
||||
if (target.kind === 'api_model') return 'ready'
|
||||
const status = getAiAgentAvailability(statuses, target.agent).status
|
||||
@@ -157,6 +176,7 @@ function resolveTarget(conversation: AiConversation, groups: AiWorkspaceTargetGr
|
||||
function createConversation(locale: AppLocale, target: AiTarget, index: number): AiConversation {
|
||||
return {
|
||||
archived: false,
|
||||
hasActivity: false,
|
||||
id: nextConversationId(),
|
||||
targetId: target.id,
|
||||
title: translate(locale, 'ai.workspace.chatTitle', { index }),
|
||||
@@ -176,6 +196,7 @@ function conversationFromSetting(setting: AiWorkspaceConversationSetting, fallba
|
||||
|
||||
return {
|
||||
archived: setting.archived === true,
|
||||
hasActivity: !isDefaultConversationTitle(title),
|
||||
id,
|
||||
targetId: setting.target_id?.trim() || fallbackTarget.id,
|
||||
title,
|
||||
@@ -204,6 +225,19 @@ function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceCo
|
||||
}))
|
||||
}
|
||||
|
||||
function activeConversationForState(
|
||||
conversations: AiConversation[],
|
||||
activeId: string,
|
||||
showArchived: boolean,
|
||||
): AiConversation | undefined {
|
||||
const selected = conversations.find((conversation) => conversation.id === activeId)
|
||||
if (selected && selected.archived === showArchived) return selected
|
||||
|
||||
return conversations.find((conversation) => conversation.archived === showArchived)
|
||||
?? conversations.find((conversation) => !conversation.archived)
|
||||
?? conversations[0]
|
||||
}
|
||||
|
||||
interface UseConversationsOptions {
|
||||
fallbackTarget: AiTarget
|
||||
locale: AppLocale
|
||||
@@ -258,11 +292,15 @@ function renameConversationState(current: AiConversation[], id: string, title: s
|
||||
|
||||
function titleConversationFromPromptState(current: AiConversation[], id: string, prompt: string): AiConversation[] {
|
||||
const generatedTitle = generateAiConversationTitle(prompt)
|
||||
if (!generatedTitle) return current
|
||||
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id && conversation.usesDefaultTitle
|
||||
? { ...conversation, title: generatedTitle, usesDefaultTitle: false }
|
||||
conversation.id === id
|
||||
? {
|
||||
...conversation,
|
||||
hasActivity: true,
|
||||
title: generatedTitle && conversation.usesDefaultTitle ? generatedTitle : conversation.title,
|
||||
usesDefaultTitle: generatedTitle ? false : conversation.usesDefaultTitle,
|
||||
}
|
||||
: conversation
|
||||
))
|
||||
}
|
||||
@@ -290,20 +328,16 @@ function useConversations({
|
||||
const onSettingsChangeRef = useRef(onSettingsChange)
|
||||
|
||||
const addConversation = useCallback((target: AiTarget) => {
|
||||
setConversations((current) => {
|
||||
const next = appendConversationState(current, locale, target)
|
||||
setActiveId(next.activeId)
|
||||
return next.conversations
|
||||
})
|
||||
}, [locale])
|
||||
const next = appendConversationState(conversations, locale, target)
|
||||
setConversations(next.conversations)
|
||||
setActiveId(next.activeId)
|
||||
}, [conversations, locale])
|
||||
|
||||
const archiveConversation = useCallback((id: string) => {
|
||||
setConversations((current) => {
|
||||
const next = archiveConversationState(current, id)
|
||||
if (next.activeId) setActiveId(next.activeId)
|
||||
return next.conversations
|
||||
})
|
||||
}, [])
|
||||
const next = archiveConversationState(conversations, id)
|
||||
setConversations(next.conversations)
|
||||
if (next.activeId) setActiveId(next.activeId)
|
||||
}, [conversations])
|
||||
|
||||
const restoreConversation = useCallback((id: string) => {
|
||||
setConversations((current) => restoreConversationState(current, id))
|
||||
@@ -419,7 +453,7 @@ function TargetPickerContent({
|
||||
const hasLocalModelsSeparator = groups.localModels.length > 0 && groups.apiModels.length > 0
|
||||
|
||||
return (
|
||||
<DropdownMenuContent align="start" className="min-w-[280px]">
|
||||
<DropdownMenuContent align="start" className="z-[12000] min-w-[280px]">
|
||||
{hasTargets ? (
|
||||
<DropdownMenuRadioGroup value={selectedTarget.id} onValueChange={onSelectTarget}>
|
||||
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalAgents')} targets={groups.localAgents} />
|
||||
@@ -511,7 +545,7 @@ function PermissionPicker({
|
||||
<CaretDown size={compact ? 12 : 13} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[180px]">
|
||||
<DropdownMenuContent align="start" className="z-[12000] min-w-[180px]">
|
||||
{(['safe', 'power_user'] as const).map((mode) => (
|
||||
<DropdownMenuItem key={mode} onSelect={() => onChange(mode)}>
|
||||
{aiAgentPermissionModeLabels(mode, locale).control}
|
||||
@@ -550,9 +584,9 @@ function GuidanceWarning({
|
||||
|
||||
function WorkspaceHeader({
|
||||
conversation,
|
||||
archiveDisabled,
|
||||
locale,
|
||||
mode,
|
||||
status,
|
||||
onArchive,
|
||||
onClose,
|
||||
onDock,
|
||||
@@ -560,9 +594,9 @@ function WorkspaceHeader({
|
||||
onPopOut,
|
||||
}: {
|
||||
conversation: AiConversation
|
||||
archiveDisabled: boolean
|
||||
locale: AppLocale
|
||||
mode: 'docked' | 'window'
|
||||
status: AgentStatus
|
||||
onArchive: () => void
|
||||
onClose: () => void
|
||||
onDock?: () => void
|
||||
@@ -580,9 +614,6 @@ function WorkspaceHeader({
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="min-w-0 max-w-[260px]">
|
||||
<div className="truncate text-[13px] font-semibold text-foreground">{conversation.title}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{translate(locale, statusLabelKey(status))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -591,7 +622,7 @@ function WorkspaceHeader({
|
||||
<GearSix size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.archive')} title={translate(locale, 'ai.workspace.archive')} onClick={onArchive}>
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.archive')} title={translate(locale, 'ai.workspace.archive')} disabled={archiveDisabled} onClick={onArchive}>
|
||||
<Archive size={16} />
|
||||
</Button>
|
||||
{mode === 'docked' ? (
|
||||
@@ -725,10 +756,10 @@ function ConversationSession({
|
||||
return (
|
||||
<div className={active ? 'flex min-h-0 flex-1 flex-col' : 'hidden'} data-testid={`ai-workspace-session-${conversation.id}`}>
|
||||
<WorkspaceHeader
|
||||
archiveDisabled={!canArchiveConversation(conversation)}
|
||||
conversation={conversation}
|
||||
locale={locale}
|
||||
mode={mode}
|
||||
status={controller.agent.status}
|
||||
onArchive={onArchive}
|
||||
onClose={onClose}
|
||||
onDock={onDock}
|
||||
@@ -772,6 +803,7 @@ interface AiWorkspaceModel {
|
||||
activeId: string
|
||||
addDefaultConversation: () => void
|
||||
archiveConversationSafely: (id: string) => void
|
||||
canArchiveConversation: (conversation: AiConversation) => boolean
|
||||
conversations: AiConversation[]
|
||||
fallbackTarget: AiTarget
|
||||
groups: AiWorkspaceTargetGroups
|
||||
@@ -800,24 +832,176 @@ function resolveAiWorkspaceProps(props: AiWorkspaceProps): ResolvedAiWorkspacePr
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceClassName(mode: 'docked' | 'window'): string {
|
||||
if (mode === 'window') return 'fixed inset-0 z-[90] flex bg-background text-foreground'
|
||||
|
||||
return 'fixed bottom-10 right-4 z-[90] flex h-[min(680px,calc(100vh-88px))] w-[min(880px,calc(100vw-32px))] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-2xl'
|
||||
interface AiWorkspaceSizing {
|
||||
onSidebarResize: (delta: number) => void
|
||||
onWorkspaceResize: (deltaWidth: number, deltaHeight: number) => void
|
||||
sidebarWidth: number
|
||||
workspaceSize: { height: number; width: number }
|
||||
}
|
||||
|
||||
function workspaceStyle(mode: 'docked' | 'window'): CSSProperties | undefined {
|
||||
function workspaceClassName(mode: 'docked' | 'window'): string {
|
||||
if (mode === 'window') {
|
||||
return 'fixed inset-0 z-[120] flex overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-2xl'
|
||||
}
|
||||
|
||||
return 'fixed bottom-20 right-4 z-[120] flex overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-2xl'
|
||||
}
|
||||
|
||||
function workspaceStyle(mode: 'docked' | 'window', size: AiWorkspaceSizing['workspaceSize']): CSSProperties | undefined {
|
||||
if (mode === 'window') return undefined
|
||||
|
||||
return {
|
||||
maxHeight: 'calc(100vh - 88px)',
|
||||
height: size.height,
|
||||
maxHeight: 'calc(100vh - 112px)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
minHeight: 420,
|
||||
minWidth: 520,
|
||||
resize: 'both',
|
||||
minHeight: MIN_DOCKED_WORKSPACE_SIZE.height,
|
||||
minWidth: MIN_DOCKED_WORKSPACE_SIZE.width,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
|
||||
function startResizeDrag(
|
||||
event: ReactMouseEvent,
|
||||
cursor: string,
|
||||
onDrag: (deltaX: number, deltaY: number) => void,
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let lastX = event.clientX
|
||||
let lastY = event.clientY
|
||||
const previousCursor = document.body.style.cursor
|
||||
const previousUserSelect = document.body.style.userSelect
|
||||
document.body.style.cursor = cursor
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - lastX
|
||||
const deltaY = moveEvent.clientY - lastY
|
||||
lastX = moveEvent.clientX
|
||||
lastY = moveEvent.clientY
|
||||
onDrag(deltaX, deltaY)
|
||||
}
|
||||
const handleMouseUp = () => {
|
||||
document.body.style.cursor = previousCursor
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
function WorkspaceResizeHandles({
|
||||
mode,
|
||||
onResize,
|
||||
}: {
|
||||
mode: 'docked' | 'window'
|
||||
onResize: (deltaWidth: number, deltaHeight: number) => void
|
||||
}) {
|
||||
if (mode === 'window') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 z-30 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-border"
|
||||
data-testid="ai-workspace-left-resize"
|
||||
onMouseDown={(event) => startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 right-0 left-0 z-30 h-1 cursor-row-resize bg-transparent transition-colors hover:bg-border"
|
||||
data-testid="ai-workspace-top-resize"
|
||||
onMouseDown={(event) => startResizeDrag(event, 'row-resize', (_deltaX, deltaY) => onResize(0, -deltaY))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useAiWorkspaceSizing(mode: 'docked' | 'window'): AiWorkspaceSizing {
|
||||
const [workspaceSize, setWorkspaceSize] = useState(DEFAULT_DOCKED_WORKSPACE_SIZE)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH)
|
||||
|
||||
const onWorkspaceResize = useCallback((deltaWidth: number, deltaHeight: number) => {
|
||||
if (mode === 'window') return
|
||||
setWorkspaceSize((current) => {
|
||||
const max = maxDockedWorkspaceSize()
|
||||
return {
|
||||
height: clampNumber(current.height + deltaHeight, MIN_DOCKED_WORKSPACE_SIZE.height, max.height),
|
||||
width: clampNumber(current.width + deltaWidth, MIN_DOCKED_WORKSPACE_SIZE.width, max.width),
|
||||
}
|
||||
})
|
||||
}, [mode])
|
||||
const onSidebarResize = useCallback((delta: number) => {
|
||||
setSidebarWidth((current) => clampNumber(current + delta, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH))
|
||||
}, [])
|
||||
|
||||
return { onSidebarResize, onWorkspaceResize, sidebarWidth, workspaceSize }
|
||||
}
|
||||
|
||||
function useActiveConversationSync(
|
||||
activeConversation: AiConversation | undefined,
|
||||
activeId: string,
|
||||
setActiveId: (id: string) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (activeConversation && activeConversation.id !== activeId) setActiveId(activeConversation.id)
|
||||
}, [activeConversation, activeId, setActiveId])
|
||||
}
|
||||
|
||||
function useArchiveConversationSafely({
|
||||
addConversation,
|
||||
archiveConversation,
|
||||
conversations,
|
||||
fallbackTarget,
|
||||
}: {
|
||||
addConversation: (target: AiTarget) => void
|
||||
archiveConversation: (id: string) => void
|
||||
conversations: AiConversation[]
|
||||
fallbackTarget: AiTarget
|
||||
}) {
|
||||
return useCallback((id: string) => {
|
||||
const conversation = conversations.find((candidate) => candidate.id === id)
|
||||
if (!conversation || !canArchiveConversation(conversation)) return
|
||||
|
||||
const activeCount = conversations.filter((conversation) => !conversation.archived).length
|
||||
archiveConversation(id)
|
||||
if (activeCount <= 1) addConversation(fallbackTarget)
|
||||
}, [addConversation, archiveConversation, conversations, fallbackTarget])
|
||||
}
|
||||
|
||||
function useTrackedConversationActions({
|
||||
conversations,
|
||||
renameConversation,
|
||||
titleConversationFromPrompt,
|
||||
}: {
|
||||
conversations: AiConversation[]
|
||||
renameConversation: (id: string, title: string) => void
|
||||
titleConversationFromPrompt: (id: string, prompt: string) => void
|
||||
}) {
|
||||
const trackedRenameConversation = useCallback((id: string, title: string) => {
|
||||
if (!title.trim()) return
|
||||
renameConversation(id, title)
|
||||
trackAiWorkspaceChatTitled('manual')
|
||||
}, [renameConversation])
|
||||
const trackedTitleConversationFromPrompt = useCallback((id: string, prompt: string) => {
|
||||
const conversation = conversations.find((candidate) => candidate.id === id)
|
||||
titleConversationFromPrompt(id, prompt)
|
||||
if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated')
|
||||
}, [conversations, titleConversationFromPrompt])
|
||||
|
||||
return { trackedRenameConversation, trackedTitleConversationFromPrompt }
|
||||
}
|
||||
|
||||
function useAiWorkspaceNewChatEvent(open: boolean, addDefaultConversation: () => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleNewChat = () => addDefaultConversation()
|
||||
window.addEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
|
||||
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
|
||||
}, [addDefaultConversation, open])
|
||||
}
|
||||
|
||||
function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceModel {
|
||||
const groups = useMemo(
|
||||
() => buildAiWorkspaceTargetGroups(workspace.aiAgentsStatus, workspace.aiModelProviders),
|
||||
@@ -849,33 +1033,26 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
})
|
||||
const [statuses, setStatuses] = useState<Record<string, AgentStatus>>({})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const activeConversation = conversations.find((conversation) => conversation.id === activeId)
|
||||
?? conversations.find((conversation) => !conversation.archived)
|
||||
?? conversations[0]
|
||||
const activeConversation = activeConversationForState(conversations, activeId, showArchived)
|
||||
|
||||
const addDefaultConversation = useCallback(() => {
|
||||
addConversation(fallbackTarget)
|
||||
}, [addConversation, fallbackTarget])
|
||||
|
||||
const archiveConversationSafely = useCallback((id: string) => {
|
||||
const activeCount = conversations.filter((conversation) => !conversation.archived).length
|
||||
archiveConversation(id)
|
||||
if (activeCount <= 1) addConversation(fallbackTarget)
|
||||
}, [addConversation, archiveConversation, conversations, fallbackTarget])
|
||||
|
||||
const archiveConversationSafely = useArchiveConversationSafely({
|
||||
addConversation,
|
||||
archiveConversation,
|
||||
conversations,
|
||||
fallbackTarget,
|
||||
})
|
||||
useActiveConversationSync(activeConversation, activeId, setActiveId)
|
||||
const handleStatusChange = useCallback((id: string, status: AgentStatus) => {
|
||||
setStatuses((current) => current[id] === status ? current : { ...current, [id]: status })
|
||||
}, [])
|
||||
const trackedRenameConversation = useCallback((id: string, title: string) => {
|
||||
if (!title.trim()) return
|
||||
renameConversation(id, title)
|
||||
trackAiWorkspaceChatTitled('manual')
|
||||
}, [renameConversation])
|
||||
const trackedTitleConversationFromPrompt = useCallback((id: string, prompt: string) => {
|
||||
const conversation = conversations.find((candidate) => candidate.id === id)
|
||||
titleConversationFromPrompt(id, prompt)
|
||||
if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated')
|
||||
}, [conversations, titleConversationFromPrompt])
|
||||
const { trackedRenameConversation, trackedTitleConversationFromPrompt } = useTrackedConversationActions({
|
||||
conversations,
|
||||
renameConversation,
|
||||
titleConversationFromPrompt,
|
||||
})
|
||||
const toggleSidebarCollapsed = useCallback(() => {
|
||||
setSidebarCollapsed((current) => {
|
||||
const next = !current
|
||||
@@ -884,13 +1061,7 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
})
|
||||
}, [workspace.mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace.open) return
|
||||
const handleNewChat = () => addDefaultConversation()
|
||||
window.addEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
|
||||
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleNewChat)
|
||||
}, [addDefaultConversation, workspace.open])
|
||||
|
||||
useAiWorkspaceNewChatEvent(workspace.open, addDefaultConversation)
|
||||
useEffect(() => {
|
||||
updateDefaultConversationTargets(fallbackTarget.id)
|
||||
}, [fallbackTarget.id, updateDefaultConversationTargets])
|
||||
@@ -900,6 +1071,7 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
activeId,
|
||||
addDefaultConversation,
|
||||
archiveConversationSafely,
|
||||
canArchiveConversation,
|
||||
conversations,
|
||||
fallbackTarget,
|
||||
groups,
|
||||
@@ -919,20 +1091,24 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
}
|
||||
|
||||
function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; workspace: ResolvedAiWorkspaceProps }) {
|
||||
const sizing = useAiWorkspaceSizing(workspace.mode)
|
||||
|
||||
return (
|
||||
<section
|
||||
className={workspaceClassName(workspace.mode)}
|
||||
style={workspaceStyle(workspace.mode)}
|
||||
style={workspaceStyle(workspace.mode, sizing.workspaceSize)}
|
||||
data-testid="ai-workspace"
|
||||
data-ai-workspace-mode={workspace.mode}
|
||||
role="dialog"
|
||||
aria-label={translate(workspace.locale, 'ai.workspace.title')}
|
||||
>
|
||||
<WorkspaceResizeHandles mode={workspace.mode} onResize={sizing.onWorkspaceResize} />
|
||||
<ConversationSidebar
|
||||
activeId={model.activeId}
|
||||
collapsed={model.sidebarCollapsed}
|
||||
conversations={model.conversations}
|
||||
locale={workspace.locale}
|
||||
onCanArchive={model.canArchiveConversation}
|
||||
onArchive={model.archiveConversationSafely}
|
||||
onNewChat={model.addDefaultConversation}
|
||||
onRename={model.renameConversation}
|
||||
@@ -941,8 +1117,12 @@ function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; work
|
||||
onToggleCollapsed={model.toggleSidebarCollapsed}
|
||||
setShowArchived={model.setShowArchived}
|
||||
showArchived={model.showArchived}
|
||||
sidebarWidth={sizing.sidebarWidth}
|
||||
statuses={model.statuses}
|
||||
/>
|
||||
{!model.sidebarCollapsed && (
|
||||
<ResizeHandle onResize={sizing.onSidebarResize} />
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ConversationSessions model={model} workspace={workspace} />
|
||||
</div>
|
||||
@@ -959,7 +1139,7 @@ function ConversationSessions({ model, workspace }: { model: AiWorkspaceModel; w
|
||||
return (
|
||||
<ConversationSession
|
||||
key={conversation.id}
|
||||
active={conversation.id === model.activeConversation?.id && !conversation.archived}
|
||||
active={conversation.id === model.activeConversation?.id}
|
||||
activeEntry={workspace.activeEntry}
|
||||
activeNoteContent={workspace.activeNoteContent}
|
||||
aiAgentsStatus={workspace.aiAgentsStatus}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ConversationSidebarProps {
|
||||
collapsed: boolean
|
||||
conversations: AiConversation[]
|
||||
locale: AppLocale
|
||||
onCanArchive: (conversation: AiConversation) => boolean
|
||||
onArchive: (id: string) => void
|
||||
onNewChat: () => void
|
||||
onRename: (id: string, title: string) => void
|
||||
@@ -29,6 +30,7 @@ interface ConversationSidebarProps {
|
||||
onToggleCollapsed: () => void
|
||||
setShowArchived: (show: boolean) => void
|
||||
showArchived: boolean
|
||||
sidebarWidth: number
|
||||
statuses: Record<string, AgentStatus>
|
||||
}
|
||||
|
||||
@@ -177,12 +179,14 @@ function CollapsedConversationSidebar({ locale, onNewChat }: { locale: AppLocale
|
||||
}
|
||||
|
||||
function ConversationArchiveButton({
|
||||
disabled,
|
||||
conversationId,
|
||||
locale,
|
||||
onArchive,
|
||||
onRestore,
|
||||
showArchived,
|
||||
}: {
|
||||
disabled: boolean
|
||||
conversationId: string
|
||||
locale: AppLocale
|
||||
onArchive: (id: string) => void
|
||||
@@ -196,9 +200,10 @@ function ConversationArchiveButton({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 disabled:opacity-0"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={() => showArchived ? onRestore(conversationId) : onArchive(conversationId)}
|
||||
>
|
||||
{showArchived ? <ArrowSquareIn size={16} /> : <Archive size={16} />}
|
||||
@@ -211,6 +216,7 @@ function ConversationRow({
|
||||
conversation,
|
||||
editing,
|
||||
locale,
|
||||
onCanArchive,
|
||||
onArchive,
|
||||
onRename,
|
||||
onRestore,
|
||||
@@ -224,6 +230,7 @@ function ConversationRow({
|
||||
conversation: AiConversation
|
||||
editing: boolean
|
||||
locale: AppLocale
|
||||
onCanArchive: (conversation: AiConversation) => boolean
|
||||
onArchive: (id: string) => void
|
||||
onRename: (id: string, title: string) => void
|
||||
onRestore: (id: string) => void
|
||||
@@ -262,6 +269,7 @@ function ConversationRow({
|
||||
</Button>
|
||||
)}
|
||||
<ConversationArchiveButton
|
||||
disabled={!onCanArchive(conversation)}
|
||||
conversationId={conversation.id}
|
||||
locale={locale}
|
||||
onArchive={onArchive}
|
||||
@@ -277,6 +285,7 @@ function ConversationList({
|
||||
conversations,
|
||||
editingId,
|
||||
locale,
|
||||
onCanArchive,
|
||||
onArchive,
|
||||
onRename,
|
||||
onRestore,
|
||||
@@ -285,7 +294,7 @@ function ConversationList({
|
||||
showArchived,
|
||||
statuses,
|
||||
}: Pick<ConversationSidebarProps,
|
||||
'activeId' | 'conversations' | 'locale' | 'onArchive' | 'onRename' | 'onRestore' | 'onSelect' | 'showArchived' | 'statuses'
|
||||
'activeId' | 'conversations' | 'locale' | 'onCanArchive' | 'onArchive' | 'onRename' | 'onRestore' | 'onSelect' | 'showArchived' | 'statuses'
|
||||
> & {
|
||||
editingId: string | null
|
||||
setEditingId: (id: string | null) => void
|
||||
@@ -306,6 +315,7 @@ function ConversationList({
|
||||
conversation={conversation}
|
||||
editing={editingId === conversation.id}
|
||||
locale={locale}
|
||||
onCanArchive={onCanArchive}
|
||||
onArchive={onArchive}
|
||||
onRename={onRename}
|
||||
onRestore={onRestore}
|
||||
@@ -343,6 +353,7 @@ function ExpandedConversationSidebar({
|
||||
activeId,
|
||||
conversations,
|
||||
locale,
|
||||
onCanArchive,
|
||||
onArchive,
|
||||
onRename,
|
||||
onRestore,
|
||||
@@ -350,7 +361,7 @@ function ExpandedConversationSidebar({
|
||||
setShowArchived,
|
||||
showArchived,
|
||||
statuses,
|
||||
}: Omit<ConversationSidebarProps, 'collapsed' | 'onNewChat' | 'onToggleCollapsed'>) {
|
||||
}: Omit<ConversationSidebarProps, 'collapsed' | 'onNewChat' | 'onToggleCollapsed' | 'sidebarWidth'>) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
@@ -361,6 +372,7 @@ function ExpandedConversationSidebar({
|
||||
conversations={conversations}
|
||||
editingId={editingId}
|
||||
locale={locale}
|
||||
onCanArchive={onCanArchive}
|
||||
onArchive={onArchive}
|
||||
onRename={onRename}
|
||||
onRestore={onRestore}
|
||||
@@ -384,6 +396,7 @@ export function ConversationSidebar({
|
||||
collapsed,
|
||||
conversations,
|
||||
locale,
|
||||
onCanArchive,
|
||||
onArchive,
|
||||
onNewChat,
|
||||
onRename,
|
||||
@@ -392,13 +405,14 @@ export function ConversationSidebar({
|
||||
onToggleCollapsed,
|
||||
setShowArchived,
|
||||
showArchived,
|
||||
sidebarWidth,
|
||||
statuses,
|
||||
}: ConversationSidebarProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width]',
|
||||
collapsed ? 'w-12' : 'w-[220px]',
|
||||
)}>
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width]"
|
||||
style={{ width: collapsed ? 48 : sidebarWidth }}
|
||||
>
|
||||
<SidebarHeader
|
||||
collapsed={collapsed}
|
||||
locale={locale}
|
||||
@@ -412,6 +426,7 @@ export function ConversationSidebar({
|
||||
activeId={activeId}
|
||||
conversations={conversations}
|
||||
locale={locale}
|
||||
onCanArchive={onCanArchive}
|
||||
onArchive={onArchive}
|
||||
onRename={onRename}
|
||||
onRestore={onRestore}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AppPreferencesProvider, useAppPreferences } from '../hooks/useAppPreferences'
|
||||
import { useAiAgentsStatus } from '../hooks/useAiAgentsStatus'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useVaultAiGuidanceStatus } from '../hooks/useVaultAiGuidanceStatus'
|
||||
import { areAiFeaturesEnabled } from '../lib/aiFeatures'
|
||||
import type { AiWorkspaceConversationSetting, Settings } from '../types'
|
||||
import { dockCurrentAiWorkspaceWindow, readAiWorkspaceWindowContext } from '../utils/openAiWorkspaceWindow'
|
||||
import {
|
||||
closeCurrentAiWorkspaceWindow,
|
||||
dockCurrentAiWorkspaceWindow,
|
||||
readAiWorkspaceWindowContext,
|
||||
} from '../utils/openAiWorkspaceWindow'
|
||||
import { AppAiWorkspaceSurface } from './AppAiWorkspaceSurface'
|
||||
import { Toast } from './Toast'
|
||||
|
||||
@@ -13,6 +17,19 @@ function useAiWorkspaceWindowContext() {
|
||||
return useMemo(() => readAiWorkspaceWindowContext(), [])
|
||||
}
|
||||
|
||||
function useTransparentWindowBackground() {
|
||||
useEffect(() => {
|
||||
const previousBodyBackground = document.body.style.background
|
||||
const previousRootBackground = document.documentElement.style.background
|
||||
document.body.style.background = 'transparent'
|
||||
document.documentElement.style.background = 'transparent'
|
||||
return () => {
|
||||
document.body.style.background = previousBodyBackground
|
||||
document.documentElement.style.background = previousRootBackground
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
function useAiWorkspaceSettingsSaver(
|
||||
enabled: boolean,
|
||||
settings: Settings,
|
||||
@@ -25,6 +42,7 @@ function useAiWorkspaceSettingsSaver(
|
||||
}
|
||||
|
||||
export function AiWorkspaceWindowApp() {
|
||||
useTransparentWindowBackground()
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const context = useAiWorkspaceWindowContext()
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
@@ -49,6 +67,11 @@ export function AiWorkspaceWindowApp() {
|
||||
console.warn('[ai] Failed to dock workspace window:', err)
|
||||
})
|
||||
}, [])
|
||||
const handleClose = useCallback(() => {
|
||||
void closeCurrentAiWorkspaceWindow().catch((err) => {
|
||||
console.warn('[ai] Failed to close workspace window:', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppPreferencesProvider dateDisplayFormat={preferences.dateDisplayFormat}>
|
||||
@@ -68,7 +91,7 @@ export function AiWorkspaceWindowApp() {
|
||||
openTabs={[]}
|
||||
noteList={[]}
|
||||
noteListFilter={{ type: null, query: '' }}
|
||||
onClose={handleDock}
|
||||
onClose={handleClose}
|
||||
onConversationSettingsChange={handleConversationSettingsChange}
|
||||
onDock={handleDock}
|
||||
onUnsupportedAiPaste={setToastMessage}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -114,9 +114,20 @@ let mockSettings: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
date_display_format: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
initial_h1_auto_rename_enabled: null,
|
||||
ai_features_enabled: null,
|
||||
default_ai_agent: 'claude_code',
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
ai_workspace_conversations: null,
|
||||
hide_gitignored_files: null,
|
||||
all_notes_show_pdfs: null,
|
||||
all_notes_show_images: null,
|
||||
all_notes_show_unsupported: null,
|
||||
multi_workspace_enabled: null,
|
||||
}
|
||||
|
||||
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
|
||||
@@ -512,9 +523,20 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
release_channel: s.release_channel,
|
||||
theme_mode: s.theme_mode ?? null,
|
||||
ui_language: s.ui_language ?? null,
|
||||
date_display_format: s.date_display_format ?? null,
|
||||
note_width_mode: s.note_width_mode ?? null,
|
||||
sidebar_type_pluralization_enabled: s.sidebar_type_pluralization_enabled ?? null,
|
||||
initial_h1_auto_rename_enabled: s.initial_h1_auto_rename_enabled ?? null,
|
||||
ai_features_enabled: s.ai_features_enabled ?? null,
|
||||
default_ai_agent: s.default_ai_agent ?? null,
|
||||
default_ai_target: s.default_ai_target ?? null,
|
||||
ai_model_providers: s.ai_model_providers ?? null,
|
||||
ai_workspace_conversations: s.ai_workspace_conversations ?? null,
|
||||
hide_gitignored_files: s.hide_gitignored_files ?? null,
|
||||
all_notes_show_pdfs: s.all_notes_show_pdfs ?? null,
|
||||
all_notes_show_images: s.all_notes_show_images ?? null,
|
||||
all_notes_show_unsupported: s.all_notes_show_unsupported ?? null,
|
||||
multi_workspace_enabled: s.multi_workspace_enabled ?? null,
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AI_WORKSPACE_WINDOW_LABEL,
|
||||
buildAiWorkspaceWindowUrl,
|
||||
buildRuntimeAiWorkspaceWindowUrl,
|
||||
closeCurrentAiWorkspaceWindow,
|
||||
dockCurrentAiWorkspaceWindow,
|
||||
openAiWorkspaceWindow,
|
||||
readAiWorkspaceWindowContext,
|
||||
@@ -15,7 +16,7 @@ const webviewGetByLabel = vi.fn()
|
||||
const existingUnminimize = vi.fn().mockResolvedValue(undefined)
|
||||
const existingSetFocus = vi.fn().mockResolvedValue(undefined)
|
||||
const emitTo = vi.fn().mockResolvedValue(undefined)
|
||||
const destroy = vi.fn().mockResolvedValue(undefined)
|
||||
const close = vi.fn().mockResolvedValue(undefined)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
@@ -47,9 +48,16 @@ vi.mock('@tauri-apps/api/event', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ destroy }),
|
||||
getCurrentWindow: () => ({ close }),
|
||||
}))
|
||||
|
||||
async function runCurrentWindowAction(action: () => Promise<void>) {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
await action()
|
||||
return dispatchSpy
|
||||
}
|
||||
|
||||
describe('openAiWorkspaceWindow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -111,6 +119,8 @@ describe('openAiWorkspaceWindow', () => {
|
||||
minHeight: 420,
|
||||
minimizable: false,
|
||||
decorations: false,
|
||||
shadow: true,
|
||||
transparent: true,
|
||||
}),
|
||||
)
|
||||
expect(localStorage.getItem('tolaria:ai-workspace-window:ai-workspace')).toBe('true')
|
||||
@@ -130,17 +140,25 @@ describe('openAiWorkspaceWindow', () => {
|
||||
expect(webviewWindowCalls).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requests docking and destroys the current AI workspace window', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
it('closes the current AI workspace window without docking it', async () => {
|
||||
const dispatchSpy = await runCurrentWindowAction(closeCurrentAiWorkspaceWindow)
|
||||
|
||||
await dockCurrentAiWorkspaceWindow()
|
||||
expect(dispatchSpy).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: AI_WORKSPACE_DOCK_REQUESTED_EVENT,
|
||||
}))
|
||||
expect(emitTo).not.toHaveBeenCalled()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requests docking and closes the current AI workspace window', async () => {
|
||||
const dispatchSpy = await runCurrentWindowAction(dockCurrentAiWorkspaceWindow)
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: AI_WORKSPACE_DOCK_REQUESTED_EVENT,
|
||||
}))
|
||||
expect(emitTo).toHaveBeenCalledWith('main', AI_WORKSPACE_DOCK_REQUESTED_EVENT)
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,11 +86,20 @@ export async function openAiWorkspaceWindow(context: AiWorkspaceWindowContext =
|
||||
resizable: true,
|
||||
minimizable: false,
|
||||
decorations: false,
|
||||
shadow: true,
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function closeCurrentAiWorkspaceWindow(): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().close().catch(() => {})
|
||||
}
|
||||
|
||||
export async function dockCurrentAiWorkspaceWindow(): Promise<void> {
|
||||
requestDockAiWorkspace()
|
||||
|
||||
@@ -99,5 +108,5 @@ export async function dockCurrentAiWorkspaceWindow(): Promise<void> {
|
||||
const { emitTo } = await import('@tauri-apps/api/event')
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await emitTo('main', AI_WORKSPACE_DOCK_REQUESTED_EVENT).catch(() => {})
|
||||
await getCurrentWindow().destroy().catch(() => {})
|
||||
await getCurrentWindow().close().catch(() => {})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,38 +1,9 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { installMockAiAgent } from './helpers'
|
||||
|
||||
test.describe('AI chat empty body fix — no regression', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
type Handler = (args?: Record<string, unknown>) => unknown
|
||||
type BrowserWindow = Window & typeof globalThis & {
|
||||
__mockHandlers?: Record<string, Handler>
|
||||
}
|
||||
|
||||
const installMockAiAgent = (handlers?: Record<string, Handler> | null) => {
|
||||
if (!handlers) return handlers ?? null
|
||||
handlers.get_ai_agents_status = () => ({
|
||||
claude_code: { installed: true, version: 'mock' },
|
||||
codex: { installed: false, version: null },
|
||||
opencode: { installed: false, version: null },
|
||||
pi: { installed: false, version: null },
|
||||
gemini: { installed: false, version: null },
|
||||
kiro: { installed: false, version: null },
|
||||
})
|
||||
return handlers
|
||||
}
|
||||
|
||||
const browserWindow = window as BrowserWindow
|
||||
let ref = installMockAiAgent(browserWindow.__mockHandlers) ?? null
|
||||
Object.defineProperty(browserWindow, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = installMockAiAgent(value as Record<string, Handler> | undefined) ?? null
|
||||
},
|
||||
get() {
|
||||
return installMockAiAgent(ref) ?? ref
|
||||
},
|
||||
})
|
||||
})
|
||||
await installMockAiAgent(page)
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
@@ -15,6 +15,40 @@ export async function closeCommandPalette(page: Page): Promise<void> {
|
||||
await expect(page.locator(COMMAND_INPUT)).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function installMockAiAgent(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
type Handler = (args?: Record<string, unknown>) => unknown
|
||||
type BrowserWindow = Window & typeof globalThis & {
|
||||
__mockHandlers?: Record<string, Handler>
|
||||
}
|
||||
|
||||
const installMockAgent = (handlers?: Record<string, Handler> | null) => {
|
||||
if (!handlers) return handlers ?? null
|
||||
handlers.get_ai_agents_status = () => ({
|
||||
claude_code: { installed: true, version: 'mock' },
|
||||
codex: { installed: false, version: null },
|
||||
opencode: { installed: false, version: null },
|
||||
pi: { installed: false, version: null },
|
||||
gemini: { installed: false, version: null },
|
||||
kiro: { installed: false, version: null },
|
||||
})
|
||||
return handlers
|
||||
}
|
||||
|
||||
const browserWindow = window as BrowserWindow
|
||||
let ref = installMockAgent(browserWindow.__mockHandlers) ?? null
|
||||
Object.defineProperty(browserWindow, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = installMockAgent(value as Record<string, Handler> | undefined) ?? null
|
||||
},
|
||||
get() {
|
||||
return installMockAgent(ref) ?? ref
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function findCommand(
|
||||
page: Page,
|
||||
name: string,
|
||||
|
||||
Reference in New Issue
Block a user