feat: refine AI workspace side panel

This commit is contained in:
lucaronin
2026-05-29 17:21:23 +02:00
parent ed8a0b155b
commit 1d5d573138
19 changed files with 795 additions and 415 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -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();

View File

@@ -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}

View File

@@ -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' })

View File

@@ -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}

View 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) ?? '')
})
})

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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')
})
})

View File

@@ -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}

View File

@@ -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>
}

View File

@@ -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}

View File

@@ -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([
{

View File

@@ -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) => ({

View 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',
}),
])
})
})

View File

@@ -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()
}

View File

@@ -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: {

View File

@@ -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,