feat: refine AI workspace side panel
This commit is contained in:
@@ -383,6 +383,16 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
crate::settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_ai_workspace_sessions() -> Result<serde_json::Value, String> {
|
||||
crate::settings::get_ai_workspace_sessions()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> {
|
||||
crate::settings::save_ai_workspace_sessions(sessions)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_update(
|
||||
|
||||
@@ -535,6 +535,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::delete_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::get_ai_workspace_sessions,
|
||||
commands::check_for_app_update,
|
||||
commands::update_menu_state,
|
||||
commands::update_app_icon,
|
||||
@@ -542,6 +543,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::update_current_window_min_size,
|
||||
commands::perform_current_window_titlebar_double_click,
|
||||
commands::save_settings,
|
||||
commands::save_ai_workspace_sessions,
|
||||
commands::download_and_install_app_update,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
|
||||
@@ -313,6 +313,55 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&preferred_app_config_path("settings.json")?, settings)
|
||||
}
|
||||
|
||||
fn ai_workspace_sessions_path() -> Result<PathBuf, String> {
|
||||
resolve_existing_or_preferred_app_config_path("ai-workspace-sessions.json")
|
||||
}
|
||||
|
||||
fn get_ai_workspace_sessions_at(path: &PathBuf) -> Result<serde_json::Value, String> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read AI workspace sessions: {}", e))?;
|
||||
let sessions: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse AI workspace sessions: {}", e))?;
|
||||
if sessions.is_object() {
|
||||
Ok(sessions)
|
||||
} else {
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
fn save_ai_workspace_sessions_at(
|
||||
path: &PathBuf,
|
||||
sessions: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
if !sessions.is_object() {
|
||||
return Err("AI workspace sessions must be a JSON object".to_string());
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&sessions)
|
||||
.map_err(|e| format!("Failed to serialize AI workspace sessions: {}", e))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write AI workspace sessions: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_ai_workspace_sessions() -> Result<serde_json::Value, String> {
|
||||
get_ai_workspace_sessions_at(&ai_workspace_sessions_path()?)
|
||||
}
|
||||
|
||||
pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> {
|
||||
save_ai_workspace_sessions_at(
|
||||
&preferred_app_config_path("ai-workspace-sessions.json")?,
|
||||
sessions,
|
||||
)
|
||||
}
|
||||
|
||||
fn last_vault_file() -> Result<PathBuf, String> {
|
||||
resolve_existing_or_preferred_app_config_path("last-vault.txt")
|
||||
}
|
||||
@@ -746,6 +795,47 @@ mod tests {
|
||||
.contains("com.tolaria.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_workspace_sessions_roundtrip() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("ai-workspace-sessions.json");
|
||||
let sessions = serde_json::json!({
|
||||
"chat-1": {
|
||||
"messages": [
|
||||
{
|
||||
"userMessage": "Hello",
|
||||
"actions": [],
|
||||
"response": "Hi"
|
||||
}
|
||||
],
|
||||
"status": "done"
|
||||
}
|
||||
});
|
||||
|
||||
save_ai_workspace_sessions_at(&path, sessions.clone()).unwrap();
|
||||
|
||||
assert_eq!(get_ai_workspace_sessions_at(&path).unwrap(), sessions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_workspace_sessions_missing_file_returns_empty_object() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("ai-workspace-sessions.json");
|
||||
|
||||
assert_eq!(
|
||||
get_ai_workspace_sessions_at(&path).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_workspace_sessions_rejects_non_object_payload() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("ai-workspace-sessions.json");
|
||||
|
||||
assert!(save_ai_workspace_sessions_at(&path, serde_json::json!([])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -2014,15 +2014,15 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onOpenAiWorkspace={aiFeaturesEnabled ? handleToggleAiWorkspace : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} locale={appLocale} />
|
||||
{aiFeaturesEnabled && !effectiveShowAIChat ? (
|
||||
<AiWorkspaceFloatingButton
|
||||
statuses={aiAgentsStatus}
|
||||
guidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultTarget={settings.default_ai_target ?? undefined}
|
||||
providers={settings.ai_model_providers ?? []}
|
||||
locale={appLocale}
|
||||
updateBannerVisible={updateStatus.state !== 'idle' && updateStatus.state !== 'error'}
|
||||
onOpen={handleToggleAiWorkspace}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { VaultEntry } from '../types'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
|
||||
let mockedAgentStatus: AgentStatus = 'idle'
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
|
||||
let controllerCalls: unknown[] = []
|
||||
const { generateTitleMock } = vi.hoisted(() => ({
|
||||
generateTitleMock: vi.fn(),
|
||||
@@ -37,7 +38,7 @@ vi.mock('./useAiPanelController', () => ({
|
||||
controllerCalls.push(args)
|
||||
return {
|
||||
agent: {
|
||||
messages: [],
|
||||
messages: mockMessages,
|
||||
status: mockedAgentStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
@@ -166,9 +167,10 @@ function contextReadyControllerCalls(): unknown[] {
|
||||
describe('AiWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
mockedAgentStatus = 'idle'
|
||||
mockMessages = []
|
||||
controllerCalls = []
|
||||
generateTitleMock.mockReset()
|
||||
generateTitleMock.mockResolvedValue('Summarize quarterly sponsor outreach')
|
||||
generateTitleMock.mockResolvedValue('Quarterly sponsor outreach')
|
||||
localStorage.clear()
|
||||
resetVaultConfigStore()
|
||||
})
|
||||
@@ -199,7 +201,7 @@ describe('AiWorkspace', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat'))
|
||||
|
||||
expect(screen.getAllByText('AI Chat 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('AI Chat 2').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -219,7 +221,7 @@ describe('AiWorkspace', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mock history scroll' }))
|
||||
expect(header).toHaveClass('border-b')
|
||||
expect(tabStrip).toHaveClass('overflow-x-auto')
|
||||
expect(screen.getByRole('button', { name: 'AI Chat 1' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'AI Chat' })).toBeTruthy()
|
||||
expect(
|
||||
within(header).getByRole('button', { name: 'Expand AI workspace' }).compareDocumentPosition(
|
||||
within(header).getByRole('button', { name: 'Close AI workspace' }),
|
||||
@@ -245,6 +247,25 @@ describe('AiWorkspace', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens AI settings from the composer controls', () => {
|
||||
const onOpenAiSettings = vi.fn()
|
||||
render(
|
||||
<AiWorkspace
|
||||
open
|
||||
mode="side"
|
||||
aiAgentsStatus={installedStatuses()}
|
||||
aiModelProviders={providers}
|
||||
vaultPath="/tmp/vault"
|
||||
onClose={vi.fn()}
|
||||
onOpenAiSettings={onOpenAiSettings}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('ai-workspace-composer-settings'))
|
||||
|
||||
expect(onOpenAiSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports the active side chat so reopening can restore it', () => {
|
||||
const onActiveConversationChange = vi.fn()
|
||||
|
||||
@@ -296,14 +317,14 @@ describe('AiWorkspace', () => {
|
||||
mode="side"
|
||||
aiAgentsStatus={installedStatuses()}
|
||||
aiModelProviders={providers}
|
||||
conversationSettings={[{ id: 'chat-a', title: 'AI Chat 1', target_id: null, archived: false }]}
|
||||
conversationSettings={[{ id: 'chat-a', title: 'AI Chat', target_id: null, archived: false }]}
|
||||
vaultPath="/tmp/vault"
|
||||
onClose={vi.fn()}
|
||||
onConversationSettingsChange={onConversationSettingsChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: 'AI Chat 1' }))
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: 'AI Chat' }))
|
||||
const input = screen.getByLabelText('Rename chat')
|
||||
fireEvent.change(input, { target: { value: 'Research thread' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
@@ -381,7 +402,7 @@ describe('AiWorkspace', () => {
|
||||
expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true)
|
||||
fireEvent.click(archiveButtons[0])
|
||||
|
||||
expect(screen.getAllByText('AI Chat 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('AI Chat 2')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -471,16 +492,35 @@ describe('AiWorkspace', () => {
|
||||
expect(within(menu).getByText('OpenAI · GPT-4.1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renames the first chat from the selected target title request and stores conversation settings', async () => {
|
||||
it('marks the first chat active when a prompt is submitted', () => {
|
||||
const onConversationSettingsChange = vi.fn()
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Send mocked prompt'))
|
||||
|
||||
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
|
||||
expect(generateTitleMock).not.toHaveBeenCalled()
|
||||
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ title: 'AI Chat' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('renames the first chat after the first assistant reply and stores conversation settings', async () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'summarize quarterly sponsor outreach',
|
||||
actions: [],
|
||||
response: 'The next step is to follow up with the sponsor pipeline.',
|
||||
id: 'msg-title',
|
||||
}]
|
||||
const onConversationSettingsChange = vi.fn()
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Summarize quarterly sponsor outreach').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(generateTitleMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
assistantResponse: 'The next step is to follow up with the sponsor pipeline.',
|
||||
permissionMode: 'safe',
|
||||
prompt: 'summarize quarterly sponsor outreach',
|
||||
target: expect.objectContaining({ kind: 'agent', agent: 'claude_code' }),
|
||||
@@ -489,11 +529,17 @@ describe('AiWorkspace', () => {
|
||||
}))
|
||||
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
|
||||
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ title: 'Summarize quarterly sponsor outreach' }),
|
||||
expect.objectContaining({ title: 'Quarterly sponsor outreach' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('renames a persisted default chat title from the first prompt', async () => {
|
||||
it('renames a persisted default chat title after the first assistant reply', async () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'summarize quarterly sponsor outreach',
|
||||
actions: [],
|
||||
response: 'The next step is to follow up with the sponsor pipeline.',
|
||||
id: 'msg-title',
|
||||
}]
|
||||
const onConversationSettingsChange = vi.fn()
|
||||
render(
|
||||
<AiWorkspace
|
||||
@@ -508,13 +554,11 @@ describe('AiWorkspace', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Send mocked prompt'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Summarize quarterly sponsor outreach').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ id: 'stored-chat', title: 'Summarize quarterly sponsor outreach' }),
|
||||
expect.objectContaining({ id: 'stored-chat', title: 'Quarterly sponsor outreach' }),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -523,7 +567,7 @@ describe('AiWorkspace', () => {
|
||||
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: /chat 1/i }))
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: /^AI Chat$/i }))
|
||||
const input = screen.getByLabelText('Rename chat')
|
||||
fireEvent.change(input, { target: { value: 'Sponsor Plan' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
} from '../lib/vaultAiGuidance'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { trackAiWorkspaceChatTitled, trackAiWorkspaceSidebarToggled } from '../lib/productAnalytics'
|
||||
import type { AgentStatus } from '../hooks/useCliAiAgent'
|
||||
import type { AgentStatus, AiAgentMessage } from '../hooks/useCliAiAgent'
|
||||
import type { AiWorkspaceConversationSetting } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -231,28 +231,44 @@ function createConversation(locale: AppLocale, target: AiTarget, index: number):
|
||||
hasActivity: false,
|
||||
id: nextConversationId(),
|
||||
targetId: target.id,
|
||||
title: translate(locale, 'ai.workspace.chatTitle', { index }),
|
||||
title: defaultConversationTitle(locale, index),
|
||||
usesDefaultTitle: true,
|
||||
usesDefaultTarget: true,
|
||||
}
|
||||
}
|
||||
|
||||
function isDefaultConversationTitle(title: string): boolean {
|
||||
return /^(AI\s+)?Chat\s+\d+$/i.test(title.trim())
|
||||
function defaultConversationTitle(locale: AppLocale, index: number): string {
|
||||
if (index <= 1) return translate(locale, 'ai.workspace.chatTitle', { index: '' }).trim()
|
||||
return translate(locale, 'ai.workspace.chatTitle', { index })
|
||||
}
|
||||
|
||||
function conversationFromSetting(setting: AiWorkspaceConversationSetting, fallbackTarget: AiTarget): AiConversation | null {
|
||||
function isDefaultConversationTitle(title: string): boolean {
|
||||
return /^(AI\s+)?Chat(?:\s+\d+)?$/i.test(title.trim())
|
||||
}
|
||||
|
||||
function defaultConversationTitleIndex(title: string): number {
|
||||
const match = title.trim().match(/\d+$/)
|
||||
if (!match) return 1
|
||||
const parsed = Number(match[0])
|
||||
return Number.isFinite(parsed) ? parsed : 1
|
||||
}
|
||||
|
||||
function conversationFromSetting(setting: AiWorkspaceConversationSetting, fallbackTarget: AiTarget, locale: AppLocale): AiConversation | null {
|
||||
const id = setting.id.trim()
|
||||
const title = setting.title.trim()
|
||||
if (!id || !title) return null
|
||||
const storedTitle = setting.title.trim()
|
||||
if (!id || !storedTitle) return null
|
||||
const usesDefaultTitle = isDefaultConversationTitle(storedTitle)
|
||||
const title = usesDefaultTitle
|
||||
? defaultConversationTitle(locale, defaultConversationTitleIndex(storedTitle))
|
||||
: storedTitle
|
||||
|
||||
return {
|
||||
archived: setting.archived === true,
|
||||
hasActivity: !isDefaultConversationTitle(title),
|
||||
hasActivity: !usesDefaultTitle,
|
||||
id,
|
||||
targetId: setting.target_id?.trim() || fallbackTarget.id,
|
||||
title,
|
||||
usesDefaultTitle: isDefaultConversationTitle(title),
|
||||
usesDefaultTitle,
|
||||
usesDefaultTarget: !setting.target_id,
|
||||
}
|
||||
}
|
||||
@@ -263,7 +279,7 @@ function conversationsFromSettings(
|
||||
locale: AppLocale,
|
||||
): AiConversation[] {
|
||||
const stored = (settings ?? [])
|
||||
.map((setting) => conversationFromSetting(setting, fallbackTarget))
|
||||
.map((setting) => conversationFromSetting(setting, fallbackTarget, locale))
|
||||
.filter((conversation): conversation is AiConversation => conversation !== null)
|
||||
return stored.length > 0 ? stored : [createConversation(locale, fallbackTarget, 1)]
|
||||
}
|
||||
@@ -325,7 +341,7 @@ function forkConversationState(
|
||||
hasActivity: true,
|
||||
id: nextConversationId(),
|
||||
targetId: source.targetId,
|
||||
title: source.usesDefaultTitle ? translate(locale, 'ai.workspace.chatTitle', { index }) : source.title,
|
||||
title: source.usesDefaultTitle ? defaultConversationTitle(locale, index) : source.title,
|
||||
usesDefaultTitle: false,
|
||||
usesDefaultTarget: source.usesDefaultTarget,
|
||||
}
|
||||
@@ -421,7 +437,7 @@ function applyGeneratedConversationTitleState(current: AiConversation[], id: str
|
||||
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id && conversation.usesDefaultTitle
|
||||
? { ...conversation, title: nextTitle, usesDefaultTitle: false }
|
||||
? { ...conversation, hasActivity: true, title: nextTitle, usesDefaultTitle: false }
|
||||
: conversation
|
||||
))
|
||||
}
|
||||
@@ -498,9 +514,11 @@ function useConversations({
|
||||
setConversations((current) => renameConversationState(current, id, title))
|
||||
}, [])
|
||||
|
||||
const titleConversationFromPrompt = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => {
|
||||
setConversations((current) => markConversationActivityState(current, request.id))
|
||||
const markConversationActivity = useCallback((id: string) => {
|
||||
setConversations((current) => markConversationActivityState(current, id))
|
||||
}, [])
|
||||
|
||||
const titleConversationFromAnswer = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => {
|
||||
void generateAiConversationTitleForTarget(request).then((title) => {
|
||||
if (!title) return
|
||||
setConversations((current) => applyGeneratedConversationTitleState(current, request.id, title))
|
||||
@@ -534,7 +552,8 @@ function useConversations({
|
||||
setConversationTarget,
|
||||
setShowArchived,
|
||||
showArchived,
|
||||
titleConversationFromPrompt,
|
||||
markConversationActivity,
|
||||
titleConversationFromAnswer,
|
||||
updateDefaultConversationTargets,
|
||||
}
|
||||
}
|
||||
@@ -835,7 +854,8 @@ type ConversationSessionProps = {
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
onSelectTarget: (targetId: string) => void
|
||||
onStatusChange: (id: string, status: AgentStatus) => void
|
||||
onTitleFromPrompt: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
onPromptSubmitted: (id: string) => void
|
||||
onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
onVaultChanged?: () => void
|
||||
openTabs?: VaultEntry[]
|
||||
@@ -845,6 +865,71 @@ type ConversationSessionProps = {
|
||||
vaultPaths?: string[]
|
||||
}
|
||||
|
||||
function firstCompletedAssistantMessage(messages: AiAgentMessage[]): AiAgentMessage | undefined {
|
||||
return messages.find((message) => (
|
||||
!message.localMarker
|
||||
&& !message.isStreaming
|
||||
&& !!message.userMessage.trim()
|
||||
&& !!message.response?.trim()
|
||||
))
|
||||
}
|
||||
|
||||
function useGeneratedConversationTitle({
|
||||
aiAgentsStatus,
|
||||
conversation,
|
||||
messages,
|
||||
onTitleFromAnswer,
|
||||
permissionMode,
|
||||
target,
|
||||
vaultPath,
|
||||
vaultPaths,
|
||||
}: {
|
||||
aiAgentsStatus: AiAgentsStatus
|
||||
conversation: AiConversation
|
||||
messages: AiAgentMessage[]
|
||||
onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
permissionMode: AiAgentPermissionMode
|
||||
target: AiTarget
|
||||
vaultPath: string
|
||||
vaultPaths?: string[]
|
||||
}) {
|
||||
const requestedTitleKeysRef = useRef(new Set<string>())
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversation.usesDefaultTitle) return
|
||||
|
||||
const firstMessage = firstCompletedAssistantMessage(messages)
|
||||
const prompt = firstMessage?.userMessage.trim()
|
||||
const assistantResponse = firstMessage?.response?.trim()
|
||||
if (!firstMessage || !prompt || !assistantResponse) return
|
||||
|
||||
const titleKey = `${conversation.id}:${firstMessage.id ?? prompt}`
|
||||
if (requestedTitleKeysRef.current.has(titleKey)) return
|
||||
requestedTitleKeysRef.current.add(titleKey)
|
||||
|
||||
onTitleFromAnswer({
|
||||
assistantResponse,
|
||||
id: conversation.id,
|
||||
permissionMode,
|
||||
prompt,
|
||||
target,
|
||||
targetReady: aiTargetReady(target, aiAgentsStatus),
|
||||
vaultPath,
|
||||
vaultPaths,
|
||||
})
|
||||
}, [
|
||||
aiAgentsStatus,
|
||||
conversation.id,
|
||||
conversation.usesDefaultTitle,
|
||||
messages,
|
||||
onTitleFromAnswer,
|
||||
permissionMode,
|
||||
target,
|
||||
vaultPath,
|
||||
vaultPaths,
|
||||
])
|
||||
}
|
||||
|
||||
function ConversationSession({
|
||||
active,
|
||||
activeEntry,
|
||||
@@ -871,7 +956,8 @@ function ConversationSession({
|
||||
onRestoreVaultAiGuidance,
|
||||
onSelectTarget,
|
||||
onStatusChange,
|
||||
onTitleFromPrompt,
|
||||
onPromptSubmitted,
|
||||
onTitleFromAnswer,
|
||||
onUnsupportedAiPaste,
|
||||
onVaultChanged,
|
||||
openTabs,
|
||||
@@ -925,12 +1011,36 @@ function ConversationSession({
|
||||
targetKind={target.kind}
|
||||
onChange={controller.handlePermissionModeChange}
|
||||
/>
|
||||
{onOpenAiSettings && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
aria-label={translate(locale, 'ai.workspace.settings')}
|
||||
title={translate(locale, 'ai.workspace.settings')}
|
||||
onClick={onOpenAiSettings}
|
||||
data-testid="ai-workspace-composer-settings"
|
||||
>
|
||||
<GearSix size={16} weight="regular" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange(conversation.id, controller.agent.status)
|
||||
}, [conversation.id, controller.agent.status, onStatusChange])
|
||||
useGeneratedConversationTitle({
|
||||
aiAgentsStatus,
|
||||
conversation,
|
||||
messages: controller.agent.messages,
|
||||
onTitleFromAnswer,
|
||||
permissionMode: controller.permissionMode,
|
||||
target,
|
||||
vaultPath,
|
||||
vaultPaths,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={active ? 'flex min-h-0 flex-1 flex-col' : 'hidden'} data-testid={`ai-workspace-session-${conversation.id}`}>
|
||||
@@ -964,15 +1074,7 @@ function ConversationSession({
|
||||
onForkMessage={onForkMessage}
|
||||
onMessageHistoryScrollStateChange={active ? onMessageHistoryScrollStateChange : undefined}
|
||||
onOpenNote={onOpenNote}
|
||||
onSendPrompt={(prompt) => onTitleFromPrompt({
|
||||
id: conversation.id,
|
||||
permissionMode: controller.permissionMode,
|
||||
prompt,
|
||||
target,
|
||||
targetReady: aiTargetReady(target, aiAgentsStatus),
|
||||
vaultPath,
|
||||
vaultPaths,
|
||||
})}
|
||||
onSendPrompt={() => onPromptSubmitted(conversation.id)}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
showHeader={false}
|
||||
showLeftBorder={false}
|
||||
@@ -1012,7 +1114,8 @@ interface AiWorkspaceModel {
|
||||
setShowArchived: (show: boolean) => void
|
||||
showArchived: boolean
|
||||
statuses: Record<string, AgentStatus>
|
||||
titleConversationFromPrompt: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
markConversationActivity: (id: string) => void
|
||||
titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
toggleSidebarCollapsed: () => void
|
||||
updateDefaultConversationTargets: (targetId: string) => void
|
||||
}
|
||||
@@ -1572,24 +1675,24 @@ function useArchiveConversationSafely({
|
||||
function useTrackedConversationActions({
|
||||
conversations,
|
||||
renameConversation,
|
||||
titleConversationFromPrompt,
|
||||
titleConversationFromAnswer,
|
||||
}: {
|
||||
conversations: AiConversation[]
|
||||
renameConversation: (id: string, title: string) => void
|
||||
titleConversationFromPrompt: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void
|
||||
}) {
|
||||
const trackedRenameConversation = useCallback((id: string, title: string) => {
|
||||
if (!title.trim()) return
|
||||
renameConversation(id, title)
|
||||
trackAiWorkspaceChatTitled('manual')
|
||||
}, [renameConversation])
|
||||
const trackedTitleConversationFromPrompt = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => {
|
||||
const trackedTitleConversationFromAnswer = useCallback((request: GenerateAiConversationTitleRequest & { id: string }) => {
|
||||
const conversation = conversations.find((candidate) => candidate.id === request.id)
|
||||
titleConversationFromPrompt(request)
|
||||
titleConversationFromAnswer(request)
|
||||
if (conversation?.usesDefaultTitle) trackAiWorkspaceChatTitled('generated')
|
||||
}, [conversations, titleConversationFromPrompt])
|
||||
}, [conversations, titleConversationFromAnswer])
|
||||
|
||||
return { trackedRenameConversation, trackedTitleConversationFromPrompt }
|
||||
return { trackedRenameConversation, trackedTitleConversationFromAnswer }
|
||||
}
|
||||
|
||||
function useAiWorkspaceNewChatEvent(open: boolean, addDefaultConversation: () => void) {
|
||||
@@ -1624,7 +1727,8 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
setConversationTarget,
|
||||
setShowArchived,
|
||||
showArchived,
|
||||
titleConversationFromPrompt,
|
||||
markConversationActivity,
|
||||
titleConversationFromAnswer,
|
||||
updateDefaultConversationTargets,
|
||||
} = useConversations({
|
||||
fallbackTarget,
|
||||
@@ -1657,10 +1761,10 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
|
||||
cloneAiWorkspaceSessionUntilMessage(sourceId, targetId, messageId)
|
||||
}, [forkConversation])
|
||||
const { trackedRenameConversation, trackedTitleConversationFromPrompt } = useTrackedConversationActions({
|
||||
const { trackedRenameConversation, trackedTitleConversationFromAnswer } = useTrackedConversationActions({
|
||||
conversations,
|
||||
renameConversation,
|
||||
titleConversationFromPrompt,
|
||||
titleConversationFromAnswer,
|
||||
})
|
||||
const toggleSidebarCollapsed = useCallback(() => {
|
||||
setSidebarCollapsed((current) => {
|
||||
@@ -1696,7 +1800,8 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo
|
||||
setShowArchived,
|
||||
showArchived,
|
||||
statuses,
|
||||
titleConversationFromPrompt: trackedTitleConversationFromPrompt,
|
||||
markConversationActivity,
|
||||
titleConversationFromAnswer: trackedTitleConversationFromAnswer,
|
||||
toggleSidebarCollapsed,
|
||||
updateDefaultConversationTargets,
|
||||
}
|
||||
@@ -1787,7 +1892,8 @@ function ConversationSessions({
|
||||
onRestoreVaultAiGuidance={workspace.onRestoreVaultAiGuidance}
|
||||
onSelectTarget={(targetId) => model.setConversationTarget(conversation.id, targetId)}
|
||||
onStatusChange={model.handleStatusChange}
|
||||
onTitleFromPrompt={model.titleConversationFromPrompt}
|
||||
onPromptSubmitted={model.markConversationActivity}
|
||||
onTitleFromAnswer={model.titleConversationFromAnswer}
|
||||
onUnsupportedAiPaste={workspace.onUnsupportedAiPaste}
|
||||
onVaultChanged={workspace.onVaultChanged}
|
||||
openTabs={workspace.openTabs}
|
||||
|
||||
75
src/components/AiWorkspaceFloatingButton.test.tsx
Normal file
75
src/components/AiWorkspaceFloatingButton.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import {
|
||||
createAiAgentAvailability,
|
||||
createMissingAiAgentsStatus,
|
||||
type AiAgentsStatus,
|
||||
} from '../lib/aiAgents'
|
||||
import { AiWorkspaceFloatingButton } from './AiWorkspaceFloatingButton'
|
||||
|
||||
function createCodexReadyStatus(): AiAgentsStatus {
|
||||
return {
|
||||
...createMissingAiAgentsStatus(),
|
||||
codex: createAiAgentAvailability('installed', '0.12.0'),
|
||||
}
|
||||
}
|
||||
|
||||
function renderButton({
|
||||
statuses = createCodexReadyStatus(),
|
||||
updateBannerVisible = false,
|
||||
}: {
|
||||
statuses?: AiAgentsStatus
|
||||
updateBannerVisible?: boolean
|
||||
} = {}) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AiWorkspaceFloatingButton
|
||||
defaultAgent="codex"
|
||||
statuses={statuses}
|
||||
updateBannerVisible={updateBannerVisible}
|
||||
onOpen={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AiWorkspaceFloatingButton', () => {
|
||||
it('uses the normal bottom offset when no update banner is visible', () => {
|
||||
renderButton()
|
||||
|
||||
expect(screen.getByTestId('ai-workspace-floating-button')).toHaveClass('bottom-11')
|
||||
})
|
||||
|
||||
it('moves above the update banner when one is visible', () => {
|
||||
renderButton({ updateBannerVisible: true })
|
||||
|
||||
expect(screen.getByTestId('ai-workspace-floating-button')).toHaveClass('bottom-[80px]')
|
||||
})
|
||||
|
||||
it('uses the selected agent icon when that agent is installed', () => {
|
||||
renderButton()
|
||||
|
||||
const iconImage = screen.getByTestId('ai-workspace-floating-button').querySelector('img')
|
||||
expect(iconImage).toHaveAttribute('src', '/ai-agent-icons/codex.svg')
|
||||
})
|
||||
|
||||
it('falls back to sparkles when no selected agent is available', () => {
|
||||
renderButton({ statuses: createMissingAiAgentsStatus() })
|
||||
|
||||
expect(screen.getByTestId('ai-workspace-floating-button').querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the AI panel shortcut in the tooltip', async () => {
|
||||
renderButton()
|
||||
|
||||
act(() => {
|
||||
fireEvent.focus(screen.getByTestId('ai-workspace-floating-button'))
|
||||
})
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent('Open the AI panel')
|
||||
expect(tooltip).toHaveTextContent(getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat) ?? '')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Sparkle, Warning } from '@phosphor-icons/react'
|
||||
import { Sparkle } from '@phosphor-icons/react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import {
|
||||
hasAnyInstalledAiAgent,
|
||||
isAiAgentInstalled,
|
||||
type AiAgentId,
|
||||
type AiAgentsStatus,
|
||||
@@ -12,20 +14,16 @@ import {
|
||||
type AiTarget,
|
||||
} from '../lib/aiTargets'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import {
|
||||
vaultAiGuidanceNeedsRestore,
|
||||
type VaultAiGuidanceStatus,
|
||||
} from '../lib/vaultAiGuidance'
|
||||
import type { Settings } from '../types'
|
||||
import { AiAgentIcon } from './AiAgentIcon'
|
||||
|
||||
interface AiWorkspaceFloatingButtonProps {
|
||||
defaultAgent: AiAgentId
|
||||
defaultTarget?: string
|
||||
guidanceStatus?: VaultAiGuidanceStatus
|
||||
locale?: AppLocale
|
||||
providers?: AiModelProvider[]
|
||||
statuses: AiAgentsStatus
|
||||
updateBannerVisible?: boolean
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
@@ -41,60 +39,50 @@ function selectedTargetForButton({
|
||||
} as Settings)
|
||||
}
|
||||
|
||||
function hasFloatingButtonWarning({
|
||||
guidanceStatus,
|
||||
function FloatingButtonIcon({
|
||||
selectedTarget,
|
||||
statuses,
|
||||
}: {
|
||||
guidanceStatus?: VaultAiGuidanceStatus
|
||||
selectedTarget: AiTarget
|
||||
statuses: AiAgentsStatus
|
||||
}): boolean {
|
||||
if (guidanceStatus && vaultAiGuidanceNeedsRestore(guidanceStatus)) return true
|
||||
if (selectedTarget.kind !== 'agent') return false
|
||||
return !hasAnyInstalledAiAgent(statuses) || !isAiAgentInstalled(statuses, selectedTarget.agent)
|
||||
}
|
||||
|
||||
function FloatingButtonIcon({
|
||||
selectedTarget,
|
||||
showWarning,
|
||||
}: {
|
||||
selectedTarget: AiTarget
|
||||
showWarning: boolean
|
||||
}) {
|
||||
if (showWarning) return <Warning size={22} weight="regular" />
|
||||
if (selectedTarget.kind === 'agent') return <AiAgentIcon agent={selectedTarget.agent} size={24} />
|
||||
if (selectedTarget.kind === 'agent' && isAiAgentInstalled(statuses, selectedTarget.agent)) {
|
||||
return <AiAgentIcon agent={selectedTarget.agent} size={24} />
|
||||
}
|
||||
return <Sparkle size={22} weight="regular" />
|
||||
}
|
||||
|
||||
export function AiWorkspaceFloatingButton({
|
||||
defaultAgent,
|
||||
defaultTarget,
|
||||
guidanceStatus,
|
||||
locale = 'en',
|
||||
providers = [],
|
||||
statuses,
|
||||
updateBannerVisible = false,
|
||||
onOpen,
|
||||
}: AiWorkspaceFloatingButtonProps) {
|
||||
const selectedTarget = selectedTargetForButton({ defaultAgent, defaultTarget, providers })
|
||||
const showWarning = hasFloatingButtonWarning({ guidanceStatus, selectedTarget, statuses })
|
||||
const label = translate(locale, 'editor.toolbar.openAi')
|
||||
const shortcut = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat)
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed right-5 bottom-11 z-30 size-12 rounded-full border border-border bg-background text-foreground shadow-[0_10px_28px_rgba(15,23,42,0.18),0_2px_8px_rgba(15,23,42,0.12)] hover:bg-background hover:text-foreground"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-tooltip-mode="native-title"
|
||||
data-testid="ai-workspace-floating-button"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className={showWarning ? 'text-[var(--accent-orange)]' : undefined}>
|
||||
<FloatingButtonIcon selectedTarget={selectedTarget} showWarning={showWarning} />
|
||||
</span>
|
||||
</Button>
|
||||
<ActionTooltip copy={{ label, shortcut }} side="top" align="end" sideOffset={10}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'fixed right-5 z-30 size-12 rounded-full border border-border bg-background text-foreground shadow-[0_10px_28px_rgba(15,23,42,0.18),0_2px_8px_rgba(15,23,42,0.12)] hover:bg-background hover:text-foreground',
|
||||
updateBannerVisible ? 'bottom-[80px]' : 'bottom-11',
|
||||
)}
|
||||
aria-label={label}
|
||||
data-testid="ai-workspace-floating-button"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center leading-none">
|
||||
<FloatingButtonIcon selectedTarget={selectedTarget} statuses={statuses} />
|
||||
</span>
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
GitBranch,
|
||||
Code,
|
||||
ListBullets,
|
||||
GearSix,
|
||||
SidebarSimple,
|
||||
Trash,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
@@ -403,7 +403,7 @@ function InspectorAction({
|
||||
testId="breadcrumb-properties-button"
|
||||
tooltipAlign="end"
|
||||
>
|
||||
<GearSix size={16} weight="regular" className={BREADCRUMB_ICON_CLASS} />
|
||||
<SidebarSimple size={16} weight="regular" className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,27 +4,12 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { StatusBarPrimarySection } from './status-bar/StatusBarSections'
|
||||
import type { VaultOption } from './StatusBar'
|
||||
vi.mock('../utils/url', async () => {
|
||||
const actual = await vi.importActual('../utils/url')
|
||||
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
|
||||
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & { openExternalUrl: ReturnType<typeof vi.fn> }
|
||||
|
||||
const vaults: VaultOption[] = [
|
||||
{ label: 'Main Vault', path: '/Users/luca/Laputa', alias: 'main', mounted: true },
|
||||
{ label: 'Work Vault', path: '/Users/luca/Work', alias: 'work', mounted: false },
|
||||
]
|
||||
|
||||
const installedAiAgentsStatus = {
|
||||
claude_code: { status: 'installed' as const, version: '1.0.20' },
|
||||
codex: { status: 'installed' as const, version: '0.37.0' },
|
||||
opencode: { status: 'installed' as const, version: '0.3.1' },
|
||||
pi: { status: 'installed' as const, version: '0.70.2' },
|
||||
gemini: { status: 'installed' as const, version: '0.5.1' },
|
||||
kiro: { status: 'installed' as const, version: '0.12.0' },
|
||||
}
|
||||
|
||||
const DEFAULT_WINDOW_WIDTH = 1280
|
||||
|
||||
function setWindowWidth(width: number) {
|
||||
@@ -50,7 +35,6 @@ function renderDenseStatusBar() {
|
||||
buildNumber="b281"
|
||||
onCheckForUpdates={vi.fn()}
|
||||
mcpStatus="not_installed"
|
||||
claudeCodeStatus="missing"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -565,7 +549,7 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-build-number')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-claude-code')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Commit')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('History')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Contribute')).not.toBeInTheDocument()
|
||||
@@ -588,7 +572,7 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the active AI agent label in compact status layout', () => {
|
||||
it('does not render the legacy AI agent control in the status bar', () => {
|
||||
setWindowWidth(920)
|
||||
render(
|
||||
<StatusBar
|
||||
@@ -596,12 +580,10 @@ describe('StatusBar', () => {
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
aiAgentsStatus={installedAiAgentsStatus}
|
||||
defaultAiAgent="claude_code"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-ai-agents')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('status-ai-agents')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Claude')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -932,106 +914,9 @@ describe('StatusBar', () => {
|
||||
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Claude Code badge when installed', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="installed" claudeCodeVersion="1.0.20" />)
|
||||
const badge = screen.getByTestId('status-claude-code')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude Code 1.0.20' }), 'Claude Code 1.0.20')
|
||||
})
|
||||
|
||||
it('shows Claude Code missing badge with warning when missing', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
|
||||
const badge = screen.getByTestId('status-claude-code')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code missing')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude Code not found — click to install' }), 'Claude Code not found — click to install')
|
||||
})
|
||||
|
||||
it('opens install URL when clicking missing Claude Code badge', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
|
||||
fireEvent.click(screen.getByTestId('status-claude-code'))
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
|
||||
})
|
||||
|
||||
it('opens install URL on Enter key for missing Claude Code badge', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
|
||||
fireEvent.keyDown(screen.getByTestId('status-claude-code'), { key: 'Enter' })
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
|
||||
})
|
||||
|
||||
it('hides Claude Code badge when status is checking', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="checking" />)
|
||||
expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Claude Code badge when no claudeCodeStatus prop provided', () => {
|
||||
it('does not render the legacy Claude Code badge in the status bar', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the active AI agent directly in the bottom bar', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
aiAgentsStatus={installedAiAgentsStatus}
|
||||
defaultAiAgent="claude_code"
|
||||
onSetDefaultAiAgent={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-ai-agents')).toHaveTextContent('Claude')
|
||||
})
|
||||
|
||||
it('opens the AI workspace from the status AI badge when provided', () => {
|
||||
const onOpenAiWorkspace = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
aiAgentsStatus={installedAiAgentsStatus}
|
||||
defaultAiAgent="claude_code"
|
||||
onOpenAiWorkspace={onOpenAiWorkspace}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-ai-agents'))
|
||||
|
||||
expect(onOpenAiWorkspace).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens the AI agent switcher from the keyboard and switches agents', () => {
|
||||
const onSetDefaultAiAgent = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
aiAgentsStatus={installedAiAgentsStatus}
|
||||
defaultAiAgent="claude_code"
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTestId('status-ai-agents')
|
||||
trigger.focus()
|
||||
act(() => {
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
})
|
||||
|
||||
const codexOption = screen.getByRole('menuitemradio', { name: /Codex/ })
|
||||
codexOption.focus()
|
||||
act(() => {
|
||||
fireEvent.keyDown(codexOption, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onSetDefaultAiAgent).toHaveBeenCalledWith('codex')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AiModelProvider } from '../lib/aiTargets'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import type { ThemeMode } from '../lib/themeMode'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
@@ -107,17 +103,6 @@ interface StatusBarProps {
|
||||
aiFeaturesEnabled?: boolean
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onOpenAiWorkspace?: () => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
@@ -166,17 +151,6 @@ function StatusBarPrimaryFromFooter({
|
||||
aiFeaturesEnabled = true,
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onOpenAiWorkspace,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
locale = 'en',
|
||||
compact,
|
||||
stacked,
|
||||
@@ -221,17 +195,6 @@ function StatusBarPrimaryFromFooter({
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
mcpStatus={aiFeaturesEnabled ? mcpStatus : undefined}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onOpenAiWorkspace={onOpenAiWorkspace}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
locale={locale}
|
||||
stacked={stacked}
|
||||
compact={compact}
|
||||
|
||||
@@ -13,6 +13,10 @@ interface UpdateBannerProps {
|
||||
|
||||
type VisibleUpdateStatus = Exclude<UpdateStatus, { state: 'idle' } | { state: 'error' }>
|
||||
|
||||
function isUpdateBannerVisible(status: UpdateStatus): status is VisibleUpdateStatus {
|
||||
return status.state !== 'idle' && status.state !== 'error'
|
||||
}
|
||||
|
||||
const bannerStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -174,7 +178,7 @@ function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions
|
||||
}
|
||||
|
||||
export function UpdateBanner({ status, actions, locale = 'en' }: UpdateBannerProps) {
|
||||
if (status.state === 'idle' || status.state === 'error') return null
|
||||
if (!isUpdateBannerVisible(status)) return null
|
||||
|
||||
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions, locale)}</div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { BookOpen, GearSix as Settings, Megaphone, Moon, Package, Sun, type IconProps } from '@phosphor-icons/react'
|
||||
import type { ComponentType, MouseEventHandler } from 'react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
|
||||
import type { AiModelProvider } from '../../lib/aiTargets'
|
||||
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
import type { ThemeMode } from '../../lib/themeMode'
|
||||
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
|
||||
@@ -11,11 +7,9 @@ import { useStatusBarAddRemote } from '../../hooks/useStatusBarAddRemote'
|
||||
import type { GitRemoteStatus, SyncStatus } from '../../types'
|
||||
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { AiAgentsBadge } from './AiAgentsBadge'
|
||||
import { AddRemoteModal } from '../AddRemoteModal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ClaudeCodeBadge,
|
||||
CommitButton,
|
||||
ConflictBadge,
|
||||
ChangesBadge,
|
||||
@@ -80,17 +74,6 @@ interface StatusBarPrimarySectionProps {
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onOpenAiWorkspace?: () => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
stacked?: boolean
|
||||
compact?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -146,59 +129,6 @@ function BuildNumberButton({
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBarAiBadge({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onOpenAiWorkspace,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
compact,
|
||||
locale,
|
||||
}: Pick<
|
||||
StatusBarPrimarySectionProps,
|
||||
| 'aiAgentsStatus'
|
||||
| 'vaultAiGuidanceStatus'
|
||||
| 'defaultAiAgent'
|
||||
| 'defaultAiTarget'
|
||||
| 'aiModelProviders'
|
||||
| 'onSetDefaultAiAgent'
|
||||
| 'onSetDefaultAiTarget'
|
||||
| 'onOpenAiWorkspace'
|
||||
| 'onRestoreVaultAiGuidance'
|
||||
| 'claudeCodeStatus'
|
||||
| 'claudeCodeVersion'
|
||||
| 'compact'
|
||||
| 'locale'
|
||||
>) {
|
||||
if (aiAgentsStatus && defaultAiAgent) {
|
||||
return (
|
||||
<AiAgentsBadge
|
||||
statuses={aiAgentsStatus}
|
||||
guidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAgent={defaultAiAgent}
|
||||
defaultTarget={defaultAiTarget}
|
||||
providers={aiModelProviders}
|
||||
onSetDefaultAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultTarget={onSetDefaultAiTarget}
|
||||
onOpenWorkspace={onOpenAiWorkspace}
|
||||
onRestoreGuidance={onRestoreVaultAiGuidance}
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!claudeCodeStatus) return null
|
||||
|
||||
return <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} showSeparator={!compact} compact={compact} locale={locale} />
|
||||
}
|
||||
|
||||
function StatusBarPrimaryBadges({
|
||||
modifiedCount,
|
||||
visibleRemoteStatus,
|
||||
@@ -221,17 +151,6 @@ function StatusBarPrimaryBadges({
|
||||
isGitVault,
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onOpenAiWorkspace,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
isOffline,
|
||||
isVaultReloading,
|
||||
compact,
|
||||
@@ -258,17 +177,6 @@ function StatusBarPrimaryBadges({
|
||||
isGitVault: boolean
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onOpenAiWorkspace?: () => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
isOffline: boolean
|
||||
isVaultReloading: boolean
|
||||
compact: boolean
|
||||
@@ -303,7 +211,6 @@ function StatusBarPrimaryBadges({
|
||||
<MissingGitBadge onClick={onInitializeGit} showSeparator={!compact} compact={compact} locale={locale} />
|
||||
) : null}
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} showSeparator={!compact} compact={compact} locale={locale} />}
|
||||
<StatusBarAiBadge aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={defaultAiAgent} defaultAiTarget={defaultAiTarget} aiModelProviders={aiModelProviders} onSetDefaultAiAgent={onSetDefaultAiAgent} onSetDefaultAiTarget={onSetDefaultAiTarget} onOpenAiWorkspace={onOpenAiWorkspace} onRestoreVaultAiGuidance={onRestoreVaultAiGuidance} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} compact={compact} locale={locale} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -439,17 +346,6 @@ function StatusBarGitControls({
|
||||
onClickPulse,
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onOpenAiWorkspace,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
compact,
|
||||
locale,
|
||||
}: StatusBarPrimarySectionProps & { compact: boolean; locale: AppLocale }) {
|
||||
@@ -487,17 +383,6 @@ function StatusBarGitControls({
|
||||
isGitVault={isGitVault !== false}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onOpenAiWorkspace={onOpenAiWorkspace}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
isOffline={isOffline === true}
|
||||
isVaultReloading={isVaultReloading === true}
|
||||
compact={compact}
|
||||
@@ -548,17 +433,6 @@ export function StatusBarPrimarySection({
|
||||
onUpdateWorkspaceIdentity,
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onOpenAiWorkspace,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
locale = 'en',
|
||||
stacked = false,
|
||||
compact = false,
|
||||
@@ -608,17 +482,6 @@ export function StatusBarPrimarySection({
|
||||
isGitVault={isGitVault}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onOpenAiWorkspace={onOpenAiWorkspace}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
isOffline={isOffline} isVaultReloading={isVaultReloading}
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
|
||||
@@ -145,6 +145,39 @@ describe('aiAgentStreamCallbacks', () => {
|
||||
expect(output).toContain('[Tool output truncated: 50 chars omitted]')
|
||||
})
|
||||
|
||||
it('repairs missing sentence boundaries between streamed text chunks', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Question',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
const responseAccRef = { current: '' }
|
||||
|
||||
const callbacks = createStreamCallbacks({
|
||||
agent: 'claude_code',
|
||||
messageId: 'msg-1',
|
||||
vaultPath: '/vault',
|
||||
setMessages: messages.setMessages,
|
||||
setStatus: createStatusStore().setStatus,
|
||||
abortRef: { current: { aborted: false } },
|
||||
responseAccRef,
|
||||
toolInputMapRef: { current: new Map() },
|
||||
fileCallbacksRef: { current: undefined },
|
||||
})
|
||||
|
||||
callbacks.onText("I'll create the Project note now.")
|
||||
callbacks.onText('Created [[Tolaria Mobile]] as a Project note with a relation to [[frontend]].')
|
||||
callbacks.onText('It covers three tech stack paths.')
|
||||
callbacks.onDone()
|
||||
|
||||
expect(messages.getMessages()[0].response).toBe(
|
||||
"I'll create the Project note now. Created [[Tolaria Mobile]] as a Project note with a relation to [[frontend]]. It covers three tech stack paths.",
|
||||
)
|
||||
})
|
||||
|
||||
it('marks pending actions as failed when the stream errors', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
|
||||
const MAX_RETAINED_TOOL_OUTPUT_CHARS = 20_000
|
||||
|
||||
function normalizeAssistantResponseText(response: string): string {
|
||||
return response
|
||||
.replace(/(?<!\b[A-Z])([.!?])(?=([A-ZÀ-ÖØ-Þ]|\[\[))/gu, '$1 ')
|
||||
.replace(/(\]\])(?=[A-ZÀ-ÖØ-Þ])/gu, '$1 ')
|
||||
}
|
||||
|
||||
export interface StreamMutationContext {
|
||||
agent: AiAgentId
|
||||
messageId: string
|
||||
@@ -28,7 +34,7 @@ export interface StreamMutationContext {
|
||||
}
|
||||
|
||||
function finalResponseText(response: string, agent: AiAgentId): string {
|
||||
if (response.trim()) return response
|
||||
if (response.trim()) return normalizeAssistantResponseText(response)
|
||||
|
||||
if (agent === 'opencode') {
|
||||
return [
|
||||
@@ -121,7 +127,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
|
||||
|
||||
setStatus('error')
|
||||
streamFailed = true
|
||||
const partial = responseAccRef.current
|
||||
const partial = normalizeAssistantResponseText(responseAccRef.current)
|
||||
failureTracked = true
|
||||
trackAiAgentResponseFailed(agent, partial, toolInputMapRef.current.size)
|
||||
updateMessage(setMessages, messageId, (message) => ({
|
||||
|
||||
123
src/lib/aiWorkspaceSessionStore.test.ts
Normal file
123
src/lib/aiWorkspaceSessionStore.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
|
||||
|
||||
function createStorageMock() {
|
||||
const store = new Map<string, string>()
|
||||
let writesFail = false
|
||||
|
||||
return {
|
||||
storage: {
|
||||
get length() {
|
||||
return store.size
|
||||
},
|
||||
clear: vi.fn(() => store.clear()),
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key)
|
||||
}),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
if (writesFail) throw new Error('Quota exceeded')
|
||||
store.set(key, value)
|
||||
}),
|
||||
} as Storage,
|
||||
failWrites() {
|
||||
writesFail = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('aiWorkspaceSessionStore', () => {
|
||||
let storageMock: ReturnType<typeof createStorageMock>
|
||||
|
||||
beforeEach(() => {
|
||||
storageMock = createStorageMock()
|
||||
vi.stubGlobal('localStorage', storageMock.storage)
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('hydrates workspace session messages after module reload', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
'chat-1': {
|
||||
messages: [
|
||||
{
|
||||
userMessage: 'Remember this',
|
||||
actions: [],
|
||||
response: 'Still here',
|
||||
id: 'message-1',
|
||||
},
|
||||
],
|
||||
status: 'done',
|
||||
},
|
||||
}))
|
||||
|
||||
const store = await import('./aiWorkspaceSessionStore')
|
||||
|
||||
expect(store.aiWorkspaceSessionSnapshot('chat-1')).toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
userMessage: 'Remember this',
|
||||
response: 'Still here',
|
||||
}),
|
||||
],
|
||||
status: 'done',
|
||||
})
|
||||
})
|
||||
|
||||
it('restores interrupted stored sessions as idle completed history', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
'chat-1': {
|
||||
messages: [
|
||||
{
|
||||
userMessage: 'Halfway',
|
||||
actions: [],
|
||||
response: 'Partial',
|
||||
isStreaming: true,
|
||||
id: 'message-1',
|
||||
},
|
||||
],
|
||||
status: 'thinking',
|
||||
},
|
||||
}))
|
||||
|
||||
const store = await import('./aiWorkspaceSessionStore')
|
||||
|
||||
expect(store.aiWorkspaceSessionSnapshot('chat-1')).toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
userMessage: 'Halfway',
|
||||
response: 'Partial',
|
||||
isStreaming: false,
|
||||
}),
|
||||
],
|
||||
status: 'idle',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps in-memory session history when localStorage persistence fails', async () => {
|
||||
const store = await import('./aiWorkspaceSessionStore')
|
||||
storageMock.failWrites()
|
||||
|
||||
store.setAiWorkspaceSessionMessages('chat-1', [
|
||||
{
|
||||
userMessage: 'Still available',
|
||||
actions: [],
|
||||
response: 'In memory',
|
||||
id: 'message-1',
|
||||
},
|
||||
])
|
||||
|
||||
expect(store.aiWorkspaceSessionSnapshot('chat-1').messages).toEqual([
|
||||
expect.objectContaining({
|
||||
userMessage: 'Still available',
|
||||
response: 'In memory',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
|
||||
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-sessions'
|
||||
const NATIVE_WRITE_DEBOUNCE_MS = 250
|
||||
|
||||
export interface AiWorkspaceSessionSnapshot {
|
||||
messages: AiAgentMessage[]
|
||||
@@ -20,6 +23,10 @@ const EMPTY_SESSION: AiWorkspaceSessionSnapshot = {
|
||||
let sessions: SessionMap = readStoredSessions()
|
||||
let broadcastChannel: BroadcastChannel | null = null
|
||||
const listeners = new Set<Listener>()
|
||||
let storeVersion = 0
|
||||
let nativeWriteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let nativeWriteInFlight = false
|
||||
let pendingNativeSessions: SessionMap | null = null
|
||||
|
||||
function isSessionSnapshot(value: unknown): value is AiWorkspaceSessionSnapshot {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
@@ -27,16 +34,48 @@ function isSessionSnapshot(value: unknown): value is AiWorkspaceSessionSnapshot
|
||||
return Array.isArray(candidate.messages) && typeof candidate.status === 'string'
|
||||
}
|
||||
|
||||
function readStoredSessions(): SessionMap {
|
||||
function normalizeStoredStatus(value: unknown, resetRunningStatus: boolean): AgentStatus {
|
||||
switch (value) {
|
||||
case 'idle':
|
||||
case 'done':
|
||||
case 'error':
|
||||
return value
|
||||
case 'thinking':
|
||||
case 'tool-executing':
|
||||
return resetRunningStatus ? 'idle' : value
|
||||
default:
|
||||
return 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredMessages(messages: AiAgentMessage[], resetRunningStatus: boolean): AiAgentMessage[] {
|
||||
if (!resetRunningStatus) return messages
|
||||
return messages.map((message) => (
|
||||
message.isStreaming ? { ...message, isStreaming: false } : message
|
||||
))
|
||||
}
|
||||
|
||||
function normalizeStoredSessions(value: unknown, resetRunningStatus: boolean): SessionMap {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, AiWorkspaceSessionSnapshot] => (
|
||||
typeof entry[0] === 'string' && isSessionSnapshot(entry[1])
|
||||
)).map(([sessionId, session]) => [
|
||||
sessionId,
|
||||
{
|
||||
messages: normalizeStoredMessages(session.messages, resetRunningStatus),
|
||||
status: normalizeStoredStatus(session.status, resetRunningStatus),
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function readStoredSessions(resetRunningStatus = true): SessionMap {
|
||||
if (typeof localStorage === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, unknown>
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter((entry): entry is [string, AiWorkspaceSessionSnapshot] => (
|
||||
typeof entry[0] === 'string' && isSessionSnapshot(entry[1])
|
||||
)),
|
||||
)
|
||||
return normalizeStoredSessions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'), resetRunningStatus)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
@@ -48,10 +87,50 @@ function writeStoredSessions(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||
} catch {
|
||||
// Session history is a convenience cache; streaming state still lives in memory.
|
||||
// Native persistence is the durable backend; localStorage is only a fast browser cache.
|
||||
}
|
||||
}
|
||||
|
||||
async function readNativeSessions(): Promise<SessionMap> {
|
||||
if (!isTauri()) return {}
|
||||
|
||||
try {
|
||||
const stored = await invoke<unknown>('get_ai_workspace_sessions')
|
||||
return normalizeStoredSessions(stored, true)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function flushNativeSessionsWrite(): Promise<void> {
|
||||
if (!isTauri() || nativeWriteInFlight || !pendingNativeSessions) return
|
||||
|
||||
nativeWriteInFlight = true
|
||||
const nextSessions = pendingNativeSessions
|
||||
pendingNativeSessions = null
|
||||
nativeWriteTimer = null
|
||||
|
||||
try {
|
||||
await invoke('save_ai_workspace_sessions', { sessions: nextSessions })
|
||||
} catch {
|
||||
// Transcript persistence should never interrupt the chat UI.
|
||||
} finally {
|
||||
nativeWriteInFlight = false
|
||||
if (pendingNativeSessions) scheduleNativeSessionsWrite(pendingNativeSessions)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNativeSessionsWrite(nextSessions: SessionMap): void {
|
||||
if (!isTauri()) return
|
||||
|
||||
pendingNativeSessions = nextSessions
|
||||
if (nativeWriteTimer || nativeWriteInFlight) return
|
||||
|
||||
nativeWriteTimer = setTimeout(() => {
|
||||
void flushNativeSessionsWrite()
|
||||
}, NATIVE_WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
@@ -64,7 +143,9 @@ function broadcastSessions(): void {
|
||||
}
|
||||
|
||||
function publishSessions(): void {
|
||||
storeVersion += 1
|
||||
writeStoredSessions()
|
||||
scheduleNativeSessionsWrite(sessions)
|
||||
broadcastSessions()
|
||||
notifyListeners()
|
||||
}
|
||||
@@ -75,7 +156,20 @@ function replaceSessions(nextSessions: SessionMap): void {
|
||||
}
|
||||
|
||||
function syncFromStorage(): void {
|
||||
replaceSessions(readStoredSessions())
|
||||
replaceSessions(readStoredSessions(false))
|
||||
}
|
||||
|
||||
async function syncFromNativeStorage(): Promise<void> {
|
||||
const loadVersion = storeVersion
|
||||
const nativeSessions = await readNativeSessions()
|
||||
if (storeVersion !== loadVersion) return
|
||||
if (Object.keys(nativeSessions).length === 0) {
|
||||
if (Object.keys(sessions).length > 0) scheduleNativeSessionsWrite(sessions)
|
||||
return
|
||||
}
|
||||
|
||||
replaceSessions(nativeSessions)
|
||||
writeStoredSessions()
|
||||
}
|
||||
|
||||
function ensureCrossWindowSync(): void {
|
||||
@@ -85,12 +179,19 @@ function ensureCrossWindowSync(): void {
|
||||
if (event.key === STORAGE_KEY) syncFromStorage()
|
||||
})
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (nativeWriteTimer) clearTimeout(nativeWriteTimer)
|
||||
nativeWriteTimer = null
|
||||
void flushNativeSessionsWrite()
|
||||
})
|
||||
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
|
||||
broadcastChannel.onmessage = syncFromStorage
|
||||
}
|
||||
|
||||
ensureCrossWindowSync()
|
||||
void syncFromNativeStorage()
|
||||
|
||||
export function aiWorkspaceSessionSnapshot(sessionId: string): AiWorkspaceSessionSnapshot {
|
||||
return sessions[sessionId] ?? EMPTY_SESSION
|
||||
@@ -167,6 +268,11 @@ export function aiWorkspaceSessionDispatchers(sessionId: string): {
|
||||
|
||||
export function resetAiWorkspaceSessionStoreForTests(): void {
|
||||
sessions = {}
|
||||
storeVersion = 0
|
||||
pendingNativeSessions = null
|
||||
if (nativeWriteTimer) clearTimeout(nativeWriteTimer)
|
||||
nativeWriteTimer = null
|
||||
nativeWriteInFlight = false
|
||||
writeStoredSessions()
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('generateAiConversationTitle', () => {
|
||||
|
||||
it('creates a sentence-case fallback title from the first prompt', () => {
|
||||
expect(generateAiConversationTitle('please summarize quarterly sponsor outreach next steps')).toBe(
|
||||
'Summarize quarterly sponsor outreach next steps',
|
||||
'Quarterly sponsor outreach',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -38,10 +38,18 @@ describe('generateAiConversationTitle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('turns short questions into noun-phrase fallback titles', () => {
|
||||
expect(generateAiConversationTitle("What's my longest essay?")).toBe('Longest essay')
|
||||
})
|
||||
|
||||
it('normalizes model title text to sentence case while preserving acronyms', () => {
|
||||
expect(normalizeAiConversationTitle('Title: Fix MCP Server Lookup.')).toBe('Fix MCP server lookup')
|
||||
})
|
||||
|
||||
it('rejects answer-like title text from the model', () => {
|
||||
expect(normalizeAiConversationTitle('There are 460 essay notes. Let me search them.')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks the selected agent for a short chat title', async () => {
|
||||
streamAiAgentMock.mockImplementation(async ({ callbacks }) => {
|
||||
callbacks.onText('Fix MCP Server Lookup')
|
||||
@@ -49,6 +57,7 @@ describe('generateAiConversationTitle', () => {
|
||||
})
|
||||
|
||||
await expect(generateAiConversationTitleForTarget({
|
||||
assistantResponse: 'The lookup now resolves the configured MCP server.',
|
||||
permissionMode: 'power_user',
|
||||
prompt: 'The MCP server cannot be found anymore',
|
||||
target: { kind: 'agent', agent: 'codex', id: 'agent:codex', label: 'Codex', shortLabel: 'Codex' },
|
||||
@@ -59,6 +68,7 @@ describe('generateAiConversationTitle', () => {
|
||||
|
||||
expect(streamAiAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent: 'codex',
|
||||
message: expect.stringContaining('Assistant answer:'),
|
||||
permissionMode: 'power_user',
|
||||
vaultPath: '/vault',
|
||||
vaultPaths: ['/vault'],
|
||||
@@ -85,6 +95,7 @@ describe('generateAiConversationTitle', () => {
|
||||
})
|
||||
|
||||
await expect(generateAiConversationTitleForTarget({
|
||||
assistantResponse: 'The sidebar needs tighter spacing and clearer grouping.',
|
||||
permissionMode: 'safe',
|
||||
prompt: 'Please review the spacing in the sidebar',
|
||||
target: {
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { AiTarget } from '../lib/aiTargets'
|
||||
import { streamAiAgent, type AgentStreamCallbacks } from './streamAiAgent'
|
||||
import { streamAiModel } from './streamAiModel'
|
||||
|
||||
const TITLE_WORD_LIMIT = 7
|
||||
const TITLE_CHAR_LIMIT = 56
|
||||
const TITLE_WORD_LIMIT = 4
|
||||
const TITLE_CHAR_LIMIT = 48
|
||||
|
||||
const TITLE_SYSTEM_PROMPT = [
|
||||
'Create a concise title for this chat.',
|
||||
'Use 2 to 6 words when possible, and never exceed 56 characters.',
|
||||
'Describe the user request specifically.',
|
||||
'Create a concise title for this chat after reading the user request and assistant answer.',
|
||||
'Use 2 to 4 words when possible, and never exceed 48 characters.',
|
||||
'Write a noun phrase, not a question and not a sentence.',
|
||||
'Describe the chat topic, not the assistant process or result count.',
|
||||
'Use sentence case: capitalize only the first word and preserve acronyms.',
|
||||
'Do not use quotation marks, markdown, emojis, or trailing punctuation.',
|
||||
'Return only the title.',
|
||||
@@ -18,25 +19,64 @@ const TITLE_SYSTEM_PROMPT = [
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a',
|
||||
'about',
|
||||
'an',
|
||||
'and',
|
||||
'are',
|
||||
'ask',
|
||||
'can',
|
||||
'could',
|
||||
'create',
|
||||
'did',
|
||||
'do',
|
||||
'does',
|
||||
'draft',
|
||||
'find',
|
||||
'for',
|
||||
'from',
|
||||
'give',
|
||||
'help',
|
||||
'how',
|
||||
'i',
|
||||
'into',
|
||||
'is',
|
||||
'make',
|
||||
'me',
|
||||
'my',
|
||||
'next',
|
||||
'please',
|
||||
'show',
|
||||
'steps',
|
||||
'summarize',
|
||||
'summary',
|
||||
'tell',
|
||||
'the',
|
||||
'this',
|
||||
'that',
|
||||
'to',
|
||||
'what',
|
||||
"what's",
|
||||
'whats',
|
||||
'when',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'why',
|
||||
'with',
|
||||
'you',
|
||||
'your',
|
||||
])
|
||||
|
||||
const ANSWER_LIKE_TITLE_PATTERNS = [
|
||||
/^(according to|after|based on|here|i\b|i'll|i will|let me|the answer|there|we\b|you\b)/i,
|
||||
/\b(i found|i searched|let me check|tool use|mcp tools?)\b/i,
|
||||
]
|
||||
|
||||
const QUESTION_TITLE_PATTERNS = [
|
||||
/^(can|could|did|do|does|how|is|are|should|what|when|where|which|who|why|would)\b/i,
|
||||
/\?$/,
|
||||
]
|
||||
|
||||
function cleanPrompt({ prompt }: { prompt: string }): string {
|
||||
return prompt
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
@@ -97,8 +137,19 @@ function trimTitleLength({ title }: { title: string }): string {
|
||||
return nextTitle || words[0]?.slice(0, TITLE_CHAR_LIMIT) || ''
|
||||
}
|
||||
|
||||
function titleLooksLikeAnswer({ title }: { title: string }): boolean {
|
||||
return ANSWER_LIKE_TITLE_PATTERNS.some((pattern) => pattern.test(title))
|
||||
}
|
||||
|
||||
function titleLooksLikeQuestion({ title }: { title: string }): boolean {
|
||||
return QUESTION_TITLE_PATTERNS.some((pattern) => pattern.test(title))
|
||||
}
|
||||
|
||||
export function normalizeAiConversationTitle(title: string): string | null {
|
||||
const cleanTitle = trimTitleLength({ title: stripTitleDecorations({ title }) })
|
||||
const decoratedTitle = stripTitleDecorations({ title })
|
||||
if (titleLooksLikeAnswer({ title: decoratedTitle }) || titleLooksLikeQuestion({ title: decoratedTitle })) return null
|
||||
|
||||
const cleanTitle = trimTitleLength({ title: decoratedTitle })
|
||||
if (!cleanTitle) return null
|
||||
|
||||
return toSentenceCase({ title: cleanTitle })
|
||||
@@ -117,6 +168,7 @@ export function generateAiConversationTitle(prompt: string): string | null {
|
||||
}
|
||||
|
||||
export interface GenerateAiConversationTitleRequest {
|
||||
assistantResponse?: string
|
||||
permissionMode: AiAgentPermissionMode
|
||||
prompt: string
|
||||
target: AiTarget
|
||||
@@ -125,8 +177,27 @@ export interface GenerateAiConversationTitleRequest {
|
||||
vaultPaths?: string[]
|
||||
}
|
||||
|
||||
function titlePrompt({ prompt }: { prompt: string }): string {
|
||||
return `User request:\n${prompt.trim()}\n\nReturn only the title.`
|
||||
function titlePrompt({ assistantResponse, prompt }: Pick<GenerateAiConversationTitleRequest, 'assistantResponse' | 'prompt'>): string {
|
||||
return [
|
||||
'Create a short title for this chat.',
|
||||
'',
|
||||
'Examples:',
|
||||
'User request: What is my longest essay?',
|
||||
'Assistant answer: The longest essay is Culture at 4,210 words.',
|
||||
'Title: Longest essay',
|
||||
'',
|
||||
'User request: Draft a launch plan for the new site',
|
||||
'Assistant answer: Here is a launch plan with owners and milestones.',
|
||||
'Title: Launch plan',
|
||||
'',
|
||||
'User request:',
|
||||
prompt.trim(),
|
||||
'',
|
||||
...(assistantResponse?.trim()
|
||||
? ['Assistant answer:', assistantResponse.trim(), '']
|
||||
: []),
|
||||
'Title:',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function createTitleStreamCallbacks(onText: (text: string) => void): AgentStreamCallbacks {
|
||||
@@ -150,7 +221,7 @@ async function generateAiTitleText(request: GenerateAiConversationTitleRequest):
|
||||
await streamAiModel({
|
||||
provider: request.target.provider,
|
||||
model: request.target.model,
|
||||
message: titlePrompt({ prompt: request.prompt }),
|
||||
message: titlePrompt(request),
|
||||
systemPrompt: TITLE_SYSTEM_PROMPT,
|
||||
callbacks,
|
||||
})
|
||||
@@ -159,7 +230,7 @@ async function generateAiTitleText(request: GenerateAiConversationTitleRequest):
|
||||
|
||||
await streamAiAgent({
|
||||
agent: request.target.agent,
|
||||
message: titlePrompt({ prompt: request.prompt }),
|
||||
message: titlePrompt(request),
|
||||
systemPrompt: TITLE_SYSTEM_PROMPT,
|
||||
vaultPath: request.vaultPath,
|
||||
vaultPaths: request.vaultPaths,
|
||||
|
||||
Reference in New Issue
Block a user