fix: stabilize AI workspace QA

This commit is contained in:
lucaronin
2026-05-26 19:15:05 +02:00
parent 8828516d43
commit 29e5dd8f7b
44 changed files with 1527 additions and 398 deletions

View File

@@ -846,6 +846,13 @@ Tolaria delegates remote auth to the user's system git setup:
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
```typescript
interface AiWorkspaceConversationSetting {
archived: boolean | null
id: string
target_id: string | null
title: string
}
interface Settings {
auto_pull_interval_minutes: number | null
autogit_enabled: boolean | null
@@ -866,6 +873,7 @@ interface Settings {
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | 'kiro' | null
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
ai_model_providers: AiModelProvider[] | null
ai_workspace_conversations: AiWorkspaceConversationSetting[] | null
hide_gitignored_files: boolean | null // null = default true
all_notes_show_pdfs: boolean | null // null = default false
all_notes_show_images: boolean | null // null = default false
@@ -873,7 +881,7 @@ interface Settings {
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -229,7 +229,7 @@ flowchart TD
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a bounded desktop tool inside the main app; pop-out mode opens a dedicated Tauri webview window labeled `ai-workspace` with macOS overlay traffic lights. Close/minimize requests from the native AI window 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, 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 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.
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 the same `App` shell with `?window=ai-workspace`, 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.
**Triggers:**
- `Cmd+Shift+Click` on any note in the note list or sidebar

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0128"
title: "Lightweight AI workspace window"
status: active
date: 2026-05-26
supersedes: "0127"
---
## Context
ADR-0127 moved the AI workspace into a native Tauri window, but the first version booted the full `App` shell and used macOS overlay traffic lights. That made pop-out feel slow, duplicated startup work, and left the chat route dependent on main-window vault loading before agent turns could run.
The AI workspace also needs installation-local chat metadata so user-facing chat titles and archive state survive dock/pop-out transitions without writing to a vault.
## Decision
**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.
`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.
## Options considered
- **Lightweight AI route** (chosen): keeps pop-out startup focused on AI state and passes explicit vault context to the agent controller.
- **Full `App` route**: preserves maximum feature parity by default, but repeats vault/editor startup work and delays a window that should contain only the AI workspace.
- **Vault-stored chat metadata**: would travel with a vault, but chat labels and archive state are installation UI preferences rather than vault content.
## 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.
- Chat titles, archived state, and target overrides persist at the installation level in `settings.json`.
- Future transcript persistence must use a separate storage decision; `ai_workspace_conversations` is intentionally metadata-only.

View File

@@ -178,4 +178,5 @@ proposed → active → superseded
| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | superseded -> [0124](0124-cached-secondary-note-window-startup.md) |
| [0124](0124-cached-secondary-note-window-startup.md) | Cached secondary note window startup | active |
| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active |
| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | active |
| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | superseded -> [0128](0128-lightweight-ai-workspace-window.md) |
| [0128](0128-lightweight-ai-workspace-window.md) | Lightweight AI workspace window | active |

View File

@@ -314,6 +314,8 @@ files:
ai.panel.send: 747c6a3564774149c989884e0c581d53
ai.workspace.title: d5b381672258b0bb54ede1e886895b3e
ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9
ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f
ai.workspace.expandSidebar: 281b619f3da2ba27e604302c3c00db56
ai.workspace.close: 184649ab030e9a609488608221c28d8c
ai.workspace.popOut: 85ec25ec056b3082651e6e3fcfa57416
ai.workspace.dock: 72e81ed23007ec6fb9820b9abec353c4
@@ -325,6 +327,7 @@ files:
ai.workspace.noActiveChats: f0cdbf09f23eb9a2efbb5977bcc39c1a
ai.workspace.noArchivedChats: 68410efd12fc0308ce1ed2225000d4d5
ai.workspace.chatTitle: 2ecae1c7ffb3296a789ff8f3b3ed2922
ai.workspace.renameChat: 0feba25e166f55afc51c4ebb33d8d757
ai.workspace.status.archived: 7d69b3cb4cada18ae61811304f8fbcb5
ai.workspace.status.idle: e599161956d626eda4cb0a5ffb85271c
ai.workspace.status.running: 5bda814c4aedb126839228f1a3d92f09

View File

@@ -4,6 +4,7 @@
"description": "enables the default permissions",
"windows": [
"main",
"ai-workspace",
"note-*"
],
"platforms": ["linux", "macOS", "windows"],

View File

