diff --git a/app/src/components/settings/panels/TaskSourcesPanel.test.tsx b/app/src/components/settings/panels/TaskSourcesPanel.test.tsx index 84b89244e..068fab6f3 100644 --- a/app/src/components/settings/panels/TaskSourcesPanel.test.tsx +++ b/app/src/components/settings/panels/TaskSourcesPanel.test.tsx @@ -20,6 +20,7 @@ const addMock = vi.fn(); const updateMock = vi.fn(); const removeMock = vi.fn(); const fetchMock = vi.fn(); +const syncMock = vi.fn(); const previewMock = vi.fn(); vi.mock('../../../utils/tauriCommands', () => ({ @@ -29,6 +30,7 @@ vi.mock('../../../utils/tauriCommands', () => ({ openhumanTaskSourcesUpdate: (id: string, patch: unknown) => updateMock(id, patch), openhumanTaskSourcesRemove: (id: string) => removeMock(id), openhumanTaskSourcesFetch: (id: string) => fetchMock(id), + openhumanTaskSourcesSync: () => syncMock(), openhumanTaskSourcesPreviewFilter: (...args: unknown[]) => previewMock(...args), })); @@ -76,7 +78,11 @@ describe('', () => { fetched: 3, routed: 2, skippedDupe: 1, + pruned: 0, }); + syncMock.mockResolvedValue([ + { sourceId: 's-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 }, + ]); previewMock.mockResolvedValue([{ externalId: '1' }, { externalId: '2' }]); }); @@ -163,7 +169,40 @@ describe('', () => { const card = await screen.findByTestId('task-source-s-1'); fireEvent.click(within(card).getByRole('button', { name: 'Fetch now' })); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('s-1')); - expect(await screen.findByText(/Routed 2 of 3 task\(s\)/)).toBeInTheDocument(); + expect( + await screen.findByText(/Routed 2 of 3 task\(s\); removed 0 stale task\(s\)/) + ).toBeInTheDocument(); + }); + + it('syncs all sources and surfaces routed/fetched/pruned counts', async () => { + renderPanel(); + await screen.findByTestId('task-source-s-1'); + fireEvent.click(screen.getByRole('button', { name: 'Sync all' })); + await waitFor(() => expect(syncMock).toHaveBeenCalled()); + expect( + await screen.findByText(/Routed 2 of 3 task\(s\); removed 1 stale task\(s\)/) + ).toBeInTheDocument(); + }); + + it('disables refresh while sync is in progress', async () => { + let resolveSync: (value: Awaited>) => void = () => {}; + syncMock.mockReturnValue( + new Promise(resolve => { + resolveSync = resolve; + }) + ); + + renderPanel(); + await screen.findByTestId('task-source-s-1'); + fireEvent.click(screen.getByRole('button', { name: 'Sync all' })); + + await waitFor(() => expect(syncMock).toHaveBeenCalled()); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled(); + + resolveSync([ + { sourceId: 's-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 }, + ]); + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()); }); it('surfaces a fetch outcome error', async () => { @@ -173,6 +212,7 @@ describe('', () => { fetched: 0, routed: 0, skippedDupe: 0, + pruned: 0, error: 'no connection', }); renderPanel(); diff --git a/app/src/components/settings/panels/TaskSourcesPanel.tsx b/app/src/components/settings/panels/TaskSourcesPanel.tsx index 8f155e745..d6e376be7 100644 --- a/app/src/components/settings/panels/TaskSourcesPanel.tsx +++ b/app/src/components/settings/panels/TaskSourcesPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { @@ -9,6 +9,7 @@ import { openhumanTaskSourcesPreviewFilter, openhumanTaskSourcesRemove, openhumanTaskSourcesStatus, + openhumanTaskSourcesSync, openhumanTaskSourcesUpdate, type TaskContainer, type TaskSource, @@ -76,6 +77,21 @@ function buildFilter( } } +function formatSyncNotice(outcomes: Array<{ fetched: number; routed: number; pruned?: number }>): { + fetched: number; + routed: number; + pruned: number; +} { + return outcomes.reduce<{ fetched: number; routed: number; pruned: number }>( + (totals, outcome) => ({ + fetched: totals.fetched + outcome.fetched, + routed: totals.routed + outcome.routed, + pruned: totals.pruned + (outcome.pruned ?? 0), + }), + { fetched: 0, routed: 0, pruned: 0 } + ); +} + const TaskSourcesPanel = () => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); @@ -86,6 +102,16 @@ const TaskSourcesPanel = () => { const [status, setStatus] = useState(null); const [busyKey, setBusyKey] = useState(null); const [notice, setNotice] = useState(null); + const loadingRef = useRef(loading); + const busyKeyRef = useRef(busyKey); + + useEffect(() => { + loadingRef.current = loading; + }, [loading]); + + useEffect(() => { + busyKeyRef.current = busyKey; + }, [busyKey]); // ── create-form state ──────────────────────────────────────────── const [provider, setProvider] = useState('github'); @@ -102,27 +128,31 @@ const TaskSourcesPanel = () => { setDatabases([]); }, [provider]); - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [list, stat] = await Promise.all([ - openhumanTaskSourcesList(), - openhumanTaskSourcesStatus(), - ]); - setSources(list); - setStatus(stat); - } catch (err) { - setError( - `${t('settings.taskSources.loadError')}: ${err instanceof Error ? err.message : String(err)}` - ); - } finally { - setLoading(false); - } - }, [t]); + const load = useCallback( + async (options?: { force?: boolean }) => { + if (!options?.force && (loadingRef.current || busyKeyRef.current !== null)) return; + setLoading(true); + setError(null); + try { + const [list, stat] = await Promise.all([ + openhumanTaskSourcesList(), + openhumanTaskSourcesStatus(), + ]); + setSources(list); + setStatus(stat); + } catch (err) { + setError( + `${t('settings.taskSources.loadError')}: ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + setLoading(false); + } + }, + [t] + ); useEffect(() => { - void load(); + void load({ force: true }); }, [load]); const primaryLabel = useMemo(() => { @@ -141,6 +171,7 @@ const TaskSourcesPanel = () => { }, [provider, t]); const addSource = async () => { + if (busyKey) return; setBusyKey('add'); setError(null); setNotice(null); @@ -153,7 +184,7 @@ const TaskSourcesPanel = () => { setName(''); setPrimary(''); setLabels(''); - await load(); + await load({ force: true }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -162,6 +193,7 @@ const TaskSourcesPanel = () => { }; const previewFilter = async () => { + if (busyKey) return; setBusyKey('preview'); setError(null); setNotice(null); @@ -181,6 +213,7 @@ const TaskSourcesPanel = () => { // Fetch the databases the connected account exposes (Notion) so the user can // pick one instead of pasting a raw id. const browseDatabases = async () => { + if (busyKey) return; setBusyKey('databases'); setError(null); setNotice(null); @@ -198,6 +231,7 @@ const TaskSourcesPanel = () => { }; const toggleSource = async (source: TaskSource) => { + if (busyKey) return; setBusyKey(`toggle:${source.id}`); setError(null); try { @@ -211,6 +245,7 @@ const TaskSourcesPanel = () => { }; const fetchNow = async (source: TaskSource) => { + if (busyKey) return; setBusyKey(`fetch:${source.id}`); setError(null); setNotice(null); @@ -219,7 +254,7 @@ const TaskSourcesPanel = () => { // Refresh the source list first (updates lastFetchAt/lastStatus); // `load()` resets the error/notice, so set the outcome message // *after* it so the message isn't immediately cleared. - await load(); + await load({ force: true }); if (outcome.error) { setError(outcome.error); } else { @@ -227,6 +262,34 @@ const TaskSourcesPanel = () => { t('settings.taskSources.fetchResult') .replace('{routed}', String(outcome.routed)) .replace('{fetched}', String(outcome.fetched)) + .replace('{pruned}', String(outcome.pruned ?? 0)) + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusyKey(null); + } + }; + + const syncAll = async () => { + if (busyKey) return; + setBusyKey('sync'); + setError(null); + setNotice(null); + try { + const outcomes = await openhumanTaskSourcesSync(); + await load({ force: true }); + const firstError = outcomes.find(outcome => outcome.error)?.error; + if (firstError) { + setError(firstError); + } else { + const totals = formatSyncNotice(outcomes); + setNotice( + t('settings.taskSources.fetchResult') + .replace('{routed}', String(totals.routed)) + .replace('{fetched}', String(totals.fetched)) + .replace('{pruned}', String(totals.pruned)) ); } } catch (err) { @@ -237,6 +300,7 @@ const TaskSourcesPanel = () => { }; const removeSource = async (source: TaskSource) => { + if (busyKey) return; if (!window.confirm(t('settings.taskSources.removeConfirm'))) return; setBusyKey(`remove:${source.id}`); setError(null); @@ -331,7 +395,7 @@ const TaskSourcesPanel = () => { @@ -397,6 +461,15 @@ const TaskSourcesPanel = () => {

{t('settings.taskSources.configured')}

+ {loading ? (

{t('common.loading')}

@@ -445,7 +518,7 @@ const TaskSourcesPanel = () => { @@ -473,7 +546,11 @@ const TaskSourcesPanel = () => { )} - diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 8c0846999..ae9c03912 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4393,7 +4393,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'مُهمة (مُهمات) مُطابقة لهذا التصفية', 'settings.taskSources.fetchNow': 'أحضر الآن', 'settings.taskSources.fetching': '....', - 'settings.taskSources.fetchResult': 'تم توجيه {routed} من أصل {fetched} مهمة/مهام', + 'settings.taskSources.fetchResult': + 'تم توجيه {routed} من أصل {fetched} مهمة/مهام؛ تمت إزالة {pruned} مهمة/مهام قديمة', + 'settings.taskSources.syncAll': 'مزامنة الكل', + 'settings.taskSources.syncing': 'جارٍ المزامنة…', 'settings.taskSources.configured': 'المصادر المحظورة', 'settings.taskSources.empty': 'لم يتم تشكيل مصادر المهمة بعد', 'settings.taskSources.proactive': 'استباقي', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index afae9c167..36a446241 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4475,7 +4475,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'এই ফিল্টারের জন্য xqxqx প্রচেষ্টা করুন', 'settings.taskSources.fetchNow': 'এখন নিয়ে এসো', 'settings.taskSources.fetching': 'প্রাপ্ত করা হচ্ছে...', - 'settings.taskSources.fetchResult': 'xxqxqx এর রুট', + 'settings.taskSources.fetchResult': + '{fetched}টি কাজের মধ্যে {routed}টি রাউট করা হয়েছে; {pruned}টি পুরোনো কাজ সরানো হয়েছে', + 'settings.taskSources.syncAll': 'সব সিঙ্ক করুন', + 'settings.taskSources.syncing': 'সিঙ্ক হচ্ছে...', 'settings.taskSources.configured': '%d-টি উৎসস্থল কনফিগার করুন', 'settings.taskSources.empty': 'কোনো কর্ম কনফিগার করা হয়নি।', 'settings.taskSources.proactive': 'আকর্ষণীয়', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index fcc7b551c..61182c40e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4597,7 +4597,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': '{count}-Aufgabe(n) entsprechen diesem Filter', 'settings.taskSources.fetchNow': 'Jetzt abholen', 'settings.taskSources.fetching': 'Abrufen…', - 'settings.taskSources.fetchResult': 'Gerouteter {routed} von {fetched}-Aufgabe(n)', + 'settings.taskSources.fetchResult': + '{routed} von {fetched} Aufgabe(n) weitergeleitet; {pruned} veraltete Aufgabe(n) entfernt', + 'settings.taskSources.syncAll': 'Alle synchronisieren', + 'settings.taskSources.syncing': 'Synchronisierung…', 'settings.taskSources.configured': 'Konfigurierte Quellen', 'settings.taskSources.empty': 'Noch keine Aufgabenquellen konfiguriert.', 'settings.taskSources.proactive': 'Proaktiv', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index fa7d15d56..ddbdbca27 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5014,7 +5014,10 @@ const en: TranslationMap = { 'settings.taskSources.previewResult': '{count} task(s) match this filter', 'settings.taskSources.fetchNow': 'Fetch now', 'settings.taskSources.fetching': 'Fetching…', - 'settings.taskSources.fetchResult': 'Routed {routed} of {fetched} task(s)', + 'settings.taskSources.fetchResult': + 'Routed {routed} of {fetched} task(s); removed {pruned} stale task(s)', + 'settings.taskSources.syncAll': 'Sync all', + 'settings.taskSources.syncing': 'Syncing…', 'settings.taskSources.configured': 'Configured sources', 'settings.taskSources.empty': 'No task sources configured yet.', 'settings.taskSources.proactive': 'Proactive', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 2506c6d9f..2ae302ab8 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4567,7 +4567,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'La(s) tarea(s) {count} coinciden con este filtro', 'settings.taskSources.fetchNow': 'Trae ahora', 'settings.taskSources.fetching': 'Obteniendo…', - 'settings.taskSources.fetchResult': 'Enrutado {routed} de la(s) tarea(s) {fetched}', + 'settings.taskSources.fetchResult': + 'Se enrutaron {routed} de {fetched} tarea(s); se eliminaron {pruned} tarea(s) obsoletas', + 'settings.taskSources.syncAll': 'Sincronizar todo', + 'settings.taskSources.syncing': 'Sincronizando…', 'settings.taskSources.configured': 'Fuentes configuradas', 'settings.taskSources.empty': 'Aún no se han configurado fuentes de tareas.', 'settings.taskSources.proactive': 'proactivo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 9e7c5d719..454b06080 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4581,7 +4581,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'La ou les tâches {count} correspondent à ce filtre', 'settings.taskSources.fetchNow': 'Ramasse maintenant', 'settings.taskSources.fetching': 'Récupération…', - 'settings.taskSources.fetchResult': 'Acheminé {routed} des tâches {fetched}', + 'settings.taskSources.fetchResult': + '{routed} tâche(s) acheminée(s) sur {fetched}; {pruned} tâche(s) obsolète(s) supprimée(s)', + 'settings.taskSources.syncAll': 'Tout synchroniser', + 'settings.taskSources.syncing': 'Synchronisation…', 'settings.taskSources.configured': 'Sources configurées', 'settings.taskSources.empty': 'Aucune source de tâche configurée pour le moment.', 'settings.taskSources.proactive': 'proactif', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7934a8436..90fc36dc7 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4485,7 +4485,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': '{count} कार्य इस फ़िल्टर से मेल खाता है', 'settings.taskSources.fetchNow': 'अभी लेना', 'settings.taskSources.fetching': '...', - 'settings.taskSources.fetchResult': '{fetched} कार्य (s) के रूटेड {routed}', + 'settings.taskSources.fetchResult': + '{fetched} कार्यों में से {routed} रूट किए गए; {pruned} पुराने कार्य हटाए गए', + 'settings.taskSources.syncAll': 'सभी सिंक करें', + 'settings.taskSources.syncing': 'सिंक हो रहा है...', 'settings.taskSources.configured': 'संरूपित सूत्र', 'settings.taskSources.empty': 'अभी तक कोई कार्य स्रोत नहीं है।', 'settings.taskSources.proactive': 'सक्रिय', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 5f7ed0d5d..123674527 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4493,7 +4493,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'Tugas {count} cocok dengan penyaring ini', 'settings.taskSources.fetchNow': 'Ambil sekarang', 'settings.taskSources.fetching': 'Mengambil...', - 'settings.taskSources.fetchResult': 'Karang tugas {routed} dari {fetched}', + 'settings.taskSources.fetchResult': + 'Merutekan {routed} dari {fetched} tugas; menghapus {pruned} tugas usang', + 'settings.taskSources.syncAll': 'Sinkronkan semua', + 'settings.taskSources.syncing': 'Menyinkronkan...', 'settings.taskSources.configured': 'Sumber yang dikonfigurasi', 'settings.taskSources.empty': 'Belum ada sumber tugas yang dikonfigurasi.', 'settings.taskSources.proactive': 'Proaktif', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index dfbc51566..03454a154 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4558,7 +4558,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'Il/i compito/i {count} corrispondono a questo filtro', 'settings.taskSources.fetchNow': 'Prendi adesso', 'settings.taskSources.fetching': 'Recupero…', - 'settings.taskSources.fetchResult': 'Instradato {routed} di {fetched} attività', + 'settings.taskSources.fetchResult': + 'Instradate {routed} di {fetched} attività; rimosse {pruned} attività obsolete', + 'settings.taskSources.syncAll': 'Sincronizza tutto', + 'settings.taskSources.syncing': 'Sincronizzazione…', 'settings.taskSources.configured': 'Fonti configurate', 'settings.taskSources.empty': 'Nessuna fonte attività configurata ancora.', 'settings.taskSources.proactive': 'Proattivo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index cb52fba9d..3b447915a 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4434,7 +4434,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': '{count}개 작업이 이 필터와 일치합니다', 'settings.taskSources.fetchNow': '지금 가져오기', 'settings.taskSources.fetching': '가져오는 중…', - 'settings.taskSources.fetchResult': '{fetched}개 작업 중 {routed}개 라우팅됨', + 'settings.taskSources.fetchResult': + '{fetched}개 작업 중 {routed}개 라우팅됨; 오래된 작업 {pruned}개 제거됨', + 'settings.taskSources.syncAll': '모두 동기화', + 'settings.taskSources.syncing': '동기화 중…', 'settings.taskSources.configured': '구성된 소스', 'settings.taskSources.empty': '아직 구성된 작업 소스가 없습니다.', 'settings.taskSources.proactive': '능동형', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 13d1a5388..d1c1155e1 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4554,7 +4554,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': '{count} zadań pasuje do tego filtra', 'settings.taskSources.fetchNow': 'Pobierz teraz', 'settings.taskSources.fetching': 'Pobieranie…', - 'settings.taskSources.fetchResult': 'Skierowano {routed} z {fetched} zadań', + 'settings.taskSources.fetchResult': + 'Skierowano {routed} z {fetched} zadań; usunięto {pruned} nieaktualnych zadań', + 'settings.taskSources.syncAll': 'Synchronizuj wszystko', + 'settings.taskSources.syncing': 'Synchronizowanie…', 'settings.taskSources.configured': 'Skonfigurowane źródła', 'settings.taskSources.empty': 'Nie skonfigurowano jeszcze źródeł zadań.', 'settings.taskSources.proactive': 'Proaktywne', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 17fb8c678..880558f4b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4554,7 +4554,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'A tarefa(s) {count} corresponde(m) a este filtro', 'settings.taskSources.fetchNow': 'Buscar agora', 'settings.taskSources.fetching': 'Buscando…', - 'settings.taskSources.fetchResult': 'Roteado {routed} de {fetched} tarefa(s)', + 'settings.taskSources.fetchResult': + 'Roteado {routed} de {fetched} tarefa(s); removidas {pruned} tarefa(s) antigas', + 'settings.taskSources.syncAll': 'Sincronizar tudo', + 'settings.taskSources.syncing': 'Sincronizando…', 'settings.taskSources.configured': 'Fontes configuradas', 'settings.taskSources.empty': 'Nenhuma fonte de tarefa configurada ainda.', 'settings.taskSources.proactive': 'Proativo', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c53425cda..8f45117ef 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4520,7 +4520,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': 'Задачи {count} соответствуют этому фильтру', 'settings.taskSources.fetchNow': 'Получить сейчас', 'settings.taskSources.fetching': 'Получение…', - 'settings.taskSources.fetchResult': 'Маршрутизация {routed} из задач {fetched}', + 'settings.taskSources.fetchResult': + 'Направлено {routed} из {fetched} задач; удалено устаревших задач: {pruned}', + 'settings.taskSources.syncAll': 'Синхронизировать все', + 'settings.taskSources.syncing': 'Синхронизация…', 'settings.taskSources.configured': 'Настроенные источники', 'settings.taskSources.empty': 'Источники задач пока не настроены.', 'settings.taskSources.proactive': 'Проактивный', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d42e323ce..a2b9e6984 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4258,7 +4258,10 @@ const messages: TranslationMap = { 'settings.taskSources.previewResult': '{count} 个任务匹配此筛选器', 'settings.taskSources.fetchNow': '立即获取', 'settings.taskSources.fetching': '获取中…', - 'settings.taskSources.fetchResult': '已路由 {routed}/{fetched} 个任务', + 'settings.taskSources.fetchResult': + '已路由 {routed}/{fetched} 个任务;已移除 {pruned} 个过期任务', + 'settings.taskSources.syncAll': '全部同步', + 'settings.taskSources.syncing': '正在同步…', 'settings.taskSources.configured': '已配置来源', 'settings.taskSources.empty': '尚未配置任务来源。', 'settings.taskSources.proactive': '主动', diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx index 6cdfebc04..13da4d072 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx @@ -7,6 +7,7 @@ import { openhumanTaskSourcesFetch, openhumanTaskSourcesList, openhumanTaskSourcesStatus, + openhumanTaskSourcesSync, openhumanTaskSourcesUpdate, } from '../../../utils/tauriCommands'; import { TaskKanbanBoard } from './TaskKanbanBoard'; @@ -18,6 +19,7 @@ vi.mock('../../../utils/tauriCommands', () => ({ openhumanTaskSourcesFetch: vi.fn(), openhumanTaskSourcesList: vi.fn(), openhumanTaskSourcesStatus: vi.fn(), + openhumanTaskSourcesSync: vi.fn(), openhumanTaskSourcesUpdate: vi.fn(), })); @@ -73,7 +75,11 @@ describe('TaskKanbanBoard approval surface', () => { fetched: 3, routed: 2, skippedDupe: 1, + pruned: 0, }); + vi.mocked(openhumanTaskSourcesSync).mockResolvedValue([ + { sourceId: 'src-1', provider: 'github', fetched: 3, routed: 2, skippedDupe: 1, pruned: 1 }, + ]); vi.mocked(openhumanTaskSourcesUpdate).mockResolvedValue({ id: 'src-1', provider: 'github', @@ -186,6 +192,9 @@ describe('TaskKanbanBoard approval surface', () => { fireEvent.click(screen.getByText('settings.taskSources.fetchNow')); await waitFor(() => expect(openhumanTaskSourcesFetch).toHaveBeenCalledWith('src-1')); + fireEvent.click(screen.getByText('settings.taskSources.syncAll')); + await waitFor(() => expect(openhumanTaskSourcesSync).toHaveBeenCalled()); + fireEvent.click(screen.getByText('settings.taskSources.disable')); await waitFor(() => expect(openhumanTaskSourcesUpdate).toHaveBeenCalledWith('src-1', { enabled: false }) diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.tsx index f33c70fd1..053148ea1 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.tsx @@ -23,6 +23,7 @@ import { openhumanTaskSourcesFetch, openhumanTaskSourcesList, openhumanTaskSourcesStatus, + openhumanTaskSourcesSync, openhumanTaskSourcesUpdate, type TaskSource, type TaskSourcesStatus, @@ -569,7 +570,23 @@ function formatUrgency( function formatFetchNotice(outcome: FetchOutcome, t: (key: string) => string): string { return t('settings.taskSources.fetchResult') .replace('{routed}', String(outcome.routed)) - .replace('{fetched}', String(outcome.fetched)); + .replace('{fetched}', String(outcome.fetched)) + .replace('{pruned}', String(outcome.pruned ?? 0)); +} + +function formatSyncNotice(outcomes: FetchOutcome[], t: (key: string) => string): string { + const totals = outcomes.reduce( + (acc, outcome) => ({ + fetched: acc.fetched + outcome.fetched, + routed: acc.routed + outcome.routed, + pruned: acc.pruned + (outcome.pruned ?? 0), + }), + { fetched: 0, routed: 0, pruned: 0 } + ); + return t('settings.taskSources.fetchResult') + .replace('{routed}', String(totals.routed)) + .replace('{fetched}', String(totals.fetched)) + .replace('{pruned}', String(totals.pruned)); } function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: boolean }) { @@ -614,6 +631,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: }, [load]); const toggleSource = async (source: TaskSource) => { + if (busyKey) return; setBusyKey(`toggle:${source.id}`); setError(null); setNotice(null); @@ -628,6 +646,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: }; const fetchSource = async (source: TaskSource) => { + if (busyKey) return; setBusyKey(`fetch:${source.id}`); setError(null); setNotice(null); @@ -646,6 +665,27 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: } }; + const syncSources = async () => { + if (busyKey) return; + setBusyKey('sync'); + setError(null); + setNotice(null); + try { + const outcomes = await openhumanTaskSourcesSync(); + await load(); + const firstError = outcomes.find(outcome => outcome.error)?.error; + if (firstError) { + setError(firstError); + } else { + setNotice(formatSyncNotice(outcomes, t)); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusyKey(null); + } + }; + return (
@@ -668,6 +708,16 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: className="text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200"> {t('conversations.taskKanban.sources.manage')} +