mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
597 lines
20 KiB
TypeScript
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;
|