diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 9eeddb1..4f5dd25 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -12,6 +12,9 @@ import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingleton import { createAndFocusPost } from '../../navigation/postCreation'; import type { SidebarView } from '../../navigation/sidebarViewRegistry'; import { useI18n } from '../../i18n'; +import { useProjectScopedSidebarData } from './useProjectScopedSidebarData'; +import { SidebarEntityList } from './SidebarEntityList'; +import { formatSidebarRelativeDate } from './sidebarDateFormatting'; import './Sidebar.css'; /** Get display name for media: title (truncated to 60 chars) or fallback to filename */ @@ -1371,23 +1374,29 @@ const SettingsNav: React.FC = () => { // Chat conversations list const ChatList: React.FC = () => { const { t, language } = useI18n(); - const { openTab, closeTab } = useAppStore(); - const [conversations, setConversations] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const { openTab, closeTab, activeProject } = useAppStore(); + const activeProjectId = activeProject?.id; const [isReady, setIsReady] = useState(false); - // Load conversations - const loadConversations = useCallback(async () => { + const loadConversations = useCallback(async (): Promise => { try { const convs = await window.electronAPI?.chat.getConversations(); - if (convs) { - setConversations(convs); - } + return convs ?? []; } catch (error) { console.error('Failed to load conversations:', error); + return []; } }, []); + const { + items: conversations, + setItems: setConversations, + isLoading, + } = useProjectScopedSidebarData({ + load: loadConversations, + activeProjectId, + }); + // Check if service is ready const checkReady = useCallback(async () => { try { @@ -1399,13 +1408,7 @@ const ChatList: React.FC = () => { }, []); useEffect(() => { - const init = async () => { - setIsLoading(true); - await checkReady(); - await loadConversations(); - setIsLoading(false); - }; - init(); + void checkReady(); // Subscribe to title updates const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { @@ -1417,7 +1420,7 @@ const ChatList: React.FC = () => { return () => { unsubTitle?.(); }; - }, [loadConversations, checkReady]); + }, [checkReady, setConversations]); const handleNewChat = async () => { try { @@ -1448,109 +1451,75 @@ const ChatList: React.FC = () => { } }; - const formatChatDate = (dateString: string) => { - const date = new Date(dateString); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); - const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en; - - if (diffDays === 0) { - return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' }); - } else if (diffDays === 1) { - return t('sidebar.chat.yesterday'); - } else if (diffDays < 7) { - return date.toLocaleDateString(uiDateLocale, { weekday: 'short' }); - } - return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' }); - }; - - if (isLoading) { - return ( -
-
- {t('sidebar.chat.header')} -
-
{t('sidebar.loading')}
-
- ); - } - return ( -
-
- {t('sidebar.chat.header')} - -
- {!isReady && ( -
-

{t('sidebar.chat.apiKeyNeeded')}

+ conversation.id} + topContent={ + !isReady ? ( +
+

{t('sidebar.chat.apiKeyNeeded')}

+
+ ) : null + } + renderItem={(conversation) => ( +
handleOpenChat(conversation.id)} + > +
+
{conversation.title}
+
+ {formatSidebarRelativeDate({ dateString: conversation.updatedAt, language, t })} +
+
+
)} -
- {conversations.length === 0 ? ( -
-

{t('sidebar.chat.noConversations')}

- -
- ) : ( - conversations.map(conv => ( -
handleOpenChat(conv.id)} - > -
-
{conv.title}
-
{formatChatDate(conv.updatedAt)}
-
- -
- )) - )} -
-
+ /> ); }; const ImportList: React.FC = () => { const { t, language } = useI18n(); const { openTab, closeTab, activeProject } = useAppStore(); - const [definitions, setDefinitions] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const activeProjectId = activeProject?.id; - const loadDefinitions = useCallback(async () => { + const loadDefinitions = useCallback(async (): Promise => { try { const defs = await window.electronAPI?.importDefinitions.getAll(); - if (defs) { - setDefinitions(defs); - } + return defs ?? []; } catch (error) { console.error('Failed to load import definitions:', error); + return []; } }, []); - // Reload definitions when project changes - useEffect(() => { - const init = async () => { - setIsLoading(true); - await loadDefinitions(); - setIsLoading(false); - }; - init(); - }, [loadDefinitions, activeProject?.id]); + const { + items: definitions, + setItems: setDefinitions, + isLoading, + } = useProjectScopedSidebarData({ + load: loadDefinitions, + activeProjectId, + }); // Listen for import definition name updates useEffect(() => { @@ -1598,126 +1567,62 @@ const ImportList: React.FC = () => { } }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); - const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en; - if (diffDays === 0) { - return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' }); - } else if (diffDays === 1) { - return t('sidebar.chat.yesterday'); - } else if (diffDays < 7) { - return date.toLocaleDateString(uiDateLocale, { weekday: 'short' }); - } - return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' }); - }; - - if (isLoading) { - return ( -
-
- {t('sidebar.import.header')} -
-
{t('sidebar.loading')}
-
- ); - } - return ( -
-
- {t('sidebar.import.header')} - -
-
- {definitions.length === 0 ? ( -
-

{t('sidebar.import.none')}

- -
- ) : ( - definitions.map(def => ( -
handleOpenDefinition(def.id)} - > -
-
{def.name}
-
{formatDate(def.updatedAt)}
-
- + definition.id} + renderItem={(definition) => ( +
handleOpenDefinition(definition.id)} + > +
+
{definition.name}
+
+ {formatSidebarRelativeDate({ dateString: definition.updatedAt, language, t })}
- )) - )} -
-
+
+ +
+ )} + /> ); }; const ScriptsList: React.FC = () => { const { t, language } = useI18n(); const { openTab, activeTabId, closeTab } = useAppStore(); - const [scripts, setScripts] = useState>([]); - const [isLoading, setIsLoading] = useState(true); + const activeProjectId = useAppStore((state) => state.activeProject?.id); - const loadScripts = useCallback(async () => { + const loadScripts = useCallback(async (): Promise> => { const items = await window.electronAPI?.scripts.getAll(); - if (!items) { - return; - } - - setScripts(items.map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt }))); + return (items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt })); }, []); - useEffect(() => { - let cancelled = false; - - const loadInitialScripts = async () => { - setIsLoading(true); - try { - const items = await window.electronAPI?.scripts.getAll(); - if (cancelled) { - return; - } - - setScripts((items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt }))); - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - }; - - void loadInitialScripts(); - - const canListen = typeof window.addEventListener === 'function' && typeof window.removeEventListener === 'function'; - const handleScriptsChanged = () => { - void loadScripts(); - }; - - if (canListen) { - window.addEventListener('bds:scripts-changed', handleScriptsChanged); - } - - return () => { - cancelled = true; - if (canListen) { - window.removeEventListener('bds:scripts-changed', handleScriptsChanged); - } - }; - }, [loadScripts]); + const { + items: scripts, + setItems: setScripts, + isLoading, + reload: reloadScripts, + } = useProjectScopedSidebarData[number]>({ + load: loadScripts, + activeProjectId, + refreshEventName: 'bds:scripts-changed', + }); const handleCreateScript = async () => { try { @@ -1741,28 +1646,13 @@ const ScriptsList: React.FC = () => { window.dispatchEvent(new CustomEvent('bds:scripts-changed')); } openScriptTab(openTab, created.id, 'pin'); - void loadScripts(); + void reloadScripts(); } catch (error) { console.error('Failed to create script:', error); showToast.error(t('sidebar.scripts.createFailed')); } }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); - const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en; - if (diffDays === 0) { - return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' }); - } else if (diffDays === 1) { - return t('sidebar.chat.yesterday'); - } else if (diffDays < 7) { - return date.toLocaleDateString(uiDateLocale, { weekday: 'short' }); - } - return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' }); - }; - const handleDeleteScript = async (event: React.MouseEvent, scriptId: string) => { event.stopPropagation(); try { @@ -1782,75 +1672,53 @@ const ScriptsList: React.FC = () => { } }; - if (isLoading) { - return ( -
-
- {t('sidebar.scripts.header')} -
-
{t('sidebar.loading')}
-
- ); - } - return ( -
-
- {t('sidebar.scripts.header')} - -
-
- {scripts.length === 0 ? ( -
-

{t('sidebar.scripts.none')}

- -
- ) : ( - scripts.map((script) => ( -
openScriptTab(openTab, script.id, 'preview')} - onDoubleClick={() => openScriptTab(openTab, script.id, 'pin')} - onKeyDown={(event) => { - if (event.key === 'Enter') { - openScriptTab(openTab, script.id, 'pin'); - return; - } - if (event.key === ' ') { - event.preventDefault(); - openScriptTab(openTab, script.id, 'preview'); - } - }} - > -
-
{script.title}
-
{formatDate(script.updatedAt)}
-
- +
+
{script.title}
+
+ {formatSidebarRelativeDate({ dateString: script.updatedAt, language, t })}
- )) - )} -
-
+
+ +
+ )} + /> ); }; diff --git a/src/renderer/components/Sidebar/SidebarEntityList.tsx b/src/renderer/components/Sidebar/SidebarEntityList.tsx new file mode 100644 index 0000000..1acccf4 --- /dev/null +++ b/src/renderer/components/Sidebar/SidebarEntityList.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +interface SidebarEntityListProps { + header: string; + createTitle: string; + onCreate: () => void; + isLoading: boolean; + loadingLabel: string; + emptyMessage: string; + emptyActionLabel: string; + onEmptyAction: () => void; + items: TItem[]; + renderItem: (item: TItem) => React.ReactNode; + getItemKey?: (item: TItem) => string; + topContent?: React.ReactNode; +} + +export function SidebarEntityList({ + header, + createTitle, + onCreate, + isLoading, + loadingLabel, + emptyMessage, + emptyActionLabel, + onEmptyAction, + items, + renderItem, + getItemKey, + topContent, +}: SidebarEntityListProps): JSX.Element { + if (isLoading) { + return ( +
+
+ {header} +
+
{loadingLabel}
+
+ ); + } + + return ( +
+
+ {header} + +
+ {topContent} +
+ {items.length === 0 ? ( +
+

{emptyMessage}

+ +
+ ) : ( + items.map((item) => ( + + {renderItem(item)} + + )) + )} +
+
+ ); +} diff --git a/src/renderer/components/Sidebar/sidebarDateFormatting.ts b/src/renderer/components/Sidebar/sidebarDateFormatting.ts new file mode 100644 index 0000000..1e52fae --- /dev/null +++ b/src/renderer/components/Sidebar/sidebarDateFormatting.ts @@ -0,0 +1,34 @@ +const UI_DATE_LOCALE: Record = { + en: 'en-US', + de: 'de-DE', + fr: 'fr-FR', + it: 'it-IT', + es: 'es-ES', +}; + +interface FormatSidebarRelativeDateArgs { + dateString: string; + language: string; + t: (key: string) => string; +} + +export function formatSidebarRelativeDate({ dateString, language, t }: FormatSidebarRelativeDateArgs): string { + const date = new Date(dateString); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en; + + if (diffDays === 0) { + return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' }); + } + + if (diffDays === 1) { + return t('sidebar.chat.yesterday'); + } + + if (diffDays < 7) { + return date.toLocaleDateString(uiDateLocale, { weekday: 'short' }); + } + + return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' }); +} diff --git a/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts b/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts new file mode 100644 index 0000000..664a10a --- /dev/null +++ b/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; + +interface ProjectScopedSidebarDataOptions { + load: () => Promise; + activeProjectId?: string; + refreshEventName?: string; +} + +interface ProjectScopedSidebarDataResult { + items: TItem[]; + setItems: Dispatch>; + isLoading: boolean; + reload: () => Promise; +} + +export function useProjectScopedSidebarData(options: ProjectScopedSidebarDataOptions): ProjectScopedSidebarDataResult { + const { load, activeProjectId, refreshEventName } = options; + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const reload = useCallback(async () => { + const nextItems = await load(); + setItems(nextItems ?? []); + }, [load]); + + useEffect(() => { + let cancelled = false; + + const loadInitial = async () => { + setIsLoading(true); + try { + const nextItems = await load(); + if (cancelled) { + return; + } + setItems(nextItems ?? []); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void loadInitial(); + + return () => { + cancelled = true; + }; + }, [load, activeProjectId]); + + useEffect(() => { + if (!refreshEventName) { + return; + } + + if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') { + return; + } + + const handleRefreshEvent = () => { + void reload(); + }; + + window.addEventListener(refreshEventName, handleRefreshEvent); + return () => { + window.removeEventListener(refreshEventName, handleRefreshEvent); + }; + }, [refreshEventName, reload]); + + return { + items, + setItems, + isLoading, + reload, + }; +} diff --git a/tests/renderer/components/SidebarChat.test.tsx b/tests/renderer/components/SidebarChat.test.tsx new file mode 100644 index 0000000..b904663 --- /dev/null +++ b/tests/renderer/components/SidebarChat.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar'; +import { useAppStore } from '../../../src/renderer/store'; + +describe('Sidebar chat list behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + chat: { + getConversations: vi.fn(), + checkReady: vi.fn().mockResolvedValue({ ready: true }), + onTitleUpdated: vi.fn(() => () => {}), + createConversation: vi.fn(), + deleteConversation: vi.fn(), + }, + }; + + useAppStore.setState({ + activeProject: null, + activeView: 'chat', + sidebarVisible: true, + tabs: [], + activeTabId: null, + }); + }); + + it('reloads chat conversations when active project becomes available after mount', async () => { + const getConversationsMock = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'conv-1', + title: 'Project Conversation', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + ]); + + (window as any).electronAPI.chat.getConversations = getConversationsMock; + + render(); + + expect(await screen.findByText('No conversations yet')).toBeInTheDocument(); + + act(() => { + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Project 1', + slug: 'project-1', + dataPath: '/tmp/project-1', + isActive: true, + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + }); + }); + + expect(await screen.findByText('Project Conversation')).toBeInTheDocument(); + expect(getConversationsMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/renderer/components/SidebarDateFormatting.test.ts b/tests/renderer/components/SidebarDateFormatting.test.ts new file mode 100644 index 0000000..84db93b --- /dev/null +++ b/tests/renderer/components/SidebarDateFormatting.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { formatSidebarRelativeDate } from '../../../src/renderer/components/Sidebar/sidebarDateFormatting'; + +describe('formatSidebarRelativeDate', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-23T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('formats same-day dates as time', () => { + const result = formatSidebarRelativeDate({ + dateString: '2026-02-23T10:30:00.000Z', + language: 'en', + t: () => 'Yesterday', + }); + + expect(result).toMatch(/\d/); + expect(result).not.toBe('Yesterday'); + }); + + it('formats one-day-old dates as localized yesterday label', () => { + const result = formatSidebarRelativeDate({ + dateString: '2026-02-22T10:30:00.000Z', + language: 'en', + t: () => 'Yesterday', + }); + + expect(result).toBe('Yesterday'); + }); + + it('formats older dates within a week using weekday', () => { + const result = formatSidebarRelativeDate({ + dateString: '2026-02-20T10:30:00.000Z', + language: 'en', + t: () => 'Yesterday', + }); + + expect(result).toMatch(/^[A-Za-z]{3}$/); + }); + + it('formats older dates with month/day', () => { + const result = formatSidebarRelativeDate({ + dateString: '2026-02-10T10:30:00.000Z', + language: 'en', + t: () => 'Yesterday', + }); + + expect(result).toMatch(/[A-Za-z]{3}/); + }); +}); diff --git a/tests/renderer/components/SidebarEntityList.test.tsx b/tests/renderer/components/SidebarEntityList.test.tsx new file mode 100644 index 0000000..df46aef --- /dev/null +++ b/tests/renderer/components/SidebarEntityList.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SidebarEntityList } from '../../../src/renderer/components/Sidebar/SidebarEntityList'; + +describe('SidebarEntityList', () => { + it('renders loading state with header', () => { + render( + null} + />, + ); + + expect(screen.getByText('Header')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders empty state and triggers empty action', () => { + const onEmptyAction = vi.fn(); + + render( + null} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Create first' })); + expect(onEmptyAction).toHaveBeenCalledTimes(1); + }); + + it('renders rows and create button action', () => { + const onCreate = vi.fn(); + + render( + item.id} + renderItem={(item) =>
{item.id}
} + />, + ); + + expect(screen.getByText('a')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + expect(onCreate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/renderer/components/SidebarScripts.test.tsx b/tests/renderer/components/SidebarScripts.test.tsx index 5b87590..0f68eea 100644 --- a/tests/renderer/components/SidebarScripts.test.tsx +++ b/tests/renderer/components/SidebarScripts.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { act, render, screen, fireEvent } from '@testing-library/react'; import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar'; import { useAppStore } from '../../../src/renderer/store'; @@ -227,4 +227,57 @@ describe('Sidebar scripts list behavior', () => { expect(await screen.findByRole('button', { name: 'Renamed Script' })).toBeInTheDocument(); }); + + it('reloads scripts when active project context becomes available after mount', async () => { + const getAllMock = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'script-1', + projectId: 'project-1', + slug: 'hello_script', + title: 'Hello Script', + kind: 'utility', + entrypoint: 'render', + enabled: true, + version: 1, + filePath: '/tmp/hello-script.py', + content: 'print("hello")', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + ]); + + (window as any).electronAPI.scripts.getAll = getAllMock; + + useAppStore.setState({ + activeProject: null, + activeView: 'scripts', + sidebarVisible: true, + tabs: [], + activeTabId: null, + }); + + render(); + + expect(await screen.findByText('No scripts yet')).toBeInTheDocument(); + + act(() => { + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Project 1', + slug: 'project-1', + dataPath: '/tmp/project-1', + isActive: true, + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + }); + }); + + expect(await screen.findByRole('button', { name: 'Hello Script' })).toBeInTheDocument(); + expect(getAllMock).toHaveBeenCalledTimes(2); + }); });