diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index 04907df2c..4fef3d3f3 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -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>({}); const [personalBoard, setPersonalBoard] = useState(null); + const [taskSourcesBoard, setTaskSourcesBoard] = useState(null); const [composerOpen, setComposerOpen] = useState(false); + const [refiningCard, setRefiningCard] = useState(null); + const [workingCardId, setWorkingCardId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [actionError, setActionError] = useState(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} /> ) : (
@@ -292,6 +509,16 @@ export default function IntelligenceTasksTab() { )} + {taskSourcesBoard && ( +
+ +
+ )} + {loading && (
@@ -329,6 +556,380 @@ export default function IntelligenceTasksTab() { {composerOpen && ( setComposerOpen(false)} /> )} + {refiningCard && ( + setRefiningCard(null)} + onApprove={handleApproveSourcePlan} + /> + )}
); } + +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 | 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 ( +
+
+
+

+ {t('settings.taskSources.title')} +

+

+ {t('intelligence.tasks.sourceList.subtitle')} +

+
+ + {t('conversations.taskKanban.sources.manage')} + +
+ + {sortedCards.length === 0 ? ( +
+ {t('intelligence.tasks.sourceList.empty')} +
+ ) : ( +
    + {sortedCards.map(card => { + const source = readSourceMetadata(card.sourceMetadata); + const done = card.status === 'done'; + return ( +
  • +
    +
    +
    + + {taskSourceLabel(card, t)} + + {done && ( + + + {t('intelligence.tasks.sourceList.queued')} + + )} +
    +

    + {card.title} +

    + {(card.objective || card.notes) && ( +

    + {card.objective || card.notes} +

    + )} +
    +
    + {source.url && ( + + + + )} + +
    +
    +
  • + ); + })} +
+ )} +
+ ); +} + +function TaskSourceRefinementDialog({ + card, + disabled, + onClose, + onApprove, +}: { + card: TaskBoardCard; + disabled: boolean; + onClose: () => void; + onApprove: (card: TaskBoardCard, draft: RefinedTaskDraft) => Promise; +}) { + 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 ( +
+
+
+
+

+ {t('intelligence.tasks.sourcePlan.title')} +

+

+ {t('intelligence.tasks.sourcePlan.subtitle')} +

+
+ +
+ +
+
+
+ + {t('intelligence.tasks.sourcePlan.researchAgent')} +
+
+ + + +