diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index c631d44e..5b5daaa4 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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:/" 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70708488..37202fd9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -229,7 +229,7 @@ flowchart TD - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). -- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a bounded 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 diff --git a/docs/adr/0128-lightweight-ai-workspace-window.md b/docs/adr/0128-lightweight-ai-workspace-window.md new file mode 100644 index 00000000..0c0f93d5 --- /dev/null +++ b/docs/adr/0128-lightweight-ai-workspace-window.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8fab6a01..a5df0824 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/lara.lock b/lara.lock index 07ab69f7..411764dc 100644 --- a/lara.lock +++ b/lara.lock @@ -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 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index abce19e3..f8038c57 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -4,6 +4,7 @@ "description": "enables the default permissions", "windows": [ "main", + "ai-workspace", "note-*" ], "platforms": ["linux", "macOS", "windows"], diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 303faeb4..63481485 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, + pub id: String, + pub target_id: Option, + pub title: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Settings { pub auto_pull_interval_minutes: Option, @@ -88,6 +96,7 @@ pub struct Settings { pub default_ai_agent: Option, pub default_ai_target: Option, pub ai_model_providers: Option>, + pub ai_workspace_conversations: Option>, pub hide_gitignored_files: Option, pub all_notes_show_pdfs: Option, pub all_notes_show_images: Option, @@ -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>, +) -> Option> { + let normalized: Vec = 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 { 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), diff --git a/src/App.tsx b/src/App.tsx index b23fa110..1d8facee 100644 --- a/src/App.tsx +++ b/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 + + return +} + +function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | null }) { + const aiWorkspaceWindow = false const [selection, setSelection] = useState(DEFAULT_SELECTION) const [noteListFilter, setNoteListFilter] = useState('open') const selectionRef = useRef(DEFAULT_SELECTION) @@ -345,6 +362,7 @@ function App() { ? onboarding.state.vaultPath : vaultSwitcher.vaultPath ) + const aiWorkspaceWindowContext = aiWorkspaceWindowContextForPath(resolvedPath) const [settingsInitialSectionId, setSettingsInitialSectionId] = useState(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 = 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 = 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 ( 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 +}) { + return ( + + ) +} + 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(null) const panelRef = useRef(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[1]) => { + if (!text.trim() || isActive) return + onSendPrompt?.(text) + handleSend(text, references) + }, [handleSend, isActive, onSendPrompt]) return ( - + ) } diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx index 15beb8e0..ebd9b566 100644 --- a/src/components/AiPanelChrome.tsx +++ b/src/components/AiPanelChrome.tsx @@ -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 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 + onChange: (value: string) => void + onSend: (text: string, references: NoteReference[]) => void + onUnsupportedAiPaste?: (message: string) => void + placeholder: string +}) { + return ( + + ) +} + +function ComposerSendButton({ + canSend, + entries, + input, + label, + onSend, +}: { + canSend: boolean + entries: VaultEntry[] + input: string + label: string + onSend: (text: string, references: NoteReference[]) => void +}) { + return ( + + ) +} + +function ComposerControlsRow({ + children, + hasControls, + sendButton, +}: { + children?: ReactNode + hasControls: boolean + sendButton: ReactNode +}) { + if (!hasControls) return <>{sendButton} + + return ( +
+
+ {children} +
+ {sendButton} +
+ ) +} + function permissionModeTooltip( mode: AiAgentPermissionMode, t: ReturnType, @@ -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 = ( + + ) return (
-
-
- +
+
- + + {controls} +
) diff --git a/src/components/AiWorkspace.test.tsx b/src/components/AiWorkspace.test.tsx index 74028f83..02afe8d8 100644 --- a/src/components/AiWorkspace.test.tsx +++ b/src/components/AiWorkspace.test.tsx @@ -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 }) => ( -
Chat surface
+ AiPanelView: ({ + composerControls, + onSendPrompt, + showHeader, + }: { + composerControls?: ReactNode + onSendPrompt?: (prompt: string) => void + showHeader?: boolean + }) => ( +
+ + {composerControls} +
), })) @@ -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() + + 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( + , + ) + + 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() + + 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() + + 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() + }) }) diff --git a/src/components/AiWorkspace.tsx b/src/components/AiWorkspace.tsx index 61475344..0ccfc9a6 100644 --- a/src/components/AiWorkspace.tsx +++ b/src/components/AiWorkspace.tsx @@ -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(() => [ - 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(() => ( + 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() - - return ( -
-
- - - {translate(locale, 'ai.workspace.title')} - -
- -
- ) -} - -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 -}) { - const visibleConversations = conversations.filter((conversation) => conversation.archived === showArchived) - const emptyLabel = showArchived - ? translate(locale, 'ai.workspace.noArchivedChats') - : translate(locale, 'ai.workspace.noActiveChats') - - return ( -
- -
- {visibleConversations.length === 0 ? ( -
{emptyLabel}
- ) : visibleConversations.map((conversation) => { - const status = statuses[conversation.id] ?? 'idle' - const active = conversation.id === activeId - return ( -
- - -
- ) - })} -
-
- -
-
- ) -} - 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 ( + + + + ) +} + +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 ( + + {hasTargets ? ( + + + {hasLocalAgentsSeparator && } + + {hasLocalModelsSeparator && } + + + ) : ( + {translate(locale, 'ai.workspace.noTargets')} + )} + + ) +} + function TargetPicker({ + compact = false, disabled, groups, locale, selectedTarget, onSelectTarget, }: { + compact?: boolean disabled: boolean groups: AiWorkspaceTargetGroups locale: AppLocale @@ -357,44 +454,33 @@ function TargetPicker({ return ( - - - - - {hasTargets ? ( - - - {groups.localAgents.length > 0 && (groups.localModels.length > 0 || groups.apiModels.length > 0) && } - - {groups.localModels.length > 0 && groups.apiModels.length > 0 && } - - - ) : ( - {translate(locale, 'ai.workspace.noTargets')} - )} - + + ) } 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 ( - ) @@ -414,15 +500,15 @@ function PermissionPicker({ @@ -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() - const running = status === 'thinking' || status === 'tool-executing' return (
-
+
{conversation.title}
{translate(locale, statusLabelKey(status))}
- -
{onOpenAiSettings && ( )} {mode === 'docked' ? ( ) : ( )}
@@ -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 = ( + <> + + + + ) useEffect(() => { onStatusChange(conversation.id, controller.agent.status) @@ -643,19 +726,14 @@ function ConversationSession({
@@ -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 + 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>({}) + 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 (
model.setConversationTarget(conversation.id, targetId)} onStatusChange={model.handleStatusChange} + onTitleFromPrompt={model.titleConversationFromPrompt} onUnsupportedAiPaste={workspace.onUnsupportedAiPaste} onVaultChanged={workspace.onVaultChanged} openTabs={workspace.openTabs} diff --git a/src/components/AiWorkspaceSidebar.tsx b/src/components/AiWorkspaceSidebar.tsx new file mode 100644 index 00000000..b853c09f --- /dev/null +++ b/src/components/AiWorkspaceSidebar.tsx @@ -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 +} + +function isRunningStatus(status: AgentStatus | undefined): boolean { + return status === 'thinking' || status === 'tool-executing' +} + +function SidebarStatusIndicator({ status }: { status: AgentStatus | undefined }) { + if (isRunningStatus(status)) { + return + } + + if (status === 'error') { + return + } + + return null +} + +function SidebarHeader({ + collapsed, + locale, + onNewChat, + onToggleCollapsed, +}: { + collapsed: boolean + locale: AppLocale + onNewChat: () => void + onToggleCollapsed: () => void +}) { + const { dragRegionRef } = useDragRegion() + const collapseLabel = translate(locale, collapsed ? 'ai.workspace.expandSidebar' : 'ai.workspace.collapseSidebar') + + return ( +
+
+ + {!collapsed && ( + <> + + + {translate(locale, 'ai.workspace.title')} + + + )} +
+ {!collapsed && ( + + )} +
+ ) +} + +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 ( +
+ 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 + /> + +
+ ) +} + +function CollapsedConversationSidebar({ locale, onNewChat }: { locale: AppLocale; onNewChat: () => void }) { + return ( +
+ +
+ ) +} + +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 ( + + ) +} + +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 ( +
+ {editing ? ( + onRename(conversation.id, title)} + /> + ) : ( + + )} + +
+ ) +} + +function ConversationList({ + activeId, + conversations, + editingId, + locale, + onArchive, + onRename, + onRestore, + onSelect, + setEditingId, + showArchived, + statuses, +}: Pick & { + 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
{emptyLabel}
+ } + + return visibleConversations.map((conversation) => ( + setEditingId(null)} + /> + )) +} + +function ArchivedConversationsToggle({ + locale, + setShowArchived, + showArchived, +}: Pick) { + return ( +
+ +
+ ) +} + +function ExpandedConversationSidebar({ + activeId, + conversations, + locale, + onArchive, + onRename, + onRestore, + onSelect, + setShowArchived, + showArchived, + statuses, +}: Omit) { + const [editingId, setEditingId] = useState(null) + + return ( + <> +
+ +
+ + + ) +} + +export function ConversationSidebar({ + activeId, + collapsed, + conversations, + locale, + onArchive, + onNewChat, + onRename, + onRestore, + onSelect, + onToggleCollapsed, + setShowArchived, + showArchived, + statuses, +}: ConversationSidebarProps) { + return ( +
+ + {collapsed ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/AiWorkspaceWindowApp.tsx b/src/components/AiWorkspaceWindowApp.tsx new file mode 100644 index 00000000..970dc1a7 --- /dev/null +++ b/src/components/AiWorkspaceWindowApp.tsx @@ -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, +) { + return useCallback((conversations: AiWorkspaceConversationSetting[]) => { + if (!enabled) return + void saveSettings({ ...settings, ai_workspace_conversations: conversations }) + }, [enabled, saveSettings, settings]) +} + +export function AiWorkspaceWindowApp() { + const [toastMessage, setToastMessage] = useState(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 ( + + {settingsLoaded ? ( + + ) : ( +
+ )} + setToastMessage(null)} /> + + ) +} diff --git a/src/components/AppAiWorkspaceSurface.tsx b/src/components/AppAiWorkspaceSurface.tsx index 2562715a..fcc798e5 100644 --- a/src/components/AppAiWorkspaceSurface.tsx +++ b/src/components/AppAiWorkspaceSurface.tsx @@ -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} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0043766f..d05db0e5 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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", }, diff --git a/src/hooks/useAppAiWorkspaceBridge.ts b/src/hooks/useAppAiWorkspaceBridge.ts index 3ec76b4a..8a910671 100644 --- a/src/hooks/useAppAiWorkspaceBridge.ts +++ b/src/hooks/useAppAiWorkspaceBridge.ts @@ -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' }) diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 0e551247..5933a4b2 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -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, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 26ec1e55..58700da8 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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 } diff --git a/src/lib/locales/be-BY.json b/src/lib/locales/be-BY.json index c7feb3a8..690f9f44 100644 --- a/src/lib/locales/be-BY.json +++ b/src/lib/locales/be-BY.json @@ -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": "Выконваецца", diff --git a/src/lib/locales/be-Latn.json b/src/lib/locales/be-Latn.json index ad96dcd3..bfb2870b 100644 --- a/src/lib/locales/be-Latn.json +++ b/src/lib/locales/be-Latn.json @@ -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", diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index f84d0f7e..018b0473 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -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", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index cd800fb1..24c6c20a 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index edbdb86f..cd105fdc 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -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", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 48c177ae..05fd1410 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -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", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index e738fc8b..6195a5c0 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -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", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index d5fa4526..02838d8e 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -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", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 8a7e82e8..6a90335d 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -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": "実行中", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 884ea751..4868e645 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -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": "실행 중", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index 2eb28954..e89684fd 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -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", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 3dea70bc..5312cbf4 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -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", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index caf20d1e..ac09d85a 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -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", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index ae5ca6d5..1dace965 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -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": "Выполняется", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 41fe298e..39d2691b 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -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", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index b6673f43..a3d3c8cb 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -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": "正在运行", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index 506e49c7..7a3cd31f 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -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": "執行中", diff --git a/src/lib/productAnalytics.ts b/src/lib/productAnalytics.ts index 6fd87907..d769cd6a 100644 --- a/src/lib/productAnalytics.ts +++ b/src/lib/productAnalytics.ts @@ -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 = [ '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 }) +} diff --git a/src/types.ts b/src/types.ts index 719fefe9..5241f74f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/src/utils/aiConversationTitle.test.ts b/src/utils/aiConversationTitle.test.ts new file mode 100644 index 00000000..decce053 --- /dev/null +++ b/src/utils/aiConversationTitle.test.ts @@ -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', + ) + }) +}) diff --git a/src/utils/aiConversationTitle.ts b/src/utils/aiConversationTitle.ts new file mode 100644 index 00000000..ce232795 --- /dev/null +++ b/src/utils/aiConversationTitle.ts @@ -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(' ') +} diff --git a/src/utils/openAiWorkspaceWindow.test.ts b/src/utils/openAiWorkspaceWindow.test.ts index afda0891..0a4460a4 100644 --- a/src/utils/openAiWorkspaceWindow.test.ts +++ b/src/utils/openAiWorkspaceWindow.test.ts @@ -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') diff --git a/src/utils/openAiWorkspaceWindow.ts b/src/utils/openAiWorkspaceWindow.ts index ccdadc18..60ad4ec1 100644 --- a/src/utils/openAiWorkspaceWindow.ts +++ b/src/utils/openAiWorkspaceWindow.ts @@ -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 { +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 { 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 { 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 { 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 { await emitTo('main', AI_WORKSPACE_DOCK_REQUESTED_EVENT).catch(() => {}) await getCurrentWindow().destroy().catch(() => {}) } - -function dockOnCloseRequest(currentWindow: ReturnType, state: AiWorkspaceDockingState) { - void currentWindow.onCloseRequested((event) => { - event.preventDefault() - void dockCurrentAiWorkspaceWindow() - }).then((unlisten) => { - if (state.cancelled) { - unlisten() - return - } - - state.unlistenClose = unlisten - }) -} - -function dockOnMinimize(currentWindow: ReturnType, 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) - } -} diff --git a/src/utils/tauriWindowControlPermissions.test.ts b/src/utils/tauriWindowControlPermissions.test.ts index e4df5998..6596024b 100644 --- a/src/utils/tauriWindowControlPermissions.test.ts +++ b/src/utils/tauriWindowControlPermissions.test.ts @@ -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-*'])) + }) }) diff --git a/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts b/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts index 1f692284..26cfba87 100644 --- a/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts +++ b/tests/smoke/fix-ai-chat-empty-body-v3.spec.ts @@ -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')