mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Add task source UI and agent launch flow (#3279)
Co-authored-by: sanil-23 <sanil@alphahuman.xyz> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
sanil-23
Claude Opus 4.8
parent
3529bfb116
commit
d40e89c7b3
@@ -14,18 +14,43 @@
|
||||
* the Conversations page where the agent write path lives.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LuPlus } from 'react-icons/lu';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
LuArrowRight,
|
||||
LuBot,
|
||||
LuCheck,
|
||||
LuExternalLink,
|
||||
LuPlus,
|
||||
LuSparkles,
|
||||
LuX,
|
||||
} from 'react-icons/lu';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { TaskKanbanBoard } from '../../pages/conversations/components/TaskKanbanBoard';
|
||||
import { threadApi } from '../../services/api/threadApi';
|
||||
import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
TASK_SOURCES_THREAD_ID,
|
||||
todosApi,
|
||||
USER_TASKS_THREAD_ID,
|
||||
} from '../../services/api/todosApi';
|
||||
import { chatSend } from '../../services/chatService';
|
||||
import { selectActiveAgentProfileId } from '../../store/agentProfileSlice';
|
||||
import { beginInferenceTurn, setToolTimelineForThread } from '../../store/chatRuntimeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
loadThreadMessages,
|
||||
loadThreads,
|
||||
setActiveThread,
|
||||
setSelectedThread,
|
||||
} from '../../store/threadSlice';
|
||||
import type { ThreadMessage } from '../../types/thread';
|
||||
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState';
|
||||
import { UserTaskComposer } from './UserTaskComposer';
|
||||
|
||||
const log = debug('intelligence:tasks');
|
||||
const AGENT_TASK_THREAD_LABEL = 'agent-task';
|
||||
const CHAT_MODEL_ID = 'reasoning-v1';
|
||||
|
||||
interface ThreadTaskBoard {
|
||||
threadId: string;
|
||||
@@ -38,14 +63,72 @@ function shortId(threadId: string): string {
|
||||
return threadId.length > 8 ? `…${threadId.slice(-8)}` : threadId;
|
||||
}
|
||||
|
||||
function agentTaskThreadTitle(title: string): string {
|
||||
const trimmed = title.trim();
|
||||
const base = trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed;
|
||||
return `Agent task: ${base || 'Untitled task'}`;
|
||||
}
|
||||
|
||||
function buildAgentTaskPrompt(
|
||||
card: TaskBoardCard,
|
||||
t: (key: string, fallback?: string) => string
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
'Work this approved agent task from the task board.',
|
||||
'',
|
||||
`Task: ${card.title}`,
|
||||
];
|
||||
if (card.objective?.trim()) {
|
||||
lines.push('', 'Objective:', card.objective.trim());
|
||||
}
|
||||
if (card.notes?.trim()) {
|
||||
lines.push('', 'Notes:', card.notes.trim());
|
||||
}
|
||||
if (card.plan && card.plan.length > 0) {
|
||||
lines.push('', 'Plan:');
|
||||
lines.push(...card.plan.map((step, index) => `${index + 1}. ${step}`));
|
||||
}
|
||||
if (card.acceptanceCriteria && card.acceptanceCriteria.length > 0) {
|
||||
lines.push('', 'Acceptance criteria:');
|
||||
lines.push(...card.acceptanceCriteria.map(item => `- ${item}`));
|
||||
}
|
||||
if (card.allowedTools && card.allowedTools.length > 0) {
|
||||
lines.push('', `Allowed tools: ${card.allowedTools.join(', ')}`);
|
||||
}
|
||||
if (card.evidence && card.evidence.length > 0) {
|
||||
lines.push('', 'Evidence / references:');
|
||||
lines.push(...card.evidence.map(item => `- ${item}`));
|
||||
}
|
||||
const source = readSourceMetadata(card.sourceMetadata);
|
||||
if (source.url || source.repo || source.externalId) {
|
||||
lines.push('', t('intelligence.workTask.sourceTaskHeading'));
|
||||
if (source.repo)
|
||||
lines.push(t('intelligence.workTask.repositoryLine').replace('{repo}', source.repo));
|
||||
if (source.externalId)
|
||||
lines.push(
|
||||
t('intelligence.workTask.externalIdLine').replace('{externalId}', source.externalId)
|
||||
);
|
||||
if (source.url) lines.push(t('intelligence.workTask.urlLine').replace('{url}', source.url));
|
||||
}
|
||||
lines.push('', t('intelligence.workTask.closingInstruction'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default function IntelligenceTasksTab() {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const liveBoards = useAppSelector(state => state.chatRuntime.taskBoardByThread);
|
||||
const threads = useAppSelector(state => state.thread.threads ?? []);
|
||||
const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId);
|
||||
const uiLocale = useAppSelector(state => state.locale?.current ?? 'en');
|
||||
|
||||
const [persistedBoards, setPersistedBoards] = useState<Record<string, TaskBoard>>({});
|
||||
const [personalBoard, setPersonalBoard] = useState<TaskBoard | null>(null);
|
||||
const [taskSourcesBoard, setTaskSourcesBoard] = useState<TaskBoard | null>(null);
|
||||
const [composerOpen, setComposerOpen] = useState(false);
|
||||
const [refiningCard, setRefiningCard] = useState<TaskBoardCard | null>(null);
|
||||
const [workingCardId, setWorkingCardId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -93,16 +176,40 @@ export default function IntelligenceTasksTab() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTaskSourcesBoard = useCallback(async () => {
|
||||
log('fetchTaskSourcesBoard: entry');
|
||||
try {
|
||||
const board = await todosApi.list(TASK_SOURCES_THREAD_ID);
|
||||
if (mountedRef.current) {
|
||||
setTaskSourcesBoard(board);
|
||||
log('fetchTaskSourcesBoard: cards=%d', board.cards.length);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('fetchTaskSourcesBoard: error %s', msg);
|
||||
if (mountedRef.current) {
|
||||
setTaskSourcesBoard({ threadId: TASK_SOURCES_THREAD_ID, cards: [], updatedAt: '' });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
// `loading` defaults to true; flip it off once both fetches settle.
|
||||
await Promise.allSettled([fetchPersistedBoards(), fetchPersonalBoard()]);
|
||||
await Promise.allSettled([
|
||||
fetchPersistedBoards(),
|
||||
fetchPersonalBoard(),
|
||||
fetchTaskSourcesBoard(),
|
||||
]);
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}, [fetchPersistedBoards, fetchPersonalBoard]);
|
||||
}, [fetchPersistedBoards, fetchPersonalBoard, fetchTaskSourcesBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void loadAll();
|
||||
const handle = window.setTimeout(() => {
|
||||
void loadAll();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [loadAll]);
|
||||
@@ -213,6 +320,113 @@ export default function IntelligenceTasksTab() {
|
||||
[personalBoard, mutatePersonal]
|
||||
);
|
||||
|
||||
const handleWorkPersonal = useCallback(
|
||||
async (card: TaskBoardCard) => {
|
||||
if (!personalBoard || workingCardId) return;
|
||||
setWorkingCardId(card.id);
|
||||
setActionError(null);
|
||||
const launchPrompt = buildAgentTaskPrompt(card, t);
|
||||
const now = new Date().toISOString();
|
||||
const threadTitle = agentTaskThreadTitle(card.title);
|
||||
try {
|
||||
const thread = await threadApi.createNewThread([AGENT_TASK_THREAD_LABEL]);
|
||||
await threadApi.updateTitle(thread.id, threadTitle);
|
||||
const userMessage: ThreadMessage = {
|
||||
id: `msg_${globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `${Date.now()}`}`,
|
||||
content: launchPrompt,
|
||||
type: 'text',
|
||||
extraMetadata: { source: 'agent-task-board', taskCardId: card.id },
|
||||
sender: 'user',
|
||||
createdAt: now,
|
||||
};
|
||||
await threadApi.appendMessage(thread.id, userMessage);
|
||||
|
||||
const startedBoard = await todosApi.updateStatus(
|
||||
USER_TASKS_THREAD_ID,
|
||||
card.id,
|
||||
'in_progress'
|
||||
);
|
||||
if (mountedRef.current) setPersonalBoard(startedBoard);
|
||||
|
||||
dispatch(setSelectedThread(thread.id));
|
||||
dispatch(setToolTimelineForThread({ threadId: thread.id, entries: [] }));
|
||||
dispatch(beginInferenceTurn({ threadId: thread.id }));
|
||||
dispatch(setActiveThread(thread.id));
|
||||
void dispatch(loadThreads());
|
||||
void dispatch(loadThreadMessages(thread.id));
|
||||
navigate('/chat');
|
||||
|
||||
await chatSend({
|
||||
threadId: thread.id,
|
||||
message: launchPrompt,
|
||||
model: CHAT_MODEL_ID,
|
||||
profileId: selectedAgentProfileId,
|
||||
locale: uiLocale,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('work personal task failed: %s', msg);
|
||||
if (mountedRef.current) setActionError(t('intelligence.tasks.workTaskFailed'));
|
||||
} finally {
|
||||
if (mountedRef.current) setWorkingCardId(null);
|
||||
}
|
||||
},
|
||||
[dispatch, navigate, personalBoard, selectedAgentProfileId, t, uiLocale, workingCardId]
|
||||
);
|
||||
|
||||
const handleApproveSourcePlan = useCallback(
|
||||
async (sourceCard: TaskBoardCard, draft: RefinedTaskDraft) => {
|
||||
const now = new Date().toISOString();
|
||||
setActionError(null);
|
||||
try {
|
||||
const added = await todosApi.add({
|
||||
threadId: USER_TASKS_THREAD_ID,
|
||||
content: draft.title,
|
||||
status: 'todo',
|
||||
objective: draft.objective,
|
||||
notes: draft.notes,
|
||||
});
|
||||
const created =
|
||||
added.cards.find(card => card.title === draft.title && card.updatedAt >= now) ??
|
||||
added.cards[added.cards.length - 1];
|
||||
const saved = created
|
||||
? await todosApi.edit({
|
||||
threadId: USER_TASKS_THREAD_ID,
|
||||
id: created.id,
|
||||
content: draft.title,
|
||||
status: 'todo',
|
||||
objective: draft.objective,
|
||||
notes: draft.notes,
|
||||
assignedAgent: 'agent_coder',
|
||||
approvalMode: 'not_required',
|
||||
plan: draft.plan,
|
||||
allowedTools: draft.allowedTools,
|
||||
acceptanceCriteria: draft.acceptanceCriteria,
|
||||
evidence: draft.evidence,
|
||||
})
|
||||
: added;
|
||||
if (mountedRef.current) {
|
||||
setPersonalBoard(saved);
|
||||
}
|
||||
|
||||
const sourceSaved = await todosApi.updateStatus(
|
||||
TASK_SOURCES_THREAD_ID,
|
||||
sourceCard.id,
|
||||
'done'
|
||||
);
|
||||
if (mountedRef.current) {
|
||||
setTaskSourcesBoard(sourceSaved);
|
||||
setRefiningCard(null);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('source task approval failed: %s', msg);
|
||||
if (mountedRef.current) setActionError(t('intelligence.tasks.sourcePlan.createFailed'));
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
// ── derived agent board list (read-only) ─────────────────────────────
|
||||
|
||||
const threadMap = new Map(threads.map(th => [th.id, th]));
|
||||
@@ -221,6 +435,7 @@ export default function IntelligenceTasksTab() {
|
||||
const boardEntries: ThreadTaskBoard[] = [];
|
||||
for (const threadId of allThreadIds) {
|
||||
if (threadId === USER_TASKS_THREAD_ID) continue; // personal board rendered separately
|
||||
if (threadId === TASK_SOURCES_THREAD_ID) continue; // task sources rendered separately
|
||||
const liveBoard = liveBoards[threadId];
|
||||
const persistedBoard = persistedBoards[threadId];
|
||||
const board = liveBoard ?? persistedBoard;
|
||||
@@ -277,6 +492,8 @@ export default function IntelligenceTasksTab() {
|
||||
onMove={handleMovePersonal}
|
||||
onUpdateCard={handleUpdatePersonal}
|
||||
onDeleteCard={handleDeletePersonal}
|
||||
onWorkTask={handleWorkPersonal}
|
||||
workingCardId={workingCardId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-stone-200 dark:border-neutral-800 py-8 text-center text-stone-400 dark:text-neutral-500">
|
||||
@@ -292,6 +509,16 @@ export default function IntelligenceTasksTab() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{taskSourcesBoard && (
|
||||
<section className="space-y-2">
|
||||
<TaskSourceTaskList
|
||||
board={taskSourcesBoard}
|
||||
disabled={loading}
|
||||
onWorkOnTask={setRefiningCard}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-6 text-stone-400 dark:text-neutral-500">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent mr-2" />
|
||||
@@ -329,6 +556,380 @@ export default function IntelligenceTasksTab() {
|
||||
{composerOpen && (
|
||||
<UserTaskComposer onCreated={handleCreated} onClose={() => setComposerOpen(false)} />
|
||||
)}
|
||||
{refiningCard && (
|
||||
<TaskSourceRefinementDialog
|
||||
card={refiningCard}
|
||||
disabled={loading}
|
||||
onClose={() => setRefiningCard(null)}
|
||||
onApprove={handleApproveSourcePlan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceMetadata {
|
||||
provider?: string;
|
||||
externalId?: string;
|
||||
url?: string;
|
||||
repo?: string;
|
||||
urgency?: number;
|
||||
}
|
||||
|
||||
interface RefinedTaskDraft {
|
||||
title: string;
|
||||
objective: string;
|
||||
notes: string;
|
||||
plan: string[];
|
||||
allowedTools: string[];
|
||||
acceptanceCriteria: string[];
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
function readSourceMetadata(value: Record<string, unknown> | null | undefined): SourceMetadata {
|
||||
if (!value) return {};
|
||||
return {
|
||||
provider: readString(value.provider),
|
||||
externalId: readString(value.external_id) ?? readString(value.externalId),
|
||||
url: readString(value.url),
|
||||
repo: readString(value.repo),
|
||||
urgency: readNumber(value.urgency),
|
||||
};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sourceProviderLabel(provider: string | undefined, 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 ?? t('conversations.taskKanban.source.unknownProvider');
|
||||
}
|
||||
}
|
||||
|
||||
function taskSourceLabel(card: TaskBoardCard, t: (key: string) => string): string {
|
||||
const source = readSourceMetadata(card.sourceMetadata);
|
||||
const provider = sourceProviderLabel(source.provider, t);
|
||||
if (source.repo && source.externalId) return `${provider} · ${source.repo}#${source.externalId}`;
|
||||
if (source.externalId) return `${provider} · ${source.externalId}`;
|
||||
return provider;
|
||||
}
|
||||
|
||||
function buildRefinedDraft(
|
||||
card: TaskBoardCard,
|
||||
t: (key: string, fallback?: string) => string
|
||||
): RefinedTaskDraft {
|
||||
const source = readSourceMetadata(card.sourceMetadata);
|
||||
const objective =
|
||||
card.objective?.trim() ||
|
||||
t('intelligence.refine.objectiveDefault').replace('{title}', card.title);
|
||||
const sourceLine = source.url
|
||||
? t('intelligence.refine.sourceLine').replace('{url}', source.url)
|
||||
: t('intelligence.refine.sourceIntake');
|
||||
const repoLine = source.repo
|
||||
? t('intelligence.refine.repositoryLine').replace('{repo}', source.repo)
|
||||
: null;
|
||||
const externalLine = source.externalId
|
||||
? t('intelligence.refine.externalTaskLine').replace('{externalId}', source.externalId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
title: card.title.replace(/^GitHub:\s*/i, '').trim() || card.title,
|
||||
objective,
|
||||
notes: [card.notes?.trim(), sourceLine, repoLine, externalLine].filter(Boolean).join('\n'),
|
||||
plan:
|
||||
card.plan && card.plan.length > 0
|
||||
? card.plan
|
||||
: [
|
||||
t('intelligence.refine.planStep1'),
|
||||
t('intelligence.refine.planStep2'),
|
||||
t('intelligence.refine.planStep3'),
|
||||
t('intelligence.refine.planStep4'),
|
||||
],
|
||||
allowedTools:
|
||||
card.allowedTools && card.allowedTools.length > 0
|
||||
? card.allowedTools
|
||||
: ['code_search', 'shell', 'edit', 'tests'],
|
||||
acceptanceCriteria:
|
||||
card.acceptanceCriteria && card.acceptanceCriteria.length > 0
|
||||
? card.acceptanceCriteria
|
||||
: [
|
||||
t('intelligence.refine.acceptance1'),
|
||||
t('intelligence.refine.acceptance2'),
|
||||
t('intelligence.refine.acceptance3'),
|
||||
],
|
||||
evidence:
|
||||
card.evidence && card.evidence.length > 0 ? card.evidence : source.url ? [source.url] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function TaskSourceTaskList({
|
||||
board,
|
||||
disabled,
|
||||
onWorkOnTask,
|
||||
}: {
|
||||
board: TaskBoard;
|
||||
disabled: boolean;
|
||||
onWorkOnTask: (card: TaskBoardCard) => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const sortedCards = useMemo(
|
||||
() => [...board.cards].sort((a, b) => a.order - b.order),
|
||||
[board.cards]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-stone-700 dark:text-neutral-200">
|
||||
{t('settings.taskSources.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('intelligence.tasks.sourceList.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="#/settings/task-sources"
|
||||
className="text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
|
||||
{t('conversations.taskKanban.sources.manage')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{sortedCards.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-stone-200 py-7 text-center text-sm text-stone-400 dark:border-neutral-800 dark:text-neutral-500">
|
||||
{t('intelligence.tasks.sourceList.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-stone-100 rounded-xl border border-stone-200 bg-white dark:divide-neutral-800 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
{sortedCards.map(card => {
|
||||
const source = readSourceMetadata(card.sourceMetadata);
|
||||
const done = card.status === 'done';
|
||||
return (
|
||||
<li key={card.id} className="p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 dark:bg-sky-500/10 dark:text-sky-200">
|
||||
{taskSourceLabel(card, t)}
|
||||
</span>
|
||||
{done && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-sage-50 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/10 dark:text-sage-200">
|
||||
<LuCheck className="h-3 w-3" />
|
||||
{t('intelligence.tasks.sourceList.queued')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="break-words text-sm font-medium leading-snug text-stone-800 dark:text-neutral-100">
|
||||
{card.title}
|
||||
</p>
|
||||
{(card.objective || card.notes) && (
|
||||
<p className="line-clamp-2 break-words text-xs leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{card.objective || card.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-2">
|
||||
{source.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('conversations.taskKanban.source.openExternal')}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-stone-200 text-stone-500 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onWorkOnTask(card)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-40">
|
||||
<LuSparkles className="h-3.5 w-3.5" />
|
||||
{t('intelligence.tasks.sourceList.workOnTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSourceRefinementDialog({
|
||||
card,
|
||||
disabled,
|
||||
onClose,
|
||||
onApprove,
|
||||
}: {
|
||||
card: TaskBoardCard;
|
||||
disabled: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: (card: TaskBoardCard, draft: RefinedTaskDraft) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const initialDraft = useMemo(() => buildRefinedDraft(card, t), [card, t]);
|
||||
const [title, setTitle] = useState(initialDraft.title);
|
||||
const [objective, setObjective] = useState(initialDraft.objective);
|
||||
const [notes, setNotes] = useState(initialDraft.notes);
|
||||
const [planText, setPlanText] = useState(initialDraft.plan.join('\n'));
|
||||
const [criteriaText, setCriteriaText] = useState(initialDraft.acceptanceCriteria.join('\n'));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const approve = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onApprove(card, {
|
||||
title: title.trim() || card.title,
|
||||
objective: objective.trim(),
|
||||
notes: notes.trim(),
|
||||
plan: lines(planText),
|
||||
allowedTools: initialDraft.allowedTools,
|
||||
acceptanceCriteria: lines(criteriaText),
|
||||
evidence: initialDraft.evidence,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-hidden rounded-xl border border-stone-200 bg-white shadow-xl dark:border-neutral-800 dark:bg-neutral-950">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-stone-100 px-4 py-3 dark:border-neutral-800">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{t('intelligence.tasks.sourcePlan.title')}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('intelligence.tasks.sourcePlan.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-md text-stone-500 hover:bg-stone-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(90vh-8rem)] space-y-4 overflow-y-auto px-4 py-4">
|
||||
<div className="rounded-lg border border-sky-100 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<LuBot className="h-3.5 w-3.5" />
|
||||
{t('intelligence.tasks.sourcePlan.researchAgent')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('conversations.taskKanban.field.title')}
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={event => setTitle(event.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 outline-none focus:border-ocean-400 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('conversations.taskKanban.field.objective')}
|
||||
</span>
|
||||
<textarea
|
||||
value={objective}
|
||||
onChange={event => setObjective(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 outline-none focus:border-ocean-400 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('conversations.taskKanban.field.plan')}
|
||||
</span>
|
||||
<textarea
|
||||
value={planText}
|
||||
onChange={event => setPlanText(event.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 outline-none focus:border-ocean-400 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('conversations.taskKanban.field.acceptanceCriteria')}
|
||||
</span>
|
||||
<textarea
|
||||
value={criteriaText}
|
||||
onChange={event => setCriteriaText(event.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 outline-none focus:border-ocean-400 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('conversations.taskKanban.field.notes')}
|
||||
</span>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={event => setNotes(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 outline-none focus:border-ocean-400 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-stone-100 px-4 py-3 dark:border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void approve()}
|
||||
disabled={disabled || saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-40">
|
||||
<LuArrowRight className="h-3.5 w-3.5" />
|
||||
{saving
|
||||
? t('intelligence.tasks.sourcePlan.creating')
|
||||
: t('intelligence.tasks.sourcePlan.approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function lines(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
listTurnStates: vi.fn(),
|
||||
createNewThread: vi.fn(),
|
||||
updateTitle: vi.fn(),
|
||||
appendMessage: vi.fn(),
|
||||
chatSend: vi.fn(),
|
||||
todosList: vi.fn(),
|
||||
todosAdd: vi.fn(),
|
||||
todosEdit: vi.fn(),
|
||||
@@ -24,14 +28,24 @@ const hoisted = vi.hoisted(() => ({
|
||||
selectorResult: {
|
||||
chatRuntime: { taskBoardByThread: {} as Record<string, unknown> },
|
||||
thread: { threads: [] as unknown[] },
|
||||
agentProfiles: { activeProfileId: 'agent-profile-1' },
|
||||
locale: { current: 'en' },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/threadApi', () => ({
|
||||
threadApi: { listTurnStates: hoisted.listTurnStates },
|
||||
threadApi: {
|
||||
listTurnStates: hoisted.listTurnStates,
|
||||
createNewThread: hoisted.createNewThread,
|
||||
updateTitle: hoisted.updateTitle,
|
||||
appendMessage: hoisted.appendMessage,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/chatService', () => ({ chatSend: hoisted.chatSend }));
|
||||
|
||||
vi.mock('../../../services/api/todosApi', () => ({
|
||||
TASK_SOURCES_THREAD_ID: 'task-sources',
|
||||
USER_TASKS_THREAD_ID: 'user-tasks',
|
||||
todosApi: {
|
||||
list: hoisted.todosList,
|
||||
@@ -48,6 +62,11 @@ vi.mock('../../../store/hooks', () => ({
|
||||
useAppDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return { ...actual, useNavigate: () => vi.fn() };
|
||||
});
|
||||
|
||||
// Stub the composer so we can drive its `onCreated` callback without
|
||||
// exercising its internals.
|
||||
vi.mock('../UserTaskComposer', () => ({
|
||||
@@ -81,14 +100,20 @@ vi.mock('../UserTaskComposer', () => ({
|
||||
vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({
|
||||
TaskKanbanBoard: ({
|
||||
board,
|
||||
headerTitleKey,
|
||||
onMove,
|
||||
onDeleteCard,
|
||||
onWorkTask,
|
||||
}: {
|
||||
board: { cards: { id: string; title: string; status: string }[] };
|
||||
board: { threadId: string; cards: { id: string; title: string; status: string }[] };
|
||||
headerTitleKey?: string;
|
||||
onMove?: (card: unknown, status: string) => void;
|
||||
onDeleteCard?: (card: unknown) => void;
|
||||
onWorkTask?: (card: unknown) => void;
|
||||
}) => (
|
||||
<div data-testid="kanban-stub">
|
||||
<span>{board.threadId}</span>
|
||||
{headerTitleKey && <span>{headerTitleKey}</span>}
|
||||
{board.cards.map(c => (
|
||||
<span key={c.id}>{c.title}</span>
|
||||
))}
|
||||
@@ -102,6 +127,11 @@ vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({
|
||||
stub-delete
|
||||
</button>
|
||||
)}
|
||||
{onWorkTask && (
|
||||
<button type="button" onClick={() => onWorkTask(board.cards[0])}>
|
||||
stub-work-task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -134,6 +164,10 @@ describe('IntelligenceTasksTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
hoisted.listTurnStates.mockReset();
|
||||
hoisted.createNewThread.mockReset();
|
||||
hoisted.updateTitle.mockReset();
|
||||
hoisted.appendMessage.mockReset();
|
||||
hoisted.chatSend.mockReset();
|
||||
hoisted.todosList.mockReset();
|
||||
hoisted.todosAdd.mockReset();
|
||||
hoisted.todosEdit.mockReset();
|
||||
@@ -141,9 +175,42 @@ describe('IntelligenceTasksTab', () => {
|
||||
hoisted.todosRemove.mockReset();
|
||||
hoisted.selectorResult.chatRuntime.taskBoardByThread = {};
|
||||
hoisted.selectorResult.thread.threads = [];
|
||||
hoisted.selectorResult.agentProfiles.activeProfileId = 'agent-profile-1';
|
||||
hoisted.selectorResult.locale.current = 'en';
|
||||
// Sensible defaults: empty personal board, no agent boards.
|
||||
hoisted.listTurnStates.mockResolvedValue([]);
|
||||
hoisted.todosList.mockResolvedValue(makeBoard('user-tasks', []));
|
||||
hoisted.createNewThread.mockResolvedValue({
|
||||
id: 'thread-agent-task',
|
||||
title: 'Agent task',
|
||||
labels: ['agent-task'],
|
||||
chatId: null,
|
||||
isActive: true,
|
||||
messageCount: 0,
|
||||
lastMessageAt: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
hoisted.updateTitle.mockResolvedValue({
|
||||
id: 'thread-agent-task',
|
||||
title: 'Agent task: My personal task',
|
||||
labels: ['agent-task'],
|
||||
chatId: null,
|
||||
isActive: true,
|
||||
messageCount: 0,
|
||||
lastMessageAt: '2026-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
hoisted.appendMessage.mockResolvedValue({
|
||||
id: 'msg-1',
|
||||
content: 'Work this approved agent task from the task board.',
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
hoisted.chatSend.mockResolvedValue(undefined);
|
||||
hoisted.todosList.mockImplementation((threadId: string) =>
|
||||
Promise.resolve(makeBoard(threadId, []))
|
||||
);
|
||||
});
|
||||
|
||||
test('shows loading spinner while fetching', async () => {
|
||||
@@ -175,6 +242,107 @@ describe('IntelligenceTasksTab', () => {
|
||||
expect(screen.getAllByRole('button', { name: /New task/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('renders the task source list even when it is empty', async () => {
|
||||
vi.resetModules();
|
||||
const Tab = await importTab();
|
||||
renderTab(Tab);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Task Sources')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('No source tasks waiting.')).toBeInTheDocument();
|
||||
expect(hoisted.todosList).toHaveBeenCalledWith('task-sources');
|
||||
});
|
||||
|
||||
test('refines a source task and approves it into the personal agent board', async () => {
|
||||
hoisted.todosList.mockImplementation((threadId: string) =>
|
||||
Promise.resolve(
|
||||
threadId === 'task-sources'
|
||||
? {
|
||||
threadId,
|
||||
cards: [
|
||||
{
|
||||
id: 'source-1',
|
||||
title: 'GitHub: tinyhumansai/openhuman#42: Fix source task',
|
||||
status: 'todo',
|
||||
objective: 'Fix the source task flow',
|
||||
notes: 'Original notes',
|
||||
sourceMetadata: {
|
||||
provider: 'github',
|
||||
repo: 'tinyhumansai/openhuman',
|
||||
external_id: '42',
|
||||
url: 'https://github.com/tinyhumansai/openhuman/issues/42',
|
||||
},
|
||||
order: 0,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
: makeBoard(threadId, [])
|
||||
)
|
||||
);
|
||||
hoisted.todosAdd.mockResolvedValue({
|
||||
threadId: 'user-tasks',
|
||||
cards: [
|
||||
{
|
||||
id: 'agent-task-1',
|
||||
title: 'tinyhumansai/openhuman#42: Fix source task',
|
||||
status: 'todo',
|
||||
order: 0,
|
||||
updatedAt: '2026-06-03T00:00:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-03T00:00:00Z',
|
||||
});
|
||||
hoisted.todosEdit.mockResolvedValue(makeBoard('user-tasks', ['Queued agent task']));
|
||||
hoisted.todosUpdateStatus.mockResolvedValue({
|
||||
threadId: 'task-sources',
|
||||
cards: [
|
||||
{
|
||||
id: 'source-1',
|
||||
title: 'GitHub: tinyhumansai/openhuman#42: Fix source task',
|
||||
status: 'done',
|
||||
order: 0,
|
||||
updatedAt: '2026-06-03T00:00:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-03T00:00:00Z',
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const Tab = await importTab();
|
||||
renderTab(Tab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Fix source task/)).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Work on task' }));
|
||||
|
||||
expect(screen.getByText('Refine source task')).toBeInTheDocument();
|
||||
expect(screen.getByText('Research agent draft')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Approve Plan' }));
|
||||
|
||||
await waitFor(() => expect(hoisted.todosAdd).toHaveBeenCalledTimes(1));
|
||||
expect(hoisted.todosAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: 'user-tasks',
|
||||
content: 'tinyhumansai/openhuman#42: Fix source task',
|
||||
status: 'todo',
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(hoisted.todosEdit).toHaveBeenCalledTimes(1));
|
||||
expect(hoisted.todosEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: 'user-tasks',
|
||||
id: 'agent-task-1',
|
||||
assignedAgent: 'agent_coder',
|
||||
approvalMode: 'not_required',
|
||||
})
|
||||
);
|
||||
expect(hoisted.todosUpdateStatus).toHaveBeenCalledWith('task-sources', 'source-1', 'done');
|
||||
});
|
||||
|
||||
test('renders persisted agent boards from the turn-state list', async () => {
|
||||
hoisted.listTurnStates.mockResolvedValue([
|
||||
{ threadId: 'thread-x', taskBoard: makeBoard('thread-x', ['Write docs', 'Fix bug']) },
|
||||
@@ -220,7 +388,13 @@ describe('IntelligenceTasksTab', () => {
|
||||
});
|
||||
|
||||
test('renders personal cards and moves one via the todos RPC', async () => {
|
||||
hoisted.todosList.mockResolvedValue(makeBoard('user-tasks', ['My personal task']));
|
||||
hoisted.todosList.mockImplementation((threadId: string) =>
|
||||
Promise.resolve(
|
||||
threadId === 'user-tasks'
|
||||
? makeBoard('user-tasks', ['My personal task'])
|
||||
: makeBoard(threadId, [])
|
||||
)
|
||||
);
|
||||
hoisted.todosUpdateStatus.mockResolvedValue(makeBoard('user-tasks', ['My personal task']));
|
||||
vi.resetModules();
|
||||
const Tab = await importTab();
|
||||
@@ -233,8 +407,84 @@ describe('IntelligenceTasksTab', () => {
|
||||
expect(hoisted.todosUpdateStatus).toHaveBeenCalledWith('user-tasks', 'card-0', 'in_progress');
|
||||
});
|
||||
|
||||
test('starts a labeled agent-task thread from a personal task', async () => {
|
||||
hoisted.todosList.mockImplementation((threadId: string) =>
|
||||
Promise.resolve(
|
||||
threadId === 'user-tasks'
|
||||
? {
|
||||
threadId: 'user-tasks',
|
||||
cards: [
|
||||
{
|
||||
id: 'personal-1',
|
||||
title: 'Implement task source worker',
|
||||
status: 'todo',
|
||||
objective: 'Ship the task source worker flow',
|
||||
notes: 'Use the approved plan.',
|
||||
plan: ['Read the source issue', 'Implement the flow'],
|
||||
acceptanceCriteria: ['Agent thread is labeled'],
|
||||
allowedTools: ['shell', 'edit'],
|
||||
order: 0,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
: makeBoard(threadId, [])
|
||||
)
|
||||
);
|
||||
hoisted.todosUpdateStatus.mockResolvedValue(
|
||||
makeBoard('user-tasks', ['Implement task source worker'])
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const Tab = await importTab();
|
||||
renderTab(Tab);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Implement task source worker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('stub-work-task'));
|
||||
|
||||
await waitFor(() => expect(hoisted.createNewThread).toHaveBeenCalledWith(['agent-task']));
|
||||
expect(hoisted.updateTitle).toHaveBeenCalledWith(
|
||||
'thread-agent-task',
|
||||
'Agent task: Implement task source worker'
|
||||
);
|
||||
expect(hoisted.appendMessage).toHaveBeenCalledWith(
|
||||
'thread-agent-task',
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining('Task: Implement task source worker'),
|
||||
sender: 'user',
|
||||
extraMetadata: expect.objectContaining({
|
||||
source: 'agent-task-board',
|
||||
taskCardId: 'personal-1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(hoisted.todosUpdateStatus).toHaveBeenCalledWith(
|
||||
'user-tasks',
|
||||
'personal-1',
|
||||
'in_progress'
|
||||
);
|
||||
expect(hoisted.chatSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: 'thread-agent-task',
|
||||
message: expect.stringContaining('Acceptance criteria:'),
|
||||
model: 'reasoning-v1',
|
||||
profileId: 'agent-profile-1',
|
||||
locale: 'en',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('deletes a personal card via the todos RPC', async () => {
|
||||
hoisted.todosList.mockResolvedValue(makeBoard('user-tasks', ['Disposable']));
|
||||
hoisted.todosList.mockImplementation((threadId: string) =>
|
||||
Promise.resolve(
|
||||
threadId === 'user-tasks'
|
||||
? makeBoard('user-tasks', ['Disposable'])
|
||||
: makeBoard(threadId, [])
|
||||
)
|
||||
);
|
||||
hoisted.todosRemove.mockResolvedValue(makeBoard('user-tasks', []));
|
||||
vi.resetModules();
|
||||
const Tab = await importTab();
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
openhumanTaskSourcesAdd,
|
||||
openhumanTaskSourcesFetch,
|
||||
openhumanTaskSourcesList,
|
||||
openhumanTaskSourcesListDatabases,
|
||||
openhumanTaskSourcesPreviewFilter,
|
||||
openhumanTaskSourcesRemove,
|
||||
openhumanTaskSourcesStatus,
|
||||
openhumanTaskSourcesUpdate,
|
||||
type TaskContainer,
|
||||
type TaskSource,
|
||||
type TaskSourceFilter,
|
||||
type TaskSourceProvider,
|
||||
@@ -91,6 +93,14 @@ const TaskSourcesPanel = () => {
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -168,6 +178,25 @@ 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 () => {
|
||||
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) => {
|
||||
setBusyKey(`toggle:${source.id}`);
|
||||
setError(null);
|
||||
@@ -297,6 +326,33 @@ const TaskSourcesPanel = () => {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{provider === 'notion' && (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={busyKey === 'databases'}
|
||||
onClick={() => void browseDatabases()}>
|
||||
{busyKey === 'databases'
|
||||
? t('settings.taskSources.notion.loadingDatabases')
|
||||
: t('settings.taskSources.notion.browseDatabases')}
|
||||
</button>
|
||||
{databases.length > 0 && (
|
||||
<select
|
||||
className="mt-1 w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100"
|
||||
value={primary}
|
||||
onChange={e => setPrimary(e.target.value)}>
|
||||
<option value="">{t('settings.taskSources.notion.selectDatabase')}</option>
|
||||
{databases.map(db => (
|
||||
<option key={db.id} value={db.id}>
|
||||
{db.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'github' && (
|
||||
<label className="block text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.taskSources.github.labels')}
|
||||
|
||||
@@ -2470,7 +2470,24 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'العنوان',
|
||||
'conversations.taskKanban.saveChanges': 'التغييرات',
|
||||
'conversations.taskKanban.deleteCard': 'Delete',
|
||||
'conversations.taskKanban.workTask': 'العمل على المهمة',
|
||||
'conversations.taskKanban.startingTask': 'جارٍ البدء…',
|
||||
'conversations.taskKanban.updateFailed': 'ولم يتمكن من استكمال المهمة؛ ولم يتم توفير التغييرات.',
|
||||
'conversations.taskKanban.sourcesButton': 'المصادر',
|
||||
'conversations.taskKanban.source.openExternal': 'فتح المهمة الخارجية',
|
||||
'conversations.taskKanban.source.openExternalShort': 'فتح',
|
||||
'conversations.taskKanban.source.unknownProvider': 'مصدر غير معروف',
|
||||
'conversations.taskKanban.source.urgencyValue': 'الأولوية {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'تتوفر عناصر التحكم في مصادر المهام في تطبيق سطح المكتب.',
|
||||
'conversations.taskKanban.sources.title': 'مصادر المهام',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'الجلب التلقائي مفعّل',
|
||||
'conversations.taskKanban.sources.manage': 'إدارة المصادر',
|
||||
'conversations.taskKanban.source.title': 'المصدر',
|
||||
'conversations.taskKanban.source.sourceId': 'معرّف المصدر',
|
||||
'conversations.taskKanban.source.externalId': 'المعرّف الخارجي',
|
||||
'conversations.taskKanban.source.repo': 'المستودع',
|
||||
'conversations.taskKanban.source.urgency': 'الأولوية',
|
||||
'conversations.toolTimeline.turn': 'دور',
|
||||
'conversations.toolTimeline.step': 'خطوة',
|
||||
'conversations.toolTimeline.workerThread': 'محادثة عامل',
|
||||
@@ -2583,6 +2600,35 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'إنشاء مهمة',
|
||||
'intelligence.tasks.composer.creating': 'خلق...',
|
||||
'intelligence.tasks.composer.createFailed': 'لا يمكن خلق المهمة',
|
||||
'intelligence.tasks.sourceList.subtitle': 'مهام المصادر التي تنتظر التحول إلى عمل للوكيل.',
|
||||
'intelligence.tasks.sourceList.empty': 'لا توجد مهام مصادر في الانتظار.',
|
||||
'intelligence.tasks.sourceList.queued': 'في قائمة الانتظار',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'العمل على المهمة',
|
||||
'intelligence.tasks.sourcePlan.title': 'تنقيح مهمة المصدر',
|
||||
'intelligence.tasks.sourcePlan.subtitle': 'راجع مسودة البحث قبل إنشاء مهمة للوكيل.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'مسودة وكيل البحث',
|
||||
'intelligence.tasks.sourcePlan.approve': 'الموافقة على الخطة',
|
||||
'intelligence.tasks.sourcePlan.creating': 'جارٍ إنشاء المهمة…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'تعذر إنشاء مهمة الوكيل',
|
||||
'intelligence.tasks.workTaskFailed': 'تعذر بدء العمل على المهمة',
|
||||
'intelligence.workTask.sourceTaskHeading': 'المهمة المصدر:',
|
||||
'intelligence.workTask.repositoryLine': '- المستودع: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- المعرّف الخارجي: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- الرابط: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'ابدأ بإعادة صياغة خطة التنفيذ الملموسة بإيجاز، ثم نفّذها. أبقِ التقدّم مرئيًا في هذا المحادثة وحدّث لوحة المهام عند تغيّر حالة العمل.',
|
||||
'intelligence.refine.objectiveDefault': 'حوّل المهمة المصدر إلى مهمة وكيل جاهزة للتنفيذ: {title}',
|
||||
'intelligence.refine.sourceLine': 'المصدر: {url}',
|
||||
'intelligence.refine.sourceIntake': 'المصدر: استقبال مصدر المهام',
|
||||
'intelligence.refine.repositoryLine': 'المستودع: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'المهمة الخارجية: {externalId}',
|
||||
'intelligence.refine.planStep1': 'اقرأ المهمة المصدر المرتبطة وتأكّد من السلوك المطلوب بالضبط.',
|
||||
'intelligence.refine.planStep2': 'افحص مسارات الكود ذات الصلة وحدّد أصغر حدود للتنفيذ.',
|
||||
'intelligence.refine.planStep3': 'نفّذ التغيير مع اختبارات مركّزة حول السلوك المرئي للمستخدم.',
|
||||
'intelligence.refine.planStep4': 'شغّل التحقق المستهدف وسجّل أي مخاطر متبقية أو أعمال متابعة.',
|
||||
'intelligence.refine.acceptance1': 'متطلبات المهمة المصدر ممثَّلة في التنفيذ النهائي.',
|
||||
'intelligence.refine.acceptance2': 'اختبارات الوحدة أو التكامل ذات الصلة تغطّي السلوك المتغيّر.',
|
||||
'intelligence.refine.acceptance3': 'نتائج التحقق وأي مخاطر غير محلولة مسجَّلة عند الإكمال.',
|
||||
'notifications.card.dismiss': 'إغلاق الإشعار',
|
||||
'notifications.card.importanceTitle': 'الأهمية: {pct}%',
|
||||
'notifications.center.empty': 'لا توجد إشعارات بعد',
|
||||
@@ -4183,6 +4229,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'المستودع (المالك/الاسم، اختياري)',
|
||||
'settings.taskSources.github.labels': 'التصنيفات (مفصولة بفاصلة)',
|
||||
'settings.taskSources.notion.database': 'قاعدة البيانات (على متنها)',
|
||||
'settings.taskSources.notion.browseDatabases': 'تصفّح قواعد البيانات',
|
||||
'settings.taskSources.notion.loadingDatabases': 'جارٍ تحميل قواعد البيانات…',
|
||||
'settings.taskSources.notion.selectDatabase': 'اختر قاعدة بيانات…',
|
||||
'settings.taskSources.notion.noDatabases': 'لا توجد قواعد بيانات لهذا الاتصال.',
|
||||
'settings.taskSources.linear.team': 'هوية الفريق (اختياري)',
|
||||
'settings.taskSources.clickup.team': 'مكان العمل (الفريق)',
|
||||
'settings.taskSources.assignedToMe': 'فقط البنود الموكلة لي',
|
||||
|
||||
@@ -2518,7 +2518,23 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'শিরোনাম',
|
||||
'conversations.taskKanban.saveChanges': 'পরিবর্তন সংরক্ষণ করা হবে',
|
||||
'conversations.taskKanban.deleteCard': 'Delete',
|
||||
'conversations.taskKanban.workTask': 'কাজ শুরু করুন',
|
||||
'conversations.taskKanban.startingTask': 'শুরু হচ্ছে...',
|
||||
'conversations.taskKanban.updateFailed': 'কাজটি সংরক্ষণ করা যায়নি।',
|
||||
'conversations.taskKanban.sourcesButton': 'উৎস',
|
||||
'conversations.taskKanban.source.openExternal': 'বাহ্যিক কাজ খুলুন',
|
||||
'conversations.taskKanban.source.openExternalShort': 'খুলুন',
|
||||
'conversations.taskKanban.source.unknownProvider': 'অজানা উৎস',
|
||||
'conversations.taskKanban.source.urgencyValue': 'জরুরিতা {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly': 'টাস্ক উৎস নিয়ন্ত্রণ ডেস্কটপ অ্যাপে উপলভ্য।',
|
||||
'conversations.taskKanban.sources.title': 'টাস্ক উৎস',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'স্বয়ংক্রিয় পোলিং চালু',
|
||||
'conversations.taskKanban.sources.manage': 'উৎস পরিচালনা করুন',
|
||||
'conversations.taskKanban.source.title': 'উৎস',
|
||||
'conversations.taskKanban.source.sourceId': 'উৎস আইডি',
|
||||
'conversations.taskKanban.source.externalId': 'বাহ্যিক আইডি',
|
||||
'conversations.taskKanban.source.repo': 'রিপোজিটরি',
|
||||
'conversations.taskKanban.source.urgency': 'জরুরিতা',
|
||||
'conversations.toolTimeline.turn': 'টার্ন',
|
||||
'conversations.toolTimeline.step': 'ধাপ',
|
||||
'conversations.toolTimeline.workerThread': 'ওয়ার্কার থ্রেড',
|
||||
@@ -2633,6 +2649,43 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'কাজ তৈরি করুন ( T)',
|
||||
'intelligence.tasks.composer.creating': 'তৈরি করা হচ্ছে...',
|
||||
'intelligence.tasks.composer.createFailed': 'কাজ তৈরি করতে ব্যর্থ',
|
||||
'intelligence.tasks.sourceList.subtitle': 'এজেন্টের কাজে রূপান্তরের অপেক্ষায় থাকা উৎস কাজ।',
|
||||
'intelligence.tasks.sourceList.empty': 'অপেক্ষায় কোনো উৎস কাজ নেই।',
|
||||
'intelligence.tasks.sourceList.queued': 'সারিবদ্ধ',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'কাজটি করুন',
|
||||
'intelligence.tasks.sourcePlan.title': 'উৎস কাজ পরিমার্জন করুন',
|
||||
'intelligence.tasks.sourcePlan.subtitle': 'এজেন্ট কাজ তৈরির আগে গবেষণা খসড়া পর্যালোচনা করুন।',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'গবেষণা এজেন্টের খসড়া',
|
||||
'intelligence.tasks.sourcePlan.approve': 'পরিকল্পনা অনুমোদন করুন',
|
||||
'intelligence.tasks.sourcePlan.creating': 'কাজ তৈরি হচ্ছে...',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'এজেন্ট কাজ তৈরি করা যায়নি',
|
||||
'intelligence.tasks.workTaskFailed': 'কাজ শুরু করা যায়নি',
|
||||
'intelligence.workTask.sourceTaskHeading': 'উৎস টাস্ক:',
|
||||
'intelligence.workTask.repositoryLine': '- রিপোজিটরি: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- বাহ্যিক আইডি: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- ইউআরএল: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'প্রথমে সুনির্দিষ্ট বাস্তবায়ন পরিকল্পনাটি সংক্ষেপে পুনরায় বলুন, তারপর তা সম্পাদন করুন। এই থ্রেডে অগ্রগতি দৃশ্যমান রাখুন এবং কাজের অবস্থা পরিবর্তিত হলে টাস্ক বোর্ড আপডেট করুন।',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'উৎস টাস্কটিকে বাস্তবায়নের জন্য প্রস্তুত একটি এজেন্ট টাস্কে রূপান্তর করুন: {title}',
|
||||
'intelligence.refine.sourceLine': 'উৎস: {url}',
|
||||
'intelligence.refine.sourceIntake': 'উৎস: টাস্ক উৎস গ্রহণ',
|
||||
'intelligence.refine.repositoryLine': 'রিপোজিটরি: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'বাহ্যিক টাস্ক: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'সংযুক্ত উৎস টাস্কটি পড়ুন এবং ঠিক কোন আচরণ চাওয়া হয়েছে তা নিশ্চিত করুন।',
|
||||
'intelligence.refine.planStep2':
|
||||
'প্রাসঙ্গিক কোড পাথগুলি পরিদর্শন করুন এবং সবচেয়ে ছোট বাস্তবায়ন সীমানা চিহ্নিত করুন।',
|
||||
'intelligence.refine.planStep3':
|
||||
'ব্যবহারকারী-দৃশ্যমান আচরণ ঘিরে কেন্দ্রীভূত পরীক্ষাসহ পরিবর্তনটি বাস্তবায়ন করুন।',
|
||||
'intelligence.refine.planStep4':
|
||||
'লক্ষ্যভিত্তিক যাচাই চালান এবং অবশিষ্ট কোনো ঝুঁকি বা পরবর্তী কাজ নথিভুক্ত করুন।',
|
||||
'intelligence.refine.acceptance1':
|
||||
'উৎস টাস্কের প্রয়োজনীয়তা চূড়ান্ত বাস্তবায়নে প্রতিফলিত হয়েছে।',
|
||||
'intelligence.refine.acceptance2':
|
||||
'প্রাসঙ্গিক ইউনিট বা ইন্টিগ্রেশন পরীক্ষা পরিবর্তিত আচরণ কভার করে।',
|
||||
'intelligence.refine.acceptance3':
|
||||
'যাচাইয়ের ফলাফল এবং যেকোনো অমীমাংসিত ঝুঁকি সমাপ্তির সময় নথিভুক্ত করা হয়।',
|
||||
'notifications.card.dismiss': 'বিজ্ঞপ্তি বাদ দিন',
|
||||
'notifications.card.importanceTitle': 'গুরুত্ব: {pct}%',
|
||||
'notifications.center.empty': 'এখনো কোনো বিজ্ঞপ্তি নেই',
|
||||
@@ -4256,6 +4309,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'ডক ( xqxqx, ঐচ্ছিক)',
|
||||
'settings.taskSources.github.labels': 'লেবেল (মাউন্ট করা যায় না)',
|
||||
'settings.taskSources.notion.database': 'ডাটাবেস (board) আইডি',
|
||||
'settings.taskSources.notion.browseDatabases': 'ডেটাবেস ব্রাউজ করুন',
|
||||
'settings.taskSources.notion.loadingDatabases': 'ডেটাবেস লোড হচ্ছে…',
|
||||
'settings.taskSources.notion.selectDatabase': 'একটি ডেটাবেস নির্বাচন করুন…',
|
||||
'settings.taskSources.notion.noDatabases': 'এই সংযোগের জন্য কোনো ডেটাবেস পাওয়া যায়নি।',
|
||||
'settings.taskSources.linear.team': 'দল ID (ঐচ্ছিক)',
|
||||
'settings.taskSources.clickup.team': 'কর্মক্ষেত্র (ঐচ্ছিক)',
|
||||
'settings.taskSources.assignedToMe': 'শুধুমাত্র আমার করা আইটেমগুলি',
|
||||
|
||||
@@ -2582,8 +2582,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Titel',
|
||||
'conversations.taskKanban.saveChanges': 'Änderungen speichern',
|
||||
'conversations.taskKanban.deleteCard': 'Löschen',
|
||||
'conversations.taskKanban.workTask': 'Aufgabe bearbeiten',
|
||||
'conversations.taskKanban.startingTask': 'Startet…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
'Aufgabe konnte nicht aktualisiert werden; Änderungen wurden nicht gespeichert.',
|
||||
'conversations.taskKanban.sourcesButton': 'Quellen',
|
||||
'conversations.taskKanban.source.openExternal': 'Externe Aufgabe öffnen',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Öffnen',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Unbekannte Quelle',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Dringlichkeit {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Aufgabenquellen-Steuerungen sind in der Desktop-App verfügbar.',
|
||||
'conversations.taskKanban.sources.title': 'Aufgabenquellen',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Automatische Abfrage aktiviert',
|
||||
'conversations.taskKanban.sources.manage': 'Quellen verwalten',
|
||||
'conversations.taskKanban.source.title': 'Quelle',
|
||||
'conversations.taskKanban.source.sourceId': 'Quellen-ID',
|
||||
'conversations.taskKanban.source.externalId': 'Externe ID',
|
||||
'conversations.taskKanban.source.repo': 'Repository',
|
||||
'conversations.taskKanban.source.urgency': 'Dringlichkeit',
|
||||
'conversations.toolTimeline.turn': 'drehen',
|
||||
'conversations.toolTimeline.step': 'Schritt',
|
||||
'conversations.toolTimeline.workerThread': 'Worker-Thread',
|
||||
@@ -2698,6 +2715,45 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Aufgabe erstellen',
|
||||
'intelligence.tasks.composer.creating': 'Erstellen…',
|
||||
'intelligence.tasks.composer.createFailed': 'Die Aufgabe konnte nicht erstellt werden',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
'Quellaufgaben, die in Agentenarbeit umgewandelt werden sollen.',
|
||||
'intelligence.tasks.sourceList.empty': 'Keine Quellaufgaben in der Warteschlange.',
|
||||
'intelligence.tasks.sourceList.queued': 'Eingereiht',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Aufgabe bearbeiten',
|
||||
'intelligence.tasks.sourcePlan.title': 'Quellaufgabe verfeinern',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'Prüfen Sie den Rechercheentwurf, bevor eine Agentenaufgabe erstellt wird.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Entwurf des Recherche-Agenten',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Plan genehmigen',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Aufgabe wird erstellt…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'Die Agentenaufgabe konnte nicht erstellt werden',
|
||||
'intelligence.tasks.workTaskFailed': 'Die Arbeit an der Aufgabe konnte nicht gestartet werden',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Quellaufgabe:',
|
||||
'intelligence.workTask.repositoryLine': '- Repository (Repo): {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- Externe ID: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Adresse: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Beginne damit, den konkreten Umsetzungsplan kurz zu wiederholen, und führe ihn dann aus. Halte den Fortschritt in diesem Thread sichtbar und aktualisiere das Aufgabenboard, wenn sich der Arbeitsstand ändert.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Verwandle die Quellaufgabe in eine umsetzungsbereite Agentenaufgabe: {title}',
|
||||
'intelligence.refine.sourceLine': 'Quelle: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Quelle: Aufgabenquellen-Eingang',
|
||||
'intelligence.refine.repositoryLine': 'Repository (Repo): {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Externe Aufgabe: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Lies die verknüpfte Quellaufgabe und bestätige das genau gewünschte Verhalten.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Untersuche die relevanten Codepfade und ermittle die kleinste Umsetzungsgrenze.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Setze die Änderung mit gezielten Tests rund um das nutzersichtbare Verhalten um.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Führe gezielte Validierung durch und halte verbleibende Risiken oder Folgearbeiten fest.',
|
||||
'intelligence.refine.acceptance1':
|
||||
'Die Anforderungen der Quellaufgabe sind in der finalen Umsetzung abgebildet.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Relevante Unit- oder Integrationstests decken das geänderte Verhalten ab.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Validierungsergebnisse und ungelöste Risiken werden bei Abschluss festgehalten.',
|
||||
'notifications.card.dismiss': 'Benachrichtigung verwerfen',
|
||||
'notifications.card.importanceTitle': 'Wichtigkeit: {pct}%',
|
||||
'notifications.center.empty': 'Noch keine Benachrichtigungen',
|
||||
@@ -4374,6 +4430,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repository (Inhaber/Name, optional)',
|
||||
'settings.taskSources.github.labels': 'Beschriftungen (durch Kommas getrennt)',
|
||||
'settings.taskSources.notion.database': 'Datenbank-(Board-)ID',
|
||||
'settings.taskSources.notion.browseDatabases': 'Datenbanken durchsuchen',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Datenbanken werden geladen…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Datenbank auswählen…',
|
||||
'settings.taskSources.notion.noDatabases': 'Keine Datenbanken für diese Verbindung gefunden.',
|
||||
'settings.taskSources.linear.team': 'Team-ID (optional)',
|
||||
'settings.taskSources.clickup.team': 'Arbeitsbereichs-(Team-)ID (optional)',
|
||||
'settings.taskSources.assignedToMe': 'Nur mir zugewiesene Artikel',
|
||||
|
||||
@@ -2900,7 +2900,24 @@ const en: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Title',
|
||||
'conversations.taskKanban.saveChanges': 'Save changes',
|
||||
'conversations.taskKanban.deleteCard': 'Delete',
|
||||
'conversations.taskKanban.workTask': 'Work task',
|
||||
'conversations.taskKanban.startingTask': 'Starting…',
|
||||
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
|
||||
'conversations.taskKanban.sourcesButton': 'Sources',
|
||||
'conversations.taskKanban.source.openExternal': 'Open external task',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Open',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Unknown source',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Urgency {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Task source controls are available in the desktop app.',
|
||||
'conversations.taskKanban.sources.title': 'Task sources',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Automatic polling enabled',
|
||||
'conversations.taskKanban.sources.manage': 'Manage sources',
|
||||
'conversations.taskKanban.source.title': 'Source',
|
||||
'conversations.taskKanban.source.sourceId': 'Source ID',
|
||||
'conversations.taskKanban.source.externalId': 'External ID',
|
||||
'conversations.taskKanban.source.repo': 'Repository',
|
||||
'conversations.taskKanban.source.urgency': 'Urgency',
|
||||
'conversations.toolTimeline.turn': 'turn',
|
||||
'conversations.toolTimeline.step': 'step',
|
||||
'conversations.toolTimeline.workerThread': 'worker thread',
|
||||
@@ -3018,6 +3035,44 @@ const en: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Create task',
|
||||
'intelligence.tasks.composer.creating': 'Creating…',
|
||||
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
|
||||
'intelligence.tasks.sourceList.subtitle': 'Source tasks waiting to become agent work.',
|
||||
'intelligence.tasks.sourceList.empty': 'No source tasks waiting.',
|
||||
'intelligence.tasks.sourceList.queued': 'Queued',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Work on task',
|
||||
'intelligence.tasks.sourcePlan.title': 'Refine source task',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'Review the research draft before creating an agent task.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Research agent draft',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Approve Plan',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Creating task…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': "Couldn't create the agent task",
|
||||
'intelligence.tasks.workTaskFailed': "Couldn't start work on the task",
|
||||
'intelligence.workTask.sourceTaskHeading': 'Source task:',
|
||||
'intelligence.workTask.repositoryLine': '- Repository: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- External ID: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- URL: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Start by restating the concrete implementation plan briefly, then execute it. Keep progress visible in this thread and update the task board when the work state changes.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Turn the source task into an implementation-ready agent task: {title}',
|
||||
'intelligence.refine.sourceLine': 'Source: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Source: task source intake',
|
||||
'intelligence.refine.repositoryLine': 'Repository: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'External task: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Read the linked source task and confirm the exact requested behavior.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Inspect the relevant code paths and identify the smallest implementation boundary.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Implement the change with focused tests around the user-visible behavior.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Run targeted validation and capture any residual risks or follow-up work.',
|
||||
'intelligence.refine.acceptance1':
|
||||
'The source task requirements are represented in the final implementation.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Relevant unit or integration tests cover the changed behavior.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Validation results and any unresolved risk are recorded on completion.',
|
||||
'notifications.card.dismiss': 'Dismiss notification',
|
||||
'notifications.card.importanceTitle': 'Importance: {pct}%',
|
||||
'notifications.center.empty': 'No notifications yet',
|
||||
@@ -4656,6 +4711,10 @@ const en: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repository (owner/name, optional)',
|
||||
'settings.taskSources.github.labels': 'Labels (comma-separated)',
|
||||
'settings.taskSources.notion.database': 'Database (board) ID',
|
||||
'settings.taskSources.notion.browseDatabases': 'Browse databases',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Loading databases…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Select a database…',
|
||||
'settings.taskSources.notion.noDatabases': 'No databases found for this connection.',
|
||||
'settings.taskSources.linear.team': 'Team ID (optional)',
|
||||
'settings.taskSources.clickup.team': 'Workspace (team) ID (optional)',
|
||||
'settings.taskSources.assignedToMe': 'Only items assigned to me',
|
||||
|
||||
@@ -2565,8 +2565,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Título',
|
||||
'conversations.taskKanban.saveChanges': 'Guardar cambios',
|
||||
'conversations.taskKanban.deleteCard': 'Borrar',
|
||||
'conversations.taskKanban.workTask': 'Trabajar tarea',
|
||||
'conversations.taskKanban.startingTask': 'Iniciando…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
'No se pudo actualizar la tarea; los cambios no se guardaron.',
|
||||
'conversations.taskKanban.sourcesButton': 'Fuentes',
|
||||
'conversations.taskKanban.source.openExternal': 'Abrir tarea externa',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Abrir',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Fuente desconocida',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Urgencia {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Los controles de fuentes de tareas están disponibles en la app de escritorio.',
|
||||
'conversations.taskKanban.sources.title': 'Fuentes de tareas',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Sondeo automático activado',
|
||||
'conversations.taskKanban.sources.manage': 'Gestionar fuentes',
|
||||
'conversations.taskKanban.source.title': 'Fuente',
|
||||
'conversations.taskKanban.source.sourceId': 'ID de fuente',
|
||||
'conversations.taskKanban.source.externalId': 'ID externo',
|
||||
'conversations.taskKanban.source.repo': 'Repositorio',
|
||||
'conversations.taskKanban.source.urgency': 'Urgencia',
|
||||
'conversations.toolTimeline.turn': 'turno',
|
||||
'conversations.toolTimeline.step': 'Paso',
|
||||
'conversations.toolTimeline.workerThread': 'hilo de worker',
|
||||
@@ -2682,6 +2699,45 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Crear tarea',
|
||||
'intelligence.tasks.composer.creating': 'Creando…',
|
||||
'intelligence.tasks.composer.createFailed': 'No se pudo crear la tarea',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
'Tareas de fuentes esperando convertirse en trabajo del agente.',
|
||||
'intelligence.tasks.sourceList.empty': 'No hay tareas de fuentes en espera.',
|
||||
'intelligence.tasks.sourceList.queued': 'En cola',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Trabajar en la tarea',
|
||||
'intelligence.tasks.sourcePlan.title': 'Refinar tarea de fuente',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'Revisa el borrador de investigación antes de crear una tarea de agente.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Borrador del agente investigador',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Aprobar plan',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Creando tarea…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'No se pudo crear la tarea del agente',
|
||||
'intelligence.tasks.workTaskFailed': 'No se pudo iniciar el trabajo en la tarea',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Tarea de origen:',
|
||||
'intelligence.workTask.repositoryLine': '- Repositorio: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- ID externo: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Dirección: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Empieza reformulando brevemente el plan de implementación concreto y luego ejecútalo. Mantén el progreso visible en este hilo y actualiza el tablero de tareas cuando cambie el estado del trabajo.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Convierte la tarea de origen en una tarea de agente lista para implementar: {title}',
|
||||
'intelligence.refine.sourceLine': 'Origen: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Origen: recepción de fuentes de tareas',
|
||||
'intelligence.refine.repositoryLine': 'Repositorio: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Tarea externa: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Lee la tarea de origen vinculada y confirma el comportamiento exacto solicitado.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Inspecciona las rutas de código relevantes e identifica el límite de implementación más pequeño.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Implementa el cambio con pruebas centradas en el comportamiento visible para el usuario.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Ejecuta una validación específica y registra los riesgos residuales o el trabajo de seguimiento.',
|
||||
'intelligence.refine.acceptance1':
|
||||
'Los requisitos de la tarea de origen están representados en la implementación final.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Las pruebas unitarias o de integración relevantes cubren el comportamiento modificado.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Los resultados de la validación y cualquier riesgo sin resolver se registran al finalizar.',
|
||||
'notifications.card.dismiss': 'Descartar notificación',
|
||||
'notifications.card.importanceTitle': 'Importancia: {pct}%',
|
||||
'notifications.center.empty': 'Sin notificaciones aún',
|
||||
@@ -4342,6 +4398,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repositorio (propietario/nombre, opcional)',
|
||||
'settings.taskSources.github.labels': 'Etiquetas (separadas por comas)',
|
||||
'settings.taskSources.notion.database': 'ID de la base de datos (tablero)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Explorar bases de datos',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Cargando bases de datos…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Selecciona una base de datos…',
|
||||
'settings.taskSources.notion.noDatabases': 'No se encontraron bases de datos para esta conexión.',
|
||||
'settings.taskSources.linear.team': 'ID del equipo (opcional)',
|
||||
'settings.taskSources.clickup.team': 'ID del espacio de trabajo (equipo) (opcional)',
|
||||
'settings.taskSources.assignedToMe': 'Solo los elementos asignados a mí',
|
||||
|
||||
@@ -2574,8 +2574,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Titre',
|
||||
'conversations.taskKanban.saveChanges': 'Enregistrer les modifications',
|
||||
'conversations.taskKanban.deleteCard': 'Supprimer',
|
||||
'conversations.taskKanban.workTask': 'Travailler la tâche',
|
||||
'conversations.taskKanban.startingTask': 'Démarrage…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
"Impossible de mettre à jour la tâche; les modifications n'ont pas été enregistrées.",
|
||||
'conversations.taskKanban.sourcesButton': 'Sources',
|
||||
'conversations.taskKanban.source.openExternal': 'Ouvrir la tâche externe',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Ouvrir',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Source inconnue',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Urgence {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Les contrôles des sources de tâches sont disponibles dans l’application de bureau.',
|
||||
'conversations.taskKanban.sources.title': 'Sources de tâches',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Interrogation automatique activée',
|
||||
'conversations.taskKanban.sources.manage': 'Gérer les sources',
|
||||
'conversations.taskKanban.source.title': 'Source',
|
||||
'conversations.taskKanban.source.sourceId': 'ID de source',
|
||||
'conversations.taskKanban.source.externalId': 'ID externe',
|
||||
'conversations.taskKanban.source.repo': 'Dépôt',
|
||||
'conversations.taskKanban.source.urgency': 'Urgence',
|
||||
'conversations.toolTimeline.turn': 'tour',
|
||||
'conversations.toolTimeline.step': 'Étape',
|
||||
'conversations.toolTimeline.workerThread': 'fil worker',
|
||||
@@ -2691,6 +2708,45 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Créer une tâche',
|
||||
'intelligence.tasks.composer.creating': 'Création…',
|
||||
'intelligence.tasks.composer.createFailed': 'Impossible de créer la tâche',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
"Tâches de sources en attente de devenir du travail d'agent.",
|
||||
'intelligence.tasks.sourceList.empty': 'Aucune tâche de source en attente.',
|
||||
'intelligence.tasks.sourceList.queued': 'En file',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Travailler sur la tâche',
|
||||
'intelligence.tasks.sourcePlan.title': 'Affiner la tâche source',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
"Vérifiez le brouillon de recherche avant de créer une tâche d'agent.",
|
||||
'intelligence.tasks.sourcePlan.researchAgent': "Brouillon de l'agent de recherche",
|
||||
'intelligence.tasks.sourcePlan.approve': 'Approuver le plan',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Création de la tâche…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': "Impossible de créer la tâche d'agent",
|
||||
'intelligence.tasks.workTaskFailed': 'Impossible de démarrer le travail sur la tâche',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Tâche source :',
|
||||
'intelligence.workTask.repositoryLine': '- Dépôt : {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- ID externe : {externalId}',
|
||||
'intelligence.workTask.urlLine': '- URL : {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
"Commencez par reformuler brièvement le plan d'implémentation concret, puis exécutez-le. Gardez la progression visible dans ce fil et mettez à jour le tableau des tâches lorsque l'état du travail change.",
|
||||
'intelligence.refine.objectiveDefault':
|
||||
"Transformez la tâche source en une tâche d'agent prête à être implémentée : {title}",
|
||||
'intelligence.refine.sourceLine': 'Source : {url}',
|
||||
'intelligence.refine.sourceIntake': 'Source : réception des sources de tâches',
|
||||
'intelligence.refine.repositoryLine': 'Dépôt : {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Tâche externe : {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Lisez la tâche source liée et confirmez le comportement exact demandé.',
|
||||
'intelligence.refine.planStep2':
|
||||
"Inspectez les chemins de code pertinents et identifiez la plus petite frontière d'implémentation.",
|
||||
'intelligence.refine.planStep3':
|
||||
"Implémentez le changement avec des tests ciblés autour du comportement visible par l'utilisateur.",
|
||||
'intelligence.refine.planStep4':
|
||||
'Exécutez une validation ciblée et notez les risques résiduels ou le travail de suivi.',
|
||||
'intelligence.refine.acceptance1':
|
||||
"Les exigences de la tâche source sont représentées dans l'implémentation finale.",
|
||||
'intelligence.refine.acceptance2':
|
||||
"Des tests unitaires ou d'intégration pertinents couvrent le comportement modifié.",
|
||||
'intelligence.refine.acceptance3':
|
||||
"Les résultats de la validation et tout risque non résolu sont consignés à l'achèvement.",
|
||||
'notifications.card.dismiss': 'Ignorer la notification',
|
||||
'notifications.card.importanceTitle': 'Importance : {pct} %',
|
||||
'notifications.center.empty': "Aucune notification pour l'instant",
|
||||
@@ -4357,6 +4413,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Dépôt (propriétaire/nom, facultatif)',
|
||||
'settings.taskSources.github.labels': 'Étiquettes (séparées par des virgules)',
|
||||
'settings.taskSources.notion.database': 'ID de la base de données (tableau)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Parcourir les bases de données',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Chargement des bases de données…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Sélectionner une base de données…',
|
||||
'settings.taskSources.notion.noDatabases': 'Aucune base de données trouvée pour cette connexion.',
|
||||
'settings.taskSources.linear.team': "ID de l'équipe (facultatif)",
|
||||
'settings.taskSources.clickup.team': 'ID de l’espace de travail (équipe) (optionnel)',
|
||||
'settings.taskSources.assignedToMe': 'Uniquement les éléments qui me sont attribués',
|
||||
|
||||
@@ -2522,7 +2522,24 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'शीर्षक',
|
||||
'conversations.taskKanban.saveChanges': 'परिवर्तन सहेजें',
|
||||
'conversations.taskKanban.deleteCard': 'Delete',
|
||||
'conversations.taskKanban.workTask': 'कार्य शुरू करें',
|
||||
'conversations.taskKanban.startingTask': 'शुरू हो रहा है…',
|
||||
'conversations.taskKanban.updateFailed': 'कार्य अद्यतन नहीं कर सका; परिवर्तन बचाया नहीं गया।',
|
||||
'conversations.taskKanban.sourcesButton': 'स्रोत',
|
||||
'conversations.taskKanban.source.openExternal': 'बाहरी कार्य खोलें',
|
||||
'conversations.taskKanban.source.openExternalShort': 'खोलें',
|
||||
'conversations.taskKanban.source.unknownProvider': 'अज्ञात स्रोत',
|
||||
'conversations.taskKanban.source.urgencyValue': 'तात्कालिकता {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'कार्य स्रोत नियंत्रण डेस्कटॉप ऐप में उपलब्ध हैं।',
|
||||
'conversations.taskKanban.sources.title': 'कार्य स्रोत',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'स्वचालित पोलिंग चालू है',
|
||||
'conversations.taskKanban.sources.manage': 'स्रोत प्रबंधित करें',
|
||||
'conversations.taskKanban.source.title': 'स्रोत',
|
||||
'conversations.taskKanban.source.sourceId': 'स्रोत ID',
|
||||
'conversations.taskKanban.source.externalId': 'बाहरी ID',
|
||||
'conversations.taskKanban.source.repo': 'रिपॉज़िटरी',
|
||||
'conversations.taskKanban.source.urgency': 'तात्कालिकता',
|
||||
'conversations.toolTimeline.turn': 'टर्न',
|
||||
'conversations.toolTimeline.step': 'चरण',
|
||||
'conversations.toolTimeline.workerThread': 'वर्कर थ्रेड',
|
||||
@@ -2637,6 +2654,44 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'कार्य',
|
||||
'intelligence.tasks.composer.creating': 'बनाना',
|
||||
'intelligence.tasks.composer.createFailed': 'काम नहीं कर सका',
|
||||
'intelligence.tasks.sourceList.subtitle': 'एजेंट कार्य बनने की प्रतीक्षा कर रहे स्रोत कार्य।',
|
||||
'intelligence.tasks.sourceList.empty': 'कोई स्रोत कार्य प्रतीक्षा में नहीं है।',
|
||||
'intelligence.tasks.sourceList.queued': 'कतार में',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'कार्य पर काम करें',
|
||||
'intelligence.tasks.sourcePlan.title': 'स्रोत कार्य को परिष्कृत करें',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'एजेंट कार्य बनाने से पहले शोध ड्राफ्ट की समीक्षा करें।',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'शोध एजेंट ड्राफ्ट',
|
||||
'intelligence.tasks.sourcePlan.approve': 'योजना स्वीकृत करें',
|
||||
'intelligence.tasks.sourcePlan.creating': 'कार्य बनाया जा रहा है…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'एजेंट कार्य नहीं बन सका',
|
||||
'intelligence.tasks.workTaskFailed': 'कार्य पर काम शुरू नहीं हो सका',
|
||||
'intelligence.workTask.sourceTaskHeading': 'स्रोत कार्य:',
|
||||
'intelligence.workTask.repositoryLine': '- रिपॉज़िटरी: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- बाहरी आईडी: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- यूआरएल: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'पहले ठोस कार्यान्वयन योजना को संक्षेप में दोहराएँ, फिर उसे निष्पादित करें। इस थ्रेड में प्रगति दिखती रहे और कार्य की स्थिति बदलने पर कार्य बोर्ड अपडेट करें।',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'स्रोत कार्य को कार्यान्वयन के लिए तैयार एजेंट कार्य में बदलें: {title}',
|
||||
'intelligence.refine.sourceLine': 'स्रोत: {url}',
|
||||
'intelligence.refine.sourceIntake': 'स्रोत: कार्य स्रोत प्रविष्टि',
|
||||
'intelligence.refine.repositoryLine': 'रिपॉज़िटरी: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'बाहरी कार्य: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'जुड़े हुए स्रोत कार्य को पढ़ें और अनुरोधित सटीक व्यवहार की पुष्टि करें।',
|
||||
'intelligence.refine.planStep2':
|
||||
'संबंधित कोड पथों का निरीक्षण करें और सबसे छोटी कार्यान्वयन सीमा पहचानें।',
|
||||
'intelligence.refine.planStep3':
|
||||
'उपयोगकर्ता-दृश्य व्यवहार के इर्द-गिर्द केंद्रित परीक्षणों के साथ परिवर्तन लागू करें।',
|
||||
'intelligence.refine.planStep4':
|
||||
'लक्षित सत्यापन चलाएँ और किसी भी शेष जोखिम या अनुवर्ती कार्य को दर्ज करें।',
|
||||
'intelligence.refine.acceptance1':
|
||||
'स्रोत कार्य की आवश्यकताएँ अंतिम कार्यान्वयन में दर्शाई गई हैं।',
|
||||
'intelligence.refine.acceptance2':
|
||||
'संबंधित यूनिट या इंटीग्रेशन परीक्षण बदले हुए व्यवहार को कवर करते हैं।',
|
||||
'intelligence.refine.acceptance3':
|
||||
'सत्यापन परिणाम और कोई भी अनसुलझा जोखिम पूर्ण होने पर दर्ज किया जाता है।',
|
||||
'notifications.card.dismiss': 'नोटिफिकेशन हटाएं',
|
||||
'notifications.card.importanceTitle': 'महत्व: {pct}%',
|
||||
'notifications.center.empty': 'अभी कोई नोटिफिकेशन नहीं',
|
||||
@@ -4264,6 +4319,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'रिपॉज़िटरी (owner/name, वैकल्पिक)',
|
||||
'settings.taskSources.github.labels': 'लेबल (comma-separated)',
|
||||
'settings.taskSources.notion.database': 'डेटाबेस (बोर्ड) ID',
|
||||
'settings.taskSources.notion.browseDatabases': 'डेटाबेस ब्राउज़ करें',
|
||||
'settings.taskSources.notion.loadingDatabases': 'डेटाबेस लोड हो रहे हैं…',
|
||||
'settings.taskSources.notion.selectDatabase': 'एक डेटाबेस चुनें…',
|
||||
'settings.taskSources.notion.noDatabases': 'इस कनेक्शन के लिए कोई डेटाबेस नहीं मिला।',
|
||||
'settings.taskSources.linear.team': 'टीम आईडी (वैकल्पिक)',
|
||||
'settings.taskSources.clickup.team': 'कार्यक्षेत्र (टीम) आईडी (वैकल्पिक)',
|
||||
'settings.taskSources.assignedToMe': 'केवल मुझे सौंपा गया आइटम',
|
||||
|
||||
@@ -2524,7 +2524,24 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Judul',
|
||||
'conversations.taskKanban.saveChanges': 'Simpan perubahan',
|
||||
'conversations.taskKanban.deleteCard': 'Hapus',
|
||||
'conversations.taskKanban.workTask': 'Kerjakan tugas',
|
||||
'conversations.taskKanban.startingTask': 'Memulai…',
|
||||
'conversations.taskKanban.updateFailed': 'Tak bisa memutakhirkan tugas; perubahan tak disimpan.',
|
||||
'conversations.taskKanban.sourcesButton': 'Sumber',
|
||||
'conversations.taskKanban.source.openExternal': 'Buka tugas eksternal',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Buka',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Sumber tidak dikenal',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Urgensi {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Kontrol sumber tugas tersedia di aplikasi desktop.',
|
||||
'conversations.taskKanban.sources.title': 'Sumber tugas',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Polling otomatis aktif',
|
||||
'conversations.taskKanban.sources.manage': 'Kelola sumber',
|
||||
'conversations.taskKanban.source.title': 'Sumber',
|
||||
'conversations.taskKanban.source.sourceId': 'ID sumber',
|
||||
'conversations.taskKanban.source.externalId': 'ID eksternal',
|
||||
'conversations.taskKanban.source.repo': 'Repositori',
|
||||
'conversations.taskKanban.source.urgency': 'Urgensi',
|
||||
'conversations.toolTimeline.turn': 'giliran',
|
||||
'conversations.toolTimeline.step': 'Langkah',
|
||||
'conversations.toolTimeline.workerThread': 'thread worker',
|
||||
@@ -2639,6 +2656,42 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Buat tugas',
|
||||
'intelligence.tasks.composer.creating': 'Membuat…',
|
||||
'intelligence.tasks.composer.createFailed': 'Gagal membuat tugas',
|
||||
'intelligence.tasks.sourceList.subtitle': 'Tugas sumber yang menunggu menjadi pekerjaan agen.',
|
||||
'intelligence.tasks.sourceList.empty': 'Tidak ada tugas sumber yang menunggu.',
|
||||
'intelligence.tasks.sourceList.queued': 'Dalam antrean',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Kerjakan tugas',
|
||||
'intelligence.tasks.sourcePlan.title': 'Perjelas tugas sumber',
|
||||
'intelligence.tasks.sourcePlan.subtitle': 'Tinjau draf riset sebelum membuat tugas agen.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Draf agen riset',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Setujui rencana',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Membuat tugas…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'Gagal membuat tugas agen',
|
||||
'intelligence.tasks.workTaskFailed': 'Gagal memulai pengerjaan tugas',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Tugas sumber:',
|
||||
'intelligence.workTask.repositoryLine': '- Repositori: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- ID eksternal: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Tautan: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Mulailah dengan menyatakan kembali rencana implementasi konkret secara singkat, lalu jalankan. Jaga agar kemajuan tetap terlihat di utas ini dan perbarui papan tugas ketika status pekerjaan berubah.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Ubah tugas sumber menjadi tugas agen yang siap diimplementasikan: {title}',
|
||||
'intelligence.refine.sourceLine': 'Sumber: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Sumber: penerimaan sumber tugas',
|
||||
'intelligence.refine.repositoryLine': 'Repositori: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Tugas eksternal: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Baca tugas sumber yang ditautkan dan konfirmasikan perilaku persis yang diminta.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Periksa jalur kode yang relevan dan identifikasi batas implementasi terkecil.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Implementasikan perubahan dengan pengujian terfokus pada perilaku yang terlihat oleh pengguna.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Jalankan validasi tertarget dan catat risiko yang tersisa atau pekerjaan lanjutan.',
|
||||
'intelligence.refine.acceptance1': 'Persyaratan tugas sumber terwakili dalam implementasi akhir.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Pengujian unit atau integrasi yang relevan mencakup perilaku yang diubah.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Hasil validasi dan risiko yang belum terselesaikan dicatat saat penyelesaian.',
|
||||
'notifications.card.dismiss': 'Abaikan notifikasi',
|
||||
'notifications.card.importanceTitle': 'Tingkat penting: {pct}%',
|
||||
'notifications.center.empty': 'Belum ada notifikasi',
|
||||
@@ -4274,6 +4327,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repositori (pemilik/nama, opsional)',
|
||||
'settings.taskSources.github.labels': 'Label (koma - dipisahkan)',
|
||||
'settings.taskSources.notion.database': 'ID Basis Data (papan)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Jelajahi basis data',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Memuat basis data…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Pilih basis data…',
|
||||
'settings.taskSources.notion.noDatabases': 'Tidak ada basis data untuk koneksi ini.',
|
||||
'settings.taskSources.linear.team': 'ID tim (opsional)',
|
||||
'settings.taskSources.clickup.team': 'ID workspace (tim) (opsional)',
|
||||
'settings.taskSources.assignedToMe': 'Hanya item yang ditugaskan kepada saya',
|
||||
|
||||
@@ -2555,8 +2555,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Titolo',
|
||||
'conversations.taskKanban.saveChanges': 'Salva modifiche',
|
||||
'conversations.taskKanban.deleteCard': 'Elimina',
|
||||
'conversations.taskKanban.workTask': "Lavora sull'attività",
|
||||
'conversations.taskKanban.startingTask': 'Avvio…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
"Impossibile aggiornare l'attività; le modifiche non sono state salvate.",
|
||||
'conversations.taskKanban.sourcesButton': 'Fonti',
|
||||
'conversations.taskKanban.source.openExternal': 'Apri attività esterna',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Apri',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Fonte sconosciuta',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Urgenza {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'I controlli delle fonti attività sono disponibili nell’app desktop.',
|
||||
'conversations.taskKanban.sources.title': 'Fonti attività',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Polling automatico attivo',
|
||||
'conversations.taskKanban.sources.manage': 'Gestisci fonti',
|
||||
'conversations.taskKanban.source.title': 'Fonte',
|
||||
'conversations.taskKanban.source.sourceId': 'ID fonte',
|
||||
'conversations.taskKanban.source.externalId': 'ID esterno',
|
||||
'conversations.taskKanban.source.repo': 'Repository',
|
||||
'conversations.taskKanban.source.urgency': 'Urgenza',
|
||||
'conversations.toolTimeline.turn': 'turno',
|
||||
'conversations.toolTimeline.step': 'Passo',
|
||||
'conversations.toolTimeline.workerThread': 'thread worker',
|
||||
@@ -2672,6 +2689,45 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Crea attività',
|
||||
'intelligence.tasks.composer.creating': 'Creando…',
|
||||
'intelligence.tasks.composer.createFailed': 'Impossibile creare il task',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
"Attività dalle fonti in attesa di diventare lavoro dell'agente.",
|
||||
'intelligence.tasks.sourceList.empty': 'Nessuna attività da fonti in attesa.',
|
||||
'intelligence.tasks.sourceList.queued': 'In coda',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Lavora sull’attività',
|
||||
'intelligence.tasks.sourcePlan.title': 'Perfeziona attività sorgente',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
"Rivedi la bozza di ricerca prima di creare un'attività agente.",
|
||||
'intelligence.tasks.sourcePlan.researchAgent': "Bozza dell'agente di ricerca",
|
||||
'intelligence.tasks.sourcePlan.approve': 'Approva piano',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Creazione attività…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': "Impossibile creare l'attività agente",
|
||||
'intelligence.tasks.workTaskFailed': "Impossibile avviare il lavoro sull'attività",
|
||||
'intelligence.workTask.sourceTaskHeading': 'Attività di origine:',
|
||||
'intelligence.workTask.repositoryLine': '- Repository (deposito): {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- ID esterno: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Indirizzo: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Inizia riformulando brevemente il piano di implementazione concreto, poi eseguilo. Mantieni i progressi visibili in questo thread e aggiorna la bacheca delle attività quando cambia lo stato del lavoro.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
"Trasforma l'attività di origine in un'attività dell'agente pronta per l'implementazione: {title}",
|
||||
'intelligence.refine.sourceLine': 'Origine: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Origine: acquisizione delle fonti delle attività',
|
||||
'intelligence.refine.repositoryLine': 'Repository (deposito): {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Attività esterna: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
"Leggi l'attività di origine collegata e conferma il comportamento esatto richiesto.",
|
||||
'intelligence.refine.planStep2':
|
||||
'Esamina i percorsi di codice rilevanti e individua il confine di implementazione più piccolo.',
|
||||
'intelligence.refine.planStep3':
|
||||
"Implementa la modifica con test mirati sul comportamento visibile all'utente.",
|
||||
'intelligence.refine.planStep4':
|
||||
'Esegui una validazione mirata e annota eventuali rischi residui o lavoro di follow-up.',
|
||||
'intelligence.refine.acceptance1':
|
||||
"I requisiti dell'attività di origine sono rappresentati nell'implementazione finale.",
|
||||
'intelligence.refine.acceptance2':
|
||||
'I test unitari o di integrazione pertinenti coprono il comportamento modificato.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'I risultati della validazione e gli eventuali rischi irrisolti vengono registrati al completamento.',
|
||||
'notifications.card.dismiss': 'Ignora notifica',
|
||||
'notifications.card.importanceTitle': 'Importanza: {pct}%',
|
||||
'notifications.center.empty': 'Nessuna notifica',
|
||||
@@ -4331,6 +4387,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repository (proprietario/nome, facoltativo)',
|
||||
'settings.taskSources.github.labels': 'Etichette (separate da virgola)',
|
||||
'settings.taskSources.notion.database': 'ID del database (board)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Sfoglia i database',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Caricamento dei database…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Seleziona un database…',
|
||||
'settings.taskSources.notion.noDatabases': 'Nessun database trovato per questa connessione.',
|
||||
'settings.taskSources.linear.team': 'ID del team (opzionale)',
|
||||
'settings.taskSources.clickup.team': 'ID dello spazio di lavoro (team) (opzionale)',
|
||||
'settings.taskSources.assignedToMe': 'Solo gli articoli assegnati a me',
|
||||
|
||||
@@ -2496,8 +2496,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': '제목',
|
||||
'conversations.taskKanban.saveChanges': '변경 사항 저장',
|
||||
'conversations.taskKanban.deleteCard': '삭제',
|
||||
'conversations.taskKanban.workTask': '작업 시작',
|
||||
'conversations.taskKanban.startingTask': '시작 중…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
'작업을 업데이트할 수 없어 변경 사항이 저장되지 않았습니다.',
|
||||
'conversations.taskKanban.sourcesButton': '소스',
|
||||
'conversations.taskKanban.source.openExternal': '외부 작업 열기',
|
||||
'conversations.taskKanban.source.openExternalShort': '열기',
|
||||
'conversations.taskKanban.source.unknownProvider': '알 수 없는 소스',
|
||||
'conversations.taskKanban.source.urgencyValue': '긴급도 {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'작업 소스 제어는 데스크톱 앱에서 사용할 수 있습니다.',
|
||||
'conversations.taskKanban.sources.title': '작업 소스',
|
||||
'conversations.taskKanban.sources.statusEnabled': '자동 폴링 켜짐',
|
||||
'conversations.taskKanban.sources.manage': '소스 관리',
|
||||
'conversations.taskKanban.source.title': '소스',
|
||||
'conversations.taskKanban.source.sourceId': '소스 ID',
|
||||
'conversations.taskKanban.source.externalId': '외부 ID',
|
||||
'conversations.taskKanban.source.repo': '저장소',
|
||||
'conversations.taskKanban.source.urgency': '긴급도',
|
||||
'conversations.toolTimeline.turn': '턴',
|
||||
'conversations.toolTimeline.step': '단계',
|
||||
'conversations.toolTimeline.workerThread': '워커 스레드',
|
||||
@@ -2612,6 +2629,37 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': '작업 만들기',
|
||||
'intelligence.tasks.composer.creating': '만드는 중…',
|
||||
'intelligence.tasks.composer.createFailed': '작업을 만들 수 없습니다',
|
||||
'intelligence.tasks.sourceList.subtitle': '에이전트 작업으로 전환될 소스 작업입니다.',
|
||||
'intelligence.tasks.sourceList.empty': '대기 중인 소스 작업이 없습니다.',
|
||||
'intelligence.tasks.sourceList.queued': '대기열에 추가됨',
|
||||
'intelligence.tasks.sourceList.workOnTask': '작업 시작',
|
||||
'intelligence.tasks.sourcePlan.title': '소스 작업 다듬기',
|
||||
'intelligence.tasks.sourcePlan.subtitle': '에이전트 작업을 만들기 전에 리서치 초안을 검토하세요.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': '리서치 에이전트 초안',
|
||||
'intelligence.tasks.sourcePlan.approve': '계획 승인',
|
||||
'intelligence.tasks.sourcePlan.creating': '작업 만드는 중…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': '에이전트 작업을 만들 수 없습니다',
|
||||
'intelligence.tasks.workTaskFailed': '작업을 시작할 수 없습니다',
|
||||
'intelligence.workTask.sourceTaskHeading': '소스 작업:',
|
||||
'intelligence.workTask.repositoryLine': '- 저장소: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- 외부 ID: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- 링크: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'먼저 구체적인 구현 계획을 간략히 다시 설명한 다음 실행하세요. 이 스레드에서 진행 상황을 계속 보이게 하고 작업 상태가 바뀌면 작업 보드를 업데이트하세요.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'소스 작업을 구현 준비가 된 에이전트 작업으로 전환하세요: {title}',
|
||||
'intelligence.refine.sourceLine': '소스: {url}',
|
||||
'intelligence.refine.sourceIntake': '소스: 작업 소스 수집',
|
||||
'intelligence.refine.repositoryLine': '저장소: {repo}',
|
||||
'intelligence.refine.externalTaskLine': '외부 작업: {externalId}',
|
||||
'intelligence.refine.planStep1': '연결된 소스 작업을 읽고 요청된 정확한 동작을 확인하세요.',
|
||||
'intelligence.refine.planStep2': '관련 코드 경로를 검토하고 가장 작은 구현 경계를 식별하세요.',
|
||||
'intelligence.refine.planStep3':
|
||||
'사용자에게 보이는 동작을 중심으로 한 집중 테스트와 함께 변경을 구현하세요.',
|
||||
'intelligence.refine.planStep4': '대상 검증을 실행하고 남은 위험이나 후속 작업을 기록하세요.',
|
||||
'intelligence.refine.acceptance1': '소스 작업 요구 사항이 최종 구현에 반영되어 있습니다.',
|
||||
'intelligence.refine.acceptance2': '관련 단위 또는 통합 테스트가 변경된 동작을 다룹니다.',
|
||||
'intelligence.refine.acceptance3': '검증 결과와 해결되지 않은 위험이 완료 시 기록됩니다.',
|
||||
'notifications.card.dismiss': '알림 닫기',
|
||||
'notifications.card.importanceTitle': '중요도: {pct}%',
|
||||
'notifications.center.empty': '아직 알림이 없습니다',
|
||||
@@ -4220,6 +4268,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': '저장소 (소유자/이름, 선택 사항)',
|
||||
'settings.taskSources.github.labels': '레이블(쉼표로 구분)',
|
||||
'settings.taskSources.notion.database': '데이터베이스(보드) ID',
|
||||
'settings.taskSources.notion.browseDatabases': '데이터베이스 찾아보기',
|
||||
'settings.taskSources.notion.loadingDatabases': '데이터베이스 불러오는 중…',
|
||||
'settings.taskSources.notion.selectDatabase': '데이터베이스 선택…',
|
||||
'settings.taskSources.notion.noDatabases': '이 연결에 대한 데이터베이스가 없습니다.',
|
||||
'settings.taskSources.linear.team': '팀 ID(선택 사항)',
|
||||
'settings.taskSources.clickup.team': '작업공간(팀) ID(선택 사항)',
|
||||
'settings.taskSources.assignedToMe': '내게 할당된 항목만',
|
||||
|
||||
@@ -2550,8 +2550,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Tytuł',
|
||||
'conversations.taskKanban.saveChanges': 'Zapisz zmiany',
|
||||
'conversations.taskKanban.deleteCard': 'Usuń',
|
||||
'conversations.taskKanban.workTask': 'Pracuj nad zadaniem',
|
||||
'conversations.taskKanban.startingTask': 'Uruchamianie…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
'Nie udało się zaktualizować zadania; zmian nie zapisano.',
|
||||
'conversations.taskKanban.sourcesButton': 'Źródła',
|
||||
'conversations.taskKanban.source.openExternal': 'Otwórz zadanie zewnętrzne',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Otwórz',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Nieznane źródło',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Pilność {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Kontrolki źródeł zadań są dostępne w aplikacji desktopowej.',
|
||||
'conversations.taskKanban.sources.title': 'Źródła zadań',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Automatyczne odpytywanie włączone',
|
||||
'conversations.taskKanban.sources.manage': 'Zarządzaj źródłami',
|
||||
'conversations.taskKanban.source.title': 'Źródło',
|
||||
'conversations.taskKanban.source.sourceId': 'ID źródła',
|
||||
'conversations.taskKanban.source.externalId': 'ID zewnętrzne',
|
||||
'conversations.taskKanban.source.repo': 'Repozytorium',
|
||||
'conversations.taskKanban.source.urgency': 'Pilność',
|
||||
'conversations.toolTimeline.turn': 'tura',
|
||||
'conversations.toolTimeline.step': 'Krok',
|
||||
'conversations.toolTimeline.workerThread': 'wątek workera',
|
||||
@@ -2669,6 +2686,45 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Utwórz zadanie',
|
||||
'intelligence.tasks.composer.creating': 'Tworzenie…',
|
||||
'intelligence.tasks.composer.createFailed': 'Nie udało się utworzyć zadania',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
'Zadania ze źródeł czekające na przekształcenie w pracę agenta.',
|
||||
'intelligence.tasks.sourceList.empty': 'Brak oczekujących zadań ze źródeł.',
|
||||
'intelligence.tasks.sourceList.queued': 'W kolejce',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Pracuj nad zadaniem',
|
||||
'intelligence.tasks.sourcePlan.title': 'Dopracuj zadanie źródłowe',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'Sprawdź szkic badawczy przed utworzeniem zadania agenta.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Szkic agenta badawczego',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Zatwierdź plan',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Tworzenie zadania…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'Nie udało się utworzyć zadania agenta',
|
||||
'intelligence.tasks.workTaskFailed': 'Nie udało się rozpocząć pracy nad zadaniem',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Zadanie źródłowe:',
|
||||
'intelligence.workTask.repositoryLine': '- Repozytorium: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- Identyfikator zewnętrzny: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Adres: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Zacznij od krótkiego powtórzenia konkretnego planu wdrożenia, a następnie go zrealizuj. Utrzymuj widoczność postępów w tym wątku i aktualizuj tablicę zadań, gdy zmienia się stan pracy.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Przekształć zadanie źródłowe w gotowe do wdrożenia zadanie agenta: {title}',
|
||||
'intelligence.refine.sourceLine': 'Źródło: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Źródło: przyjęcie źródła zadań',
|
||||
'intelligence.refine.repositoryLine': 'Repozytorium: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Zadanie zewnętrzne: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Przeczytaj powiązane zadanie źródłowe i potwierdź dokładnie żądane zachowanie.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Przejrzyj odpowiednie ścieżki kodu i wskaż najmniejszą granicę wdrożenia.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Wdróż zmianę z ukierunkowanymi testami wokół zachowania widocznego dla użytkownika.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Uruchom ukierunkowaną walidację i zapisz wszelkie pozostałe ryzyka lub prace następcze.',
|
||||
'intelligence.refine.acceptance1':
|
||||
'Wymagania zadania źródłowego są odzwierciedlone w ostatecznym wdrożeniu.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Odpowiednie testy jednostkowe lub integracyjne obejmują zmienione zachowanie.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Wyniki walidacji i wszelkie nierozwiązane ryzyka są zapisywane po zakończeniu.',
|
||||
'notifications.card.dismiss': 'Odrzuć powiadomienie',
|
||||
'notifications.card.importanceTitle': 'Ważność: {pct}%',
|
||||
'notifications.center.empty': 'Brak powiadomień',
|
||||
@@ -4330,6 +4386,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repozytorium (właściciel/nazwa, opcjonalnie)',
|
||||
'settings.taskSources.github.labels': 'Etykiety (rozdzielone przecinkami)',
|
||||
'settings.taskSources.notion.database': 'ID bazy danych (tablicy)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Przeglądaj bazy danych',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Ładowanie baz danych…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Wybierz bazę danych…',
|
||||
'settings.taskSources.notion.noDatabases': 'Nie znaleziono baz danych dla tego połączenia.',
|
||||
'settings.taskSources.linear.team': 'ID zespołu (opcjonalnie)',
|
||||
'settings.taskSources.clickup.team': 'ID przestrzeni roboczej (zespołu, opcjonalnie)',
|
||||
'settings.taskSources.assignedToMe': 'Tylko elementy przypisane do mnie',
|
||||
|
||||
@@ -2563,8 +2563,25 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Título',
|
||||
'conversations.taskKanban.saveChanges': 'Salvar alterações',
|
||||
'conversations.taskKanban.deleteCard': 'Excluir',
|
||||
'conversations.taskKanban.workTask': 'Trabalhar na tarefa',
|
||||
'conversations.taskKanban.startingTask': 'Iniciando…',
|
||||
'conversations.taskKanban.updateFailed':
|
||||
'Não foi possível atualizar a tarefa; as alterações não foram salvas.',
|
||||
'conversations.taskKanban.sourcesButton': 'Fontes',
|
||||
'conversations.taskKanban.source.openExternal': 'Abrir tarefa externa',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Abrir',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Fonte desconhecida',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Urgência {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Os controles de fontes de tarefas estão disponíveis no app de desktop.',
|
||||
'conversations.taskKanban.sources.title': 'Fontes de tarefas',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Polling automático ativado',
|
||||
'conversations.taskKanban.sources.manage': 'Gerenciar fontes',
|
||||
'conversations.taskKanban.source.title': 'Fonte',
|
||||
'conversations.taskKanban.source.sourceId': 'ID da fonte',
|
||||
'conversations.taskKanban.source.externalId': 'ID externo',
|
||||
'conversations.taskKanban.source.repo': 'Repositório',
|
||||
'conversations.taskKanban.source.urgency': 'Urgência',
|
||||
'conversations.toolTimeline.turn': 'turno',
|
||||
'conversations.toolTimeline.step': 'Passo',
|
||||
'conversations.toolTimeline.workerThread': 'thread de worker',
|
||||
@@ -2680,6 +2697,45 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Criar tarefa',
|
||||
'intelligence.tasks.composer.creating': 'Criando…',
|
||||
'intelligence.tasks.composer.createFailed': 'Não foi possível criar a tarefa',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
'Tarefas de fontes aguardando virar trabalho de agente.',
|
||||
'intelligence.tasks.sourceList.empty': 'Nenhuma tarefa de fonte aguardando.',
|
||||
'intelligence.tasks.sourceList.queued': 'Na fila',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Trabalhar na tarefa',
|
||||
'intelligence.tasks.sourcePlan.title': 'Refinar tarefa da fonte',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'Revise o rascunho de pesquisa antes de criar uma tarefa de agente.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Rascunho do agente de pesquisa',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Aprovar plano',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Criando tarefa…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'Não foi possível criar a tarefa de agente',
|
||||
'intelligence.tasks.workTaskFailed': 'Não foi possível iniciar o trabalho na tarefa',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Tarefa de origem:',
|
||||
'intelligence.workTask.repositoryLine': '- Repositório: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- ID externo: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Endereço: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Comece reformulando brevemente o plano de implementação concreto e depois execute-o. Mantenha o progresso visível neste tópico e atualize o quadro de tarefas quando o estado do trabalho mudar.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Transforme a tarefa de origem em uma tarefa de agente pronta para implementação: {title}',
|
||||
'intelligence.refine.sourceLine': 'Origem: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Origem: recepção de fontes de tarefas',
|
||||
'intelligence.refine.repositoryLine': 'Repositório: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Tarefa externa: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Leia a tarefa de origem vinculada e confirme o comportamento exato solicitado.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Inspecione os caminhos de código relevantes e identifique o menor limite de implementação.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Implemente a mudança com testes focados no comportamento visível ao usuário.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Execute validação direcionada e registre quaisquer riscos remanescentes ou trabalho de acompanhamento.',
|
||||
'intelligence.refine.acceptance1':
|
||||
'Os requisitos da tarefa de origem estão representados na implementação final.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Testes unitários ou de integração relevantes cobrem o comportamento alterado.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Os resultados da validação e qualquer risco não resolvido são registrados na conclusão.',
|
||||
'notifications.card.dismiss': 'Dispensar notificação',
|
||||
'notifications.card.importanceTitle': 'Importância: {pct}%',
|
||||
'notifications.center.empty': 'Nenhuma notificação ainda',
|
||||
@@ -4332,6 +4388,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Repositório (proprietário/nome, opcional)',
|
||||
'settings.taskSources.github.labels': 'Etiquetas (separadas por vírgula)',
|
||||
'settings.taskSources.notion.database': 'ID do banco de dados (quadro)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Explorar bancos de dados',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Carregando bancos de dados…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Selecione um banco de dados…',
|
||||
'settings.taskSources.notion.noDatabases': 'Nenhum banco de dados encontrado para esta conexão.',
|
||||
'settings.taskSources.linear.team': 'ID da equipe (opcional)',
|
||||
'settings.taskSources.clickup.team': 'ID do espaço de trabalho (equipe) (opcional)',
|
||||
'settings.taskSources.assignedToMe': 'Apenas itens atribuídos a mim',
|
||||
|
||||
@@ -2537,7 +2537,24 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': 'Заголовок',
|
||||
'conversations.taskKanban.saveChanges': 'Сохранить изменения',
|
||||
'conversations.taskKanban.deleteCard': 'Удалить',
|
||||
'conversations.taskKanban.workTask': 'Работать над задачей',
|
||||
'conversations.taskKanban.startingTask': 'Запуск…',
|
||||
'conversations.taskKanban.updateFailed': 'Не удалось обновить задачу; изменения не сохранились.',
|
||||
'conversations.taskKanban.sourcesButton': 'Источники',
|
||||
'conversations.taskKanban.source.openExternal': 'Открыть внешнюю задачу',
|
||||
'conversations.taskKanban.source.openExternalShort': 'Открыть',
|
||||
'conversations.taskKanban.source.unknownProvider': 'Неизвестный источник',
|
||||
'conversations.taskKanban.source.urgencyValue': 'Срочность {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly':
|
||||
'Управление источниками задач доступно в настольном приложении.',
|
||||
'conversations.taskKanban.sources.title': 'Источники задач',
|
||||
'conversations.taskKanban.sources.statusEnabled': 'Автоматический опрос включен',
|
||||
'conversations.taskKanban.sources.manage': 'Управлять источниками',
|
||||
'conversations.taskKanban.source.title': 'Источник',
|
||||
'conversations.taskKanban.source.sourceId': 'ID источника',
|
||||
'conversations.taskKanban.source.externalId': 'Внешний ID',
|
||||
'conversations.taskKanban.source.repo': 'Репозиторий',
|
||||
'conversations.taskKanban.source.urgency': 'Срочность',
|
||||
'conversations.toolTimeline.turn': 'ход',
|
||||
'conversations.toolTimeline.step': 'Шаг',
|
||||
'conversations.toolTimeline.workerThread': 'чат воркера',
|
||||
@@ -2652,6 +2669,44 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': 'Создать задачу',
|
||||
'intelligence.tasks.composer.creating': 'Создание…',
|
||||
'intelligence.tasks.composer.createFailed': 'Не удалось создать задачу',
|
||||
'intelligence.tasks.sourceList.subtitle':
|
||||
'Задачи из источников, ожидающие превращения в работу агента.',
|
||||
'intelligence.tasks.sourceList.empty': 'Нет ожидающих задач из источников.',
|
||||
'intelligence.tasks.sourceList.queued': 'В очереди',
|
||||
'intelligence.tasks.sourceList.workOnTask': 'Работать над задачей',
|
||||
'intelligence.tasks.sourcePlan.title': 'Уточнить задачу из источника',
|
||||
'intelligence.tasks.sourcePlan.subtitle':
|
||||
'Проверьте черновик исследования перед созданием задачи агента.',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': 'Черновик исследовательского агента',
|
||||
'intelligence.tasks.sourcePlan.approve': 'Утвердить план',
|
||||
'intelligence.tasks.sourcePlan.creating': 'Создание задачи…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': 'Не удалось создать задачу агента',
|
||||
'intelligence.tasks.workTaskFailed': 'Не удалось начать работу над задачей',
|
||||
'intelligence.workTask.sourceTaskHeading': 'Исходная задача:',
|
||||
'intelligence.workTask.repositoryLine': '- Репозиторий: {repo}',
|
||||
'intelligence.workTask.externalIdLine': '- Внешний ID: {externalId}',
|
||||
'intelligence.workTask.urlLine': '- Ссылка: {url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'Начните с краткого повторения конкретного плана реализации, затем выполните его. Поддерживайте видимость прогресса в этой ветке и обновляйте доску задач при изменении состояния работы.',
|
||||
'intelligence.refine.objectiveDefault':
|
||||
'Превратите исходную задачу в готовую к реализации задачу агента: {title}',
|
||||
'intelligence.refine.sourceLine': 'Источник: {url}',
|
||||
'intelligence.refine.sourceIntake': 'Источник: приём источников задач',
|
||||
'intelligence.refine.repositoryLine': 'Репозиторий: {repo}',
|
||||
'intelligence.refine.externalTaskLine': 'Внешняя задача: {externalId}',
|
||||
'intelligence.refine.planStep1':
|
||||
'Прочитайте связанную исходную задачу и подтвердите точно запрошенное поведение.',
|
||||
'intelligence.refine.planStep2':
|
||||
'Изучите соответствующие пути кода и определите наименьшую границу реализации.',
|
||||
'intelligence.refine.planStep3':
|
||||
'Реализуйте изменение с целевыми тестами вокруг видимого пользователю поведения.',
|
||||
'intelligence.refine.planStep4':
|
||||
'Запустите целевую проверку и зафиксируйте оставшиеся риски или последующую работу.',
|
||||
'intelligence.refine.acceptance1': 'Требования исходной задачи отражены в итоговой реализации.',
|
||||
'intelligence.refine.acceptance2':
|
||||
'Соответствующие модульные или интеграционные тесты покрывают изменённое поведение.',
|
||||
'intelligence.refine.acceptance3':
|
||||
'Результаты проверки и любые нерешённые риски фиксируются при завершении.',
|
||||
'notifications.card.dismiss': 'Закрыть уведомление',
|
||||
'notifications.card.importanceTitle': 'Важность: {pct}%',
|
||||
'notifications.center.empty': 'Уведомлений пока нет',
|
||||
@@ -4297,6 +4352,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': 'Репозиторий (владелец/имя, необязательно)',
|
||||
'settings.taskSources.github.labels': 'Ярлыки (через запятую)',
|
||||
'settings.taskSources.notion.database': 'Идентификатор базы данных (доски)',
|
||||
'settings.taskSources.notion.browseDatabases': 'Обзор баз данных',
|
||||
'settings.taskSources.notion.loadingDatabases': 'Загрузка баз данных…',
|
||||
'settings.taskSources.notion.selectDatabase': 'Выберите базу данных…',
|
||||
'settings.taskSources.notion.noDatabases': 'Базы данных для этого подключения не найдены.',
|
||||
'settings.taskSources.linear.team': 'Идентификатор команды (необязательно)',
|
||||
'settings.taskSources.clickup.team': 'Идентификатор рабочей области (команды) (необязательно)',
|
||||
'settings.taskSources.assignedToMe': 'Только элементы, назначенные мне',
|
||||
|
||||
@@ -2396,7 +2396,23 @@ const messages: TranslationMap = {
|
||||
'conversations.taskKanban.field.title': '标题',
|
||||
'conversations.taskKanban.saveChanges': '保存更改',
|
||||
'conversations.taskKanban.deleteCard': '删除',
|
||||
'conversations.taskKanban.workTask': '处理任务',
|
||||
'conversations.taskKanban.startingTask': '正在启动…',
|
||||
'conversations.taskKanban.updateFailed': '无法更新任务;更改未保存。',
|
||||
'conversations.taskKanban.sourcesButton': '来源',
|
||||
'conversations.taskKanban.source.openExternal': '打开外部任务',
|
||||
'conversations.taskKanban.source.openExternalShort': '打开',
|
||||
'conversations.taskKanban.source.unknownProvider': '未知来源',
|
||||
'conversations.taskKanban.source.urgencyValue': '紧急度 {percent}%',
|
||||
'conversations.taskKanban.sources.desktopOnly': '任务来源控件可在桌面应用中使用。',
|
||||
'conversations.taskKanban.sources.title': '任务来源',
|
||||
'conversations.taskKanban.sources.statusEnabled': '自动轮询已启用',
|
||||
'conversations.taskKanban.sources.manage': '管理来源',
|
||||
'conversations.taskKanban.source.title': '来源',
|
||||
'conversations.taskKanban.source.sourceId': '来源 ID',
|
||||
'conversations.taskKanban.source.externalId': '外部 ID',
|
||||
'conversations.taskKanban.source.repo': '仓库',
|
||||
'conversations.taskKanban.source.urgency': '紧急度',
|
||||
'conversations.toolTimeline.turn': '轮次',
|
||||
'conversations.toolTimeline.step': '步骤',
|
||||
'conversations.toolTimeline.workerThread': '工作线程',
|
||||
@@ -2507,6 +2523,35 @@ const messages: TranslationMap = {
|
||||
'intelligence.tasks.composer.create': '创建任务',
|
||||
'intelligence.tasks.composer.creating': '正在创建…',
|
||||
'intelligence.tasks.composer.createFailed': '无法创建任务',
|
||||
'intelligence.tasks.sourceList.subtitle': '等待转为智能体工作的来源任务。',
|
||||
'intelligence.tasks.sourceList.empty': '没有等待处理的来源任务。',
|
||||
'intelligence.tasks.sourceList.queued': '已入队',
|
||||
'intelligence.tasks.sourceList.workOnTask': '处理任务',
|
||||
'intelligence.tasks.sourcePlan.title': '细化来源任务',
|
||||
'intelligence.tasks.sourcePlan.subtitle': '创建智能体任务前,请检查研究草稿。',
|
||||
'intelligence.tasks.sourcePlan.researchAgent': '研究智能体草稿',
|
||||
'intelligence.tasks.sourcePlan.approve': '批准计划',
|
||||
'intelligence.tasks.sourcePlan.creating': '正在创建任务…',
|
||||
'intelligence.tasks.sourcePlan.createFailed': '无法创建智能体任务',
|
||||
'intelligence.tasks.workTaskFailed': '无法开始处理任务',
|
||||
'intelligence.workTask.sourceTaskHeading': '来源任务:',
|
||||
'intelligence.workTask.repositoryLine': '- 仓库:{repo}',
|
||||
'intelligence.workTask.externalIdLine': '- 外部 ID:{externalId}',
|
||||
'intelligence.workTask.urlLine': '- 网址:{url}',
|
||||
'intelligence.workTask.closingInstruction':
|
||||
'先简要重述具体的实施计划,然后执行它。在此线程中保持进度可见,并在工作状态变化时更新任务看板。',
|
||||
'intelligence.refine.objectiveDefault': '将来源任务转化为可立即实施的智能体任务:{title}',
|
||||
'intelligence.refine.sourceLine': '来源:{url}',
|
||||
'intelligence.refine.sourceIntake': '来源:任务来源接收',
|
||||
'intelligence.refine.repositoryLine': '仓库:{repo}',
|
||||
'intelligence.refine.externalTaskLine': '外部任务:{externalId}',
|
||||
'intelligence.refine.planStep1': '阅读关联的来源任务,确认所请求的确切行为。',
|
||||
'intelligence.refine.planStep2': '检查相关代码路径,确定最小的实施边界。',
|
||||
'intelligence.refine.planStep3': '围绕用户可见的行为,通过有针对性的测试实现该更改。',
|
||||
'intelligence.refine.planStep4': '运行有针对性的验证,并记录任何残留风险或后续工作。',
|
||||
'intelligence.refine.acceptance1': '来源任务的需求已体现在最终实现中。',
|
||||
'intelligence.refine.acceptance2': '相关的单元测试或集成测试覆盖了已更改的行为。',
|
||||
'intelligence.refine.acceptance3': '验证结果和任何未解决的风险在完成时被记录。',
|
||||
'notifications.card.dismiss': '忽略通知',
|
||||
'notifications.card.importanceTitle': '重要性',
|
||||
'notifications.center.empty': '暂无通知',
|
||||
@@ -4052,6 +4097,10 @@ const messages: TranslationMap = {
|
||||
'settings.taskSources.github.repo': '仓库(所有者/名称,可选)',
|
||||
'settings.taskSources.github.labels': '标签(逗号分隔)',
|
||||
'settings.taskSources.notion.database': '数据库(看板)ID',
|
||||
'settings.taskSources.notion.browseDatabases': '浏览数据库',
|
||||
'settings.taskSources.notion.loadingDatabases': '正在加载数据库…',
|
||||
'settings.taskSources.notion.selectDatabase': '选择一个数据库…',
|
||||
'settings.taskSources.notion.noDatabases': '未找到此连接的数据库。',
|
||||
'settings.taskSources.linear.team': '团队 ID(可选)',
|
||||
'settings.taskSources.clickup.team': '工作区(团队)ID(可选)',
|
||||
'settings.taskSources.assignedToMe': '仅限分配给我的项目',
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TaskBoard, TaskBoardCard } from '../../../types/turnState';
|
||||
import {
|
||||
isTauri,
|
||||
openhumanTaskSourcesFetch,
|
||||
openhumanTaskSourcesList,
|
||||
openhumanTaskSourcesStatus,
|
||||
openhumanTaskSourcesUpdate,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import { TaskKanbanBoard } from './TaskKanbanBoard';
|
||||
|
||||
// Echo i18n keys so we can query by the stable key strings.
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock('../../../utils/tauriCommands', () => ({
|
||||
isTauri: vi.fn(),
|
||||
openhumanTaskSourcesFetch: vi.fn(),
|
||||
openhumanTaskSourcesList: vi.fn(),
|
||||
openhumanTaskSourcesStatus: vi.fn(),
|
||||
openhumanTaskSourcesUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
function card(partial: Partial<TaskBoardCard>): TaskBoardCard {
|
||||
return {
|
||||
@@ -23,6 +37,48 @@ function board(cards: TaskBoardCard[]): TaskBoard {
|
||||
}
|
||||
|
||||
describe('TaskKanbanBoard approval surface', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(openhumanTaskSourcesList).mockResolvedValue([
|
||||
{
|
||||
id: 'src-1',
|
||||
provider: 'github',
|
||||
name: 'Open issues',
|
||||
enabled: true,
|
||||
filter: { provider: 'github', repo: 'tinyhumans/openhuman', assignee_is_me: true },
|
||||
intervalSecs: 600,
|
||||
target: 'agent_todo_proactive',
|
||||
maxTasksPerFetch: 10,
|
||||
createdAt: '2026-06-02T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(openhumanTaskSourcesStatus).mockResolvedValue({
|
||||
enabled: true,
|
||||
defaultIntervalSecs: 600,
|
||||
sourceCount: 1,
|
||||
enabledSourceCount: 1,
|
||||
});
|
||||
vi.mocked(openhumanTaskSourcesFetch).mockResolvedValue({
|
||||
sourceId: 'src-1',
|
||||
provider: 'github',
|
||||
fetched: 3,
|
||||
routed: 2,
|
||||
skippedDupe: 1,
|
||||
});
|
||||
vi.mocked(openhumanTaskSourcesUpdate).mockResolvedValue({
|
||||
id: 'src-1',
|
||||
provider: 'github',
|
||||
name: 'Open issues',
|
||||
enabled: false,
|
||||
filter: { provider: 'github', repo: 'tinyhumans/openhuman', assignee_is_me: true },
|
||||
intervalSecs: 600,
|
||||
target: 'agent_todo_proactive',
|
||||
maxTasksPerFetch: 10,
|
||||
createdAt: '2026-06-02T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Approve/Reject on an awaiting_approval card and calls onDecidePlan', () => {
|
||||
const onDecidePlan = vi.fn();
|
||||
render(
|
||||
@@ -75,4 +131,56 @@ describe('TaskKanbanBoard approval surface', () => {
|
||||
screen.getByDisplayValue('conversations.taskKanban.awaitingApproval')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders task-source metadata on cards and in the brief dialog', () => {
|
||||
render(
|
||||
<TaskKanbanBoard
|
||||
board={board([
|
||||
card({
|
||||
id: 'sourced',
|
||||
title: '[github] Fix issue',
|
||||
sourceMetadata: {
|
||||
provider: 'github',
|
||||
source_id: 'src-1',
|
||||
external_id: '42',
|
||||
repo: 'tinyhumans/openhuman',
|
||||
url: 'https://github.com/tinyhumans/openhuman/issues/42',
|
||||
urgency: 0.82,
|
||||
},
|
||||
}),
|
||||
])}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('settings.taskSources.providers.github · tinyhumans/openhuman#42')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('conversations.taskKanban.source.openExternalShort')).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/tinyhumans/openhuman/issues/42'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('conversations.taskKanban.briefButton'));
|
||||
|
||||
expect(screen.getByText('conversations.taskKanban.source.title')).toBeInTheDocument();
|
||||
expect(screen.getByText('src-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText('conversations.taskKanban.source.urgencyValue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens task-source controls and calls fetch/toggle actions', async () => {
|
||||
render(<TaskKanbanBoard board={{ threadId: 'task-sources', updatedAt: '', cards: [] }} />);
|
||||
|
||||
fireEvent.click(screen.getByText('conversations.taskKanban.sourcesButton'));
|
||||
|
||||
expect(await screen.findByText('Open issues')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('settings.taskSources.fetchNow'));
|
||||
await waitFor(() => expect(openhumanTaskSourcesFetch).toHaveBeenCalledWith('src-1'));
|
||||
|
||||
fireEvent.click(screen.getByText('settings.taskSources.disable'));
|
||||
await waitFor(() =>
|
||||
expect(openhumanTaskSourcesUpdate).toHaveBeenCalledWith('src-1', { enabled: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
LuArrowLeft,
|
||||
LuArrowRight,
|
||||
LuBot,
|
||||
LuCircleCheck,
|
||||
LuClipboardList,
|
||||
LuDatabase,
|
||||
LuExternalLink,
|
||||
LuPlay,
|
||||
LuRefreshCw,
|
||||
LuShieldCheck,
|
||||
LuWrench,
|
||||
LuX,
|
||||
@@ -12,9 +16,21 @@ import {
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../../types/turnState';
|
||||
import {
|
||||
type FetchOutcome,
|
||||
isTauri,
|
||||
openhumanTaskSourcesFetch,
|
||||
openhumanTaskSourcesList,
|
||||
openhumanTaskSourcesStatus,
|
||||
openhumanTaskSourcesUpdate,
|
||||
type TaskSource,
|
||||
type TaskSourcesStatus,
|
||||
} from '../../../utils/tauriCommands';
|
||||
|
||||
type ColumnDef = { status: TaskBoardCardStatus; labelKey: string };
|
||||
|
||||
const TASK_SOURCES_THREAD_ID = 'task-sources';
|
||||
|
||||
// The board surfaces exactly three columns — Pending / Working / Done. The
|
||||
// richer status set the core tracks (approval flow, blocked, rejected) is
|
||||
// bucketed into these three via `columnFor`.
|
||||
@@ -70,6 +86,7 @@ function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus {
|
||||
interface TaskKanbanBoardProps {
|
||||
board: TaskBoard;
|
||||
disabled?: boolean;
|
||||
headerTitleKey?: string;
|
||||
/** Hide the board's own "Tasks" title row — used where the caller already
|
||||
* renders a heading for the board, to avoid a doubled-up title. */
|
||||
hideHeader?: boolean;
|
||||
@@ -78,25 +95,35 @@ interface TaskKanbanBoardProps {
|
||||
onDeleteCard?: (card: TaskBoardCard) => void;
|
||||
/** Approve/reject a card awaiting plan approval. */
|
||||
onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void;
|
||||
/** Start work on a card from a higher-level task board. */
|
||||
onWorkTask?: (card: TaskBoardCard) => void;
|
||||
workingCardId?: string | null;
|
||||
}
|
||||
|
||||
export function TaskKanbanBoard({
|
||||
board,
|
||||
disabled = false,
|
||||
headerTitleKey = 'conversations.taskKanban.title',
|
||||
hideHeader = false,
|
||||
onMove,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onDecidePlan,
|
||||
onWorkTask,
|
||||
workingCardId = null,
|
||||
}: TaskKanbanBoardProps) {
|
||||
const { t } = useT();
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
||||
const [sourceControlsOpen, setSourceControlsOpen] = useState(false);
|
||||
const selectedCard = useMemo(
|
||||
() => board.cards.find(card => card.id === selectedCardId) ?? null,
|
||||
[board.cards, selectedCardId]
|
||||
);
|
||||
const isTaskSourcesBoard = board.threadId === TASK_SOURCES_THREAD_ID;
|
||||
const hasSourceCards = board.cards.some(card => readSourceMetadata(card.sourceMetadata));
|
||||
const showSourceControls = isTaskSourcesBoard || hasSourceCards;
|
||||
|
||||
if (board.cards.length === 0) return null;
|
||||
if (board.cards.length === 0 && !isTaskSourcesBoard) return null;
|
||||
|
||||
const cardsByStatus = COLUMN_DEFS.reduce(
|
||||
(acc, column) => {
|
||||
@@ -122,13 +149,28 @@ export function TaskKanbanBoard({
|
||||
{!hideHeader && (
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('conversations.taskKanban.title')}
|
||||
{t(headerTitleKey)}
|
||||
</h4>
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{board.cards.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{showSourceControls && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={sourceControlsOpen}
|
||||
onClick={() => setSourceControlsOpen(open => !open)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[10px] font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuDatabase className="h-3 w-3" />
|
||||
{t('conversations.taskKanban.sourcesButton')}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{board.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showSourceControls && sourceControlsOpen && (
|
||||
<TaskSourceControls disabled={disabled} compact={!isTaskSourcesBoard} />
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{COLUMN_DEFS.map(column => (
|
||||
<section
|
||||
@@ -144,116 +186,18 @@ export function TaskKanbanBoard({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{cardsByStatus[column.status].map(card => (
|
||||
<article
|
||||
<TaskBoardArticle
|
||||
key={card.id}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2.5 py-2 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-xs font-medium leading-snug text-stone-800 dark:text-neutral-100">
|
||||
{card.title}
|
||||
</p>
|
||||
{card.status === 'awaiting_approval' && onDecidePlan ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title={t('chat.approval.approve')}
|
||||
disabled={disabled}
|
||||
onClick={() => onDecidePlan(card, true)}
|
||||
className="rounded-md bg-ocean-600 px-1.5 py-0.5 text-[10px] font-medium text-white transition-colors hover:bg-ocean-700 disabled:opacity-40">
|
||||
{t('chat.approval.approve')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('chat.approval.deny')}
|
||||
disabled={disabled}
|
||||
onClick={() => onDecidePlan(card, false)}
|
||||
className="rounded-md border border-stone-200 px-1.5 py-0.5 text-[10px] font-medium text-stone-600 transition-colors hover:bg-stone-100 disabled:opacity-40 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{t('chat.approval.deny')}
|
||||
</button>
|
||||
</div>
|
||||
) : onMove && isColumnStatus(card.status) ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title={t('conversations.taskKanban.moveLeft')}
|
||||
aria-label={t('conversations.taskKanban.moveLeft')}
|
||||
disabled={disabled || column.status === 'todo'}
|
||||
onClick={() => moveCard(card, -1)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
|
||||
<LuArrowLeft className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('conversations.taskKanban.moveRight')}
|
||||
aria-label={t('conversations.taskKanban.moveRight')}
|
||||
disabled={disabled || column.status === 'done'}
|
||||
onClick={() => moveCard(card, 1)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
|
||||
<LuArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{card.assignedAgent && (
|
||||
<span className="inline-flex max-w-full items-center gap-1 rounded-md bg-ocean-50 px-1.5 py-0.5 text-[10px] text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200">
|
||||
<LuBot className="h-3 w-3 flex-none" />
|
||||
<span className="truncate">{card.assignedAgent}</span>
|
||||
</span>
|
||||
)}
|
||||
{card.allowedTools && card.allowedTools.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
<LuWrench className="h-3 w-3" />
|
||||
{card.allowedTools.length}
|
||||
</span>
|
||||
)}
|
||||
{card.approvalMode && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
<LuShieldCheck className="h-3 w-3" />
|
||||
{card.approvalMode === 'required'
|
||||
? t('conversations.taskKanban.approval.requiredBadge')
|
||||
: t('conversations.taskKanban.approval.notRequiredBadge')}
|
||||
</span>
|
||||
)}
|
||||
{card.acceptanceCriteria && card.acceptanceCriteria.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-sage-50 px-1.5 py-0.5 text-[10px] text-sage-700 dark:bg-sage-500/10 dark:text-sage-200">
|
||||
<LuCircleCheck className="h-3 w-3" />
|
||||
{card.acceptanceCriteria.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{card.objective && (
|
||||
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{card.objective}
|
||||
</p>
|
||||
)}
|
||||
{card.notes && (
|
||||
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{card.notes}
|
||||
</p>
|
||||
)}
|
||||
{card.status === 'blocked' && card.blocker && (
|
||||
<p className="mt-1 break-words text-[11px] leading-snug text-coral-600">
|
||||
{card.blocker}
|
||||
</p>
|
||||
)}
|
||||
{(onUpdateCard ||
|
||||
onDeleteCard ||
|
||||
card.plan?.length ||
|
||||
card.allowedTools?.length ||
|
||||
card.acceptanceCriteria?.length ||
|
||||
card.evidence?.length ||
|
||||
card.objective ||
|
||||
card.assignedAgent ||
|
||||
card.approvalMode) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCardId(card.id)}
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
|
||||
<LuClipboardList className="h-3 w-3" />
|
||||
{t('conversations.taskKanban.briefButton')}
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
card={card}
|
||||
columnStatus={column.status}
|
||||
disabled={disabled}
|
||||
onMove={onMove ? moveCard : undefined}
|
||||
hasBriefActions={Boolean(onUpdateCard || onDeleteCard)}
|
||||
onDecidePlan={onDecidePlan}
|
||||
onWorkTask={onWorkTask}
|
||||
working={workingCardId === card.id}
|
||||
onOpenBrief={() => setSelectedCardId(card.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -272,6 +216,424 @@ export function TaskKanbanBoard({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskBoardArticle({
|
||||
card,
|
||||
columnStatus,
|
||||
disabled,
|
||||
onMove,
|
||||
hasBriefActions,
|
||||
onDecidePlan,
|
||||
onWorkTask,
|
||||
working,
|
||||
onOpenBrief,
|
||||
}: {
|
||||
card: TaskBoardCard;
|
||||
columnStatus: TaskBoardCardStatus;
|
||||
disabled: boolean;
|
||||
onMove?: (card: TaskBoardCard, direction: -1 | 1) => void;
|
||||
hasBriefActions: boolean;
|
||||
onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void;
|
||||
onWorkTask?: (card: TaskBoardCard) => void;
|
||||
working: boolean;
|
||||
onOpenBrief: () => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const source = readSourceMetadata(card.sourceMetadata);
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2.5 py-2 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-xs font-medium leading-snug text-stone-800 dark:text-neutral-100">
|
||||
{card.title}
|
||||
</p>
|
||||
{card.status === 'awaiting_approval' && onDecidePlan ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title={t('chat.approval.approve')}
|
||||
disabled={disabled}
|
||||
onClick={() => onDecidePlan(card, true)}
|
||||
className="rounded-md bg-ocean-600 px-1.5 py-0.5 text-[10px] font-medium text-white transition-colors hover:bg-ocean-700 disabled:opacity-40">
|
||||
{t('chat.approval.approve')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('chat.approval.deny')}
|
||||
disabled={disabled}
|
||||
onClick={() => onDecidePlan(card, false)}
|
||||
className="rounded-md border border-stone-200 px-1.5 py-0.5 text-[10px] font-medium text-stone-600 transition-colors hover:bg-stone-100 disabled:opacity-40 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{t('chat.approval.deny')}
|
||||
</button>
|
||||
</div>
|
||||
) : onWorkTask && card.status !== 'done' ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t('conversations.taskKanban.workTask')}
|
||||
disabled={disabled || working}
|
||||
onClick={() => onWorkTask(card)}
|
||||
className="inline-flex flex-shrink-0 items-center gap-1 rounded-md bg-ocean-600 px-1.5 py-0.5 text-[10px] font-medium text-white transition-colors hover:bg-ocean-700 disabled:opacity-40">
|
||||
<LuPlay className="h-3 w-3" />
|
||||
{working
|
||||
? t('conversations.taskKanban.startingTask')
|
||||
: t('conversations.taskKanban.workTask')}
|
||||
</button>
|
||||
) : onMove && isColumnStatus(card.status) ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title={t('conversations.taskKanban.moveLeft')}
|
||||
aria-label={t('conversations.taskKanban.moveLeft')}
|
||||
disabled={disabled || columnStatus === 'todo'}
|
||||
onClick={() => onMove(card, -1)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
|
||||
<LuArrowLeft className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('conversations.taskKanban.moveRight')}
|
||||
aria-label={t('conversations.taskKanban.moveRight')}
|
||||
disabled={disabled || columnStatus === 'done'}
|
||||
onClick={() => onMove(card, 1)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
|
||||
<LuArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{card.assignedAgent && (
|
||||
<span className="inline-flex max-w-full items-center gap-1 rounded-md bg-ocean-50 px-1.5 py-0.5 text-[10px] text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200">
|
||||
<LuBot className="h-3 w-3 flex-none" />
|
||||
<span className="truncate">{card.assignedAgent}</span>
|
||||
</span>
|
||||
)}
|
||||
{card.allowedTools && card.allowedTools.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] text-stone-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
<LuWrench className="h-3 w-3" />
|
||||
{card.allowedTools.length}
|
||||
</span>
|
||||
)}
|
||||
{source && (
|
||||
<span className="inline-flex max-w-full items-center gap-1 rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] text-sky-700 dark:bg-sky-500/10 dark:text-sky-200">
|
||||
<LuDatabase className="h-3 w-3 flex-none" />
|
||||
<span className="truncate">{sourceBadgeLabel(source, t)}</span>
|
||||
</span>
|
||||
)}
|
||||
{source?.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('conversations.taskKanban.source.openExternal')}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] text-stone-600 hover:bg-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700">
|
||||
<LuExternalLink className="h-3 w-3" />
|
||||
{t('conversations.taskKanban.source.openExternalShort')}
|
||||
</a>
|
||||
)}
|
||||
{card.approvalMode && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
<LuShieldCheck className="h-3 w-3" />
|
||||
{card.approvalMode === 'required'
|
||||
? t('conversations.taskKanban.approval.requiredBadge')
|
||||
: t('conversations.taskKanban.approval.notRequiredBadge')}
|
||||
</span>
|
||||
)}
|
||||
{card.acceptanceCriteria && card.acceptanceCriteria.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-sage-50 px-1.5 py-0.5 text-[10px] text-sage-700 dark:bg-sage-500/10 dark:text-sage-200">
|
||||
<LuCircleCheck className="h-3 w-3" />
|
||||
{card.acceptanceCriteria.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{card.objective && (
|
||||
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{card.objective}
|
||||
</p>
|
||||
)}
|
||||
{card.notes && (
|
||||
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{card.notes}
|
||||
</p>
|
||||
)}
|
||||
{card.status === 'blocked' && card.blocker && (
|
||||
<p className="mt-1 break-words text-[11px] leading-snug text-coral-600">{card.blocker}</p>
|
||||
)}
|
||||
{(hasBriefActions ||
|
||||
card.plan?.length ||
|
||||
card.allowedTools?.length ||
|
||||
card.acceptanceCriteria?.length ||
|
||||
card.evidence?.length ||
|
||||
card.objective ||
|
||||
card.assignedAgent ||
|
||||
card.approvalMode ||
|
||||
source) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBrief}
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
|
||||
<LuClipboardList className="h-3 w-3" />
|
||||
{t('conversations.taskKanban.briefButton')}
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskSourceMetadata {
|
||||
provider?: string;
|
||||
sourceId?: string;
|
||||
externalId?: string;
|
||||
url?: string;
|
||||
repo?: string;
|
||||
urgency?: number;
|
||||
}
|
||||
|
||||
function readSourceMetadata(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): TaskSourceMetadata | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const provider = readString(value.provider);
|
||||
const sourceId = readString(value.source_id) ?? readString(value.sourceId);
|
||||
const externalId = readString(value.external_id) ?? readString(value.externalId);
|
||||
const url = readString(value.url);
|
||||
const repo = readString(value.repo);
|
||||
const urgency = readNumber(value.urgency);
|
||||
if (!provider && !sourceId && !externalId && !url && !repo && urgency === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { provider, sourceId, externalId, url, repo, urgency };
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function providerLabel(provider: string | undefined, 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 ?? t('conversations.taskKanban.source.unknownProvider');
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBadgeLabel(source: TaskSourceMetadata, t: (key: string) => string): string {
|
||||
const provider = providerLabel(source.provider, t);
|
||||
if (source.repo && source.externalId) return `${provider} · ${source.repo}#${source.externalId}`;
|
||||
if (source.externalId) return `${provider} · ${source.externalId}`;
|
||||
return provider;
|
||||
}
|
||||
|
||||
function formatUrgency(
|
||||
urgency: number | undefined,
|
||||
t: (key: string) => string
|
||||
): string | undefined {
|
||||
if (urgency === undefined) return undefined;
|
||||
const percent = Math.round(Math.max(0, Math.min(1, urgency)) * 100);
|
||||
return t('conversations.taskKanban.source.urgencyValue').replace('{percent}', String(percent));
|
||||
}
|
||||
|
||||
function formatFetchNotice(outcome: FetchOutcome, t: (key: string) => string): string {
|
||||
return t('settings.taskSources.fetchResult')
|
||||
.replace('{routed}', String(outcome.routed))
|
||||
.replace('{fetched}', String(outcome.fetched));
|
||||
}
|
||||
|
||||
function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: boolean }) {
|
||||
const { t } = useT();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sources, setSources] = useState<TaskSource[]>([]);
|
||||
const [status, setStatus] = useState<TaskSourcesStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
setLoading(false);
|
||||
setError(t('conversations.taskKanban.sources.desktopOnly'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextSources, nextStatus] = await Promise.all([
|
||||
openhumanTaskSourcesList(),
|
||||
openhumanTaskSourcesStatus(),
|
||||
]);
|
||||
setSources(nextSources);
|
||||
setStatus(nextStatus);
|
||||
} catch (err) {
|
||||
setError(
|
||||
`${t('settings.taskSources.loadError')}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [load]);
|
||||
|
||||
const toggleSource = async (source: TaskSource) => {
|
||||
setBusyKey(`toggle:${source.id}`);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const updated = await openhumanTaskSourcesUpdate(source.id, { enabled: !source.enabled });
|
||||
setSources(prev => prev.map(item => (item.id === updated.id ? updated : item)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSource = async (source: TaskSource) => {
|
||||
setBusyKey(`fetch:${source.id}`);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const outcome = await openhumanTaskSourcesFetch(source.id);
|
||||
await load();
|
||||
if (outcome.error) {
|
||||
setError(outcome.error);
|
||||
} else {
|
||||
setNotice(formatFetchNotice(outcome, t));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-3 rounded-lg border border-stone-200 bg-white p-3 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h5 className="text-xs font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{t('conversations.taskKanban.sources.title')}
|
||||
</h5>
|
||||
{!compact && status && (
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{status.enabled
|
||||
? t('conversations.taskKanban.sources.statusEnabled')
|
||||
: t('settings.taskSources.disabledBanner')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="#/settings/task-sources"
|
||||
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')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.taskSources.refresh')}
|
||||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-stone-200 text-stone-500 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuRefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-md bg-coral-50 px-2 py-1.5 text-[11px] text-coral-700 dark:bg-coral-500/10 dark:text-coral-200">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="mt-2 rounded-md bg-sky-50 px-2 py-1.5 text-[11px] text-sky-700 dark:bg-sky-500/10 dark:text-sky-200">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
) : sources.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.taskSources.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{sources.map(source => (
|
||||
<li
|
||||
key={source.id}
|
||||
className="min-w-0 rounded-lg border border-stone-200 px-2.5 py-2 dark:border-neutral-800">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-stone-800 dark:text-neutral-100">
|
||||
{source.name || providerLabel(source.provider, t)}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{providerLabel(source.provider, t)}
|
||||
{source.target === 'agent_todo_proactive'
|
||||
? ` · ${t('settings.taskSources.proactive')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-none rounded-md px-1.5 py-0.5 text-[10px] ${
|
||||
source.enabled
|
||||
? 'bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-200'
|
||||
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
|
||||
}`}>
|
||||
{source.enabled
|
||||
? t('settings.taskSources.statusEnabled')
|
||||
: t('settings.taskSources.statusDisabled')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busyKey === `fetch:${source.id}`}
|
||||
onClick={() => void fetchSource(source)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuRefreshCw className="h-3 w-3" />
|
||||
{busyKey === `fetch:${source.id}`
|
||||
? t('settings.taskSources.fetching')
|
||||
: t('settings.taskSources.fetchNow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busyKey === `toggle:${source.id}`}
|
||||
onClick={() => void toggleSource(source)}
|
||||
className="rounded-md border border-stone-200 px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-50 disabled:opacity-40 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{source.enabled
|
||||
? t('settings.taskSources.disable')
|
||||
: t('settings.taskSources.enable')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskBriefDialog({
|
||||
card,
|
||||
disabled,
|
||||
@@ -286,6 +648,7 @@ function TaskBriefDialog({
|
||||
onDelete?: (card: TaskBoardCard) => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const source = readSourceMetadata(card.sourceMetadata);
|
||||
const editable = Boolean(onUpdate) && !disabled;
|
||||
const deletable = Boolean(onDelete) && !disabled;
|
||||
|
||||
@@ -349,6 +712,8 @@ function TaskBriefDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{source && <SourceBrief source={source} />}
|
||||
|
||||
{editable ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<label className="block">
|
||||
@@ -528,6 +893,72 @@ function TaskBriefDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBrief({ source }: { source: TaskSourceMetadata }) {
|
||||
const { t } = useT();
|
||||
const urgency = formatUrgency(source.urgency, t);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm dark:border-sky-500/20 dark:bg-sky-500/10">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold text-sky-800 dark:text-sky-100">
|
||||
{t('conversations.taskKanban.source.title')}
|
||||
</h4>
|
||||
{source.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
|
||||
<LuExternalLink className="h-3 w-3" />
|
||||
{t('conversations.taskKanban.source.openExternal')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<SourceBriefField
|
||||
label={t('settings.taskSources.provider')}
|
||||
value={providerLabel(source.provider, t)}
|
||||
/>
|
||||
<SourceBriefField
|
||||
label={t('conversations.taskKanban.source.sourceId')}
|
||||
value={source.sourceId}
|
||||
mono
|
||||
/>
|
||||
<SourceBriefField
|
||||
label={t('conversations.taskKanban.source.externalId')}
|
||||
value={source.externalId}
|
||||
mono
|
||||
/>
|
||||
<SourceBriefField label={t('conversations.taskKanban.source.repo')} value={source.repo} />
|
||||
<SourceBriefField label={t('conversations.taskKanban.source.urgency')} value={urgency} />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBriefField({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] font-semibold text-sky-700 dark:text-sky-200">{label}</dt>
|
||||
<dd
|
||||
className={`mt-0.5 break-words text-xs text-stone-800 dark:text-neutral-100 ${
|
||||
mono ? 'font-mono' : ''
|
||||
}`}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefInput({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -29,6 +29,12 @@ const log = debug('todosApi');
|
||||
*/
|
||||
export const USER_TASKS_THREAD_ID = 'user-tasks';
|
||||
|
||||
/**
|
||||
* Reserved board id used by the task source ingestion flow. Source-backed
|
||||
* tasks land here before they are pulled into an agent workstream.
|
||||
*/
|
||||
export const TASK_SOURCES_THREAD_ID = 'task-sources';
|
||||
|
||||
/** Wire shape returned by every `todos_*` handler (`TodosSnapshot`). */
|
||||
interface TodosSnapshotWire {
|
||||
threadId?: string | null;
|
||||
|
||||
@@ -13,6 +13,13 @@ export type TaskSourceProvider = 'github' | 'notion' | 'linear' | 'clickup';
|
||||
|
||||
export type TaskSourceTarget = 'agent_todo_proactive' | 'todo_only';
|
||||
|
||||
/** A selectable container a task source can target (e.g. a Notion database).
|
||||
* Mirrors the Rust `TaskContainer` (`{ id, title }`). */
|
||||
export interface TaskContainer {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Per-provider filter, discriminated by `provider`. Mirrors the Rust
|
||||
* `FilterSpec` (serde snake_case, tagged by `provider`). */
|
||||
export type TaskSourceFilter =
|
||||
@@ -195,6 +202,20 @@ export async function openhumanTaskSourcesPreviewFilter(
|
||||
});
|
||||
}
|
||||
|
||||
/** List the selectable containers (e.g. Notion databases) a provider exposes
|
||||
* for the given connection, so the create form can offer a picker instead of
|
||||
* a raw-id text field. */
|
||||
export async function openhumanTaskSourcesListDatabases(
|
||||
provider: TaskSourceProvider,
|
||||
connectionId?: string
|
||||
): Promise<TaskContainer[]> {
|
||||
ensureTauri();
|
||||
return await callCoreRpc<TaskContainer[]>({
|
||||
method: 'openhuman.task_sources_list_databases',
|
||||
params: { provider, connection_id: connectionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanTaskSourcesStatus(): Promise<TaskSourcesStatus> {
|
||||
ensureTauri();
|
||||
return await callCoreRpc<TaskSourcesStatus>({ method: 'openhuman.task_sources_status' });
|
||||
|
||||
@@ -31,7 +31,7 @@ use super::{ingest::ingest_task_into_memory_tree, sync};
|
||||
use crate::openhuman::memory_sync::composio::providers::sync_state::SyncState;
|
||||
use crate::openhuman::memory_sync::composio::providers::{
|
||||
first_array_str, merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask,
|
||||
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
|
||||
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter, TaskKind,
|
||||
};
|
||||
|
||||
pub(crate) const ACTION_GET_AUTHORIZED_USER: &str = "CLICKUP_GET_AUTHORIZED_USER";
|
||||
@@ -584,6 +584,7 @@ fn normalize_clickup_task(task: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
external_id,
|
||||
source_id: String::new(),
|
||||
provider: "clickup".to_string(),
|
||||
kind: TaskKind::Generic,
|
||||
title,
|
||||
body: pick_str(task, &["description", "data.description", "text_content"]),
|
||||
url: pick_str(task, &["url", "data.url"]),
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
//! "fetch-what-the-user-sees" model gmail / notion already follow.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::ingest::ingest_issue_into_memory_tree;
|
||||
use super::sync;
|
||||
use crate::openhuman::memory_sync::composio::providers::sync_state::SyncState;
|
||||
use crate::openhuman::memory_sync::composio::providers::{
|
||||
merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext,
|
||||
ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
|
||||
merge_extra, pick_str, ComposioProvider, CuratedTool, GithubFetchMode, NormalizedTask,
|
||||
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter, TaskKind,
|
||||
};
|
||||
|
||||
pub(crate) const ACTION_GET_AUTHENTICATED_USER: &str = "GITHUB_GET_THE_AUTHENTICATED_USER";
|
||||
@@ -43,6 +44,9 @@ const INITIAL_PAGE_SIZE: u32 = 100;
|
||||
/// over to the next scheduled interval.
|
||||
const MAX_PAGES: u32 = 20;
|
||||
|
||||
const GH_CLI_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const GITHUB_TASK_SEARCH_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub struct GitHubProvider;
|
||||
|
||||
impl GitHubProvider {
|
||||
@@ -419,32 +423,37 @@ impl ComposioProvider for GitHubProvider {
|
||||
tracing::debug!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
max,
|
||||
mode = ?filter.github_fetch_mode,
|
||||
query = %query,
|
||||
"[composio:github] fetch_tasks"
|
||||
);
|
||||
|
||||
let mut args = json!({
|
||||
"q": query,
|
||||
"sort": "updated",
|
||||
"order": "desc",
|
||||
"per_page": max.min(100) as u32,
|
||||
"page": 1,
|
||||
});
|
||||
merge_extra(&mut args, &filter.extra);
|
||||
|
||||
let resp = ctx
|
||||
.execute(ACTION_SEARCH_ISSUES, Some(args))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:github] {ACTION_SEARCH_ISSUES}: {e:#}"))?;
|
||||
if !resp.successful {
|
||||
return Err(format!(
|
||||
"[composio:github] {ACTION_SEARCH_ISSUES}: {}",
|
||||
resp.error.unwrap_or_else(|| "provider failure".into())
|
||||
));
|
||||
}
|
||||
// Select the data source by the user-configured fetch mode. `Auto`
|
||||
// (the default) keeps the shipped Composio path as primary and treats
|
||||
// local `gh`/REST as a true fallback — only used when the Composio
|
||||
// round-trip errors or is unavailable. `Composio` / `Local` force one
|
||||
// path. Normalization happens ONCE below regardless of source.
|
||||
let data = match filter.github_fetch_mode {
|
||||
GithubFetchMode::Composio => {
|
||||
fetch_github_tasks_composio(ctx, &query, max, &filter.extra).await?
|
||||
}
|
||||
GithubFetchMode::Local => fetch_github_tasks_local(&query, max, &filter.extra).await?,
|
||||
GithubFetchMode::Auto => {
|
||||
match fetch_github_tasks_composio(ctx, &query, max, &filter.extra).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
error = %e,
|
||||
"[composio:github] Composio fetch unavailable; falling back to local gh/REST"
|
||||
);
|
||||
fetch_github_tasks_local(&query, max, &filter.extra).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut out: Vec<NormalizedTask> = Vec::new();
|
||||
for issue in sync::extract_issues(&resp.data) {
|
||||
for issue in sync::extract_issues(&data) {
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
@@ -457,12 +466,239 @@ impl ComposioProvider for GitHubProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch GitHub issues/PRs through the connected Composio account.
|
||||
///
|
||||
/// This is the original shipped `fetch_tasks` data path: it builds the
|
||||
/// `GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS` search args, merges any advanced
|
||||
/// `extra` query fragment, fires the action through the mode-aware
|
||||
/// `ctx.execute` chokepoint, and returns the raw response `data` for the
|
||||
/// shared normalization loop. Kept as a sibling of
|
||||
/// [`fetch_github_tasks_local`] so `fetch_tasks` can select between them by
|
||||
/// [`GithubFetchMode`].
|
||||
async fn fetch_github_tasks_composio(
|
||||
ctx: &ProviderContext,
|
||||
query: &str,
|
||||
max: usize,
|
||||
extra: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let mut args = json!({
|
||||
"q": query,
|
||||
"sort": "updated",
|
||||
"order": "desc",
|
||||
"per_page": max.min(100) as u32,
|
||||
"page": 1,
|
||||
});
|
||||
merge_extra(&mut args, extra);
|
||||
|
||||
let resp = ctx
|
||||
.execute(ACTION_SEARCH_ISSUES, Some(args))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:github] {ACTION_SEARCH_ISSUES}: {e:#}"))?;
|
||||
if !resp.successful {
|
||||
return Err(format!(
|
||||
"[composio:github] {ACTION_SEARCH_ISSUES}: {}",
|
||||
resp.error.unwrap_or_else(|| "provider failure".into())
|
||||
));
|
||||
}
|
||||
Ok(resp.data)
|
||||
}
|
||||
|
||||
async fn fetch_github_tasks_local(query: &str, max: usize, extra: &Value) -> Result<Value, String> {
|
||||
let mut args = json!({
|
||||
"q": query,
|
||||
"sort": "updated",
|
||||
"order": "desc",
|
||||
"per_page": max.min(100) as u32,
|
||||
"page": 1,
|
||||
});
|
||||
merge_extra(&mut args, extra);
|
||||
expand_me_in_github_search_args(&mut args).await;
|
||||
|
||||
match gh_search_issues(&args).await {
|
||||
Ok(data) => Ok(data),
|
||||
Err(gh_err) => {
|
||||
tracing::debug!(
|
||||
error = %gh_err,
|
||||
"[task_sources:github] gh api search failed, falling back to REST"
|
||||
);
|
||||
rest_search_issues(&args).await.map_err(|rest_err| {
|
||||
format!("[task_sources:github] local GitHub search failed: gh: {gh_err}; REST: {rest_err}")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn gh_search_issues(args: &Value) -> Result<Value, String> {
|
||||
let mut cmd = tokio::process::Command::new("gh");
|
||||
cmd.arg("api")
|
||||
.arg("--method")
|
||||
.arg("GET")
|
||||
.arg("search/issues");
|
||||
for (key, value) in github_search_arg_pairs(args)? {
|
||||
cmd.arg("-f").arg(format!("{key}={value}"));
|
||||
}
|
||||
|
||||
let output = tokio::time::timeout(GH_CLI_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| format!("gh command timed out after {}s", GH_CLI_TIMEOUT.as_secs()))?
|
||||
.map_err(|e| format!("gh command failed: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("gh exited {}: {stderr}", output.status));
|
||||
}
|
||||
|
||||
let stdout =
|
||||
String::from_utf8(output.stdout).map_err(|e| format!("gh output not utf8: {e}"))?;
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("parse gh search response: {e}"))
|
||||
}
|
||||
|
||||
async fn rest_search_issues(args: &Value) -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(GITHUB_TASK_SEARCH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build GitHub client: {e}"))?;
|
||||
|
||||
let mut request = client
|
||||
.get("https://api.github.com/search/issues")
|
||||
.header("User-Agent", "openhuman")
|
||||
.header("Accept", "application/vnd.github+json");
|
||||
|
||||
if let Some(token) = github_env_token() {
|
||||
request = request.header("Authorization", format!("Bearer {token}"));
|
||||
}
|
||||
|
||||
let pairs = github_search_arg_pairs(args)?;
|
||||
let resp = request
|
||||
.query(&pairs)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API returned {status}: {body}"));
|
||||
}
|
||||
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("parse GitHub API response: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn github_env_token() -> Option<String> {
|
||||
std::env::var("GH_TOKEN")
|
||||
.or_else(|_| std::env::var("GITHUB_TOKEN"))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
async fn expand_me_in_github_search_args(args: &mut Value) {
|
||||
let Some(query) = args.get("q").and_then(Value::as_str).map(str::to_string) else {
|
||||
return;
|
||||
};
|
||||
if !query.contains("@me") {
|
||||
return;
|
||||
}
|
||||
let Some(login) = resolve_github_login().await else {
|
||||
return;
|
||||
};
|
||||
if let Some(obj) = args.as_object_mut() {
|
||||
obj.insert("q".to_string(), Value::String(query.replace("@me", &login)));
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_github_login() -> Option<String> {
|
||||
if let Some(login) = resolve_github_login_with_gh().await {
|
||||
return Some(login);
|
||||
}
|
||||
resolve_github_login_with_rest().await
|
||||
}
|
||||
|
||||
async fn resolve_github_login_with_gh() -> Option<String> {
|
||||
let output = tokio::time::timeout(
|
||||
GH_CLI_TIMEOUT,
|
||||
tokio::process::Command::new("gh")
|
||||
.arg("api")
|
||||
.arg("user")
|
||||
.arg("--jq")
|
||||
.arg(".login")
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
async fn resolve_github_login_with_rest() -> Option<String> {
|
||||
let token = github_env_token()?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(GITHUB_TASK_SEARCH_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
let resp = client
|
||||
.get("https://api.github.com/user")
|
||||
.header("User-Agent", "openhuman")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("login")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn github_search_arg_pairs(args: &Value) -> Result<Vec<(String, String)>, String> {
|
||||
let obj = args
|
||||
.as_object()
|
||||
.ok_or_else(|| "GitHub search args must be a JSON object".to_string())?;
|
||||
let mut out = Vec::with_capacity(obj.len());
|
||||
for (key, value) in obj {
|
||||
let rendered = match value {
|
||||
Value::String(s) => s.trim().to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => continue,
|
||||
other => other.to_string(),
|
||||
};
|
||||
if !rendered.is_empty() {
|
||||
out.push((key.clone(), rendered));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build a GitHub Search-Issues query from a [`TaskFetchFilter`].
|
||||
///
|
||||
/// Combines repo / label / state / assignee qualifiers. When the filter
|
||||
/// carries no constraints at all we fall back to `involves:@me` so a
|
||||
/// task source never accidentally pulls the entire public issue
|
||||
/// universe.
|
||||
/// carries no scoping constraints at all we fall back to `involves:@me` so a
|
||||
/// task source never accidentally pulls the entire public issue universe.
|
||||
///
|
||||
/// State bias: when the filter sets no explicit `state`, we append `is:open`
|
||||
/// so closed issues and merged/closed PRs aren't fetched in the first place
|
||||
/// (the unconditional skip in `normalize_github_issue` is the hard guarantee;
|
||||
/// this is the fetch-side optimization). An explicit `state` is respected and
|
||||
/// `is:open` is not double-added.
|
||||
pub(super) fn build_fetch_query(filter: &TaskFetchFilter) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(repo) = filter
|
||||
@@ -470,6 +706,7 @@ pub(super) fn build_fetch_query(filter: &TaskFetchFilter) -> String {
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(normalize_github_repo_filter)
|
||||
{
|
||||
parts.push(format!("repo:{repo}"));
|
||||
}
|
||||
@@ -481,36 +718,94 @@ pub(super) fn build_fetch_query(filter: &TaskFetchFilter) -> String {
|
||||
{
|
||||
parts.push(format!("label:\"{label}\""));
|
||||
}
|
||||
if let Some(state) = filter
|
||||
.state
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
parts.push(format!("state:{state}"));
|
||||
}
|
||||
if filter.assignee_is_me {
|
||||
parts.push("assignee:@me".to_string());
|
||||
}
|
||||
// If no repo/label/assignee scoping was supplied, fall back to
|
||||
// `involves:@me` (plus the open bias) rather than the whole issue universe.
|
||||
if parts.is_empty() {
|
||||
return "involves:@me".to_string();
|
||||
parts.push("involves:@me".to_string());
|
||||
}
|
||||
let explicit_state = filter
|
||||
.state
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match explicit_state {
|
||||
// Caller pinned a state — respect it verbatim, don't add `is:open`.
|
||||
Some(state) => parts.push(format!("state:{state}")),
|
||||
// No explicit state — bias the fetch toward open items.
|
||||
None => parts.push("is:open".to_string()),
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn normalize_github_repo_filter(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
let without_scheme = trimmed
|
||||
.strip_prefix("https://github.com/")
|
||||
.or_else(|| trimmed.strip_prefix("http://github.com/"))
|
||||
.or_else(|| trimmed.strip_prefix("git@github.com:"))
|
||||
.unwrap_or(trimmed);
|
||||
let cleaned = without_scheme
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches(".git");
|
||||
let mut parts = cleaned.split('/').filter(|part| !part.is_empty());
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(owner), Some(repo)) => {
|
||||
let repo = repo.trim_end_matches(".git");
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{owner}/{repo}")
|
||||
}
|
||||
}
|
||||
_ => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a raw GitHub issue/PR payload into a [`NormalizedTask`].
|
||||
fn normalize_github_issue(issue: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
///
|
||||
/// GitHub's search-issues-and-PRs endpoint returns both shapes; a hit is a
|
||||
/// pull request iff it carries a `pull_request` object. We tag the kind here
|
||||
/// so enrichment can phrase the objective as "review" vs "resolve".
|
||||
///
|
||||
/// Returns `None` when the item's state is `"closed"` — a merged/closed PR
|
||||
/// and a closed issue both report `state == "closed"`, and there is no point
|
||||
/// ingesting work that is already done. This skip is unconditional (it does
|
||||
/// not depend on the fetch query), so even if a `closed` item slips through
|
||||
/// the query bias it is dropped here.
|
||||
pub(super) fn normalize_github_issue(issue: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
let external_id = sync::extract_issue_id(issue)?;
|
||||
let status = pick_str(issue, &["state", "data.state"]);
|
||||
if status
|
||||
.as_deref()
|
||||
.map(|s| s.eq_ignore_ascii_case("closed"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
tracing::debug!(
|
||||
external_id = %external_id,
|
||||
"[composio:github] normalize_github_issue: skipping closed item (merged PR / closed issue)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let title =
|
||||
sync::extract_issue_title(issue).unwrap_or_else(|| format!("GitHub issue {external_id}"));
|
||||
let kind = if is_pull_request(issue) {
|
||||
TaskKind::PullRequest
|
||||
} else {
|
||||
TaskKind::Issue
|
||||
};
|
||||
Some(NormalizedTask {
|
||||
external_id,
|
||||
source_id: String::new(),
|
||||
provider: "github".to_string(),
|
||||
kind,
|
||||
title,
|
||||
body: pick_str(issue, &["body", "data.body"]),
|
||||
url: pick_str(issue, &["html_url", "data.html_url"]),
|
||||
status: pick_str(issue, &["state", "data.state"]),
|
||||
status,
|
||||
assignee: pick_str(issue, &["assignee.login", "data.assignee.login"]),
|
||||
due: None,
|
||||
labels: extract_github_labels(issue),
|
||||
@@ -520,6 +815,16 @@ fn normalize_github_issue(issue: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A GitHub search hit is a pull request iff it carries a non-null
|
||||
/// `pull_request` object (issues never do). Tolerant of the Composio `data`
|
||||
/// wrapper.
|
||||
fn is_pull_request(issue: &serde_json::Value) -> bool {
|
||||
let pr = issue
|
||||
.get("pull_request")
|
||||
.or_else(|| issue.get("data").and_then(|d| d.get("pull_request")));
|
||||
matches!(pr, Some(v) if !v.is_null())
|
||||
}
|
||||
|
||||
/// Extract label names from a GitHub issue payload (`labels` is an array
|
||||
/// of `{ name }` objects). Tolerant of the Composio `data` wrapper.
|
||||
fn extract_github_labels(issue: &serde_json::Value) -> Vec<String> {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Unit tests for the GitHub Composio provider.
|
||||
|
||||
use super::provider::{build_search_query, ACTION_GET_AUTHENTICATED_USER, ACTION_SEARCH_ISSUES};
|
||||
use super::provider::github_env_token;
|
||||
use super::provider::{
|
||||
build_fetch_query, build_search_query, github_search_arg_pairs, normalize_github_issue,
|
||||
normalize_github_repo_filter, ACTION_GET_AUTHENTICATED_USER, ACTION_SEARCH_ISSUES,
|
||||
};
|
||||
use super::sync::{
|
||||
extract_issue_id, extract_issue_title, extract_issue_updated_at, extract_issues,
|
||||
extract_user_login,
|
||||
@@ -8,6 +12,9 @@ use super::sync::{
|
||||
use super::tools::GITHUB_CURATED;
|
||||
use super::GitHubProvider;
|
||||
use crate::openhuman::memory_sync::composio::providers::ComposioProvider;
|
||||
use crate::openhuman::memory_sync::composio::providers::{
|
||||
GithubFetchMode, TaskFetchFilter, TaskKind,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ── extract_issues ───────────────────────────────────────────────────────────
|
||||
@@ -233,6 +240,103 @@ fn build_search_query_interpolates_login_verbatim() {
|
||||
assert!(query.contains("updated:>2026-01-02T03:04:05Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fetch_query_scopes_repo_labels_state_and_assignee() {
|
||||
let query = build_fetch_query(&TaskFetchFilter {
|
||||
repo: Some("tinyhumansai/openhuman".to_string()),
|
||||
labels: vec!["bug".to_string(), "agent harness".to_string()],
|
||||
state: Some("open".to_string()),
|
||||
assignee_is_me: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
query,
|
||||
"repo:tinyhumansai/openhuman label:\"bug\" label:\"agent harness\" assignee:@me state:open"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fetch_query_normalizes_github_repo_urls() {
|
||||
let query = build_fetch_query(&TaskFetchFilter {
|
||||
repo: Some("https://github.com/tinyhumansai/openhuman/pull/3267".to_string()),
|
||||
state: Some("open".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(query, "repo:tinyhumansai/openhuman state:open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_github_repo_filter_accepts_common_repo_inputs() {
|
||||
assert_eq!(
|
||||
normalize_github_repo_filter("tinyhumansai/openhuman"),
|
||||
"tinyhumansai/openhuman"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_github_repo_filter("https://github.com/tinyhumansai/openhuman.git"),
|
||||
"tinyhumansai/openhuman"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_github_repo_filter("git@github.com:tinyhumansai/openhuman.git"),
|
||||
"tinyhumansai/openhuman"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fetch_query_falls_back_to_involves_me_when_unscoped() {
|
||||
// No scoping and no explicit state: fall back to `involves:@me` and bias
|
||||
// toward open items so closed issues / merged PRs aren't even fetched.
|
||||
assert_eq!(
|
||||
build_fetch_query(&TaskFetchFilter::default()),
|
||||
"involves:@me is:open"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fetch_query_appends_is_open_when_no_explicit_state() {
|
||||
// Scoped by repo but no explicit state — `is:open` is appended.
|
||||
let query = build_fetch_query(&TaskFetchFilter {
|
||||
repo: Some("tinyhumansai/openhuman".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(query, "repo:tinyhumansai/openhuman is:open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fetch_query_respects_explicit_state_without_double_open() {
|
||||
// Explicit `state` is respected verbatim and `is:open` is NOT added.
|
||||
let query = build_fetch_query(&TaskFetchFilter {
|
||||
repo: Some("tinyhumansai/openhuman".to_string()),
|
||||
state: Some("closed".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(query, "repo:tinyhumansai/openhuman state:closed");
|
||||
assert!(!query.contains("is:open"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_arg_pairs_render_cli_and_rest_params() {
|
||||
let args = json!({
|
||||
"q": "repo:tinyhumansai/openhuman state:open",
|
||||
"sort": "updated",
|
||||
"order": "desc",
|
||||
"per_page": 25,
|
||||
"page": 1,
|
||||
"include_prs": true,
|
||||
"skip": null
|
||||
});
|
||||
|
||||
let pairs = github_search_arg_pairs(&args).expect("pairs");
|
||||
assert!(pairs.contains(&(
|
||||
"q".to_string(),
|
||||
"repo:tinyhumansai/openhuman state:open".to_string()
|
||||
)));
|
||||
assert!(pairs.contains(&("per_page".to_string(), "25".to_string())));
|
||||
assert!(pairs.contains(&("include_prs".to_string(), "true".to_string())));
|
||||
assert!(!pairs.iter().any(|(key, _)| key == "skip"));
|
||||
}
|
||||
|
||||
// ── slug regression tests (#2768) ───────────────────────────────────────────
|
||||
//
|
||||
// Guard the current Composio action slug values used by the GitHub provider.
|
||||
@@ -318,3 +422,234 @@ fn curated_list_contains_current_write_slugs() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GithubFetchMode (#3279) ─────────────────────────────────────────────────
|
||||
//
|
||||
// The fetch-mode selector makes the local `gh`/REST path a *true fallback*
|
||||
// (default `Auto`) instead of a hard Composio replacement. These tests pin the
|
||||
// default and the serde wire contract the UI persists into a source's
|
||||
// `FilterSpec::Github { fetch_mode }`.
|
||||
|
||||
#[test]
|
||||
fn github_fetch_mode_defaults_to_auto() {
|
||||
// `Auto` must be the default so shipped Composio users keep working and
|
||||
// local/dev setups still get the fallback — neither side regresses.
|
||||
assert_eq!(GithubFetchMode::default(), GithubFetchMode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_fetch_filter_default_uses_auto_fetch_mode() {
|
||||
// A filter built with no explicit mode (the common path) carries `Auto`.
|
||||
let filter = TaskFetchFilter::default();
|
||||
assert_eq!(filter.github_fetch_mode, GithubFetchMode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_fetch_mode_serializes_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(GithubFetchMode::Auto).expect("ser auto"),
|
||||
json!("auto")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GithubFetchMode::Composio).expect("ser composio"),
|
||||
json!("composio")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GithubFetchMode::Local).expect("ser local"),
|
||||
json!("local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_fetch_mode_deserializes_each_variant() {
|
||||
let auto: GithubFetchMode = serde_json::from_value(json!("auto")).expect("de auto");
|
||||
let composio: GithubFetchMode = serde_json::from_value(json!("composio")).expect("de composio");
|
||||
let local: GithubFetchMode = serde_json::from_value(json!("local")).expect("de local");
|
||||
assert_eq!(auto, GithubFetchMode::Auto);
|
||||
assert_eq!(composio, GithubFetchMode::Composio);
|
||||
assert_eq!(local, GithubFetchMode::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_fetch_mode_round_trips_through_json() {
|
||||
for mode in [
|
||||
GithubFetchMode::Auto,
|
||||
GithubFetchMode::Composio,
|
||||
GithubFetchMode::Local,
|
||||
] {
|
||||
let json = serde_json::to_string(&mode).expect("ser");
|
||||
let back: GithubFetchMode = serde_json::from_str(&json).expect("de");
|
||||
assert_eq!(back, mode, "round-trip must preserve {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_fetch_mode_rejects_unknown_variant() {
|
||||
let parsed: Result<GithubFetchMode, _> = serde_json::from_value(json!("remote"));
|
||||
assert!(parsed.is_err(), "unknown mode strings must fail to parse");
|
||||
}
|
||||
|
||||
// ── github_search_arg_pairs edge cases (#3279) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn github_search_arg_pairs_skips_null_and_empty_string_values() {
|
||||
// Null values are dropped entirely; whitespace-only / empty strings are
|
||||
// trimmed to empty and also dropped, so they never reach the gh CLI / REST
|
||||
// query as blank params.
|
||||
let args = json!({
|
||||
"q": "involves:@me",
|
||||
"empty": "",
|
||||
"blank": " ",
|
||||
"missing": null,
|
||||
"page": 1,
|
||||
});
|
||||
let pairs = github_search_arg_pairs(&args).expect("pairs");
|
||||
assert!(pairs.contains(&("q".to_string(), "involves:@me".to_string())));
|
||||
assert!(pairs.contains(&("page".to_string(), "1".to_string())));
|
||||
assert!(!pairs.iter().any(|(k, _)| k == "empty"));
|
||||
assert!(!pairs.iter().any(|(k, _)| k == "blank"));
|
||||
assert!(!pairs.iter().any(|(k, _)| k == "missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_arg_pairs_errors_when_not_an_object() {
|
||||
// A non-object value (array, scalar) is a programmer error — surface it
|
||||
// rather than silently producing an empty arg set.
|
||||
let err = github_search_arg_pairs(&json!(["not", "an", "object"]))
|
||||
.expect_err("array args must error");
|
||||
assert!(err.contains("JSON object"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── github_env_token (#3279) ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn github_env_token_reads_env_and_is_none_when_unset() {
|
||||
// Env-mutation test: this whole suite is the only reader of GH_TOKEN /
|
||||
// GITHUB_TOKEN, but cargo runs tests in parallel threads sharing the
|
||||
// process env. Hold a process-wide lock so concurrent token reads don't
|
||||
// race, and restore the original values on exit.
|
||||
use std::sync::Mutex;
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let prev_gh = std::env::var("GH_TOKEN").ok();
|
||||
let prev_github = std::env::var("GITHUB_TOKEN").ok();
|
||||
|
||||
// Neither set → None.
|
||||
std::env::remove_var("GH_TOKEN");
|
||||
std::env::remove_var("GITHUB_TOKEN");
|
||||
assert_eq!(github_env_token(), None, "no token vars → None");
|
||||
|
||||
// GH_TOKEN takes precedence; surrounding whitespace is trimmed.
|
||||
std::env::set_var("GH_TOKEN", " gh-pat-123 ");
|
||||
assert_eq!(github_env_token().as_deref(), Some("gh-pat-123"));
|
||||
|
||||
// Falls back to GITHUB_TOKEN when GH_TOKEN is absent.
|
||||
std::env::remove_var("GH_TOKEN");
|
||||
std::env::set_var("GITHUB_TOKEN", "github-pat-456");
|
||||
assert_eq!(github_env_token().as_deref(), Some("github-pat-456"));
|
||||
|
||||
// A blank token is treated as unset.
|
||||
std::env::set_var("GH_TOKEN", " ");
|
||||
std::env::remove_var("GITHUB_TOKEN");
|
||||
assert_eq!(github_env_token(), None, "blank token → None");
|
||||
|
||||
// Restore original env.
|
||||
match prev_gh {
|
||||
Some(v) => std::env::set_var("GH_TOKEN", v),
|
||||
None => std::env::remove_var("GH_TOKEN"),
|
||||
}
|
||||
match prev_github {
|
||||
Some(v) => std::env::set_var("GITHUB_TOKEN", v),
|
||||
None => std::env::remove_var("GITHUB_TOKEN"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── issue vs pull-request kind detection ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_tags_pull_request_when_pull_request_object_present() {
|
||||
// GitHub's issues-and-PRs search marks a PR hit with a `pull_request` object.
|
||||
let pr = json!({
|
||||
"id": 42,
|
||||
"title": "Add retry to fetch",
|
||||
"state": "open",
|
||||
"html_url": "https://github.com/o/r/pull/42",
|
||||
"pull_request": { "url": "https://api.github.com/repos/o/r/pulls/42" }
|
||||
});
|
||||
let nt = normalize_github_issue(&pr).expect("normalizes");
|
||||
assert_eq!(nt.kind, TaskKind::PullRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_tags_issue_when_no_pull_request_object() {
|
||||
let issue = json!({
|
||||
"id": 7,
|
||||
"title": "Login throws on empty password",
|
||||
"state": "open",
|
||||
"html_url": "https://github.com/o/r/issues/7"
|
||||
});
|
||||
let nt = normalize_github_issue(&issue).expect("normalizes");
|
||||
assert_eq!(nt.kind, TaskKind::Issue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_tags_issue_when_pull_request_is_null() {
|
||||
// The REST issue payload carries `pull_request: null` for plain issues.
|
||||
let issue = json!({
|
||||
"id": 8,
|
||||
"title": "Docs typo",
|
||||
"state": "open",
|
||||
"html_url": "https://github.com/o/r/issues/8",
|
||||
"pull_request": null
|
||||
});
|
||||
let nt = normalize_github_issue(&issue).expect("normalizes");
|
||||
assert_eq!(nt.kind, TaskKind::Issue);
|
||||
}
|
||||
|
||||
// ── skip merged PRs / closed issues ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_skips_closed_issue() {
|
||||
// A closed issue is already-done work — drop it.
|
||||
let issue = json!({
|
||||
"id": 100,
|
||||
"title": "Old bug",
|
||||
"state": "closed",
|
||||
"html_url": "https://github.com/o/r/issues/100"
|
||||
});
|
||||
assert!(
|
||||
normalize_github_issue(&issue).is_none(),
|
||||
"closed issue must be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_skips_merged_or_closed_pull_request() {
|
||||
// A merged/closed PR also reports `state == "closed"` — drop it too.
|
||||
let pr = json!({
|
||||
"id": 101,
|
||||
"title": "Shipped feature",
|
||||
"state": "closed",
|
||||
"html_url": "https://github.com/o/r/pull/101",
|
||||
"pull_request": { "url": "https://api.github.com/repos/o/r/pulls/101" }
|
||||
});
|
||||
assert!(
|
||||
normalize_github_issue(&pr).is_none(),
|
||||
"merged/closed PR must be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_open_item() {
|
||||
// An open item is kept and tagged with its kind.
|
||||
let issue = json!({
|
||||
"id": 102,
|
||||
"title": "Active work",
|
||||
"state": "open",
|
||||
"html_url": "https://github.com/o/r/issues/102"
|
||||
});
|
||||
let nt = normalize_github_issue(&issue).expect("open item is kept");
|
||||
assert_eq!(nt.kind, TaskKind::Issue);
|
||||
assert_eq!(nt.status.as_deref(), Some("open"));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use super::{ingest::ingest_issue_into_memory_tree, sync};
|
||||
use crate::openhuman::memory_sync::composio::providers::sync_state::{extract_item_id, SyncState};
|
||||
use crate::openhuman::memory_sync::composio::providers::{
|
||||
merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask, ProviderContext,
|
||||
ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
|
||||
ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter, TaskKind,
|
||||
};
|
||||
|
||||
const ACTION_LIST_USERS: &str = "LINEAR_LIST_LINEAR_USERS";
|
||||
@@ -479,6 +479,7 @@ fn normalize_linear_issue(issue: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
external_id,
|
||||
source_id: String::new(),
|
||||
provider: "linear".to_string(),
|
||||
kind: TaskKind::Generic,
|
||||
title,
|
||||
body: pick_str(issue, &["description", "data.description"]),
|
||||
url: pick_str(issue, &["url", "data.url"]),
|
||||
|
||||
@@ -284,8 +284,8 @@ pub use scope_lookup::{curated_scope_for, toolkit_has_scope};
|
||||
pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope};
|
||||
pub use traits::ComposioProvider;
|
||||
pub use types::{
|
||||
ComposioUsage, ComposioUsageHandle, NormalizedTask, ProviderContext, ProviderUserProfile,
|
||||
SyncOutcome, SyncReason, TaskFetchFilter,
|
||||
ComposioUsage, ComposioUsageHandle, GithubFetchMode, NormalizedTask, ProviderContext,
|
||||
ProviderUserProfile, SyncOutcome, SyncReason, TaskContainer, TaskFetchFilter, TaskKind,
|
||||
};
|
||||
pub use user_scopes::{load_or_default as load_user_scope_or_default, UserScopePref};
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ use super::sync;
|
||||
use crate::openhuman::memory_sync::composio::providers::sync_state::{extract_item_id, SyncState};
|
||||
use crate::openhuman::memory_sync::composio::providers::{
|
||||
first_array_str, merge_extra, pick_str, ComposioProvider, CuratedTool, NormalizedTask,
|
||||
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
|
||||
ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskContainer, TaskFetchFilter,
|
||||
TaskKind,
|
||||
};
|
||||
|
||||
pub(crate) const ACTION_GET_ABOUT_ME: &str = "NOTION_GET_ABOUT_ME";
|
||||
pub(crate) const ACTION_FETCH_DATA: &str = "NOTION_FETCH_DATA";
|
||||
pub(crate) const ACTION_QUERY_DATABASE: &str = "NOTION_QUERY_DATABASE";
|
||||
pub(crate) const ACTION_SEARCH_NOTION_PAGE: &str = "NOTION_SEARCH_NOTION_PAGE";
|
||||
|
||||
/// Page size per API call.
|
||||
const PAGE_SIZE: u32 = 25;
|
||||
@@ -473,6 +475,53 @@ impl ComposioProvider for NotionProvider {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// List the Notion databases (tables) the connected integration can see,
|
||||
/// via `NOTION_SEARCH_NOTION_PAGE` filtered to database objects, so the
|
||||
/// task-source UI can offer a picker for `database_id`. Only databases the
|
||||
/// integration has been *shared with* in Notion are returned.
|
||||
async fn list_databases(&self, ctx: &ProviderContext) -> Result<Vec<TaskContainer>, String> {
|
||||
tracing::debug!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
"[composio:notion] list_databases via {ACTION_SEARCH_NOTION_PAGE}"
|
||||
);
|
||||
// Composio's NOTION_SEARCH_NOTION_PAGE *flattens* Notion's native
|
||||
// `filter: { value, property }` into top-level `filter_value` /
|
||||
// `filter_property` params and silently drops the nested form (which
|
||||
// returned only pages). We send the flat params here; the nested
|
||||
// `filter` is kept too as a belt-and-braces hint for any variant that
|
||||
// honours it, and the parser still drops any stray `page` items.
|
||||
let args = json!({
|
||||
"query": "",
|
||||
"filter_value": "database",
|
||||
"filter_property": "object",
|
||||
"filter": { "value": "database", "property": "object" },
|
||||
"page_size": 100,
|
||||
});
|
||||
let resp = ctx
|
||||
.execute(ACTION_SEARCH_NOTION_PAGE, Some(args))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:notion] {ACTION_SEARCH_NOTION_PAGE}: {e:#}"))?;
|
||||
if !resp.successful {
|
||||
return Err(format!(
|
||||
"[composio:notion] {ACTION_SEARCH_NOTION_PAGE}: {}",
|
||||
resp.error.unwrap_or_else(|| "provider failure".into())
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
successful = resp.successful,
|
||||
data_is_array = resp.data.is_array(),
|
||||
data_keys = ?resp.data.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()),
|
||||
"[composio:notion] list_databases raw response shape"
|
||||
);
|
||||
let out = parse_database_results(&resp.data);
|
||||
tracing::info!(
|
||||
count = out.len(),
|
||||
"[composio:notion] list_databases complete"
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn on_trigger(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
@@ -508,6 +557,7 @@ fn normalize_notion_page(page: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
external_id,
|
||||
source_id: String::new(),
|
||||
provider: "notion".to_string(),
|
||||
kind: TaskKind::Generic,
|
||||
title,
|
||||
body: None,
|
||||
url: pick_str(page, &["url", "data.url"]),
|
||||
@@ -546,3 +596,63 @@ fn normalize_notion_page(page: &serde_json::Value) -> Option<NormalizedTask> {
|
||||
raw: page.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a `NOTION_SEARCH_NOTION_PAGE` response into the database containers
|
||||
/// the UI picker needs.
|
||||
///
|
||||
/// We send a server-side `object: database` filter, so the response is
|
||||
/// already scoped — we therefore *trust* it and only drop items explicitly
|
||||
/// typed as `page`. This is intentional: Composio's response items don't
|
||||
/// always carry a top-level `object` field, and an over-strict
|
||||
/// "keep only object==database" check silently dropped every database.
|
||||
/// Pure (no I/O) so it is unit-testable.
|
||||
pub(super) fn parse_database_results(data: &serde_json::Value) -> Vec<TaskContainer> {
|
||||
let results = sync::extract_results(data);
|
||||
let mut kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
let mut out: Vec<TaskContainer> = Vec::new();
|
||||
for item in &results {
|
||||
let object = pick_str(item, &["object", "data.object"]);
|
||||
*kinds
|
||||
.entry(object.clone().unwrap_or_else(|| "<none>".to_string()))
|
||||
.or_default() += 1;
|
||||
// Trust the server-side database filter: keep databases / data_sources
|
||||
// *and* objectless items; only drop items explicitly typed as pages.
|
||||
if object.as_deref() == Some("page") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = extract_item_id(item, PAGE_ID_PATHS) else {
|
||||
continue;
|
||||
};
|
||||
let title = extract_database_title(item).unwrap_or_else(|| format!("Notion database {id}"));
|
||||
out.push(TaskContainer { id, title });
|
||||
}
|
||||
tracing::info!(
|
||||
raw = results.len(),
|
||||
kept = out.len(),
|
||||
object_kinds = ?kinds,
|
||||
"[composio:notion] parse_database_results"
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract a Notion database's display title from its top-level `title`
|
||||
/// rich-text array (`title[].plain_text`), tolerant of the Composio `data`
|
||||
/// wrapper. Returns `None` for an untitled / shapeless database.
|
||||
fn extract_database_title(db: &serde_json::Value) -> Option<String> {
|
||||
let arr = db
|
||||
.get("title")
|
||||
.or_else(|| db.get("data").and_then(|d| d.get("title")))
|
||||
.and_then(|v| v.as_array())?;
|
||||
let text: String = arr
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
t.get("plain_text").and_then(|p| p.as_str()).or_else(|| {
|
||||
t.get("text")
|
||||
.and_then(|x| x.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let text = text.trim();
|
||||
(!text.is_empty()).then(|| text.to_string())
|
||||
}
|
||||
|
||||
@@ -71,3 +71,49 @@ fn default_impl_matches_new() {
|
||||
let _a = NotionProvider::new();
|
||||
let _b = NotionProvider::default();
|
||||
}
|
||||
|
||||
// ── parse_database_results (list_databases parser) ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_database_results_keeps_databases_and_extracts_title() {
|
||||
use super::provider::parse_database_results;
|
||||
let data = json!({
|
||||
"results": [
|
||||
{
|
||||
"object": "database",
|
||||
"id": "db-1",
|
||||
"title": [{ "plain_text": "Engineering " }, { "plain_text": "Tasks" }]
|
||||
},
|
||||
// A page hit must be filtered out — list_databases is databases only.
|
||||
{ "object": "page", "id": "pg-9", "title": [{ "plain_text": "Some page" }] },
|
||||
// Newest API exposes databases as `data_source`.
|
||||
{ "object": "data_source", "id": "db-2", "title": [{ "plain_text": "Roadmap" }] },
|
||||
// Untitled database falls back to a synthesized label.
|
||||
{ "object": "database", "id": "db-3", "title": [] }
|
||||
]
|
||||
});
|
||||
let dbs = parse_database_results(&data);
|
||||
assert_eq!(
|
||||
dbs.len(),
|
||||
3,
|
||||
"two named databases + one data_source, page dropped"
|
||||
);
|
||||
assert_eq!(dbs[0].id, "db-1");
|
||||
assert_eq!(dbs[0].title, "Engineering Tasks");
|
||||
assert_eq!(dbs[1].id, "db-2");
|
||||
assert_eq!(dbs[1].title, "Roadmap");
|
||||
assert_eq!(dbs[2].title, "Notion database db-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_database_results_handles_data_wrapper_and_empty() {
|
||||
use super::provider::parse_database_results;
|
||||
let wrapped = json!({ "data": { "results": [
|
||||
{ "object": "database", "id": "x", "title": [{ "plain_text": "Wrapped" }] }
|
||||
] } });
|
||||
let dbs = parse_database_results(&wrapped);
|
||||
assert_eq!(dbs.len(), 1);
|
||||
assert_eq!(dbs[0].title, "Wrapped");
|
||||
|
||||
assert!(parse_database_results(&json!({ "results": [] })).is_empty());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ use async_trait::async_trait;
|
||||
|
||||
use super::tool_scope::CuratedTool;
|
||||
use super::types::{
|
||||
NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskFetchFilter,
|
||||
NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason, TaskContainer,
|
||||
TaskFetchFilter,
|
||||
};
|
||||
|
||||
/// Native provider implementation for a specific Composio toolkit.
|
||||
@@ -84,6 +85,20 @@ pub trait ComposioProvider: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// List the selectable containers the connected account exposes —
|
||||
/// today Notion databases — so the task-source UI can offer a picker
|
||||
/// instead of a raw-id text field.
|
||||
///
|
||||
/// Default impl: `Err` — providers without a container surface opt out,
|
||||
/// mirroring [`Self::fetch_tasks`].
|
||||
async fn list_databases(&self, ctx: &ProviderContext) -> Result<Vec<TaskContainer>, String> {
|
||||
let _ = ctx;
|
||||
Err(format!(
|
||||
"[composio:{}] provider has no database/container surface",
|
||||
self.toolkit_slug()
|
||||
))
|
||||
}
|
||||
|
||||
/// Standardized identity callback for provider implementations.
|
||||
///
|
||||
/// Providers can override this to customize how identity fragments
|
||||
|
||||
@@ -33,6 +33,35 @@ impl SyncReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of work an ingested task implies. GitHub's issues-and-PRs
|
||||
/// search returns both shapes, and the job differs fundamentally —
|
||||
/// *resolve* an issue vs *review* a pull request — so providers tag each
|
||||
/// task and the `task_sources` enrichment phrases the objective / agent
|
||||
/// prompt accordingly (the triage LLM then knows what to do). Providers
|
||||
/// that don't distinguish (notion, linear, clickup) leave this `Generic`.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskKind {
|
||||
/// No issue/PR distinction — the default for non-code providers.
|
||||
#[default]
|
||||
Generic,
|
||||
/// A tracker issue: the job is to resolve / implement it.
|
||||
Issue,
|
||||
/// A pull request: the job is to review it (read the diff, give feedback).
|
||||
PullRequest,
|
||||
}
|
||||
|
||||
impl TaskKind {
|
||||
/// Stable lowercase tag, mirrored into the card's `source_metadata`.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TaskKind::Generic => "generic",
|
||||
TaskKind::Issue => "issue",
|
||||
TaskKind::PullRequest => "pull_request",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized user profile shape returned by every provider.
|
||||
///
|
||||
/// The shared fields (`display_name`, `email`, `username`, `avatar_url`,
|
||||
@@ -102,6 +131,10 @@ pub struct NormalizedTask {
|
||||
pub source_id: String,
|
||||
/// Toolkit slug, e.g. `"github"`.
|
||||
pub provider: String,
|
||||
/// Whether this task is an issue, a pull request, or undifferentiated.
|
||||
/// Drives intent-aware objective / prompt phrasing in enrichment.
|
||||
#[serde(default)]
|
||||
pub kind: TaskKind,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
@@ -127,6 +160,19 @@ pub struct NormalizedTask {
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A selectable upstream task container (board / database / list) used to
|
||||
/// populate a picker so the user chooses from a list instead of pasting a
|
||||
/// raw id. Today this is a Notion database, later a Linear team or ClickUp
|
||||
/// list. Surfaced to the task-source UI as `{ id, title }`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskContainer {
|
||||
/// Provider-native id (e.g. a Notion database id) used as the filter id.
|
||||
pub id: String,
|
||||
/// Human-readable label for the picker.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Provider-agnostic filter passed into
|
||||
/// [`super::ComposioProvider::fetch_tasks`].
|
||||
///
|
||||
@@ -136,12 +182,33 @@ pub struct NormalizedTask {
|
||||
/// `database_id`; linear/clickup read `team_id`; …) and ignores the
|
||||
/// rest. `extra` is a free-form escape hatch surfaced in the UI for
|
||||
/// advanced provider-native query fragments.
|
||||
/// How the GitHub task-source fetch reaches GitHub. Shipped desktop users
|
||||
/// connect GitHub via Composio OAuth (no `gh` on PATH, no `GITHUB_TOKEN`),
|
||||
/// while local dev / self-host setups often have the reverse. `Auto` does the
|
||||
/// right thing for both; `Composio` / `Local` force a path when the user wants.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GithubFetchMode {
|
||||
/// Try the connected Composio account first; fall back to local `gh`/REST
|
||||
/// only when Composio is unavailable. The safe default — no regression for
|
||||
/// shipped users, still a true fallback for local/dev.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force the connected Composio account (classic shipped-app behaviour).
|
||||
Composio,
|
||||
/// Force local `gh` CLI / REST with a `GH_TOKEN`/`GITHUB_TOKEN` env token.
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskFetchFilter {
|
||||
/// Scope to items assigned to (or involving) the authenticated user.
|
||||
#[serde(default)]
|
||||
pub assignee_is_me: bool,
|
||||
/// GitHub fetch path selector (Composio vs local `gh`/REST). Default `Auto`.
|
||||
#[serde(default)]
|
||||
pub github_fetch_mode: GithubFetchMode,
|
||||
/// GitHub `owner/name` repository scope.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repo: Option<String>,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use super::types::EnrichedTask;
|
||||
use super::NormalizedTask;
|
||||
use super::{NormalizedTask, TaskKind};
|
||||
|
||||
/// Maximum length of the derived summary, in characters.
|
||||
const SUMMARY_MAX_CHARS: usize = 200;
|
||||
@@ -30,6 +30,7 @@ pub fn enrich_task(task: NormalizedTask) -> EnrichedTask {
|
||||
.map(|s| vec![s.to_string()])
|
||||
.unwrap_or_default();
|
||||
let agent_prompt = build_agent_prompt(&task, &summary, urgency);
|
||||
let objective = derive_objective(&task);
|
||||
|
||||
EnrichedTask {
|
||||
task,
|
||||
@@ -38,10 +39,48 @@ pub fn enrich_task(task: NormalizedTask) -> EnrichedTask {
|
||||
linked_people,
|
||||
linked_memory_ids: Vec::new(),
|
||||
agent_prompt,
|
||||
objective,
|
||||
enriched_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Intent-aware phrasing for an issue vs a pull request.
|
||||
///
|
||||
/// Returns `(objective_verb, prompt_directive)`. The job differs
|
||||
/// fundamentally — *resolve* an issue vs *review* a PR — so this is what
|
||||
/// makes the ingested card self-describe what the picking agent (and the
|
||||
/// triage LLM) should actually do. `Generic` returns `None`; generic
|
||||
/// phrasing is used.
|
||||
pub(crate) fn intent_phrasing(kind: TaskKind) -> Option<(&'static str, &'static str)> {
|
||||
match kind {
|
||||
TaskKind::Issue => Some((
|
||||
"Resolve issue",
|
||||
"This is an issue — investigate the root cause, implement and validate a fix, \
|
||||
then update the card with evidence.",
|
||||
)),
|
||||
TaskKind::PullRequest => Some((
|
||||
"Review pull request",
|
||||
"This is a pull request — read the diff and assess correctness, risk, and test \
|
||||
coverage, then post review feedback (approve or request changes). Do not merge.",
|
||||
)),
|
||||
TaskKind::Generic => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The card objective: an intent-framed goal for the executing agent
|
||||
/// (`"Review pull request: <title>"` / `"Resolve issue: <title>"`), or the
|
||||
/// bare title for undifferentiated tasks. `None` when the title is empty.
|
||||
pub(crate) fn derive_objective(task: &NormalizedTask) -> Option<String> {
|
||||
let title = task.title.trim();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(match intent_phrasing(task.kind) {
|
||||
Some((verb, _)) => format!("{verb}: {title}"),
|
||||
None => title.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// One- to two-line summary: the first non-empty line of the body, else
|
||||
/// the title. Truncated on a char boundary.
|
||||
fn derive_summary(task: &NormalizedTask) -> String {
|
||||
@@ -104,10 +143,21 @@ fn derive_urgency(task: &NormalizedTask) -> f32 {
|
||||
}
|
||||
|
||||
fn build_agent_prompt(task: &NormalizedTask, summary: &str, urgency: f32) -> String {
|
||||
let mut lines = vec![format!(
|
||||
"A task was ingested from {} and needs your attention.",
|
||||
task.provider
|
||||
)];
|
||||
let opener = match task.kind {
|
||||
TaskKind::Issue => format!(
|
||||
"An issue was ingested from {} and needs to be resolved.",
|
||||
task.provider
|
||||
),
|
||||
TaskKind::PullRequest => format!(
|
||||
"A pull request was ingested from {} and needs review.",
|
||||
task.provider
|
||||
),
|
||||
TaskKind::Generic => format!(
|
||||
"A task was ingested from {} and needs your attention.",
|
||||
task.provider
|
||||
),
|
||||
};
|
||||
let mut lines = vec![opener];
|
||||
lines.push(format!("Title: {}", task.title));
|
||||
if !summary.is_empty() && summary != task.title.trim() {
|
||||
lines.push(format!("Summary: {summary}"));
|
||||
@@ -125,10 +175,13 @@ fn build_agent_prompt(task: &NormalizedTask, summary: &str, urgency: f32) -> Str
|
||||
lines.push(format!("Link: {url}"));
|
||||
}
|
||||
lines.push(format!("Estimated urgency: {:.0}%.", urgency * 100.0));
|
||||
lines.push(
|
||||
"Decide whether this is actionable now; if so, make progress and update the todo card."
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(match intent_phrasing(task.kind) {
|
||||
Some((_, directive)) => directive.to_string(),
|
||||
None => {
|
||||
"Decide whether this is actionable now; if so, make progress and update the todo card."
|
||||
.to_string()
|
||||
}
|
||||
});
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -221,4 +274,38 @@ mod tests {
|
||||
assert!(e.agent_prompt.contains("Fix login bug"));
|
||||
assert!(e.agent_prompt.contains("https://example.com/1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_objective_and_prompt_say_review() {
|
||||
let mut t = task();
|
||||
t.kind = TaskKind::PullRequest;
|
||||
let e = enrich_task(t);
|
||||
assert_eq!(
|
||||
e.objective.as_deref(),
|
||||
Some("Review pull request: Fix login bug")
|
||||
);
|
||||
assert!(e.agent_prompt.contains("needs review"));
|
||||
assert!(e.agent_prompt.contains("Do not merge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_objective_and_prompt_say_resolve() {
|
||||
let mut t = task();
|
||||
t.kind = TaskKind::Issue;
|
||||
let e = enrich_task(t);
|
||||
assert_eq!(e.objective.as_deref(), Some("Resolve issue: Fix login bug"));
|
||||
assert!(e.agent_prompt.contains("needs to be resolved"));
|
||||
assert!(e.agent_prompt.contains("implement and validate a fix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_objective_is_bare_title_and_prompt_is_neutral() {
|
||||
// notion/linear/clickup default to Generic — no review/resolve framing.
|
||||
let e = enrich_task(task());
|
||||
assert_eq!(e.objective.as_deref(), Some("Fix login bug"));
|
||||
assert!(e.agent_prompt.contains("needs your attention"));
|
||||
assert!(e
|
||||
.agent_prompt
|
||||
.contains("make progress and update the todo card"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ pub fn to_fetch_filter(spec: &FilterSpec, max: u32) -> TaskFetchFilter {
|
||||
labels,
|
||||
assignee_is_me,
|
||||
state,
|
||||
fetch_mode,
|
||||
extra,
|
||||
} => TaskFetchFilter {
|
||||
assignee_is_me: *assignee_is_me,
|
||||
github_fetch_mode: *fetch_mode,
|
||||
repo: repo.clone(),
|
||||
labels: labels.clone(),
|
||||
state: state.clone(),
|
||||
@@ -83,6 +85,7 @@ mod tests {
|
||||
labels: vec!["bug".into(), "p1".into()],
|
||||
assignee_is_me: true,
|
||||
state: Some("open".into()),
|
||||
fetch_mode: Default::default(),
|
||||
extra: json!({"per_page": 10}),
|
||||
};
|
||||
let f = to_fetch_filter(&spec, 25);
|
||||
|
||||
@@ -27,7 +27,9 @@ pub mod store;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use crate::openhuman::memory_sync::composio::providers::{NormalizedTask, TaskFetchFilter};
|
||||
pub use crate::openhuman::memory_sync::composio::providers::{
|
||||
NormalizedTask, TaskContainer, TaskFetchFilter, TaskKind,
|
||||
};
|
||||
pub use periodic::start_periodic_poll;
|
||||
pub use pipeline::run_source_once;
|
||||
pub use route::TASK_SOURCES_THREAD_ID;
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory_sync::composio::providers::{
|
||||
get_provider, NormalizedTask, ProviderContext,
|
||||
get_provider, NormalizedTask, ProviderContext, TaskContainer,
|
||||
};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
@@ -164,6 +164,34 @@ pub async fn preview_filter(
|
||||
Ok(RpcOutcome::new(tasks, vec![]))
|
||||
}
|
||||
|
||||
/// List the selectable containers (today: Notion databases) a connected
|
||||
/// provider exposes, so the UI can offer a picker instead of a raw-id text
|
||||
/// field. Mirrors [`preview_filter`]'s context setup.
|
||||
pub async fn list_databases(
|
||||
config: &Config,
|
||||
provider: ProviderSlug,
|
||||
connection_id: Option<String>,
|
||||
) -> Result<RpcOutcome<Vec<TaskContainer>>, String> {
|
||||
let provider_impl = get_provider(provider.as_str())
|
||||
.ok_or_else(|| format!("no native provider registered for '{}'", provider.as_str()))?;
|
||||
let ctx = ProviderContext {
|
||||
config: Arc::new(config.clone()),
|
||||
toolkit: provider.as_str().to_string(),
|
||||
connection_id: connection_id.filter(|s| !s.trim().is_empty()),
|
||||
usage: Default::default(),
|
||||
};
|
||||
let databases = provider_impl
|
||||
.list_databases(&ctx)
|
||||
.await
|
||||
.map_err(|e| format!("list databases failed: {e}"))?;
|
||||
tracing::debug!(
|
||||
count = databases.len(),
|
||||
provider = provider.as_str(),
|
||||
"[task_sources:ops] list_databases"
|
||||
);
|
||||
Ok(RpcOutcome::new(databases, vec![]))
|
||||
}
|
||||
|
||||
/// Domain status: enabled flag + source counts.
|
||||
pub async fn status(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||
let sources = store::list_sources(config).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -160,6 +160,7 @@ mod tests {
|
||||
labels: vec![],
|
||||
assignee_is_me: true,
|
||||
state: None,
|
||||
fetch_mode: Default::default(),
|
||||
extra: json!({}),
|
||||
},
|
||||
interval_secs,
|
||||
|
||||
@@ -85,6 +85,7 @@ fn add_github_source(config: &Config) -> TaskSource {
|
||||
labels: vec![],
|
||||
assignee_is_me: true,
|
||||
state: None,
|
||||
fetch_mode: Default::default(),
|
||||
extra: json!({}),
|
||||
},
|
||||
1800,
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::openhuman::todos::ops::{
|
||||
use crate::openhuman::{scheduler_gate, todos};
|
||||
|
||||
use super::types::{EnrichedTask, FilterSpec, SourceTarget, TaskSource};
|
||||
use super::TaskKind;
|
||||
|
||||
/// Stable thread id whose board collects every ingested task.
|
||||
pub const TASK_SOURCES_THREAD_ID: &str = "task-sources";
|
||||
@@ -112,13 +113,12 @@ fn add_card(
|
||||
Some(notes_parts.join("\n"))
|
||||
};
|
||||
|
||||
// Objective: the bare upstream title (the card `content`/title is the
|
||||
// `[provider] title` display form; the objective is the clean goal the
|
||||
// executing agent works toward).
|
||||
let objective = {
|
||||
let t = task.title.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
};
|
||||
// Objective: the intent-framed goal from enrichment ("Review pull
|
||||
// request: …" / "Resolve issue: …" / bare title for generic tasks). The
|
||||
// card `content`/title is the `[provider] title` display form; the
|
||||
// objective is the clean goal the executing agent — and the triage LLM —
|
||||
// works toward, so it must state *what kind of job* this is.
|
||||
let objective = enriched.objective.clone();
|
||||
|
||||
// Stamp the source identifiers the downstream dispatcher / write-back
|
||||
// needs (provider + repo + issue id + url) plus the enrichment urgency
|
||||
@@ -177,6 +177,11 @@ fn build_source_metadata(source: &TaskSource, enriched: &EnrichedTask) -> serde_
|
||||
"external_id": task.external_id,
|
||||
"urgency": enriched.urgency,
|
||||
});
|
||||
// Only stamp `kind` when the provider differentiated it (issue vs PR), so
|
||||
// the FE card and triage can tell "review this" from "solve this".
|
||||
if task.kind != TaskKind::Generic {
|
||||
meta["kind"] = json!(task.kind.as_str());
|
||||
}
|
||||
if let Some(url) = task.url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
meta["url"] = json!(url);
|
||||
}
|
||||
@@ -318,6 +323,7 @@ mod tests {
|
||||
labels: vec![],
|
||||
assignee_is_me: true,
|
||||
state: None,
|
||||
fetch_mode: Default::default(),
|
||||
extra: json!({}),
|
||||
},
|
||||
interval_secs: 1800,
|
||||
@@ -338,6 +344,9 @@ mod tests {
|
||||
url: url.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
// Objective is derived in enrichment — mirror that here so the helper
|
||||
// stays truthful (generic kind → bare title).
|
||||
let objective = crate::openhuman::task_sources::enrich::derive_objective(&task);
|
||||
EnrichedTask {
|
||||
task,
|
||||
summary: "Fix the bug".into(),
|
||||
@@ -345,6 +354,7 @@ mod tests {
|
||||
linked_people: vec![],
|
||||
linked_memory_ids: vec![],
|
||||
agent_prompt: "do it".into(),
|
||||
objective,
|
||||
enriched_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -413,6 +423,47 @@ mod tests {
|
||||
.expect("source_metadata present");
|
||||
assert_eq!(meta["external_id"], json!("123"));
|
||||
assert_eq!(meta["repo"], json!("octo/repo"));
|
||||
// Generic kind is not stamped onto metadata.
|
||||
assert!(meta.get("kind").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_card_carries_review_objective_and_kind_metadata() {
|
||||
let (_tmp, config) = temp_config();
|
||||
let src = github_source(Some("octo/repo"));
|
||||
let mut task = NormalizedTask {
|
||||
external_id: "55".into(),
|
||||
provider: "github".into(),
|
||||
title: "Add retry".into(),
|
||||
..Default::default()
|
||||
};
|
||||
task.kind = TaskKind::PullRequest;
|
||||
let objective = crate::openhuman::task_sources::enrich::derive_objective(&task);
|
||||
let e = EnrichedTask {
|
||||
task,
|
||||
summary: "Add retry".into(),
|
||||
urgency: 0.5,
|
||||
linked_people: vec![],
|
||||
linked_memory_ids: vec![],
|
||||
agent_prompt: "review it".into(),
|
||||
objective,
|
||||
enriched_at: Utc::now(),
|
||||
};
|
||||
|
||||
add_card(&config, &src, &e, None).expect("add_card succeeds");
|
||||
|
||||
let cards = board_cards(&config).expect("board_cards");
|
||||
let card = &cards[0];
|
||||
// The objective tells the picking agent (and triage) the job is a review.
|
||||
assert_eq!(
|
||||
card.objective.as_deref(),
|
||||
Some("Review pull request: Add retry")
|
||||
);
|
||||
let meta = card
|
||||
.source_metadata
|
||||
.as_ref()
|
||||
.expect("source_metadata present");
|
||||
assert_eq!(meta["kind"], json!("pull_request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -55,6 +55,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("fetch"),
|
||||
schemas("list_tasks"),
|
||||
schemas("preview_filter"),
|
||||
schemas("list_databases"),
|
||||
schemas("status"),
|
||||
]
|
||||
}
|
||||
@@ -93,6 +94,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("preview_filter"),
|
||||
handler: handle_preview_filter,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list_databases"),
|
||||
handler: handle_list_databases,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("status"),
|
||||
handler: handle_status,
|
||||
@@ -292,6 +297,26 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"list_databases" => ControllerSchema {
|
||||
namespace: "task_sources",
|
||||
function: "list_databases",
|
||||
description: "List selectable containers (e.g. Notion databases) for a provider/connection so the UI can offer a picker.",
|
||||
inputs: vec![
|
||||
provider_input(),
|
||||
FieldSchema {
|
||||
name: "connection_id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional Composio connection id.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "databases",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
|
||||
comment: "Selectable containers, each `{ id, title }`.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"status" => ControllerSchema {
|
||||
namespace: "task_sources",
|
||||
function: "status",
|
||||
@@ -445,6 +470,15 @@ fn handle_preview_filter(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_databases(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let provider = read_provider(¶ms)?;
|
||||
let connection_id = read_optional::<String>(¶ms, "connection_id")?;
|
||||
to_json(ops::list_databases(&config, provider, connection_id).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
@@ -518,6 +552,7 @@ mod tests {
|
||||
"fetch",
|
||||
"list_tasks",
|
||||
"preview_filter",
|
||||
"list_databases",
|
||||
"status"
|
||||
]
|
||||
);
|
||||
@@ -526,7 +561,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_registered_controllers_has_handler_per_schema() {
|
||||
let controllers = all_registered_controllers();
|
||||
assert_eq!(controllers.len(), 9);
|
||||
assert_eq!(controllers.len(), 10);
|
||||
assert!(controllers
|
||||
.iter()
|
||||
.all(|c| c.schema.namespace == "task_sources"));
|
||||
|
||||
@@ -20,6 +20,7 @@ fn github_filter() -> FilterSpec {
|
||||
labels: vec!["bug".into()],
|
||||
assignee_is_me: true,
|
||||
state: Some("open".into()),
|
||||
fetch_mode: Default::default(),
|
||||
extra: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ pub enum FilterSpec {
|
||||
assignee_is_me: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
state: Option<String>,
|
||||
/// How to fetch: Composio connection, local `gh`/REST, or `auto`
|
||||
/// (Composio-first with local fallback). Defaults to `auto`.
|
||||
#[serde(default)]
|
||||
fetch_mode: crate::openhuman::memory_sync::composio::providers::GithubFetchMode,
|
||||
#[serde(default)]
|
||||
extra: Value,
|
||||
},
|
||||
@@ -216,6 +220,10 @@ pub struct EnrichedTask {
|
||||
pub linked_memory_ids: Vec<String>,
|
||||
/// Actionable prompt handed to the agent turn.
|
||||
pub agent_prompt: String,
|
||||
/// Intent-framed goal for the card (`"Review pull request: …"` /
|
||||
/// `"Resolve issue: …"`), or the bare title for undifferentiated tasks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub objective: Option<String>,
|
||||
pub enriched_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -270,6 +278,7 @@ mod tests {
|
||||
labels: vec!["bug".into()],
|
||||
assignee_is_me: true,
|
||||
state: Some("open".into()),
|
||||
fetch_mode: Default::default(),
|
||||
extra: json!({}),
|
||||
};
|
||||
let s = serde_json::to_value(&f).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user