fix: stabilize AI workspace QA
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
35
docs/adr/0128-lightweight-ai-workspace-window.md
Normal file
35
docs/adr/0128-lightweight-ai-workspace-window.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main",
|
||||
"ai-workspace",
|
||||
"note-*"
|
||||
],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
28
src/App.tsx
28
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
426
src/components/AiWorkspaceSidebar.tsx
Normal file
426
src/components/AiWorkspaceSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
src/components/AiWorkspaceWindowApp.tsx
Normal file
86
src/components/AiWorkspaceWindowApp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "Выконваецца",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "実行中",
|
||||
|
||||
@@ -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": "실행 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выполняется",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "正在运行",
|
||||
|
||||
@@ -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": "執行中",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
16
src/utils/aiConversationTitle.test.ts
Normal file
16
src/utils/aiConversationTitle.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
49
src/utils/aiConversationTitle.ts
Normal file
49
src/utils/aiConversationTitle.ts
Normal 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(' ')
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-*']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user