From e223df6c85bee0e03de86881965efe01a6ebebe4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 27 Jul 2026 19:50:47 -0700 Subject: [PATCH 1/5] refactor(web): redesign assistant management UI --- .../profile/views/AssistantCard.test.tsx | 107 +++ .../scenes/profile/views/AssistantCard.tsx | 206 ++--- .../scenes/profile/views/NurseryGallery.tsx | 341 +++++--- .../app/scenes/profile/views/NurseryView.scss | 754 +++++++----------- .../src/locales/en-US/scenes/profile.json | 23 +- .../src/locales/zh-CN/scenes/profile.json | 25 +- .../src/locales/zh-TW/scenes/profile.json | 25 +- 7 files changed, 779 insertions(+), 702 deletions(-) create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantCard.test.tsx 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/NurseryGallery.tsx b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx index 4ebcf427b7..27149ba704 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,58 @@ interface TemplateStats { enabledSkillCount: number; } +type TemplateStatsStatus = 'loading' | 'ready' | 'error'; + 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 +100,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 +115,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 +151,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 +172,87 @@ const NurseryGallery: React.FC = () => { title={t('nursery.gallery.title')} subtitle={t('nursery.gallery.subtitle')} actions={( - + {creating ? ( +