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:
Steven Enamakel
2026-06-03 11:17:22 -04:00
committed by GitHub
co-authored by sanil-23 Claude Opus 4.8
parent 3529bfb116
commit d40e89c7b3
40 changed files with 3565 additions and 192 deletions
@@ -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')}
+50
View File
@@ -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': 'فقط البنود الموكلة لي',
+57
View File
@@ -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': 'শুধুমাত্র আমার করা আইটেমগুলি',
+60
View File
@@ -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',
+59
View File
@@ -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',
+60
View File
@@ -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í',
+60
View File
@@ -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 lapplication 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 lespace de travail (équipe) (optionnel)',
'settings.taskSources.assignedToMe': 'Uniquement les éléments qui me sont attribués',
+59
View File
@@ -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': 'केवल मुझे सौंपा गया आइटम',
+57
View File
@@ -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',
+60
View File
@@ -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 nellapp 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 sullattività',
'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',
+52
View File
@@ -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': '내게 할당된 항목만',
+60
View File
@@ -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',
+60
View File
@@ -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',
+59
View File
@@ -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': 'Только элементы, назначенные мне',
+49
View File
@@ -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,
+6
View File
@@ -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>,
+96 -9
View File
@@ -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"));
}
}
+3
View File
@@ -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);
+3 -1
View File
@@ -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;
+29 -1
View File
@@ -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())?;
+1
View File
@@ -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,
+58 -7
View File
@@ -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]
+36 -1
View File
@@ -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(&params)?;
let connection_id = read_optional::<String>(&params, "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!({}),
}
}
+9
View File
@@ -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();