Files
openhuman/app/src/components/settings/panels/TaskSourcesPanel.tsx
T

597 lines
20 KiB
TypeScript

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<string | null>(null);
const [sources, setSources] = useState<TaskSource[]>([]);
const [status, setStatus] = useState<TaskSourcesStatus | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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<TaskSourceProvider>('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<TaskContainer[]>([]);
// 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 (
<div className="z-10 relative" data-testid="task-sources-panel">
<SettingsHeader
title={t('settings.taskSources.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 pt-2 space-y-5">
<div className="space-y-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.taskSources.description')}
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.taskSources.connectHint')}
</p>
</div>
{status && !status.enabled && (
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.taskSources.disabledBanner')}
</div>
)}
<SettingsStatusLine saving={false} savedNote={notice} error={error} savingLabel="" />
{/* ── Add a source ─────────────────────────────────────────── */}
<SettingsSection title={t('settings.taskSources.addTitle')}>
<SettingsRow
label={t('settings.taskSources.provider')}
htmlFor="task-source-provider"
stacked
control={
<SettingsSelect
id="task-source-provider"
value={provider}
onChange={e => setProvider(e.target.value as TaskSourceProvider)}
className="w-full">
{PROVIDERS.map(p => (
<option key={p} value={p}>
{providerLabel(p, t)}
</option>
))}
</SettingsSelect>
}
/>
<SettingsRow
label={t('settings.taskSources.name')}
htmlFor="task-source-name"
stacked
control={
<SettingsTextField
id="task-source-name"
type="text"
placeholder={t('settings.taskSources.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
aria-label={t('settings.taskSources.name')}
/>
}
/>
<SettingsRow
label={primaryLabel}
htmlFor="task-source-primary"
stacked
control={
<SettingsTextField
id="task-source-primary"
type="text"
value={primary}
onChange={e => setPrimary(e.target.value)}
aria-label={primaryLabel}
/>
}
/>
{provider === 'notion' && (
<SettingsRow
stacked
control={
<div className="space-y-1">
<Button
type="button"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void browseDatabases()}>
{busyKey === 'databases'
? t('settings.taskSources.notion.loadingDatabases')
: t('settings.taskSources.notion.browseDatabases')}
</Button>
{databases.length > 0 && (
<SettingsSelect
value={primary}
onChange={e => setPrimary(e.target.value)}
className="w-full mt-1">
<option value="">{t('settings.taskSources.notion.selectDatabase')}</option>
{databases.map(db => (
<option key={db.id} value={db.id}>
{db.title}
</option>
))}
</SettingsSelect>
)}
</div>
}
/>
)}
{provider === 'github' && (
<SettingsRow
label={t('settings.taskSources.github.labels')}
htmlFor="task-source-labels"
stacked
control={
<SettingsTextField
id="task-source-labels"
type="text"
value={labels}
onChange={e => setLabels(e.target.value)}
aria-label={t('settings.taskSources.github.labels')}
/>
}
/>
)}
<SettingsRow
htmlFor="task-source-assigned"
label={t('settings.taskSources.assignedToMe')}
control={
<SettingsCheckbox
id="task-source-assigned"
checked={assignedToMe}
onCheckedChange={next => setAssignedToMe(next)}
aria-label={t('settings.taskSources.assignedToMe')}
/>
}
/>
<div className="flex gap-2 px-4 py-3 border-t border-neutral-100 dark:border-neutral-800">
<Button
type="button"
variant="primary"
size="xs"
disabled={busyKey !== null}
onClick={() => void addSource()}>
{busyKey === 'add' ? t('settings.taskSources.adding') : t('settings.taskSources.add')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void previewFilter()}>
{t('settings.taskSources.preview')}
</Button>
</div>
</SettingsSection>
{/* ── Configured sources ───────────────────────────────────── */}
<SettingsSection title={t('settings.taskSources.configured')}>
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
<Button
type="button"
variant="secondary"
size="xs"
disabled={loading || busyKey !== null || sources.length === 0}
onClick={() => void syncAll()}>
{busyKey === 'sync'
? t('settings.taskSources.syncing')
: t('settings.taskSources.syncAll')}
</Button>
</div>
{loading ? (
<div className="px-4 py-3">
<p className="text-sm text-neutral-400 dark:text-neutral-500">
{t('common.loading')}
</p>
</div>
) : sources.length === 0 ? (
<SettingsEmptyState label={t('settings.taskSources.empty')} />
) : (
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
{sources.map(source => (
<li
key={source.id}
className="p-3 space-y-2"
data-testid={`task-source-${source.id}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
{source.name || providerLabel(source.provider, t)}
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{providerLabel(source.provider, t)}
{source.target === 'agent_todo_proactive'
? ` · ${t('settings.taskSources.proactive')}`
: ''}
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">
{t('settings.taskSources.lastFetch')}:{' '}
{source.lastFetchAt
? new Date(source.lastFetchAt).toLocaleString()
: t('settings.taskSources.never')}
</p>
</div>
<SettingsBadge variant={source.enabled ? 'success' : 'neutral'}>
{source.enabled
? t('settings.taskSources.statusEnabled')
: t('settings.taskSources.statusDisabled')}
</SettingsBadge>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void toggleSource(source)}>
{source.enabled
? t('settings.taskSources.disable')
: t('settings.taskSources.enable')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
disabled={busyKey !== null}
onClick={() => void fetchNow(source)}>
{busyKey === `fetch:${source.id}`
? t('settings.taskSources.fetching')
: t('settings.taskSources.fetchNow')}
</Button>
<Button
type="button"
variant="danger"
size="xs"
disabled={busyKey !== null}
onClick={() => void removeSource(source)}>
{t('settings.taskSources.remove')}
</Button>
</div>
</li>
))}
</ul>
)}
<div className="px-4 py-3 border-t border-neutral-100 dark:border-neutral-800">
<Button
type="button"
variant="ghost"
size="xs"
disabled={loading || busyKey !== null}
onClick={() => void load()}>
{t('settings.taskSources.refresh')}
</Button>
</div>
</SettingsSection>
</div>
</div>
);
};
export default TaskSourcesPanel;