@@ -65,6 +65,14 @@ const SUPPORTED_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[
("vi-vn", "vi"),
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AiWorkspaceConversationSetting {
pub archived: Option<bool>,
pub id: String,
pub target_id: Option<String>,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
pub auto_pull_interval_minutes: Option<u32>,
@@ -88,6 +96,7 @@ pub struct Settings {
pub default_ai_agent: Option<String>,
pub default_ai_target: Option<String>,
pub ai_model_providers: Option<Vec<AiModelProvider>>,
pub ai_workspace_conversations: Option<Vec<AiWorkspaceConversationSetting>>,
pub hide_gitignored_files: Option<bool>,
pub all_notes_show_pdfs: Option<bool>,
pub all_notes_show_images: Option<bool>,
@@ -203,6 +212,9 @@ fn normalize_settings(settings: Settings) -> Settings {
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
default_ai_target: normalize_optional_string(settings.default_ai_target),
ai_model_providers: normalize_ai_model_providers(settings.ai_model_providers),
ai_workspace_conversations: normalize_ai_workspace_conversations(
settings.ai_workspace_conversations,
),
hide_gitignored_files: settings.hide_gitignored_files,
all_notes_show_pdfs: settings.all_notes_show_pdfs,
all_notes_show_images: settings.all_notes_show_images,
@@ -211,6 +223,36 @@ fn normalize_settings(settings: Settings) -> Settings {
}
}
fn normalize_ai_workspace_conversations(
conversations: Option<Vec<AiWorkspaceConversationSetting>>,
) -> Option<Vec<AiWorkspaceConversationSetting>> {
let normalized: Vec<AiWorkspaceConversationSetting> = conversations
.unwrap_or_default()
.into_iter()
.filter_map(|conversation| {
let id = conversation.id.trim().to_string();
let title = conversation.title.trim().to_string();
if id.is_empty() || title.is_empty() {
return None;
}
Some(AiWorkspaceConversationSetting {
archived: conversation.archived,
id,
target_id: normalize_optional_string(conversation.target_id),
title,
})
})
.take(100)
.collect();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn app_config_dir() -> Result<PathBuf, String> {
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
}
@@ -357,6 +399,7 @@ mod tests {
default_ai_agent: Some("codex".to_string()),
default_ai_target: Some("agent:codex".to_string()),
ai_model_providers: None,
ai_workspace_conversations: None,
hide_gitignored_files: Some(false),
multi_workspace_enabled: Some(true),
all_notes_show_pdfs: Some(true),

View File

@@ -13,6 +13,7 @@ import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { AppAiWorkspaceSurface } from './components/AppAiWorkspaceSurface'
import { AiWorkspaceWindowApp } from './components/AiWorkspaceWindowApp'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { FeedbackDialog } from './components/FeedbackDialog'
@@ -82,14 +83,14 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { GitSetupPreference, SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
import type { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
import type { NoteListItem } from './utils/ai-context'
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams, type NoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
@@ -132,6 +133,7 @@ import { useStartupScreenState } from './hooks/useStartupScreenState'
import { useGitFileWorkflows } from './hooks/useGitFileWorkflows'
import { useAutoGitWork } from './hooks/useAutoGitWork'
import { useAppAiWorkspaceBridge } from './hooks/useAppAiWorkspaceBridge'
import type { AiWorkspaceWindowContext } from './utils/openAiWorkspaceWindow'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
@@ -240,10 +242,25 @@ function shouldPreserveViewRootPath(views: ViewFile[], editingRootPath?: string)
return Boolean(editingRootPath) || views.some((view) => view.rootPath)
}
function aiWorkspaceWindowContextForPath(resolvedPath: string): AiWorkspaceWindowContext {
return {
vaultPath: resolvedPath,
vaultPaths: resolvedPath ? [resolvedPath] : [],
}
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
const aiWorkspaceWindow = useMemo(() => isAiWorkspaceWindow(), [])
if (aiWorkspaceWindow) return <AiWorkspaceWindowApp />
return <MainApp noteWindowParams={noteWindowParams} />
}
function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | null }) {
const aiWorkspaceWindow = false
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const selectionRef = useRef<SidebarSelection>(DEFAULT_SELECTION)
@@ -345,6 +362,7 @@ function App() {
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
const aiWorkspaceWindowContext = aiWorkspaceWindowContextForPath(resolvedPath)
const [settingsInitialSectionId, setSettingsInitialSectionId] = useState<string | null>(null)
const {
effectiveShowAIChat,
@@ -360,6 +378,7 @@ function App() {
openSettings: dialogs.openSettings,
setSettingsInitialSectionId,
showAIChat,
windowContext: aiWorkspaceWindowContext,
})
const {
folderVaults,
@@ -1723,7 +1742,10 @@ function App() {
vaultIsLoading: vault.isLoading,
vaultSwitcher,
})
const aiWorkspaceSurface = <AppAiWorkspaceSurface mode={aiWorkspaceWindow ? 'window' : 'docked'} open={aiWorkspaceWindow || effectiveShowAIChat} aiAgentsStatus={aiAgentsStatus} aiModelProviders={settings.ai_model_providers ?? []} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={aiAgentPreferences.defaultAiTarget} defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness} defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady} activeEntry={activeTab?.entry ?? null} activeNoteContent={activeTab?.content ?? null} entries={visibleEntries} openTabs={notes.tabs.map((tab) => tab.entry)} noteList={aiNoteList} noteListFilter={aiNoteListFilter} onClose={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : closeAIChat} onDock={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : undefined} onPopOut={aiWorkspaceWindow ? undefined : handlePopOutAiWorkspace} onOpenAiSettings={aiWorkspaceWindow ? undefined : handleOpenAiSettings} onOpenNote={notes.handleNavigateWikilink} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} onUnsupportedAiPaste={setToastMessage} onFileCreated={vaultBridge.handleAgentFileCreated} onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} vaultAiGuidanceStatus={vaultAiGuidanceStatus} vaultPath={activeEditorVaultPath} vaultPaths={writableVaultPaths} locale={appLocale} />
const handleAiWorkspaceConversationsChange = useCallback((conversations: AiWorkspaceConversationSetting[]) => {
void saveSettings({ ...settings, ai_workspace_conversations: conversations })
}, [saveSettings, settings])
const aiWorkspaceSurface = <AppAiWorkspaceSurface mode={aiWorkspaceWindow ? 'window' : 'docked'} open={aiWorkspaceWindow || effectiveShowAIChat} aiAgentsStatus={aiAgentsStatus} aiModelProviders={settings.ai_model_providers ?? []} conversationSettings={settings.ai_workspace_conversations ?? null} conversationSettingsReady={settingsLoaded} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={aiAgentPreferences.defaultAiTarget} defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness} defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady} activeEntry={activeTab?.entry ?? null} activeNoteContent={activeTab?.content ?? null} entries={visibleEntries} openTabs={notes.tabs.map((tab) => tab.entry)} noteList={aiNoteList} noteListFilter={aiNoteListFilter} onClose={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : closeAIChat} onConversationSettingsChange={handleAiWorkspaceConversationsChange} onDock={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : undefined} onPopOut={aiWorkspaceWindow ? undefined : handlePopOutAiWorkspace} onOpenAiSettings={aiWorkspaceWindow ? undefined : handleOpenAiSettings} onOpenNote={notes.handleNavigateWikilink} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} onUnsupportedAiPaste={setToastMessage} onFileCreated={vaultBridge.handleAgentFileCreated} onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} vaultAiGuidanceStatus={vaultAiGuidanceStatus} vaultPath={activeEditorVaultPath} vaultPaths={writableVaultPaths} locale={appLocale} />
if (shouldShowStartupScreen) {
return (
<StartupScreen

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useCallback, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react'
import {
AiPanelComposer,
AiPanelContextBar,
@@ -58,12 +58,77 @@ interface AiPanelViewProps {
entries?: VaultEntry[]
interactive?: boolean
showHeader?: boolean
composerControls?: ReactNode
onSendPrompt?: (text: string) => void
}
function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
return (ready ?? true) ? 'ready' : 'missing'
}
interface AiPanelViewModel {
agentLabel: string
defaultAiAgent: AiAgentId
defaultAiAgentReadiness: AiAgentReadiness
targetKind: AiTarget['kind']
}
function resolveAiPanelViewModel({
defaultAiAgent,
defaultAiAgentReadiness,
defaultAiAgentReady,
defaultAiTarget,
}: {
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
defaultAiTarget?: AiTarget
}): AiPanelViewModel {
const resolvedAgent = defaultAiAgent ?? DEFAULT_AI_AGENT
const resolvedReadiness = defaultAiAgentReadiness ?? readinessFromReadyFlag(defaultAiAgentReady)
return {
agentLabel: defaultAiTarget?.label ?? getAiAgentDefinition(resolvedAgent).label,
defaultAiAgent: resolvedAgent,
defaultAiAgentReadiness: resolvedReadiness,
targetKind: defaultAiTarget?.kind ?? 'agent',
}
}
function aiPanelFrameStyle(isActive: boolean): CSSProperties {
return {
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
}
}
function AiPanelFrame({
children,
isActive,
panelRef,
}: {
children: ReactNode
isActive: boolean
panelRef: RefObject<HTMLElement | null>
}) {
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={aiPanelFrameStyle(isActive)}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
{children}
</aside>
)
}
export function AiPanelView({
controller,
onClose,
@@ -78,15 +143,17 @@ export function AiPanelView({
entries,
interactive = true,
showHeader = true,
composerControls,
onSendPrompt,
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const view = resolveAiPanelViewModel({
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
defaultAiTarget,
})
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const activeTarget = defaultAiTarget
const agentLabel = activeTarget?.label ?? getAiAgentDefinition(defaultAiAgent).label
const targetKind = activeTarget?.kind ?? 'agent'
const {
agent,
input,
@@ -110,28 +177,19 @@ export function AiPanelView({
onClose,
enabled: interactive,
})
const handleComposerSend = useCallback((text: string, references: Parameters<typeof handleSend>[1]) => {
if (!text.trim() || isActive) return
onSendPrompt?.(text)
handleSend(text, references)
}, [handleSend, isActive, onSendPrompt])
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={{
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
}}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<AiPanelFrame panelRef={panelRef} isActive={isActive}>
{showHeader && (
<AiPanelHeader
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
targetKind={targetKind}
agentLabel={view.agentLabel}
agentReadiness={view.defaultAiAgentReadiness}
targetKind={view.targetKind}
locale={locale}
permissionMode={permissionMode}
permissionModeDisabled={isActive}
@@ -144,8 +202,8 @@ export function AiPanelView({
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} locale={locale} />
)}
<AiPanelMessageHistory
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
agentLabel={view.agentLabel}
agentReadiness={view.defaultAiAgentReadiness}
locale={locale}
messages={agent.messages}
isActive={isActive}
@@ -155,17 +213,18 @@ export function AiPanelView({
/>
<AiPanelComposer
entries={entries ?? []}
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
agentLabel={view.agentLabel}
agentReadiness={view.defaultAiAgentReadiness}
locale={locale}
input={input}
inputRef={inputRef}
isActive={isActive}
controls={composerControls}
onChange={setInput}
onSend={handleSend}
onSend={handleComposerSend}
onUnsupportedAiPaste={onUnsupportedAiPaste}
/>
</aside>
</AiPanelFrame>
)
}

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useRef } from 'react'
import { memo, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
@@ -15,6 +15,7 @@ import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
interface AiPanelHeaderProps {
agentLabel: string
@@ -53,6 +54,7 @@ interface AiPanelComposerProps {
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
controls?: ReactNode
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedAiPaste?: (message: string) => void
@@ -74,6 +76,109 @@ function getComposerPlaceholder(
return t('ai.panel.placeholder.ready', { agent: agentLabel })
}
function composerSendButtonStyle(canSend: boolean): CSSProperties {
return {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
borderRadius: 8,
width: 32,
height: 34,
cursor: canSend ? 'pointer' : 'not-allowed',
}
}
function ComposerInput({
disabled,
entries,
hasControls,
input,
inputRef,
onChange,
onSend,
onUnsupportedAiPaste,
placeholder,
}: {
disabled: boolean
entries: VaultEntry[]
hasControls: boolean
input: string
inputRef: React.RefObject<HTMLDivElement | null>
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedAiPaste?: (message: string) => void
placeholder: string
}) {
return (
<WikilinkChatInput
entries={entries}
value={input}
onChange={onChange}
onSend={onSend}
onUnsupportedPaste={onUnsupportedAiPaste}
disabled={disabled}
placeholder={placeholder}
inputRef={inputRef}
editorClassName={cn(
'max-h-[160px] overflow-y-auto overscroll-contain',
hasControls && 'min-h-[72px] border-0 px-2 py-2',
)}
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
/>
)
}
function ComposerSendButton({
canSend,
entries,
input,
label,
onSend,
}: {
canSend: boolean
entries: VaultEntry[]
input: string
label: string
onSend: (text: string, references: NoteReference[]) => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={composerSendButtonStyle(canSend)}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
aria-label={label}
title={label}
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</Button>
)
}
function ComposerControlsRow({
children,
hasControls,
sendButton,
}: {
children?: ReactNode
hasControls: boolean
sendButton: ReactNode
}) {
if (!hasControls) return <>{sendButton}</>
return (
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
{children}
</div>
{sendButton}
</div>
)
}
function permissionModeTooltip(
mode: AiAgentPermissionMode,
t: ReturnType<typeof createTranslator>,
@@ -360,6 +465,7 @@ export function AiPanelComposer({
input,
inputRef,
isActive,
controls,
onChange,
onSend,
onUnsupportedAiPaste,
@@ -368,49 +474,41 @@ export function AiPanelComposer({
const composerDisabled = isActive || agentReadiness !== 'ready'
const canSend = !composerDisabled && input.trim().length > 0
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness, t)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
borderRadius: 8,
width: 32,
height: 34,
cursor: canSend ? 'pointer' : 'not-allowed',
} as const
const hasControls = controls !== undefined && controls !== null
const sendButton = (
<ComposerSendButton
canSend={canSend}
entries={entries}
input={input}
label={t('ai.panel.send')}
onSend={onSend}
/>
)
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<WikilinkChatInput
<div className={cn(
hasControls ? 'rounded-xl border border-border bg-background p-2 shadow-xs' : 'flex items-end gap-2',
)}>
<div className={cn('min-w-0 flex-1', hasControls && 'w-full')}>
<ComposerInput
disabled={composerDisabled}
entries={entries}
value={input}
hasControls={hasControls}
input={input}
inputRef={inputRef}
onChange={onChange}
onSend={onSend}
onUnsupportedPaste={onUnsupportedAiPaste}
disabled={composerDisabled}
onUnsupportedAiPaste={onUnsupportedAiPaste}
placeholder={placeholder}
inputRef={inputRef}
editorClassName="max-h-[160px] overflow-y-auto overscroll-contain"
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={sendButtonStyle}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
aria-label={t('ai.panel.send')}
title={t('ai.panel.send')}
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</Button>
<ComposerControlsRow hasControls={hasControls} sendButton={sendButton}>
{controls}
</ComposerControlsRow>
</div>
</div>
)

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { AiWorkspace } from './AiWorkspace'
import { buildAiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
import {
@@ -8,13 +9,16 @@ import {
type AiAgentsStatus,
} from '../lib/aiAgents'
import type { AiModelProvider } from '../lib/aiTargets'
import type { AgentStatus } from '../hooks/useCliAiAgent'
import { resetVaultConfigStore } from '../utils/vaultConfigStore'
let mockedAgentStatus: AgentStatus = 'idle'
vi.mock('./useAiPanelController', () => ({
useAiPanelController: () => ({
agent: {
messages: [],
status: 'idle',
status: mockedAgentStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
addLocalMarker: vi.fn(),
@@ -33,8 +37,21 @@ vi.mock('./useAiPanelController', () => ({
}))
vi.mock('./AiPanel', () => ({
AiPanelView: ({ showHeader }: { showHeader?: boolean }) => (
<div data-testid="ai-panel-view" data-show-header={String(showHeader)}>Chat surface</div>
AiPanelView: ({
composerControls,
onSendPrompt,
showHeader,
}: {
composerControls?: ReactNode
onSendPrompt?: (prompt: string) => void
showHeader?: boolean
}) => (
<div data-testid="ai-panel-view" data-show-header={String(showHeader)}>
<button type="button" onClick={() => onSendPrompt?.('summarize quarterly sponsor outreach')}>
Send mocked prompt
</button>
{composerControls}
</div>
),
}))
@@ -75,6 +92,7 @@ const providers: AiModelProvider[] = [
describe('AiWorkspace', () => {
beforeEach(() => {
mockedAgentStatus = 'idle'
resetVaultConfigStore()
})
@@ -119,4 +137,63 @@ describe('AiWorkspace', () => {
expect(within(menu).getByText('Ollama · Llama 3.2')).toBeTruthy()
expect(within(menu).getByText('OpenAI · GPT-4.1')).toBeTruthy()
})
it('renames the first chat from the first prompt and stores conversation settings', () => {
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ title: 'Summarize Quarterly Sponsor Outreach' }),
])
})
it('renames a persisted default chat title from the first prompt', () => {
const onConversationSettingsChange = vi.fn()
render(
<AiWorkspace
open
mode="docked"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[{ id: 'stored-chat', title: 'Chat 1', target_id: null, archived: false }]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onConversationSettingsChange={onConversationSettingsChange}
/>,
)
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'stored-chat', title: 'Summarize Quarterly Sponsor Outreach' }),
])
})
it('allows a chat title to be renamed from the sidebar', () => {
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
fireEvent.doubleClick(screen.getByRole('button', { name: /chat 1/i }))
const input = screen.getByLabelText('Rename chat')
fireEvent.change(input, { target: { value: 'Sponsor Plan' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.getAllByText('Sponsor Plan').length).toBeGreaterThan(0)
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ title: 'Sponsor Plan' }),
])
})
it('collapses the workspace sidebar from the sidebar header', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Collapse AI chat list' }))
expect(screen.queryByText('AI workspace')).toBeNull()
expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy()
})
})

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
Archive,
ArrowSquareIn,
ArrowSquareOut,
CaretDown,
GearSix,
Plus,
Robot,
WarningCircle,
X,
} from '@phosphor-icons/react'
@@ -47,19 +45,24 @@ import {
type VaultAiGuidanceStatus,
} from '../lib/vaultAiGuidance'
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
import { trackAiWorkspaceChatTitled, trackAiWorkspaceSidebarToggled } from '../lib/productAnalytics'
import type { AgentStatus } from '../hooks/useCliAiAgent'
import type { AiWorkspaceConversationSetting } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
import { generateAiConversationTitle } from '../utils/aiConversationTitle'
import { AiPanelView } from './AiPanel'
import { ConversationSidebar } from './AiWorkspaceSidebar'
import { useAiPanelController } from './useAiPanelController'
import { buildAiWorkspaceTargetGroups, type AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
interface AiConversation {
export interface AiConversation {
archived: boolean
id: string
targetId: string
title: string
usesDefaultTitle: boolean
usesDefaultTarget: boolean
}
@@ -68,6 +71,8 @@ interface AiWorkspaceProps {
activeNoteContent?: string | null
aiAgentsStatus: AiAgentsStatus
aiModelProviders?: AiModelProvider[]
conversationSettings?: AiWorkspaceConversationSetting[] | null
conversationSettingsReady?: boolean
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
@@ -78,6 +83,7 @@ interface AiWorkspaceProps {
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onClose: () => void
onConversationSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
onDock?: () => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -94,22 +100,34 @@ interface AiWorkspaceProps {
vaultPaths?: string[]
}
let fallbackConversationIdCounter = 0
function randomConversationIdPart(): string {
const cryptoApi = globalThis.crypto
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID().slice(0, 8)
if (typeof cryptoApi?.getRandomValues === 'function') {
const values = new Uint32Array(2)
cryptoApi.getRandomValues(values)
return Array.from(values, (value) => value.toString(36)).join('').slice(0, 8)
}
fallbackConversationIdCounter += 1
return fallbackConversationIdCounter.toString(36).padStart(4, '0')
}
function nextConversationId(): string {
return `ai-chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
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'
if (status === 'done') return 'ai.workspace.status.done'
return 'ai.workspace.status.idle'
}
function statusDotClassName(status: AgentStatus | undefined): string {
if (status === 'thinking' || status === 'tool-executing') return 'bg-blue-500'
if (status === 'error') return 'bg-destructive'
if (status === 'done') return 'bg-green-600'
return 'bg-muted-foreground'
function isRunningStatus(status: AgentStatus | undefined): boolean {
return status === 'thinking' || status === 'tool-executing'
}
function agentReadinessForTarget(target: AiTarget, statuses: AiAgentsStatus): AiAgentReadiness {
@@ -142,189 +160,198 @@ function createConversation(locale: AppLocale, target: AiTarget, index: number):
id: nextConversationId(),
targetId: target.id,
title: translate(locale, 'ai.workspace.chatTitle', { index }),
usesDefaultTitle: true,
usesDefaultTarget: true,
}
}
function useConversations(locale: AppLocale, fallbackTarget: AiTarget) {
const [conversations, setConversations] = useState<AiConversation[]>(() => [
createConversation(locale, fallbackTarget, 1),
])
function isDefaultConversationTitle(title: string): boolean {
return /^Chat\s+\d+$/i.test(title.trim())
}
function conversationFromSetting(setting: AiWorkspaceConversationSetting, fallbackTarget: AiTarget): AiConversation | null {
const id = setting.id.trim()
const title = setting.title.trim()
if (!id || !title) return null
return {
archived: setting.archived === true,
id,
targetId: setting.target_id?.trim() || fallbackTarget.id,
title,
usesDefaultTitle: isDefaultConversationTitle(title),
usesDefaultTarget: !setting.target_id,
}
}
function conversationsFromSettings(
settings: AiWorkspaceConversationSetting[] | null | undefined,
fallbackTarget: AiTarget,
locale: AppLocale,
): AiConversation[] {
const stored = (settings ?? [])
.map((setting) => conversationFromSetting(setting, fallbackTarget))
.filter((conversation): conversation is AiConversation => conversation !== null)
return stored.length > 0 ? stored : [createConversation(locale, fallbackTarget, 1)]
}
function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceConversationSetting[] {
return conversations.map((conversation) => ({
archived: conversation.archived,
id: conversation.id,
target_id: conversation.usesDefaultTarget ? null : conversation.targetId,
title: conversation.title,
}))
}
interface UseConversationsOptions {
fallbackTarget: AiTarget
locale: AppLocale
onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
settings?: AiWorkspaceConversationSetting[] | null
settingsReady: boolean
}
function appendConversationState(
current: AiConversation[],
locale: AppLocale,
target: AiTarget,
): { activeId: string; conversations: AiConversation[] } {
const next = createConversation(locale, target, current.length + 1)
return {
activeId: next.id,
conversations: [...current, next],
}
}
function archiveConversationState(
current: AiConversation[],
id: string,
): { activeId?: string; conversations: AiConversation[] } {
const conversations = current.map((conversation) => (
conversation.id === id ? { ...conversation, archived: true } : conversation
))
const fallback = conversations.find((conversation) => !conversation.archived && conversation.id !== id)
return { activeId: fallback?.id, conversations }
}
function restoreConversationState(current: AiConversation[], id: string): AiConversation[] {
return current.map((conversation) => (
conversation.id === id ? { ...conversation, archived: false } : conversation
))
}
function retargetConversationState(current: AiConversation[], id: string, targetId: string): AiConversation[] {
return current.map((conversation) => (
conversation.id === id ? { ...conversation, targetId, usesDefaultTarget: false } : conversation
))
}
function renameConversationState(current: AiConversation[], id: string, title: string): AiConversation[] {
const nextTitle = title.trim()
if (!nextTitle) return current
return current.map((conversation) => (
conversation.id === id ? { ...conversation, title: nextTitle, usesDefaultTitle: false } : conversation
))
}
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
))
}
function updateDefaultConversationTargetState(current: AiConversation[], targetId: string): AiConversation[] {
return current.map((conversation) => (
conversation.usesDefaultTarget && conversation.targetId !== targetId
? { ...conversation, targetId }
: conversation
))
}
function useConversations({
fallbackTarget,
locale,
onSettingsChange,
settings,
settingsReady,
}: UseConversationsOptions) {
const [conversations, setConversations] = useState<AiConversation[]>(() => (
conversationsFromSettings(settings, fallbackTarget, locale)
))
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
const [showArchived, setShowArchived] = useState(false)
const onSettingsChangeRef = useRef(onSettingsChange)
const addConversation = useCallback((target: AiTarget) => {
setConversations((current) => {
const next = createConversation(locale, target, current.length + 1)
setActiveId(next.id)
return [...current, next]
const next = appendConversationState(current, locale, target)
setActiveId(next.activeId)
return next.conversations
})
}, [locale])
const archiveConversation = useCallback((id: string) => {
setConversations((current) => {
const next = current.map((conversation) => (
conversation.id === id ? { ...conversation, archived: true } : conversation
))
const fallback = next.find((conversation) => !conversation.archived && conversation.id !== id)
if (fallback) setActiveId(fallback.id)
return next
const next = archiveConversationState(current, id)
if (next.activeId) setActiveId(next.activeId)
return next.conversations
})
}, [])
const restoreConversation = useCallback((id: string) => {
setConversations((current) => current.map((conversation) => (
conversation.id === id ? { ...conversation, archived: false } : conversation
)))
setConversations((current) => restoreConversationState(current, id))
setActiveId(id)
setShowArchived(false)
}, [])
const setConversationTarget = useCallback((id: string, targetId: string) => {
setConversations((current) => current.map((conversation) => (
conversation.id === id ? { ...conversation, targetId, usesDefaultTarget: false } : conversation
)))
setConversations((current) => retargetConversationState(current, id, targetId))
}, [])
const renameConversation = useCallback((id: string, title: string) => {
setConversations((current) => renameConversationState(current, id, title))
}, [])
const titleConversationFromPrompt = useCallback((id: string, prompt: string) => {
setConversations((current) => titleConversationFromPromptState(current, id, prompt))
}, [])
const updateDefaultConversationTargets = useCallback((targetId: string) => {
setConversations((current) => current.map((conversation) => (
conversation.usesDefaultTarget && conversation.targetId !== targetId
? { ...conversation, targetId }
: conversation
)))
setConversations((current) => updateDefaultConversationTargetState(current, targetId))
}, [])
useEffect(() => {
onSettingsChangeRef.current = onSettingsChange
}, [onSettingsChange])
useEffect(() => {
if (!settingsReady) return
onSettingsChangeRef.current?.(conversationsToSettings(conversations))
}, [conversations, settingsReady])
return {
activeId,
addConversation,
archiveConversation,
conversations,
renameConversation,
restoreConversation,
setActiveId,
setConversationTarget,
setShowArchived,
showArchived,
titleConversationFromPrompt,
updateDefaultConversationTargets,
}
}
function SidebarHeader({ locale, onNewChat }: { locale: AppLocale; onNewChat: () => void }) {
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
return (
<div
ref={dragRegionRef}
className="flex h-12 shrink-0 items-center justify-between border-b border-border px-3"
data-tauri-drag-region
data-testid="ai-workspace-sidebar-header"
>
<div className="flex min-w-0 items-center gap-2">
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="truncate text-[13px] font-semibold text-foreground">
{translate(locale, 'ai.workspace.title')}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, 'ai.workspace.newChat')}
title={translate(locale, 'ai.workspace.newChat')}
data-testid="ai-workspace-sidebar-new-chat"
onClick={onNewChat}
>
<Plus size={15} />
</Button>
</div>
)
}
function ConversationSidebar({
activeId,
conversations,
locale,
onArchive,
onNewChat,
onRestore,
onSelect,
setShowArchived,
showArchived,
statuses,
}: {
activeId: string
conversations: AiConversation[]
locale: AppLocale
onArchive: (id: string) => void
onNewChat: () => void
onRestore: (id: string) => void
onSelect: (id: string) => void
setShowArchived: (show: boolean) => void
showArchived: boolean
statuses: Record<string, AgentStatus>
}) {
const visibleConversations = conversations.filter((conversation) => conversation.archived === showArchived)
const emptyLabel = showArchived
? translate(locale, 'ai.workspace.noArchivedChats')
: translate(locale, 'ai.workspace.noActiveChats')
return (
<div className="flex w-[220px] shrink-0 flex-col border-r border-border bg-sidebar">
<SidebarHeader locale={locale} onNewChat={onNewChat} />
<div className="flex-1 overflow-y-auto p-2">
{visibleConversations.length === 0 ? (
<div className="px-2 py-4 text-[12px] text-muted-foreground">{emptyLabel}</div>
) : visibleConversations.map((conversation) => {
const status = statuses[conversation.id] ?? 'idle'
const active = conversation.id === activeId
return (
<div key={conversation.id} className="group flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'min-w-0 flex-1 justify-start rounded-md px-2 text-left text-[12px]',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
aria-pressed={active}
onClick={() => onSelect(conversation.id)}
>
<span className={cn('h-2 w-2 rounded-full', statusDotClassName(status))} />
<span className="truncate">{conversation.title}</span>
<span className="ml-auto truncate text-[10px] opacity-70">
{translate(locale, statusLabelKey(status))}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, showArchived ? 'ai.workspace.restore' : 'ai.workspace.archive')}
title={translate(locale, showArchived ? 'ai.workspace.restore' : 'ai.workspace.archive')}
onClick={() => showArchived ? onRestore(conversation.id) : onArchive(conversation.id)}
>
{showArchived ? <ArrowSquareIn size={14} /> : <Archive size={14} />}
</Button>
</div>
)
})}
</div>
<div className="border-t border-border p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-[12px] text-muted-foreground"
onClick={() => setShowArchived(!showArchived)}
>
<Archive size={14} />
{translate(locale, showArchived ? 'ai.workspace.hideArchived' : 'ai.workspace.showArchived')}
</Button>
</div>
</div>
)
}
function TargetGroup({ label, targets }: { label: string; targets: AiTarget[] }) {
if (targets.length === 0) return null
@@ -340,13 +367,83 @@ function TargetGroup({ label, targets }: { label: string; targets: AiTarget[] })
)
}
function TargetPickerTrigger({
compact,
disabled,
hasTargets,
locale,
selectedTarget,
}: {
compact: boolean
disabled: boolean
hasTargets: boolean
locale: AppLocale
selectedTarget: AiTarget
}) {
return (
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={compact ? 'ghost' : 'outline'}
size={compact ? 'xs' : 'sm'}
className={cn(
'justify-between gap-1.5',
compact ? 'max-w-[150px] rounded-full px-2 text-[12px]' : 'max-w-[240px] gap-2',
)}
disabled={disabled || !hasTargets}
aria-label={translate(locale, 'ai.workspace.targetLabel')}
data-testid="ai-workspace-target-trigger"
>
<span className="truncate">{selectedTarget.shortLabel}</span>
<CaretDown size={compact ? 12 : 13} />
</Button>
</DropdownMenuTrigger>
)
}
function TargetPickerContent({
groups,
hasTargets,
locale,
onSelectTarget,
selectedTarget,
}: {
groups: AiWorkspaceTargetGroups
hasTargets: boolean
locale: AppLocale
selectedTarget: AiTarget
onSelectTarget: (targetId: string) => void
}) {
const hasLocalAgentsSeparator = groups.localAgents.length > 0
&& (groups.localModels.length > 0 || groups.apiModels.length > 0)
const hasLocalModelsSeparator = groups.localModels.length > 0 && groups.apiModels.length > 0
return (
<DropdownMenuContent align="start" className="min-w-[280px]">
{hasTargets ? (
<DropdownMenuRadioGroup value={selectedTarget.id} onValueChange={onSelectTarget}>
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalAgents')} targets={groups.localAgents} />
{hasLocalAgentsSeparator && <DropdownMenuSeparator />}
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalModels')} targets={groups.localModels} />
{hasLocalModelsSeparator && <DropdownMenuSeparator />}
<TargetGroup label={translate(locale, 'ai.workspace.targetApiModels')} targets={groups.apiModels} />
</DropdownMenuRadioGroup>
) : (
<DropdownMenuItem disabled>{translate(locale, 'ai.workspace.noTargets')}</DropdownMenuItem>
)}
</DropdownMenuContent>
)
}
function TargetPicker({
compact = false,
disabled,
groups,
locale,
selectedTarget,
onSelectTarget,
}: {
compact?: boolean
disabled: boolean
groups: AiWorkspaceTargetGroups
locale: AppLocale
@@ -357,44 +454,33 @@ function TargetPicker({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="max-w-[240px] justify-between gap-2"
disabled={disabled || !hasTargets}
aria-label={translate(locale, 'ai.workspace.targetLabel')}
data-testid="ai-workspace-target-trigger"
>
<span className="truncate">{selectedTarget.shortLabel}</span>
<CaretDown size={13} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
{hasTargets ? (
<DropdownMenuRadioGroup value={selectedTarget.id} onValueChange={onSelectTarget}>
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalAgents')} targets={groups.localAgents} />
{groups.localAgents.length > 0 && (groups.localModels.length > 0 || groups.apiModels.length > 0) && <DropdownMenuSeparator />}
<TargetGroup label={translate(locale, 'ai.workspace.targetLocalModels')} targets={groups.localModels} />
{groups.localModels.length > 0 && groups.apiModels.length > 0 && <DropdownMenuSeparator />}
<TargetGroup label={translate(locale, 'ai.workspace.targetApiModels')} targets={groups.apiModels} />
</DropdownMenuRadioGroup>
) : (
<DropdownMenuItem disabled>{translate(locale, 'ai.workspace.noTargets')}</DropdownMenuItem>
)}
</DropdownMenuContent>
<TargetPickerTrigger
compact={compact}
disabled={disabled}
hasTargets={hasTargets}
locale={locale}
selectedTarget={selectedTarget}
/>
<TargetPickerContent
groups={groups}
hasTargets={hasTargets}
locale={locale}
selectedTarget={selectedTarget}
onSelectTarget={onSelectTarget}
/>
</DropdownMenu>
)
}
function PermissionPicker({
compact = false,
disabled,
locale,
permissionMode,
targetKind,
onChange,
}: {
compact?: boolean
disabled: boolean
locale: AppLocale
permissionMode: AiAgentPermissionMode
@@ -403,7 +489,7 @@ function PermissionPicker({
}) {
if (targetKind === 'api_model') {
return (
<Button type="button" variant="outline" size="sm" disabled className="text-muted-foreground">
<Button type="button" variant={compact ? 'ghost' : 'outline'} size={compact ? 'xs' : 'sm'} disabled className="rounded-full px-2 text-[12px] text-muted-foreground">
{translate(locale, 'ai.panel.mode.chat')}
</Button>
)
@@ -414,15 +500,15 @@ function PermissionPicker({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="justify-between gap-2"
variant={compact ? 'ghost' : 'outline'}
size={compact ? 'xs' : 'sm'}
className={cn('justify-between', compact ? 'rounded-full px-2 text-[12px]' : 'gap-2')}
disabled={disabled}
aria-label={translate(locale, 'ai.workspace.permissionMode')}
data-testid="ai-workspace-permission-trigger"
>
{aiAgentPermissionModeLabels(permissionMode, locale).control}
<CaretDown size={13} />
<CaretDown size={compact ? 12 : 13} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[180px]">
@@ -464,87 +550,61 @@ function GuidanceWarning({
function WorkspaceHeader({
conversation,
groups,
locale,
mode,
permissionMode,
selectedTarget,
status,
onArchive,
onClose,
onDock,
onOpenAiSettings,
onPermissionModeChange,
onPopOut,
onSelectTarget,
}: {
conversation: AiConversation
groups: AiWorkspaceTargetGroups
locale: AppLocale
mode: 'docked' | 'window'
permissionMode: AiAgentPermissionMode
selectedTarget: AiTarget
status: AgentStatus
onArchive: () => void
onClose: () => void
onDock?: () => void
onOpenAiSettings?: () => void
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
onPopOut?: () => void
onSelectTarget: (targetId: string) => void
}) {
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const running = status === 'thinking' || status === 'tool-executing'
return (
<div
ref={dragRegionRef}
className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-3"
data-tauri-drag-region
data-testid="ai-workspace-chat-header"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="min-w-[92px] max-w-[160px]">
<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>
<TargetPicker
disabled={running}
groups={groups}
locale={locale}
selectedTarget={selectedTarget}
onSelectTarget={onSelectTarget}
/>
<PermissionPicker
disabled={running}
locale={locale}
permissionMode={permissionMode}
targetKind={selectedTarget.kind}
onChange={onPermissionModeChange}
/>
</div>
<div className="flex items-center gap-1">
{onOpenAiSettings && (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.settings')} title={translate(locale, 'ai.workspace.settings')} onClick={onOpenAiSettings}>
<GearSix size={15} />
<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}>
<Archive size={15} />
<Archive size={16} />
</Button>
{mode === 'docked' ? (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.popOut')} title={translate(locale, 'ai.workspace.popOut')} onClick={onPopOut}>
<ArrowSquareOut size={15} />
<ArrowSquareOut size={16} />
</Button>
) : (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.dock')} title={translate(locale, 'ai.workspace.dock')} onClick={onDock}>
<ArrowSquareIn size={15} />
<ArrowSquareIn size={16} />
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.close')} title={translate(locale, 'ai.workspace.close')} onClick={onClose}>
<X size={15} />
<X size={16} />
</Button>
</div>
</div>
@@ -575,6 +635,7 @@ function ConversationSession({
onRestoreVaultAiGuidance,
onSelectTarget,
onStatusChange,
onTitleFromPrompt,
onUnsupportedAiPaste,
onVaultChanged,
openTabs,
@@ -606,6 +667,7 @@ function ConversationSession({
onRestoreVaultAiGuidance?: () => void
onSelectTarget: (targetId: string) => void
onStatusChange: (id: string, status: AgentStatus) => void
onTitleFromPrompt: (id: string, prompt: string) => void
onUnsupportedAiPaste?: (message: string) => void
onVaultChanged?: () => void
openTabs?: VaultEntry[]
@@ -634,6 +696,27 @@ function ConversationSession({
onFileModified,
onVaultChanged,
})
const running = isRunningStatus(controller.agent.status)
const composerControls = (
<>
<TargetPicker
compact
disabled={running}
groups={groups}
locale={locale}
selectedTarget={target}
onSelectTarget={onSelectTarget}
/>
<PermissionPicker
compact
disabled={running}
locale={locale}
permissionMode={controller.permissionMode}
targetKind={target.kind}
onChange={controller.handlePermissionModeChange}
/>
</>
)
useEffect(() => {
onStatusChange(conversation.id, controller.agent.status)
@@ -643,19 +726,14 @@ function ConversationSession({
<div className={active ? 'flex min-h-0 flex-1 flex-col' : 'hidden'} data-testid={`ai-workspace-session-${conversation.id}`}>
<WorkspaceHeader
conversation={conversation}
groups={groups}
locale={locale}
mode={mode}
permissionMode={controller.permissionMode}
selectedTarget={target}
status={controller.agent.status}
onArchive={onArchive}
onClose={onClose}
onDock={onDock}
onOpenAiSettings={onOpenAiSettings}
onPermissionModeChange={controller.handlePermissionModeChange}
onPopOut={onPopOut}
onSelectTarget={onSelectTarget}
/>
<GuidanceWarning locale={locale} onRestore={onRestoreVaultAiGuidance} status={vaultAiGuidanceStatus} />
<div className="flex min-h-0 flex-1">
@@ -667,10 +745,12 @@ function ConversationSession({
defaultAiTarget={target}
entries={entries}
activeEntry={activeEntry}
composerControls={composerControls}
interactive={active}
locale={locale}
onClose={onClose}
onOpenNote={onOpenNote}
onSendPrompt={(prompt) => onTitleFromPrompt(conversation.id, prompt)}
onUnsupportedAiPaste={onUnsupportedAiPaste}
showHeader={false}
/>
@@ -696,12 +776,16 @@ interface AiWorkspaceModel {
fallbackTarget: AiTarget
groups: AiWorkspaceTargetGroups
handleStatusChange: (id: string, status: AgentStatus) => void
renameConversation: (id: string, title: string) => void
restoreConversation: (id: string) => void
sidebarCollapsed: boolean
setActiveId: (id: string) => void
setConversationTarget: (id: string, targetId: string) => void
setShowArchived: (show: boolean) => void
showArchived: boolean
statuses: Record<string, AgentStatus>
titleConversationFromPrompt: (id: string, prompt: string) => void
toggleSidebarCollapsed: () => void
updateDefaultConversationTargets: (targetId: string) => void
}
@@ -722,6 +806,18 @@ function workspaceClassName(mode: 'docked' | 'window'): string {
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'
}
function workspaceStyle(mode: 'docked' | 'window'): CSSProperties | undefined {
if (mode === 'window') return undefined
return {
maxHeight: 'calc(100vh - 88px)',
maxWidth: 'calc(100vw - 32px)',
minHeight: 420,
minWidth: 520,
resize: 'both',
}
}
function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceModel {
const groups = useMemo(
() => buildAiWorkspaceTargetGroups(workspace.aiAgentsStatus, workspace.aiModelProviders),
@@ -736,14 +832,23 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
addConversation,
archiveConversation,
conversations,
renameConversation,
restoreConversation,
setActiveId,
setConversationTarget,
setShowArchived,
showArchived,
titleConversationFromPrompt,
updateDefaultConversationTargets,
} = useConversations(workspace.locale, fallbackTarget)
} = useConversations({
fallbackTarget,
locale: workspace.locale,
onSettingsChange: workspace.onConversationSettingsChange,
settings: workspace.conversationSettings,
settingsReady: workspace.conversationSettingsReady ?? true,
})
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]
@@ -761,6 +866,23 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
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 toggleSidebarCollapsed = useCallback(() => {
setSidebarCollapsed((current) => {
const next = !current
trackAiWorkspaceSidebarToggled(next, workspace.mode)
return next
})
}, [workspace.mode])
useEffect(() => {
if (!workspace.open) return
@@ -782,12 +904,16 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
fallbackTarget,
groups,
handleStatusChange,
renameConversation: trackedRenameConversation,
restoreConversation,
sidebarCollapsed,
setActiveId,
setConversationTarget,
setShowArchived,
showArchived,
statuses,
titleConversationFromPrompt: trackedTitleConversationFromPrompt,
toggleSidebarCollapsed,
updateDefaultConversationTargets,
}
}
@@ -796,6 +922,7 @@ function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; work
return (
<section
className={workspaceClassName(workspace.mode)}
style={workspaceStyle(workspace.mode)}
data-testid="ai-workspace"
data-ai-workspace-mode={workspace.mode}
role="dialog"
@@ -803,12 +930,15 @@ function AiWorkspaceLayout({ model, workspace }: { model: AiWorkspaceModel; work
>
<ConversationSidebar
activeId={model.activeId}
collapsed={model.sidebarCollapsed}
conversations={model.conversations}
locale={workspace.locale}
onArchive={model.archiveConversationSafely}
onNewChat={model.addDefaultConversation}
onRename={model.renameConversation}
onRestore={model.restoreConversation}
onSelect={model.setActiveId}
onToggleCollapsed={model.toggleSidebarCollapsed}
setShowArchived={model.setShowArchived}
showArchived={model.showArchived}
statuses={model.statuses}
@@ -852,6 +982,7 @@ function ConversationSessions({ model, workspace }: { model: AiWorkspaceModel; w
onRestoreVaultAiGuidance={workspace.onRestoreVaultAiGuidance}
onSelectTarget={(targetId) => model.setConversationTarget(conversation.id, targetId)}
onStatusChange={model.handleStatusChange}
onTitleFromPrompt={model.titleConversationFromPrompt}
onUnsupportedAiPaste={workspace.onUnsupportedAiPaste}
onVaultChanged={workspace.onVaultChanged}
openTabs={workspace.openTabs}

View File

@@ -0,0 +1,426 @@
import { useRef, useState } from 'react'
import {
Archive,
ArrowSquareIn,
Check,
CircleNotch,
Plus,
Robot,
SidebarSimple,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion'
import { translate, type AppLocale } from '../lib/i18n'
import type { AgentStatus } from '../hooks/useCliAiAgent'
import type { AiConversation } from './AiWorkspace'
interface ConversationSidebarProps {
activeId: string
collapsed: boolean
conversations: AiConversation[]
locale: AppLocale
onArchive: (id: string) => void
onNewChat: () => void
onRename: (id: string, title: string) => void
onRestore: (id: string) => void
onSelect: (id: string) => void
onToggleCollapsed: () => void
setShowArchived: (show: boolean) => void
showArchived: boolean
statuses: Record<string, AgentStatus>
}
function isRunningStatus(status: AgentStatus | undefined): boolean {
return status === 'thinking' || status === 'tool-executing'
}
function SidebarStatusIndicator({ status }: { status: AgentStatus | undefined }) {
if (isRunningStatus(status)) {
return <CircleNotch size={14} className="animate-spin text-muted-foreground" aria-hidden />
}
if (status === 'error') {
return <span className="h-2 w-2 rounded-full bg-destructive" aria-hidden />
}
return null
}
function SidebarHeader({
collapsed,
locale,
onNewChat,
onToggleCollapsed,
}: {
collapsed: boolean
locale: AppLocale
onNewChat: () => void
onToggleCollapsed: () => void
}) {
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
const collapseLabel = translate(locale, collapsed ? 'ai.workspace.expandSidebar' : 'ai.workspace.collapseSidebar')
return (
<div
ref={dragRegionRef}
className={cn(
'flex h-12 shrink-0 items-center border-b border-border px-2',
collapsed ? 'justify-center' : 'justify-between',
)}
data-testid="ai-workspace-sidebar-header"
>
<div className="flex min-w-0 items-center gap-2" data-no-drag>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={collapseLabel}
title={collapseLabel}
onClick={onToggleCollapsed}
>
<SidebarSimple size={16} />
</Button>
{!collapsed && (
<>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="truncate text-[13px] font-semibold text-foreground">
{translate(locale, 'ai.workspace.title')}
</span>
</>
)}
</div>
{!collapsed && (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, 'ai.workspace.newChat')}
title={translate(locale, 'ai.workspace.newChat')}
data-testid="ai-workspace-sidebar-new-chat"
data-no-drag
onClick={onNewChat}
>
<Plus size={16} />
</Button>
)}
</div>
)
}
function ConversationTitleEditor({
conversation,
locale,
onCancel,
onRename,
}: {
conversation: AiConversation
locale: AppLocale
onCancel: () => void
onRename: (title: string) => void
}) {
const [draft, setDraft] = useState(conversation.title)
const finishedRef = useRef(false)
const submit = () => {
if (finishedRef.current) return
const nextTitle = draft.trim()
if (!nextTitle) {
finishedRef.current = true
onCancel()
return
}
finishedRef.current = true
onRename(nextTitle)
onCancel()
}
const cancel = () => {
finishedRef.current = true
onCancel()
}
return (
<div className="flex min-w-0 flex-1 items-center gap-1 pr-7">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={submit}
onKeyDown={(event) => {
if (event.key === 'Enter') submit()
if (event.key === 'Escape') cancel()
}}
aria-label={translate(locale, 'ai.workspace.renameChat')}
className="h-7 min-w-0 flex-1 px-2 text-[12px]"
autoFocus
/>
<Check size={14} className="shrink-0 text-muted-foreground" />
</div>
)
}
function CollapsedConversationSidebar({ locale, onNewChat }: { locale: AppLocale; onNewChat: () => void }) {
return (
<div className="flex flex-1 flex-col items-center gap-2 p-2">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, 'ai.workspace.newChat')}
title={translate(locale, 'ai.workspace.newChat')}
data-testid="ai-workspace-sidebar-new-chat"
onClick={onNewChat}
>
<Plus size={16} />
</Button>
</div>
)
}
function ConversationArchiveButton({
conversationId,
locale,
onArchive,
onRestore,
showArchived,
}: {
conversationId: string
locale: AppLocale
onArchive: (id: string) => void
onRestore: (id: string) => void
showArchived: boolean
}) {
const label = translate(locale, showArchived ? 'ai.workspace.restore' : 'ai.workspace.archive')
return (
<Button
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"
aria-label={label}
title={label}
onClick={() => showArchived ? onRestore(conversationId) : onArchive(conversationId)}
>
{showArchived ? <ArrowSquareIn size={16} /> : <Archive size={16} />}
</Button>
)
}
function ConversationRow({
active,
conversation,
editing,
locale,
onArchive,
onRename,
onRestore,
onSelect,
onStartEditing,
showArchived,
status,
stopEditing,
}: {
active: boolean
conversation: AiConversation
editing: boolean
locale: AppLocale
onArchive: (id: string) => void
onRename: (id: string, title: string) => void
onRestore: (id: string) => void
onSelect: (id: string) => void
onStartEditing: (id: string) => void
showArchived: boolean
status: AgentStatus
stopEditing: () => void
}) {
return (
<div className="group relative flex min-w-0 items-center">
{editing ? (
<ConversationTitleEditor
conversation={conversation}
locale={locale}
onCancel={stopEditing}
onRename={(title) => onRename(conversation.id, title)}
/>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'min-w-0 flex-1 justify-start gap-2 rounded-md px-2 pr-8 text-left text-[12px]',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
aria-pressed={active}
onClick={() => onSelect(conversation.id)}
onDoubleClick={() => onStartEditing(conversation.id)}
>
<span className="truncate">{conversation.title}</span>
<span className="ml-auto flex shrink-0 items-center">
<SidebarStatusIndicator status={status} />
</span>
</Button>
)}
<ConversationArchiveButton
conversationId={conversation.id}
locale={locale}
onArchive={onArchive}
onRestore={onRestore}
showArchived={showArchived}
/>
</div>
)
}
function ConversationList({
activeId,
conversations,
editingId,
locale,
onArchive,
onRename,
onRestore,
onSelect,
setEditingId,
showArchived,
statuses,
}: Pick<ConversationSidebarProps,
'activeId' | 'conversations' | 'locale' | 'onArchive' | 'onRename' | 'onRestore' | 'onSelect' | 'showArchived' | 'statuses'
> & {
editingId: string | null
setEditingId: (id: string | null) => void
}) {
const visibleConversations = conversations.filter((conversation) => conversation.archived === showArchived)
const emptyLabel = showArchived
? translate(locale, 'ai.workspace.noArchivedChats')
: translate(locale, 'ai.workspace.noActiveChats')
if (visibleConversations.length === 0) {
return <div className="px-2 py-4 text-[12px] text-muted-foreground">{emptyLabel}</div>
}
return visibleConversations.map((conversation) => (
<ConversationRow
key={conversation.id}
active={conversation.id === activeId}
conversation={conversation}
editing={editingId === conversation.id}
locale={locale}
onArchive={onArchive}
onRename={onRename}
onRestore={onRestore}
onSelect={onSelect}
onStartEditing={setEditingId}
showArchived={showArchived}
status={statuses[conversation.id] ?? 'idle'}
stopEditing={() => setEditingId(null)}
/>
))
}
function ArchivedConversationsToggle({
locale,
setShowArchived,
showArchived,
}: Pick<ConversationSidebarProps, 'locale' | 'setShowArchived' | 'showArchived'>) {
return (
<div className="border-t border-border p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-[12px] text-muted-foreground"
onClick={() => setShowArchived(!showArchived)}
>
<Archive size={16} />
{translate(locale, showArchived ? 'ai.workspace.hideArchived' : 'ai.workspace.showArchived')}
</Button>
</div>
)
}
function ExpandedConversationSidebar({
activeId,
conversations,
locale,
onArchive,
onRename,
onRestore,
onSelect,
setShowArchived,
showArchived,
statuses,
}: Omit<ConversationSidebarProps, 'collapsed' | 'onNewChat' | 'onToggleCollapsed'>) {
const [editingId, setEditingId] = useState<string | null>(null)
return (
<>
<div className="flex-1 overflow-y-auto p-2">
<ConversationList
activeId={activeId}
conversations={conversations}
editingId={editingId}
locale={locale}
onArchive={onArchive}
onRename={onRename}
onRestore={onRestore}
onSelect={onSelect}
setEditingId={setEditingId}
showArchived={showArchived}
statuses={statuses}
/>
</div>
<ArchivedConversationsToggle
locale={locale}
setShowArchived={setShowArchived}
showArchived={showArchived}
/>
</>
)
}
export function ConversationSidebar({
activeId,
collapsed,
conversations,
locale,
onArchive,
onNewChat,
onRename,
onRestore,
onSelect,
onToggleCollapsed,
setShowArchived,
showArchived,
statuses,
}: ConversationSidebarProps) {
return (
<div className={cn(
'flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width]',
collapsed ? 'w-12' : 'w-[220px]',
)}>
<SidebarHeader
collapsed={collapsed}
locale={locale}
onNewChat={onNewChat}
onToggleCollapsed={onToggleCollapsed}
/>
{collapsed ? (
<CollapsedConversationSidebar locale={locale} onNewChat={onNewChat} />
) : (
<ExpandedConversationSidebar
activeId={activeId}
conversations={conversations}
locale={locale}
onArchive={onArchive}
onRename={onRename}
onRestore={onRestore}
onSelect={onSelect}
setShowArchived={setShowArchived}
showArchived={showArchived}
statuses={statuses}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useCallback, 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 { AppAiWorkspaceSurface } from './AppAiWorkspaceSurface'
import { Toast } from './Toast'
function useAiWorkspaceWindowContext() {
return useMemo(() => readAiWorkspaceWindowContext(), [])
}
function useAiWorkspaceSettingsSaver(
enabled: boolean,
settings: Settings,
saveSettings: (settings: Settings) => void | Promise<void>,
) {
return useCallback((conversations: AiWorkspaceConversationSetting[]) => {
if (!enabled) return
void saveSettings({ ...settings, ai_workspace_conversations: conversations })
}, [enabled, saveSettings, settings])
}
export function AiWorkspaceWindowApp() {
const [toastMessage, setToastMessage] = useState<string | null>(null)
const context = useAiWorkspaceWindowContext()
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
const aiAgentsStatus = useAiAgentsStatus()
const aiFeaturesEnabled = areAiFeaturesEnabled(settings)
const preferences = useAppPreferences({
aiAgentsStatus,
onToast: setToastMessage,
saveSettings,
settings,
settingsLoaded,
})
const vaultPath = context.vaultPath ?? ''
const vaultPaths = context.vaultPaths ?? (vaultPath ? [vaultPath] : [])
const { status: vaultAiGuidanceStatus } = useVaultAiGuidanceStatus(
aiFeaturesEnabled && vaultPath ? vaultPath : null,
vaultPath,
)
const handleConversationSettingsChange = useAiWorkspaceSettingsSaver(settingsLoaded, settings, saveSettings)
const handleDock = useCallback(() => {
void dockCurrentAiWorkspaceWindow().catch((err) => {
console.warn('[ai] Failed to dock workspace window:', err)
})
}, [])
return (
<AppPreferencesProvider dateDisplayFormat={preferences.dateDisplayFormat}>
{settingsLoaded ? (
<AppAiWorkspaceSurface
mode="window"
open
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={settings.ai_model_providers ?? []}
conversationSettings={settings.ai_workspace_conversations ?? null}
conversationSettingsReady={settingsLoaded}
defaultAiAgent={preferences.aiAgentPreferences.defaultAiAgent}
defaultAiTarget={preferences.aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={preferences.aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={preferences.aiAgentPreferences.defaultAiAgentReady}
entries={[]}
openTabs={[]}
noteList={[]}
noteListFilter={{ type: null, query: '' }}
onClose={handleDock}
onConversationSettingsChange={handleConversationSettingsChange}
onDock={handleDock}
onUnsupportedAiPaste={setToastMessage}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
vaultPath={vaultPath}
vaultPaths={vaultPaths}
locale={preferences.appLocale}
/>
) : (
<div className="fixed inset-0 bg-background" aria-hidden />
)}
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</AppPreferencesProvider>
)
}

View File

@@ -3,7 +3,7 @@ import type { AiModelProvider, AiTarget } from '../lib/aiTargets'
import type { AppLocale } from '../lib/i18n'
import type { NoteListItem } from '../utils/ai-context'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import type { VaultEntry } from '../types'
import type { AiWorkspaceConversationSetting, VaultEntry } from '../types'
import { AiWorkspace } from './AiWorkspace'
interface AppAiWorkspaceSurfaceProps {
@@ -11,6 +11,8 @@ interface AppAiWorkspaceSurfaceProps {
activeNoteContent?: string | null
aiAgentsStatus: AiAgentsStatus
aiModelProviders?: AiModelProvider[]
conversationSettings?: AiWorkspaceConversationSetting[] | null
conversationSettingsReady?: boolean
defaultAiAgent: AiAgentId
defaultAiAgentReadiness: AiAgentReadiness
defaultAiAgentReady: boolean
@@ -21,6 +23,7 @@ interface AppAiWorkspaceSurfaceProps {
noteList: NoteListItem[]
noteListFilter: { type: string | null; query: string }
onClose: () => void
onConversationSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
onDock?: () => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -42,6 +45,8 @@ export function AppAiWorkspaceSurface({
activeNoteContent,
aiAgentsStatus,
aiModelProviders,
conversationSettings,
conversationSettingsReady,
defaultAiAgent,
defaultAiAgentReadiness,
defaultAiAgentReady,
@@ -52,6 +57,7 @@ export function AppAiWorkspaceSurface({
noteList,
noteListFilter,
onClose,
onConversationSettingsChange,
onDock,
onFileCreated,
onFileModified,
@@ -73,6 +79,8 @@ export function AppAiWorkspaceSurface({
open={open}
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={aiModelProviders}
conversationSettings={conversationSettings}
conversationSettingsReady={conversationSettingsReady}
defaultAiAgent={defaultAiAgent}
defaultAiTarget={defaultAiTarget}
defaultAiAgentReadiness={defaultAiAgentReadiness}
@@ -84,6 +92,7 @@ export function AppAiWorkspaceSurface({
noteList={noteList}
noteListFilter={noteListFilter}
onClose={onClose}
onConversationSettingsChange={onConversationSettingsChange}
onDock={onDock}
onPopOut={onPopOut}
onOpenAiSettings={onOpenAiSettings}

View File

@@ -26,7 +26,7 @@ const buttonVariants = cva(
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-4",
"icon-sm": "size-8",
"icon-lg": "size-10",
},

View File

@@ -9,7 +9,7 @@ import {
import {
dockCurrentAiWorkspaceWindow,
openAiWorkspaceWindow,
registerAiWorkspaceTrafficLightDocking,
type AiWorkspaceWindowContext,
} from '../utils/openAiWorkspaceWindow'
interface UseAppAiWorkspaceBridgeOptions {
@@ -20,6 +20,7 @@ interface UseAppAiWorkspaceBridgeOptions {
openSettings: () => void
setSettingsInitialSectionId: (sectionId: string | null) => void
showAIChat: boolean
windowContext?: AiWorkspaceWindowContext
}
interface AppAiWorkspaceBridge {
@@ -79,13 +80,6 @@ function useCloseDisabledAiWorkspace(aiFeaturesEnabled: boolean, closeAIChat: ()
}, [aiFeaturesEnabled, closeAIChat, showAIChat])
}
function useAiWorkspaceTrafficLights(aiWorkspaceWindow: boolean) {
useEffect(() => {
if (!aiWorkspaceWindow) return
return registerAiWorkspaceTrafficLightDocking()
}, [aiWorkspaceWindow])
}
export function useAppAiWorkspaceBridge({
aiFeaturesEnabled,
aiWorkspaceWindow,
@@ -94,11 +88,11 @@ export function useAppAiWorkspaceBridge({
openSettings,
setSettingsInitialSectionId,
showAIChat,
windowContext,
}: UseAppAiWorkspaceBridgeOptions): AppAiWorkspaceBridge {
useOpenAiChatEvent(aiFeaturesEnabled, openAIChat)
useCloseDisabledAiWorkspace(aiFeaturesEnabled, closeAIChat, showAIChat)
useDockRequestEvent(aiFeaturesEnabled, aiWorkspaceWindow, openAIChat)
useAiWorkspaceTrafficLights(aiWorkspaceWindow)
const handleOpenAiSettings = useCallback(() => {
setSettingsInitialSectionId(SETTINGS_SECTION_IDS.ai)
@@ -113,7 +107,7 @@ export function useAppAiWorkspaceBridge({
const handlePopOutAiWorkspace = useCallback(() => {
closeAIChat()
trackEvent('ai_workspace_pop_out')
void openAiWorkspaceWindow()
void openAiWorkspaceWindow(windowContext)
.then((opened) => {
if (!opened) openAIChat()
})
@@ -121,7 +115,7 @@ export function useAppAiWorkspaceBridge({
console.warn('[ai] Failed to open workspace window:', err)
openAIChat()
})
}, [closeAIChat, openAIChat])
}, [closeAIChat, openAIChat, windowContext])
const handleDockCurrentAiWorkspaceWindow = useCallback(() => {
trackEvent('ai_workspace_docked', { source: 'button' })

View File

@@ -33,6 +33,7 @@ const defaultSettings: Settings = {
default_ai_agent: null,
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: null,
multi_workspace_enabled: null,
all_notes_show_pdfs: null,
@@ -61,6 +62,7 @@ const savedSettings: Settings = {
default_ai_agent: null,
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: null,
multi_workspace_enabled: null,
all_notes_show_pdfs: null,
@@ -126,6 +128,7 @@ function changedSettings(): Settings {
default_ai_agent: null,
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: false,
multi_workspace_enabled: null,
all_notes_show_pdfs: true,

View File

@@ -55,6 +55,7 @@ const EMPTY_SETTINGS: Settings = {
ai_features_enabled: null,
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,
@@ -80,6 +81,7 @@ function normalizeSettings(settings: Settings): Settings {
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
default_ai_target: settings.default_ai_target?.trim() || null,
ai_model_providers: aiModelProviders.length > 0 ? aiModelProviders : null,
ai_workspace_conversations: normalizeAiWorkspaceConversations(settings.ai_workspace_conversations),
hide_gitignored_files: settings.hide_gitignored_files ?? null,
all_notes_show_pdfs: settings.all_notes_show_pdfs ?? null,
all_notes_show_images: settings.all_notes_show_images ?? null,
@@ -88,6 +90,17 @@ function normalizeSettings(settings: Settings): Settings {
}
}
function normalizeAiWorkspaceConversations(settings: Settings['ai_workspace_conversations']) {
const conversations = (settings ?? []).map((conversation) => ({
archived: conversation.archived === true,
id: conversation.id.trim(),
target_id: conversation.target_id?.trim() || null,
title: conversation.title.trim(),
})).filter((conversation) => conversation.id && conversation.title)
return conversations.length > 0 ? conversations : null
}
function effectiveThemeMode(settings: Settings): ThemeMode {
return normalizeThemeMode(settings.theme_mode) ?? DEFAULT_THEME_MODE
}

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Адправіць паведамленне",
"ai.workspace.title": "Працоўная прастора ШІ",
"ai.workspace.newChat": "Новы чат",
"ai.workspace.collapseSidebar": "Згарнуць спіс чатаў са ШІ",
"ai.workspace.expandSidebar": "Разгарнуць спіс чатаў са ШІ",
"ai.workspace.close": "Зачыніць працоўную прастору ШІ",
"ai.workspace.popOut": "Адкрыць у асобным акне",
"ai.workspace.dock": "Закрыць працоўную прастору ШІ",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Няма актыўных чатаў",
"ai.workspace.noArchivedChats": "Няма архіваваных чатаў",
"ai.workspace.chatTitle": "Чат {index}",
"ai.workspace.renameChat": "Перайменаваць чат",
"ai.workspace.status.archived": "Архівавана",
"ai.workspace.status.idle": "Бяздзейны",
"ai.workspace.status.running": "Выконваецца",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Adpravić paviedamliennie",
"ai.workspace.title": "ŠI prastora",
"ai.workspace.newChat": "Novy čat",
"ai.workspace.collapseSidebar": "Zharnuć spis čataŭ sa ŠI",
"ai.workspace.expandSidebar": "Razhornuć spis čataŭ sa ŠI",
"ai.workspace.close": "Zakryć ŠI prastoru",
"ai.workspace.popOut": "Adkryć u asobnym aknie",
"ai.workspace.dock": "Prymacavać ŠI prastoru",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Niama aktyŭnych čataŭ",
"ai.workspace.noArchivedChats": "Niama archivavanych čataŭ",
"ai.workspace.chatTitle": "Čat {index}",
"ai.workspace.renameChat": "Pierajmienavać čat",
"ai.workspace.status.archived": "Archivavany",
"ai.workspace.status.idle": "Čakannie",
"ai.workspace.status.running": "Pracuje",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Nachricht senden",
"ai.workspace.title": "KI-Workspace",
"ai.workspace.newChat": "Neuer Chat",
"ai.workspace.collapseSidebar": "AI-Chat-Liste einklappen",
"ai.workspace.expandSidebar": "KI-Chat-Liste erweitern",
"ai.workspace.close": "AI-Workspace schließen",
"ai.workspace.popOut": "In einem separaten Fenster öffnen",
"ai.workspace.dock": "AI-Workspace andocken",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Keine aktiven Chats",
"ai.workspace.noArchivedChats": "Keine archivierten Chats",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Chat umbenennen",
"ai.workspace.status.archived": "Archiviert",
"ai.workspace.status.idle": "Inaktiv",
"ai.workspace.status.running": "Wird ausgeführt",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Send message",
"ai.workspace.title": "AI workspace",
"ai.workspace.newChat": "New chat",
"ai.workspace.collapseSidebar": "Collapse AI chat list",
"ai.workspace.expandSidebar": "Expand AI chat list",
"ai.workspace.close": "Close AI workspace",
"ai.workspace.popOut": "Open in separate window",
"ai.workspace.dock": "Dock AI workspace",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "No active chats",
"ai.workspace.noArchivedChats": "No archived chats",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Rename chat",
"ai.workspace.status.archived": "Archived",
"ai.workspace.status.idle": "Idle",
"ai.workspace.status.running": "Running",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Enviar mensaje",
"ai.workspace.title": "Espacio de trabajo de IA",
"ai.workspace.newChat": "Nuevo chat",
"ai.workspace.collapseSidebar": "Contraer la lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir la lista de chat de IA",
"ai.workspace.close": "Cerrar el espacio de trabajo de IA",
"ai.workspace.popOut": "Abrir en una ventana separada",
"ai.workspace.dock": "Acoplar el espacio de trabajo de IA",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "No hay chats activos",
"ai.workspace.noArchivedChats": "No hay chats archivados",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Cambiar el nombre del chat",
"ai.workspace.status.archived": "Archivado",
"ai.workspace.status.idle": "Inactivo",
"ai.workspace.status.running": "En ejecución",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Enviar mensaje",
"ai.workspace.title": "Espacio de trabajo de IA",
"ai.workspace.newChat": "Nuevo chat",
"ai.workspace.collapseSidebar": "Contraer la lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir la lista de chat de IA",
"ai.workspace.close": "Cerrar el espacio de trabajo de IA",
"ai.workspace.popOut": "Abrir en una ventana separada",
"ai.workspace.dock": "Acoplar el espacio de trabajo de IA",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "No hay chats activos",
"ai.workspace.noArchivedChats": "No hay chats archivados",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Cambiar el nombre del chat",
"ai.workspace.status.archived": "Archivado",
"ai.workspace.status.idle": "Inactivo",
"ai.workspace.status.running": "En ejecución",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Envoyer un message",
"ai.workspace.title": "Espace de travail IA",
"ai.workspace.newChat": "Nouveau chat",
"ai.workspace.collapseSidebar": "Réduire la liste de chat IA",
"ai.workspace.expandSidebar": "Développer la liste de chat IA",
"ai.workspace.close": "Fermer l'espace de travail IA",
"ai.workspace.popOut": "Ouvrir dans une fenêtre séparée",
"ai.workspace.dock": "Ancrer l'espace de travail IA",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Aucun chat actif",
"ai.workspace.noArchivedChats": "Aucun chat archivé",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Renommer le chat",
"ai.workspace.status.archived": "Archivé",
"ai.workspace.status.idle": "Inactif",
"ai.workspace.status.running": "En cours d'exécution",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Invia messaggio",
"ai.workspace.title": "Area di lavoro IA",
"ai.workspace.newChat": "Nuova chat",
"ai.workspace.collapseSidebar": "Comprimi l'elenco delle chat con l'IA",
"ai.workspace.expandSidebar": "Espandi l'elenco delle chat con l'IA",
"ai.workspace.close": "Chiudi l'area di lavoro IA",
"ai.workspace.popOut": "Apri in una finestra separata",
"ai.workspace.dock": "Ancora l'area di lavoro IA",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Nessuna chat attiva",
"ai.workspace.noArchivedChats": "Nessuna chat archiviata",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Rinomina chat",
"ai.workspace.status.archived": "Archiviate",
"ai.workspace.status.idle": "Inattivo",
"ai.workspace.status.running": "In esecuzione",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "メッセージを送信",
"ai.workspace.title": "AIワークスペース",
"ai.workspace.newChat": "新しいチャット",
"ai.workspace.collapseSidebar": "AIチャットリストを折りたたむ",
"ai.workspace.expandSidebar": "AIチャットリストを展開",
"ai.workspace.close": "AIワークスペースを閉じる",
"ai.workspace.popOut": "別のウィンドウで開く",
"ai.workspace.dock": "AIワークスペースをドック",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "アクティブなチャットはありません",
"ai.workspace.noArchivedChats": "アーカイブされたチャットはありません",
"ai.workspace.chatTitle": "チャット {index}",
"ai.workspace.renameChat": "チャットの名前を変更",
"ai.workspace.status.archived": "アーカイブ済み",
"ai.workspace.status.idle": "アイドル",
"ai.workspace.status.running": "実行中",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "메시지 보내기",
"ai.workspace.title": "AI 작업 공간",
"ai.workspace.newChat": "새 채팅",
"ai.workspace.collapseSidebar": "AI 채팅 목록 접기",
"ai.workspace.expandSidebar": "AI 채팅 목록 펼치기",
"ai.workspace.close": "AI 작업 공간 닫기",
"ai.workspace.popOut": "별도의 창에서 열기",
"ai.workspace.dock": "AI 작업 공간 고정",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "진행 중인 채팅 없음",
"ai.workspace.noArchivedChats": "보관된 채팅 없음",
"ai.workspace.chatTitle": "채팅 {index}",
"ai.workspace.renameChat": "채팅 이름 변경",
"ai.workspace.status.archived": "보관됨",
"ai.workspace.status.idle": "유휴",
"ai.workspace.status.running": "실행 중",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Wyślij wiadomość",
"ai.workspace.title": "Obszar roboczy AI",
"ai.workspace.newChat": "Nowy czat",
"ai.workspace.collapseSidebar": "Zwiń listę czatów AI",
"ai.workspace.expandSidebar": "Rozwiń listę czatów AI",
"ai.workspace.close": "Zamknij obszar roboczy AI",
"ai.workspace.popOut": "Otwórz w oddzielnym oknie",
"ai.workspace.dock": "Zadokuj obszar roboczy AI",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Brak aktywnych czatów",
"ai.workspace.noArchivedChats": "Brak zarchiwizowanych czatów",
"ai.workspace.chatTitle": "Czat {index}",
"ai.workspace.renameChat": "Zmień nazwę czatu",
"ai.workspace.status.archived": "Zarchiwizowane",
"ai.workspace.status.idle": "Bezczynny",
"ai.workspace.status.running": "W toku",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Enviar mensagem",
"ai.workspace.title": "Espaço de trabalho de IA",
"ai.workspace.newChat": "Novo chat",
"ai.workspace.collapseSidebar": "Recolher lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir lista de chat de IA",
"ai.workspace.close": "Fechar o espaço de trabalho de IA",
"ai.workspace.popOut": "Abrir em uma janela separada",
"ai.workspace.dock": "Encaixar o espaço de trabalho de IA",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Nenhum chat ativo",
"ai.workspace.noArchivedChats": "Nenhum chat arquivado",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Renomear chat",
"ai.workspace.status.archived": "Arquivado",
"ai.workspace.status.idle": "Ocioso",
"ai.workspace.status.running": "Em execução",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Enviar mensagem",
"ai.workspace.title": "Espaço de trabalho de IA",
"ai.workspace.newChat": "Novo chat",
"ai.workspace.collapseSidebar": "Recolher a lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir a lista de chat de IA",
"ai.workspace.close": "Fechar espaço de trabalho de IA",
"ai.workspace.popOut": "Abrir numa janela separada",
"ai.workspace.dock": "Afixar espaço de trabalho de IA",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Sem chats ativos",
"ai.workspace.noArchivedChats": "Sem chats arquivados",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.renameChat": "Mudar o nome do chat",
"ai.workspace.status.archived": "Arquivado",
"ai.workspace.status.idle": "Inativo",
"ai.workspace.status.running": "Em execução",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Отправить сообщение",
"ai.workspace.title": "Рабочее пространство ИИ",
"ai.workspace.newChat": "Новый чат",
"ai.workspace.collapseSidebar": "Свернуть список чатов с ИИ",
"ai.workspace.expandSidebar": "Развернуть список чатов с ИИ",
"ai.workspace.close": "Закрыть рабочее пространство ИИ",
"ai.workspace.popOut": "Открыть в отдельном окне",
"ai.workspace.dock": "Закрепить рабочее пространство ИИ",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Нет активных чатов",
"ai.workspace.noArchivedChats": "Нет заархивированных чатов",
"ai.workspace.chatTitle": "Чат {index}",
"ai.workspace.renameChat": "Переименовать чат",
"ai.workspace.status.archived": "Архивировано",
"ai.workspace.status.idle": "Бездействует",
"ai.workspace.status.running": "Выполняется",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "Send message",
"ai.workspace.title": "Không gian làm việc AI",
"ai.workspace.newChat": "Trò chuyện mới",
"ai.workspace.collapseSidebar": "Thu gọn danh sách trò chuyện AI",
"ai.workspace.expandSidebar": "Mở rộng danh sách trò chuyện AI",
"ai.workspace.close": "Đóng không gian làm việc AI",
"ai.workspace.popOut": "Mở trong cửa sổ riêng",
"ai.workspace.dock": "Gắn không gian làm việc AI vào màn hình",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "Không có cuộc trò chuyện đang hoạt động",
"ai.workspace.noArchivedChats": "Không có cuộc trò chuyện nào được lưu trữ",
"ai.workspace.chatTitle": "Trò chuyện {index}",
"ai.workspace.renameChat": "Đổi tên cuộc trò chuyện",
"ai.workspace.status.archived": "Đã lưu trữ",
"ai.workspace.status.idle": "Không hoạt động",
"ai.workspace.status.running": "Đang chạy",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "发送消息",
"ai.workspace.title": "AI 工作区",
"ai.workspace.newChat": "新聊天",
"ai.workspace.collapseSidebar": "收起 AI 聊天列表",
"ai.workspace.expandSidebar": "展开 AI 聊天列表",
"ai.workspace.close": "关闭 AI 工作区",
"ai.workspace.popOut": "在单独窗口中打开",
"ai.workspace.dock": "停靠 AI 工作区",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "没有活跃的聊天",
"ai.workspace.noArchivedChats": "没有已归档的聊天",
"ai.workspace.chatTitle": "聊天 {index}",
"ai.workspace.renameChat": "重命名聊天",
"ai.workspace.status.archived": "已归档",
"ai.workspace.status.idle": "空闲",
"ai.workspace.status.running": "正在运行",

View File

@@ -312,6 +312,8 @@
"ai.panel.send": "傳送訊息",
"ai.workspace.title": "AI 工作區",
"ai.workspace.newChat": "新對話",
"ai.workspace.collapseSidebar": "收合 AI 聊天清單",
"ai.workspace.expandSidebar": "展開 AI 聊天清單",
"ai.workspace.close": "關閉 AI 工作區",
"ai.workspace.popOut": "在單獨視窗中開啟",
"ai.workspace.dock": "固定 AI 工作區",
@@ -323,6 +325,7 @@
"ai.workspace.noActiveChats": "沒有進行中的聊天",
"ai.workspace.noArchivedChats": "沒有已封存的聊天",
"ai.workspace.chatTitle": "聊天 {index}",
"ai.workspace.renameChat": "重新命名聊天",
"ai.workspace.status.archived": "已封存",
"ai.workspace.status.idle": "閒置",
"ai.workspace.status.running": "執行中",

View File

@@ -10,6 +10,8 @@ import type { ThemeMode } from './themeMode'
type TrackedPreviewKind = FilePreviewKind | 'unsupported'
type FilePreviewAction = 'copy_path' | 'open_external' | 'reveal'
type AgentBlockedReason = 'agent_unavailable' | 'missing_vault'
type AiWorkspaceMode = 'docked' | 'window'
type AiWorkspaceTitleSource = 'generated' | 'manual'
const ALL_NOTES_VISIBILITY_CATEGORIES: ReadonlyArray<keyof AllNotesFileVisibility> = [
'pdfs',
@@ -144,3 +146,14 @@ export function trackAiAgentPermissionModeChanged(agent: AiAgentId, permissionMo
permission_mode: permissionMode,
})
}
export function trackAiWorkspaceSidebarToggled(collapsed: boolean, mode: AiWorkspaceMode): void {
trackEvent('ai_workspace_sidebar_toggled', {
collapsed: numericFlag(collapsed),
mode,
})
}
export function trackAiWorkspaceChatTitled(source: AiWorkspaceTitleSource): void {
trackEvent('ai_workspace_chat_titled', { source })
}

View File

@@ -126,6 +126,7 @@ export interface Settings {
default_ai_agent?: AiAgentId | null
default_ai_target?: string | null
ai_model_providers?: AiModelProvider[] | null
ai_workspace_conversations?: AiWorkspaceConversationSetting[] | null
hide_gitignored_files?: boolean | null
all_notes_show_pdfs?: boolean | null
all_notes_show_images?: boolean | null
@@ -133,6 +134,13 @@ export interface Settings {
multi_workspace_enabled?: boolean | null
}
export interface AiWorkspaceConversationSetting {
archived?: boolean | null
id: string
target_id?: string | null
title: string
}
export interface GitPullResult {
status: 'up_to_date' | 'updated' | 'conflict' | 'no_remote' | 'error'
message: string

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { generateAiConversationTitle } from './aiConversationTitle'
describe('generateAiConversationTitle', () => {
it('creates a compact title from the first prompt', () => {
expect(generateAiConversationTitle('please summarize quarterly sponsor outreach next steps')).toBe(
'Summarize Quarterly Sponsor Outreach',
)
})
it('strips wikilinks and urls before choosing title words', () => {
expect(generateAiConversationTitle('help with [[Sponsor Onboarding]] https://example.com plan')).toBe(
'Sponsor Onboarding Plan',
)
})
})

View File

@@ -0,0 +1,49 @@
const TITLE_WORD_LIMIT = 4
const STOP_WORDS = new Set([
'a',
'an',
'and',
'are',
'can',
'could',
'for',
'from',
'help',
'how',
'into',
'make',
'please',
'the',
'this',
'that',
'with',
'you',
])
function cleanPrompt(prompt: string): string {
return prompt
.replace(/```[\s\S]*?```/g, ' ')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/[^\p{L}\p{N}\s'-]/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function toTitleWord(word: string): string {
if (word.length <= 1) return word.toUpperCase()
return `${word[0].toUpperCase()}${word.slice(1)}`
}
export function generateAiConversationTitle(prompt: string): string | null {
const words = cleanPrompt(prompt)
.split(' ')
.map((word) => word.trim())
.filter(Boolean)
const meaningfulWords = words.filter((word) => !STOP_WORDS.has(word.toLowerCase()))
const titleWords = (meaningfulWords.length > 0 ? meaningfulWords : words).slice(0, TITLE_WORD_LIMIT)
if (titleWords.length === 0) return null
return titleWords.map(toTitleWord).join(' ')
}

View File

@@ -5,9 +5,9 @@ import {
buildRuntimeAiWorkspaceWindowUrl,
dockCurrentAiWorkspaceWindow,
openAiWorkspaceWindow,
readAiWorkspaceWindowContext,
} from './openAiWorkspaceWindow'
import { isTauri } from '../mock-tauri'
import { shouldUseCustomWindowChrome } from './platform'
import { AI_WORKSPACE_DOCK_REQUESTED_EVENT } from './aiPromptBridge'
const webviewWindowCalls = vi.fn()
@@ -32,10 +32,6 @@ vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(),
}))
vi.mock('./platform', () => ({
shouldUseCustomWindowChrome: vi.fn(),
}))
vi.mock('@tauri-apps/api/webviewWindow', () => ({
WebviewWindow: class MockWebviewWindow {
static getByLabel = webviewGetByLabel
@@ -46,12 +42,6 @@ vi.mock('@tauri-apps/api/webviewWindow', () => ({
},
}))
vi.mock('@tauri-apps/api/dpi', () => ({
LogicalPosition: class MockLogicalPosition {
constructor(public x: number, public y: number) {}
},
}))
vi.mock('@tauri-apps/api/event', () => ({
emitTo,
}))
@@ -64,18 +54,31 @@ describe('openAiWorkspaceWindow', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isTauri).mockReturnValue(false)
vi.mocked(shouldUseCustomWindowChrome).mockReturnValue(false)
webviewGetByLabel.mockResolvedValue(null)
localStorage.clear()
})
it('builds the AI workspace route', () => {
const url = buildAiWorkspaceWindowUrl('ai-workspace')
const url = buildAiWorkspaceWindowUrl('ai-workspace', {
vaultPath: '/tmp/vault',
vaultPaths: ['/tmp/vault', '/tmp/other'],
})
const parsed = new URL(url, 'https://tolaria.localhost')
expect(parsed.pathname).toBe('/')
expect(parsed.searchParams.get('window')).toBe('ai-workspace')
expect(parsed.searchParams.get('windowLabel')).toBe('ai-workspace')
expect(parsed.searchParams.get('vault')).toBe('/tmp/vault')
expect(JSON.parse(parsed.searchParams.get('vaultPaths') ?? '[]')).toEqual(['/tmp/vault', '/tmp/other'])
})
it('reads the AI workspace route context', () => {
const search = '?window=ai-workspace&vault=%2Ftmp%2Fvault&vaultPaths=%5B%22%2Ftmp%2Fvault%22%5D'
expect(readAiWorkspaceWindowContext(search)).toEqual({
vaultPath: '/tmp/vault',
vaultPaths: ['/tmp/vault'],
})
})
it('resolves the runtime route against the current app origin', () => {
@@ -95,20 +98,19 @@ describe('openAiWorkspaceWindow', () => {
it('opens one native Tauri AI workspace window', async () => {
vi.mocked(isTauri).mockReturnValue(true)
await openAiWorkspaceWindow()
await openAiWorkspaceWindow({ vaultPath: '/tmp/vault' })
expect(webviewWindowCalls).toHaveBeenCalledWith(
AI_WORKSPACE_WINDOW_LABEL,
expect.objectContaining({
url: expect.stringContaining('vault=%2Ftmp%2Fvault'),
title: 'Tolaria AI',
width: 940,
height: 680,
minWidth: 520,
minHeight: 420,
titleBarStyle: 'overlay',
trafficLightPosition: expect.objectContaining({ x: 18, y: 22 }),
hiddenTitle: true,
decorations: true,
minimizable: false,
decorations: false,
}),
)
expect(localStorage.getItem('tolaria:ai-workspace-window:ai-workspace')).toBe('true')

View File

@@ -1,26 +1,27 @@
import { isTauri } from '../mock-tauri'
import { shouldUseCustomWindowChrome } from './platform'
import { rememberAiWorkspaceWindow } from './windowMode'
import { AI_WORKSPACE_DOCK_REQUESTED_EVENT, requestDockAiWorkspace } from './aiPromptBridge'
export const AI_WORKSPACE_WINDOW_LABEL = 'ai-workspace'
const AI_WORKSPACE_WINDOW_TITLE = 'Tolaria AI'
const MACOS_TRAFFIC_LIGHT_POSITION = { x: 18, y: 22 } as const
const APP_ORIGIN_PROTOCOLS = new Set(['http:', 'https:'])
const MINIMIZE_DOCK_POLL_MS = 250
interface AiWorkspaceDockingState {
cancelled: boolean
minimizeTimer: number | null
unlistenClose: (() => void) | null
export interface AiWorkspaceWindowContext {
vaultPath?: string
vaultPaths?: string[]
}
export function buildAiWorkspaceWindowUrl(windowLabel = AI_WORKSPACE_WINDOW_LABEL): string {
export function buildAiWorkspaceWindowUrl(
windowLabel = AI_WORKSPACE_WINDOW_LABEL,
context: AiWorkspaceWindowContext = {},
): string {
const params = new URLSearchParams({
window: 'ai-workspace',
windowLabel,
})
if (context.vaultPath) params.set('vault', context.vaultPath)
if (context.vaultPaths?.length) params.set('vaultPaths', JSON.stringify(context.vaultPaths))
return `/?${params.toString()}`
}
@@ -31,14 +32,39 @@ function resolveAiWorkspaceWindowUrlForRuntime(route: string): string {
return new URL(route, window.location.origin).toString()
}
export function buildRuntimeAiWorkspaceWindowUrl(windowLabel = AI_WORKSPACE_WINDOW_LABEL): string {
return resolveAiWorkspaceWindowUrlForRuntime(buildAiWorkspaceWindowUrl(windowLabel))
export function buildRuntimeAiWorkspaceWindowUrl(
windowLabel = AI_WORKSPACE_WINDOW_LABEL,
context: AiWorkspaceWindowContext = {},
): string {
return resolveAiWorkspaceWindowUrlForRuntime(buildAiWorkspaceWindowUrl(windowLabel, context))
}
export async function openAiWorkspaceWindow(): Promise<boolean> {
export function readAiWorkspaceWindowContext(search = window.location.search): AiWorkspaceWindowContext {
const params = new URLSearchParams(search)
const vaultPath = params.get('vault') ?? undefined
const vaultPaths = parseVaultPathsParam(params.get('vaultPaths'))
return { vaultPath, vaultPaths }
}
function parseVaultPathsParam(raw: string | null): string[] | undefined {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
const paths = parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
return paths.length > 0 ? paths : undefined
}
} catch {
return undefined
}
return undefined
}
export async function openAiWorkspaceWindow(context: AiWorkspaceWindowContext = {}): Promise<boolean> {
if (!isTauri()) return false
const { LogicalPosition } = await import('@tauri-apps/api/dpi')
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existingWindow = await WebviewWindow.getByLabel(AI_WORKSPACE_WINDOW_LABEL)
if (existingWindow) {
@@ -50,7 +76,7 @@ export async function openAiWorkspaceWindow(): Promise<boolean> {
rememberAiWorkspaceWindow()
new WebviewWindow(AI_WORKSPACE_WINDOW_LABEL, {
url: buildRuntimeAiWorkspaceWindowUrl(AI_WORKSPACE_WINDOW_LABEL),
url: buildRuntimeAiWorkspaceWindowUrl(AI_WORKSPACE_WINDOW_LABEL, context),
title: AI_WORKSPACE_WINDOW_TITLE,
width: 940,
height: 680,
@@ -58,11 +84,8 @@ export async function openAiWorkspaceWindow(): Promise<boolean> {
minHeight: 420,
center: true,
resizable: true,
minimizable: true,
titleBarStyle: 'overlay',
trafficLightPosition: new LogicalPosition(MACOS_TRAFFIC_LIGHT_POSITION.x, MACOS_TRAFFIC_LIGHT_POSITION.y),
hiddenTitle: true,
decorations: !shouldUseCustomWindowChrome(),
minimizable: false,
decorations: false,
})
return true
@@ -78,57 +101,3 @@ export async function dockCurrentAiWorkspaceWindow(): Promise<void> {
await emitTo('main', AI_WORKSPACE_DOCK_REQUESTED_EVENT).catch(() => {})
await getCurrentWindow().destroy().catch(() => {})
}
function dockOnCloseRequest(currentWindow: ReturnType<typeof import('@tauri-apps/api/window').getCurrentWindow>, state: AiWorkspaceDockingState) {
void currentWindow.onCloseRequested((event) => {
event.preventDefault()
void dockCurrentAiWorkspaceWindow()
}).then((unlisten) => {
if (state.cancelled) {
unlisten()
return
}
state.unlistenClose = unlisten
})
}
function dockOnMinimize(currentWindow: ReturnType<typeof import('@tauri-apps/api/window').getCurrentWindow>, state: AiWorkspaceDockingState) {
state.minimizeTimer = window.setInterval(() => {
void currentWindow.isMinimized()
.then((minimized) => {
if (!minimized || state.cancelled) return
void currentWindow.unminimize().catch(() => {})
void dockCurrentAiWorkspaceWindow()
})
.catch(() => {})
}, MINIMIZE_DOCK_POLL_MS)
}
function connectTrafficLightDocking(state: AiWorkspaceDockingState) {
void import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
if (state.cancelled) return
const currentWindow = getCurrentWindow()
dockOnCloseRequest(currentWindow, state)
dockOnMinimize(currentWindow, state)
})
}
export function registerAiWorkspaceTrafficLightDocking(): () => void {
if (!isTauri()) return () => {}
const state: AiWorkspaceDockingState = {
cancelled: false,
minimizeTimer: null,
unlistenClose: null,
}
connectTrafficLightDocking(state)
return () => {
state.cancelled = true
state.unlistenClose?.()
if (state.minimizeTimer !== null) window.clearInterval(state.minimizeTimer)
}
}

View File

@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
interface TauriCapability {
permissions: string[]
windows: string[]
}
const LINUX_WINDOW_CHROME_PERMISSIONS = [
@@ -22,4 +23,12 @@ describe('Tauri window-control permissions', () => {
expect.arrayContaining([...LINUX_WINDOW_CHROME_PERMISSIONS]),
)
})
it('allows the AI workspace pop-out window to use app APIs', () => {
const capability = JSON.parse(
readFileSync(`${process.cwd()}/src-tauri/capabilities/default.json`, 'utf8'),
) as TauriCapability
expect(capability.windows).toEqual(expect.arrayContaining(['main', 'ai-workspace', 'note-*']))
})
})

View File

@@ -49,7 +49,10 @@ test.describe('AI chat empty body fix — no regression', () => {
// Open the AI panel from the editor toolbar
await page.getByRole('button', { name: 'Open the AI panel' }).click()
await expect(page.getByTestId('ai-workspace')).toBeVisible({ timeout: 3000 })
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
await expect(page.getByTestId('ai-workspace-target-trigger')).toBeVisible()
await expect(page.getByTestId('ai-workspace-permission-trigger')).toBeVisible()
// Send a message
const input = page.getByTestId('agent-input')