import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { openhumanTaskSourcesAdd, openhumanTaskSourcesFetch, openhumanTaskSourcesList, openhumanTaskSourcesListDatabases, openhumanTaskSourcesPreviewFilter, openhumanTaskSourcesRemove, openhumanTaskSourcesStatus, openhumanTaskSourcesSync, openhumanTaskSourcesUpdate, type TaskContainer, type TaskSource, type TaskSourceFilter, type TaskSourceProvider, type TaskSourcesStatus, } from '../../../utils/tauriCommands'; import Button from '../../ui/Button'; import SettingsHeader from '../components/SettingsHeader'; import { SettingsBadge, SettingsCheckbox, SettingsEmptyState, SettingsRow, SettingsSection, SettingsSelect, SettingsStatusLine, SettingsTextField, } from '../controls'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const PROVIDERS: TaskSourceProvider[] = ['github', 'notion', 'linear', 'clickup']; function providerLabel(provider: TaskSourceProvider, t: (key: string) => string): string { switch (provider) { case 'github': return t('settings.taskSources.providers.github'); case 'notion': return t('settings.taskSources.providers.notion'); case 'linear': return t('settings.taskSources.providers.linear'); case 'clickup': return t('settings.taskSources.providers.clickup'); default: return provider; } } /** Build a `TaskSourceFilter` from the create-form fields. */ function buildFilter( provider: TaskSourceProvider, fields: { primary: string; labels: string; assignedToMe: boolean } ): TaskSourceFilter { const primary = fields.primary.trim(); switch (provider) { case 'github': return { provider: 'github', repo: primary || undefined, labels: fields.labels .split(',') .map(l => l.trim()) .filter(Boolean), assignee_is_me: fields.assignedToMe, }; case 'notion': return { provider: 'notion', database_id: primary || undefined, assigned_to_me: fields.assignedToMe, }; case 'linear': return { provider: 'linear', team_id: primary || undefined, assignee_is_me: fields.assignedToMe, }; case 'clickup': return { provider: 'clickup', team_id: primary || undefined, assignee_is_me: fields.assignedToMe, }; default: return { provider: 'github', assignee_is_me: fields.assignedToMe }; } } 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(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [sources, setSources] = useState([]); 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'); const [name, setName] = useState(''); const [primary, setPrimary] = useState(''); const [labels, setLabels] = useState(''); const [assignedToMe, setAssignedToMe] = useState(true); // Notion database picker: populated on demand via `browseDatabases`. const [databases, setDatabases] = useState([]); // Clear any loaded database picker when the provider changes — the list is // provider-specific (today only Notion exposes one). useEffect(() => { setDatabases([]); }, [provider]); 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({ force: true }); }, [load]); const primaryLabel = useMemo(() => { switch (provider) { case 'github': return t('settings.taskSources.github.repo'); case 'notion': return t('settings.taskSources.notion.database'); case 'linear': return t('settings.taskSources.linear.team'); case 'clickup': return t('settings.taskSources.clickup.team'); default: return ''; } }, [provider, t]); const addSource = async () => { if (busyKey) return; setBusyKey('add'); setError(null); setNotice(null); try { await openhumanTaskSourcesAdd({ provider, name: name.trim() || undefined, filter: buildFilter(provider, { primary, labels, assignedToMe }), }); setName(''); setPrimary(''); setLabels(''); await load({ force: true }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }; const previewFilter = async () => { if (busyKey) return; setBusyKey('preview'); setError(null); setNotice(null); try { const tasks = await openhumanTaskSourcesPreviewFilter( provider, buildFilter(provider, { primary, labels, assignedToMe }) ); setNotice(t('settings.taskSources.previewResult').replace('{count}', String(tasks.length))); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }; // 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); try { const dbs = await openhumanTaskSourcesListDatabases(provider); setDatabases(dbs); if (dbs.length === 0) { setNotice(t('settings.taskSources.notion.noDatabases')); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }; const toggleSource = async (source: TaskSource) => { if (busyKey) return; setBusyKey(`toggle:${source.id}`); setError(null); try { const updated = await openhumanTaskSourcesUpdate(source.id, { enabled: !source.enabled }); setSources(prev => prev.map(s => (s.id === updated.id ? updated : s))); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }; const fetchNow = async (source: TaskSource) => { if (busyKey) return; setBusyKey(`fetch:${source.id}`); setError(null); setNotice(null); try { const outcome = await openhumanTaskSourcesFetch(source.id); // 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({ force: true }); if (outcome.error) { setError(outcome.error); } else { setNotice( 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) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }; const removeSource = async (source: TaskSource) => { if (busyKey) return; if (!window.confirm(t('settings.taskSources.removeConfirm'))) return; setBusyKey(`remove:${source.id}`); setError(null); try { await openhumanTaskSourcesRemove(source.id); setSources(prev => prev.filter(s => s.id !== source.id)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyKey(null); } }; return (

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

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

{status && !status.enabled && (
{t('settings.taskSources.disabledBanner')}
)} {/* ── Add a source ─────────────────────────────────────────── */} setProvider(e.target.value as TaskSourceProvider)} className="w-full"> {PROVIDERS.map(p => ( ))} } /> setName(e.target.value)} aria-label={t('settings.taskSources.name')} /> } /> setPrimary(e.target.value)} aria-label={primaryLabel} /> } /> {provider === 'notion' && ( {databases.length > 0 && ( setPrimary(e.target.value)} className="w-full mt-1"> {databases.map(db => ( ))} )}
} /> )} {provider === 'github' && ( setLabels(e.target.value)} aria-label={t('settings.taskSources.github.labels')} /> } /> )} setAssignedToMe(next)} aria-label={t('settings.taskSources.assignedToMe')} /> } />
{/* ── Configured sources ───────────────────────────────────── */}
{loading ? (

{t('common.loading')}

) : sources.length === 0 ? ( ) : (
    {sources.map(source => (
  • {source.name || providerLabel(source.provider, t)}

    {providerLabel(source.provider, t)} {source.target === 'agent_todo_proactive' ? ` · ${t('settings.taskSources.proactive')}` : ''}

    {t('settings.taskSources.lastFetch')}:{' '} {source.lastFetchAt ? new Date(source.lastFetchAt).toLocaleString() : t('settings.taskSources.never')}

    {source.enabled ? t('settings.taskSources.statusEnabled') : t('settings.taskSources.statusDisabled')}
  • ))}
)}
); }; export default TaskSourcesPanel;