diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 23ea8b1a46..31b2d9a86d 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -117,6 +117,10 @@ async fn lookup_remote_entry_for_path( path: &str, request_preferred: Option<&str>, ) -> Option { + if should_force_local_assistant_path(path, request_preferred) { + return None; + } + let manager = get_remote_workspace_manager()?; let legacy = app_state .get_remote_workspace_async() @@ -126,6 +130,14 @@ async fn lookup_remote_entry_for_path( manager.lookup_connection(path, preferred.as_deref()).await } +fn should_force_local_assistant_path( + path: &str, + explicit_remote_connection_id: Option<&str>, +) -> bool { + explicit_remote_connection_id.is_none() + && get_path_manager_arc().is_local_assistant_workspace_path(path) +} + pub async fn resolve_desktop_path_target( app_state: &AppState, raw_path: &str, @@ -289,7 +301,9 @@ fn encode_remote_file_bytes(bytes: Vec, encoding: Option<&str>) -> Result ({ + readFileContent: vi.fn(), + writeFileContent: vi.fn(), +})); +const watchFileChangesMock = vi.hoisted(() => vi.fn(() => vi.fn())); + +vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ + workspaceAPI: { + readFileContent: apiMocks.readFileContent, + writeFileContent: apiMocks.writeFileContent, + }, +})); + +vi.mock('@/tools/file-system/services/FileSystemService', () => ({ + fileSystemService: { + watchFileChanges: watchFileChangesMock, + }, +})); + +vi.mock('@/shared/services/ide-control', () => ({ + ideControl: { + navigation: { + goToFile: vi.fn(), + }, + }, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ + error: vi.fn(), + }), +})); + +const INITIAL_IDENTITY = [ + '---', + 'name: Mira', + 'creature: Assistant', + 'vibe: Focused', + 'emoji: ๐Ÿ’ผ', + '---', + '', +].join('\n'); + +describe('useAgentIdentityDocument autosave', () => { + let container: HTMLDivElement; + let root: Root; + let latestResult: UseAgentIdentityDocumentResult | null; + + const Harness = () => { + latestResult = useAgentIdentityDocument('/tmp/assistant'); + return null; + }; + + beforeEach(async () => { + vi.useFakeTimers(); + apiMocks.readFileContent.mockReset(); + apiMocks.writeFileContent.mockReset(); + watchFileChangesMock.mockClear(); + apiMocks.readFileContent.mockResolvedValue(INITIAL_IDENTITY); + apiMocks.writeFileContent + .mockRejectedValueOnce(new Error('write failed')) + .mockResolvedValue(undefined); + + latestResult = null; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + it('stops retrying after a failed write until the user edits again', async () => { + act(() => latestResult?.updateField('emoji', '๐Ÿš€')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(800); + }); + + expect(apiMocks.writeFileContent).toHaveBeenCalledTimes(1); + expect(latestResult?.saveStatus).toBe('error'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + }); + expect(apiMocks.writeFileContent).toHaveBeenCalledTimes(1); + + act(() => latestResult?.updateField('emoji', '๐Ÿงญ')); + expect(latestResult?.saveStatus).toBe('idle'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(800); + }); + expect(apiMocks.writeFileContent).toHaveBeenCalledTimes(2); + expect(latestResult?.saveStatus).toBe('saved'); + }); +}); diff --git a/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts b/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts index c3fcd24f4b..6dd3da7962 100644 --- a/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts +++ b/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts @@ -180,16 +180,6 @@ export function useAgentIdentityDocument( useEffect(() => { if (!workspacePath || !identityFilePath || !hasUnsavedChanges) { - if (saveStatus === 'saved') { - const clearSavedStatus = setTimeout(() => { - if (mountedRef.current) { - setSaveStatus((currentStatus) => (currentStatus === 'saved' ? 'idle' : currentStatus)); - } - }, 1500); - - return () => clearTimeout(clearSavedStatus); - } - return; } @@ -198,6 +188,7 @@ export function useAgentIdentityDocument( } saveTimerRef.current = setTimeout(() => { + saveTimerRef.current = null; void saveDocument(); }, AUTOSAVE_DEBOUNCE_MS); @@ -206,7 +197,21 @@ export function useAgentIdentityDocument( clearTimeout(saveTimerRef.current); } }; - }, [hasUnsavedChanges, identityFilePath, saveDocument, saveStatus, workspacePath]); + }, [hasUnsavedChanges, identityFilePath, saveDocument, workspacePath]); + + useEffect(() => { + if (saveStatus !== 'saved') { + return; + } + + const clearSavedStatus = setTimeout(() => { + if (mountedRef.current) { + setSaveStatus((currentStatus) => (currentStatus === 'saved' ? 'idle' : currentStatus)); + } + }, 1500); + + return () => clearTimeout(clearSavedStatus); + }, [saveStatus]); useEffect(() => { if (!workspacePath || !identityFilePath) { @@ -255,7 +260,8 @@ export function useAgentIdentityDocument( const updateField = useCallback( (field: K, value: IdentityDocument[K]) => { setDocument((previous) => ({ ...previous, [field]: value })); - if (saveStatus === 'external-update') { + if (saveStatus === 'external-update' || saveStatus === 'error') { + setError(null); setSaveStatus('idle'); } }, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.test.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.test.tsx new file mode 100644 index 0000000000..41df7771e5 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import AssistantAvatarPicker from './AssistantAvatarPicker'; +import { firstAvatarGrapheme } from './assistantAvatar'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: React.ButtonHTMLAttributes & { + variant?: string; + size?: string; + }) => , + IconButton: ({ + children, + variant: _variant, + size: _size, + ...props + }: React.ButtonHTMLAttributes & { + variant?: string; + size?: string; + }) => , + Input: ({ + size: _size, + ...props + }: React.InputHTMLAttributes & { + size?: string; + }) => , +})); + +describe('AssistantAvatarPicker', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps a combined emoji sequence as one avatar', () => { + expect(firstAvatarGrapheme(' ๐Ÿง‘โ€๐Ÿ’ป๐Ÿš€ ')).toBe('๐Ÿง‘โ€๐Ÿ’ป'); + }); + + it('opens from the avatar and applies a preset through the shared change callback', () => { + const onChange = vi.fn(); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector('.acp-avatar-picker__trigger') as HTMLButtonElement; + act(() => trigger.click()); + + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + const compassOption = Array.from( + container.querySelectorAll('.acp-avatar-picker__option'), + ).find((option) => option.textContent === '๐Ÿงญ'); + expect(compassOption).toBeTruthy(); + + act(() => compassOption?.click()); + expect(onChange).toHaveBeenCalledWith('๐Ÿงญ'); + }); + + it('announces the autosave result while the picker is open', () => { + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector('.acp-avatar-picker__trigger') as HTMLButtonElement; + act(() => trigger.click()); + + expect(container.querySelector('.acp-avatar-picker__status')?.textContent) + .toContain('identity.avatarSaved'); + }); +}); diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.tsx new file mode 100644 index 0000000000..b0d333724f --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantAvatarPicker.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Bot, Check, Pencil, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, IconButton, Input } from '@/component-library'; +import type { IdentitySaveStatus } from '@/app/scenes/my-agent/useAgentIdentityDocument'; +import { ASSISTANT_AVATAR_PRESETS, firstAvatarGrapheme } from './assistantAvatar'; + +interface AssistantAvatarPickerProps { + value: string; + saveStatus: IdentitySaveStatus; + saveError?: string | null; + onChange: (value: string) => void; +} + +const AssistantAvatarPicker: React.FC = ({ + value, + saveStatus, + saveError, + onChange, +}) => { + const { t } = useTranslation('scenes/profile'); + const pickerId = useId(); + const titleId = `${pickerId}-title`; + const rootRef = useRef(null); + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const displayedValue = useMemo(() => firstAvatarGrapheme(value), [value]); + const [customValue, setCustomValue] = useState(displayedValue); + + useEffect(() => { + setCustomValue(displayedValue); + }, [displayedValue]); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + setIsOpen(false); + triggerRef.current?.focus(); + }; + + document.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + const chooseAvatar = (nextValue: string) => { + setCustomValue(nextValue); + onChange(nextValue); + }; + + const handleCustomSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const nextValue = firstAvatarGrapheme(customValue); + if (!nextValue) return; + chooseAvatar(nextValue); + }; + + const normalizedCustomValue = firstAvatarGrapheme(customValue); + const statusContent = saveStatus === 'saving' + ? t('identity.avatarSaving') + : saveStatus === 'saved' + ? t('identity.avatarSaved') + : saveStatus === 'error' + ? t('identity.avatarSaveFailed') + : t('identity.avatarAutosave'); + + return ( +
+ + + {isOpen ? ( +
+
+
+ {t('identity.avatarPickerTitle')} + {t('identity.avatarPickerHint')} +
+ { + setIsOpen(false); + triggerRef.current?.focus(); + }} + > + + +
+ +
+ {ASSISTANT_AVATAR_PRESETS.map((preset) => ( + + ))} +
+ +
+ setCustomValue(event.target.value)} + /> + +
+ +
+ + {saveStatus === 'saved' ? +
+
+ ) : null} +
+ ); +}; + +export default AssistantAvatarPicker; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantCard.test.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantCard.test.tsx new file mode 100644 index 0000000000..25b885ffea --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantCard.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceInfo } from '@/shared/types'; +import AssistantCard from './AssistantCard'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/component-library', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => {children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const workspace = { + id: 'assistant-1', + name: 'Research assistant', + rootPath: '/tmp/assistant-1', + identity: { + name: 'Mira', + emoji: '๐Ÿงญ', + creature: 'Researcher', + vibe: 'Methodical and concise', + }, +} as WorkspaceInfo; + +describe('AssistantCard actions', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps configuration and new-session actions distinct', () => { + const onConfigure = vi.fn(); + const onNewSession = vi.fn(); + const onDelete = vi.fn(); + + act(() => { + root.render( +
+ +
, + ); + }); + + const card = container.querySelector('[role="listitem"]'); + const configure = container.querySelector('.assistant-card__main') as HTMLButtonElement; + const newSession = container.querySelector('.assistant-card__new-session-btn') as HTMLButtonElement; + const remove = container.querySelector('.assistant-card__delete-btn') as HTMLButtonElement; + + expect(card?.tagName).toBe('ARTICLE'); + expect(configure.getAttribute('aria-label')).toContain('Mira'); + + act(() => configure.click()); + expect(onConfigure).toHaveBeenCalledTimes(1); + expect(onNewSession).not.toHaveBeenCalled(); + + act(() => newSession.click()); + expect(onNewSession).toHaveBeenCalledTimes(1); + expect(onConfigure).toHaveBeenCalledTimes(1); + + act(() => remove.click()); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onConfigure).toHaveBeenCalledTimes(1); + }); + + it('disables session actions while a new session is starting', () => { + act(() => { + root.render( +
+ +
, + ); + }); + + const newSession = container.querySelector('.assistant-card__new-session-btn') as HTMLButtonElement; + expect(newSession.disabled).toBe(true); + expect(newSession.getAttribute('aria-busy')).toBe('true'); + expect(newSession.textContent).toContain('nursery.card.startingSession'); + }); +}); diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx index 103ebe2f85..6d5a778b6b 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx @@ -1,9 +1,15 @@ import React from 'react'; -import { Bot, MessageSquarePlus, Trash2 } from 'lucide-react'; +import { + Bot, + ChevronRight, + LoaderCircle, + MessageSquarePlus, + Settings2, + Trash2, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Badge, Tooltip } from '@/component-library'; import type { WorkspaceInfo } from '@/shared/types'; -import { getCardGradient } from '@/shared/utils/cardGradients'; interface AssistantCardProps { workspace: WorkspaceInfo; @@ -11,10 +17,21 @@ interface AssistantCardProps { onNewSession?: () => void; onDelete?: () => void; isPrimary?: boolean; + isDeleting?: boolean; + isStartingSession?: boolean; style?: React.CSSProperties; } -const AssistantCard: React.FC = ({ workspace, onClick, onNewSession, onDelete, isPrimary, style }) => { +const AssistantCard: React.FC = ({ + workspace, + onClick, + onNewSession, + onDelete, + isPrimary, + isDeleting = false, + isStartingSession = false, + style, +}) => { const { t } = useTranslation('scenes/profile'); const identity = workspace.identity; @@ -23,106 +40,107 @@ const AssistantCard: React.FC = ({ workspace, onClick, onNew const creature = identity?.creature?.trim() || ''; const vibe = identity?.vibe?.trim() || ''; - const gradient = getCardGradient(workspace.id || name); - - const handleCardKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }; - return ( -
- {/* Header: avatar + name + badges */} -
-
- {emoji ? ( - {emoji} +
-
-
- {name} - {isPrimary && ( - - {t('nursery.card.primaryBadge')} - - )} -
-
- {creature && {creature}} -
-
-
+ + + + + - {/* Body: vibe / description */} -
- {vibe ? ( -

{vibe}

+
+ {onNewSession ? ( + ) : ( -

- {t('nursery.card.noVibe')} -

+ )} -
- {/* Footer */} -
-
- - {t('nursery.card.configure')} - - {(onNewSession || onDelete) ? ( -
- {onNewSession && ( - - - - )} - {onDelete && ( - - - + {onDelete ? ( + +
- ) : null} -
-
-
+ + + ) : null} + + ); }; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx index d7b7b75da6..b48bbbecc1 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx @@ -30,6 +30,7 @@ import { splitMarkdownFrontmatter, } from '@/app/scenes/my-agent/identityDocument'; import SessionsSection from '@/app/components/NavPanel/sections/sessions/SessionsSection'; +import AssistantAvatarPicker from './AssistantAvatarPicker'; import AssistantQuickInput from './AssistantQuickInput'; import { useNurseryStore } from '../nurseryStore'; @@ -132,6 +133,8 @@ const AssistantConfigPage: React.FC = () => { const { document: identityDocument, + error: identitySaveError, + saveStatus: identitySaveStatus, updateField: updateIdentityField, reload: reloadIdentityDocument, } = useAgentIdentityDocument(workspacePath); @@ -146,7 +149,7 @@ const AssistantConfigPage: React.FC = () => { }; }, [identityDocument, workspace?.identity]); - const [editingField, setEditingField] = useState<'name' | 'emoji' | 'creature' | 'vibe' | null>(null); + const [editingField, setEditingField] = useState<'name' | 'creature' | 'vibe' | null>(null); const [editValue, setEditValue] = useState(''); const nameInputRef = useRef(null); const metaInputRef = useRef(null); @@ -310,16 +313,14 @@ const AssistantConfigPage: React.FC = () => { return () => window.removeEventListener('keydown', onKey); }, [rightView, closePersonaDoc]); - const startEdit = useCallback((field: 'name' | 'emoji' | 'creature' | 'vibe') => { + const startEdit = useCallback((field: 'name' | 'creature' | 'vibe') => { setEditingField(field); setEditValue( field === 'name' ? displayIdentity.name - : field === 'emoji' - ? displayIdentity.emoji - : field === 'creature' - ? displayIdentity.creature - : displayIdentity.vibe, + : field === 'creature' + ? displayIdentity.creature + : displayIdentity.vibe, ); setTimeout(() => { (field === 'name' ? nameInputRef : metaInputRef).current?.focus(); @@ -522,6 +523,12 @@ const AssistantConfigPage: React.FC = () => {
{/* Identity header above the input */}
+ updateIdentityField('emoji', emoji)} + />
{editingField === 'name' ? ( { + it('layers a dedicated wink frame over the stable panda avatar', () => { + const source = readSibling('./NurseryGallery.tsx'); + + expect(source).toContain('src="/panda_1.png"'); + expect(source).toContain('src="/panda_wink.png"'); + expect(source).toContain('nursery-defaults__avatar-image--wink'); + }); + + it('keeps the wink local to the panda and disables it for reduced motion', () => { + const stylesheet = readSibling('./NurseryView.scss'); + const reducedMotionStart = stylesheet.indexOf('@media (prefers-reduced-motion: reduce)'); + const reducedMotionEnd = stylesheet.indexOf('// โ”€โ”€ Responsive', reducedMotionStart); + const reducedMotionSection = stylesheet.slice(reducedMotionStart, reducedMotionEnd); + + expect(stylesheet).toMatch( + /\.nursery-defaults__avatar:hover \.nursery-defaults__avatar-art[\s\S]*nursery-panda-wink-tilt/, + ); + expect(stylesheet).toContain('@keyframes nursery-panda-wink-frame'); + expect(stylesheet).toMatch(/&--wink \{\s+opacity: 0;/); + expect(reducedMotionSection).toContain('.nursery-defaults__avatar-art'); + expect(reducedMotionSection).toContain('.nursery-defaults__avatar-image--wink'); + expect(reducedMotionSection).toContain('animation: none;'); + }); +}); diff --git a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx index 4ebcf427b7..e2909e28e5 100644 --- a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx +++ b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx @@ -1,27 +1,36 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Egg, Puzzle, Settings, Wrench } from 'lucide-react'; import { + Bot, + ChevronRight, + CircleAlert, + LoaderCircle, + Plus, + Puzzle, + Settings2, + Wrench, +} from 'lucide-react'; +import { + GalleryEmpty, GalleryLayout, GalleryPageHeader, GalleryZone, GalleryGrid, + GallerySkeleton, } from '@/app/components'; +import { Button } from '@/component-library'; +import { confirmDanger } from '@/component-library/components/ConfirmDialog/confirmService'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { useApp } from '@/app/hooks/useApp'; import { useSceneStore } from '@/app/stores/sceneStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import type { WorkspaceInfo } from '@/shared/types'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import AssistantCard from './AssistantCard'; import { useNurseryStore } from '../nurseryStore'; -interface DeleteConfirmState { - workspaceId: string; - name: string; -} - const log = createLogger('NurseryGallery'); const ASSISTANT_MODE_ID = 'Claw'; @@ -30,34 +39,79 @@ interface TemplateStats { enabledSkillCount: number; } +type TemplateStatsStatus = 'loading' | 'ready' | 'error'; + +const NurseryPandaAvatar: React.FC = () => ( + +); + const NurseryGallery: React.FC = () => { const { t } = useTranslation('scenes/profile'); - const { assistantWorkspacesList, createAssistantWorkspace, setActiveWorkspace, deleteAssistantWorkspace } = useWorkspaceContext(); + const { + assistantWorkspacesList, + createAssistantWorkspace, + deleteAssistantWorkspace, + error: workspaceError, + loading: workspaceLoading, + setActiveWorkspace, + } = useWorkspaceContext(); const openScene = useSceneStore(s => s.openScene); const { switchLeftPanelTab } = useApp(); const { openDefaults, openAssistant } = useNurseryStore(); + const notification = useNotification(); const [creating, setCreating] = useState(false); - const [deleting, setDeleting] = useState(false); - const [deleteConfirm, setDeleteConfirm] = useState(null); + const [deletingWorkspaceId, setDeletingWorkspaceId] = useState(null); + const [startingSessionWorkspaceId, setStartingSessionWorkspaceId] = useState(null); const [templateStats, setTemplateStats] = useState(null); + const [templateStatsStatus, setTemplateStatsStatus] = useState('loading'); useEffect(() => { - (async () => { + let cancelled = false; + + void (async () => { + setTemplateStatsStatus('loading'); try { const [modeConf, skills] = await Promise.all([ - configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null), - configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => []), + configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID), + configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }), ]); + if (cancelled) return; setTemplateStats({ enabledToolCount: modeConf?.enabled_tools?.length ?? 0, enabledSkillCount: skills.filter((skill) => skill.effectiveEnabled).length, }); + setTemplateStatsStatus('ready'); } catch (e) { log.error('Failed to load template stats', e); + if (!cancelled) { + setTemplateStats(null); + setTemplateStatsStatus('error'); + } } })(); - }, [t]); + + return () => { + cancelled = true; + }; + }, []); const handleCreateAssistant = useCallback(async () => { if (creating) return; @@ -67,10 +121,11 @@ const NurseryGallery: React.FC = () => { openAssistant(newWorkspace.id); } catch (e) { log.error('Failed to create assistant workspace', e); + notification.error(t('nursery.gallery.createFailed')); } finally { setCreating(false); } - }, [creating, createAssistantWorkspace, openAssistant]); + }, [creating, createAssistantWorkspace, notification, openAssistant, t]); const sortedAssistantWorkspacesList = useMemo( () => { @@ -81,31 +136,35 @@ const NurseryGallery: React.FC = () => { [assistantWorkspacesList] ); - const handleDeleteRequest = useCallback((workspace: WorkspaceInfo) => { + const handleDeleteRequest = useCallback(async (workspace: WorkspaceInfo) => { + if (deletingWorkspaceId) return; const identity = workspace.identity; const name = identity?.name?.trim() || workspace.name || t('nursery.card.unnamed'); - setDeleteConfirm({ workspaceId: workspace.id, name }); - }, [t]); + const confirmed = await confirmDanger( + t('nursery.card.deleteConfirmTitle'), + t('nursery.card.deleteConfirmMessage', { name }), + { + confirmText: t('nursery.card.deleteConfirm'), + cancelText: t('nursery.card.deleteCancel'), + }, + ); + if (!confirmed) return; - const handleDeleteConfirm = useCallback(async () => { - if (!deleteConfirm || deleting) return; - setDeleting(true); + setDeletingWorkspaceId(workspace.id); try { - await deleteAssistantWorkspace(deleteConfirm.workspaceId); + await deleteAssistantWorkspace(workspace.id); } catch (e) { log.error('Failed to delete assistant workspace', e); + notification.error(t('nursery.card.deleteFailed')); } finally { - setDeleting(false); - setDeleteConfirm(null); + setDeletingWorkspaceId(null); } - }, [deleteConfirm, deleting, deleteAssistantWorkspace]); - - const handleDeleteCancel = useCallback(() => { - setDeleteConfirm(null); - }, []); + }, [deleteAssistantWorkspace, deletingWorkspaceId, notification, t]); const handleNewAssistantSession = useCallback( async (workspace: WorkspaceInfo) => { + if (startingSessionWorkspaceId) return; + setStartingSessionWorkspaceId(workspace.id); openScene('session'); switchLeftPanelTab('sessions'); try { @@ -113,9 +172,19 @@ const NurseryGallery: React.FC = () => { await setActiveWorkspace(workspace.id); } catch (e) { log.error('Failed to create assistant session from gallery', e); + notification.error(t('nursery.card.newSessionFailed')); + } finally { + setStartingSessionWorkspaceId(null); } }, - [openScene, setActiveWorkspace, switchLeftPanelTab], + [ + notification, + openScene, + setActiveWorkspace, + startingSessionWorkspaceId, + switchLeftPanelTab, + t, + ], ); return ( @@ -124,77 +193,80 @@ const NurseryGallery: React.FC = () => { title={t('nursery.gallery.title')} subtitle={t('nursery.gallery.subtitle')} actions={( - + {creating ? ( +