diff --git a/app/src/components/intelligence/IntelligenceCallsTab.test.tsx b/app/src/components/intelligence/IntelligenceCallsTab.test.tsx deleted file mode 100644 index dbf477cb8..000000000 --- a/app/src/components/intelligence/IntelligenceCallsTab.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { render, screen } from '@testing-library/react'; -// import { fireEvent, waitFor } from '@testing-library/react'; // re-enable with the full UI -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { joinMeetCall } from '../../services/meetCallService'; -import IntelligenceCallsTab from './IntelligenceCallsTab'; - -vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => undefined) })); - -vi.mock('../../services/meetCallService', () => ({ - joinMeetCall: vi.fn(), - closeMeetCall: vi.fn(), - listMeetCalls: vi.fn().mockResolvedValue([]), -})); - -describe('IntelligenceCallsTab', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders coming soon placeholder', () => { - render(); - expect(screen.getByText('Calls')).toBeInTheDocument(); - expect(screen.getByText('Coming Soon')).toBeInTheDocument(); - }); - - it('does not render a form in the coming soon view', () => { - render(); - expect(screen.queryByRole('form')).not.toBeInTheDocument(); - }); - - it('accepts an onToast prop without throwing', () => { - const onToast = vi.fn(); - expect(() => render()).not.toThrow(); - }); - - it('does not call joinMeetCall on initial render', () => { - render(); - expect(joinMeetCall).not.toHaveBeenCalled(); - }); -}); diff --git a/app/src/components/intelligence/IntelligenceCallsTab.tsx b/app/src/components/intelligence/IntelligenceCallsTab.tsx deleted file mode 100644 index 8783c4e76..000000000 --- a/app/src/components/intelligence/IntelligenceCallsTab.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { useEffect, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { closeMeetCall, joinMeetCall } from '../../services/meetCallService'; - -type ActiveCall = { requestId: string; meetUrl: string; displayName: string }; - -type Props = { - onToast?: (toast: { - type: 'success' | 'error' | 'info'; - title: string; - message?: string; - }) => void; -}; - -const PLACEHOLDER_URL = 'https://meet.google.com/abc-defg-hij'; - -/** - * Calls tab on the Intelligence page. - * - * Lets the user paste a Google Meet link, choose a display name, and have - * the agent join the call as an anonymous guest in a dedicated CEF - * webview window. The window itself is opened by the Tauri shell — this - * component just collects inputs, fires the RPC + invoke pair, and - * tracks active calls so the user can close them from the same surface. - */ -export default function IntelligenceCallsTab({ onToast }: Props) { - const { t } = useT(); - const [meetUrl, setMeetUrl] = useState(''); - const [displayName, setDisplayName] = useState('OpenHuman Agent'); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [activeCalls, setActiveCalls] = useState([]); - - // Listen for shell-emitted close events so the in-flight list stays - // accurate when the user closes a Meet window directly. Outside the - // Tauri shell `listen` rejects with a transport error — we swallow it. - useEffect(() => { - let unlisten: UnlistenFn | undefined; - let cancelled = false; - - listen<{ request_id: string }>('meet-call:closed', event => { - const closedId = event.payload?.request_id; - if (!closedId) return; - setActiveCalls(prev => prev.filter(call => call.requestId !== closedId)); - }) - .then(stop => { - if (cancelled) stop(); - else unlisten = stop; - }) - .catch(() => { - // Browser dev surface — no Tauri event bridge available. - }); - - return () => { - cancelled = true; - if (unlisten) unlisten(); - }; - }, []); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); - setSubmitting(true); - try { - // ownerDisplayName left empty here because this tab's UI is hidden - // behind a "Coming Soon" gate (see render branch below) — the call - // is dead-code-reachable only. When the tab is revived it must - // collect an owner-name input the same way `MeetingBotsCard` does - // (privacy lock for the in-call wake gate). Empty fails closed in - // core, so we're safe in the meantime. - const result = await joinMeetCall({ meetUrl, displayName, ownerDisplayName: '' }); - setActiveCalls(prev => [ - ...prev.filter(call => call.requestId !== result.requestId), - { requestId: result.requestId, meetUrl: result.meetUrl, displayName: result.displayName }, - ]); - setMeetUrl(''); - onToast?.({ - type: 'success', - title: t('calls.joiningCall'), - message: t('calls.meetWindowOpening'), - }); - } catch (err) { - const message = err instanceof Error ? err.message : t('calls.failedToStart'); - setError(message); - onToast?.({ type: 'error', title: t('calls.couldNotStart'), message }); - } finally { - setSubmitting(false); - } - }; - - const handleClose = async (requestId: string) => { - try { - const closed = await closeMeetCall(requestId); - if (closed) { - // Only drop the row when the shell confirms the window is gone. - // The `meet-call:closed` event listener also clears the row, so - // a manual window-close still keeps the list accurate. - setActiveCalls(prev => prev.filter(call => call.requestId !== requestId)); - } - } catch (err) { - const message = err instanceof Error ? err.message : t('calls.failedToClose'); - onToast?.({ type: 'error', title: t('calls.couldNotClose'), message }); - } - }; - - // Suppress unused-variable warnings while the UI is hidden behind Coming Soon. - void t; - void meetUrl; - void setMeetUrl; - void displayName; - void setDisplayName; - void submitting; - void error; - void activeCalls; - void handleSubmit; - void handleClose; - void PLACEHOLDER_URL; - - return ( -
-
- - - -
-

- {t('memory.tab.calls')} -

-

- {t('calls.comingSoonDescription')} -

- - {t('common.comingSoon')} - -
- ); - - /* Original Calls UI — re-enable when the feature is ready - return ( -
-
-

- {t('calls.joinMeet')} -

-

- {t('calls.joinMeetDescription')} -

-
- -
- - - - - {error && ( -
- {error} -
- )} - - -
- - {activeCalls.length > 0 && ( -
-

- {t('calls.activeCalls')} -

-
    - {activeCalls.map(call => ( -
  • -
    -
    - {call.displayName} -
    -
    - {call.meetUrl} -
    -
    - -
  • - ))} -
-
- )} -
- ); - */ -} diff --git a/app/src/components/intelligence/IntelligenceDreamsTab.test.tsx b/app/src/components/intelligence/IntelligenceDreamsTab.test.tsx deleted file mode 100644 index 7315ce56b..000000000 --- a/app/src/components/intelligence/IntelligenceDreamsTab.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import IntelligenceDreamsTab from './IntelligenceDreamsTab'; - -describe('', () => { - it('renders the dreams title, description and coming-soon line', () => { - render(); - // useT resolves against the bundled English map by default. - expect(screen.getByRole('heading', { level: 2, name: /^Dreams$/ })).toBeInTheDocument(); - expect(screen.getByText(/AI-generated reflections/i)).toBeInTheDocument(); - expect(screen.getByText(/Coming soon/i)).toBeInTheDocument(); - }); - - it('renders a decorative svg icon', () => { - const { container } = render(); - expect(container.querySelector('svg')).not.toBeNull(); - }); -}); diff --git a/app/src/components/intelligence/IntelligenceDreamsTab.tsx b/app/src/components/intelligence/IntelligenceDreamsTab.tsx deleted file mode 100644 index a938ffce2..000000000 --- a/app/src/components/intelligence/IntelligenceDreamsTab.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useT } from '../../lib/i18n/I18nContext'; - -export default function IntelligenceDreamsTab() { - const { t } = useT(); - return ( -
-
- - - - -
-

- {t('memory.tab.dreams')} -

-

{t('dreams.description')}

-

{t('dreams.comingSoon')}

-
- ); -} diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index d21b81e3f..04907df2c 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -1,26 +1,29 @@ /** - * IntelligenceTasksTab — shows all agent task boards across conversations. + * IntelligenceTasksTab — shows all task boards across the workspace. * - * Aggregates: - * 1. Live boards from `chatRuntime.taskBoardByThread` (updated in real-time - * while a conversation is running via socket events). - * 2. Persisted boards fetched once on mount from `threadApi.listTurnStates` - * (each turn state may carry a saved `taskBoard`). + * Surfaces three sources, in priority order: + * 1. The user's personal board ({@link USER_TASKS_THREAD_ID}), pinned to + * the top. This is the only board editable here — users create, move, + * edit, and delete their own cards via the `todos_*` RPC. + * 2. Live agent boards from `chatRuntime.taskBoardByThread` (updated in + * real-time while a conversation runs via socket events). + * 3. Persisted agent boards fetched once on mount from + * `threadApi.listTurnStates` (each turn state may carry a `taskBoard`). * - * Thread titles are resolved from `thread.threads` when available. Boards - * from threads not present in the list fall back to a shortened thread id. - * - * This component is read-only — moves are not surfaced here; the user manages - * task cards from the Conversations page where the write path lives. + * Agent boards (2 + 3) stay read-only here — those cards are managed from + * 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 { 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 type { TaskBoard } from '../../types/turnState'; +import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState'; +import { UserTaskComposer } from './UserTaskComposer'; const log = debug('intelligence:tasks'); @@ -41,13 +44,15 @@ export default function IntelligenceTasksTab() { const threads = useAppSelector(state => state.thread.threads ?? []); const [persistedBoards, setPersistedBoards] = useState>({}); + const [personalBoard, setPersonalBoard] = useState(null); + const [composerOpen, setComposerOpen] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); const mountedRef = useRef(true); const fetchPersistedBoards = useCallback(async () => { log('fetchPersistedBoards: entry'); - setLoading(true); setError(null); try { const turnStates = await threadApi.listTurnStates(); @@ -56,11 +61,6 @@ export default function IntelligenceTasksTab() { for (const ts of turnStates) { if (ts.taskBoard && ts.taskBoard.cards.length > 0) { boards[ts.threadId] = ts.taskBoard; - log( - 'fetchPersistedBoards: board threadId=%s cards=%d', - ts.threadId, - ts.taskBoard.cards.length - ); } } if (mountedRef.current) { @@ -70,32 +70,157 @@ export default function IntelligenceTasksTab() { } catch (err) { const msg = err instanceof Error ? err.message : String(err); log('fetchPersistedBoards: error %s', msg); + if (mountedRef.current) setError(msg); + } + }, []); + + const fetchPersonalBoard = useCallback(async () => { + log('fetchPersonalBoard: entry'); + try { + const board = await todosApi.list(USER_TASKS_THREAD_ID); if (mountedRef.current) { - setError(msg); + setPersonalBoard(board); + log('fetchPersonalBoard: cards=%d', board.cards.length); } - } finally { + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('fetchPersonalBoard: error %s', msg); + // A missing personal board is expected on first run — fall back to + // an empty board so the create affordance still has a home. if (mountedRef.current) { - setLoading(false); + setPersonalBoard({ threadId: USER_TASKS_THREAD_ID, cards: [], updatedAt: '' }); } } }, []); + const loadAll = useCallback(async () => { + // `loading` defaults to true; flip it off once both fetches settle. + await Promise.allSettled([fetchPersistedBoards(), fetchPersonalBoard()]); + if (mountedRef.current) setLoading(false); + }, [fetchPersistedBoards, fetchPersonalBoard]); + useEffect(() => { mountedRef.current = true; - fetchPersistedBoards(); + void loadAll(); return () => { mountedRef.current = false; }; - }, [fetchPersistedBoards]); + }, [loadAll]); + + // A task created from the composer lands either on the personal board or + // on a chosen conversation thread. `add` returns the updated board, so we + // merge it directly — re-fetching listTurnStates would return a stale + // turn-state snapshot that doesn't reflect the just-added card. + const handleCreated = useCallback((threadId: string, board: TaskBoard) => { + log('handleCreated threadId=%s cards=%d', threadId, board.cards.length); + if (threadId === USER_TASKS_THREAD_ID) { + setPersonalBoard(board); + } else { + setPersistedBoards(prev => ({ ...prev, [threadId]: board })); + } + }, []); + + // ── personal-board mutations (optimistic, with rollback) ───────────── + + const mutatePersonal = useCallback( + async (optimistic: TaskBoard, call: () => Promise, previous: TaskBoard) => { + setActionError(null); + setPersonalBoard(optimistic); + try { + const saved = await call(); + if (mountedRef.current) setPersonalBoard(saved); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('personal board mutation failed: %s', msg); + if (mountedRef.current) { + setPersonalBoard(previous); + setActionError(t('conversations.taskKanban.updateFailed')); + } + } + }, + [t] + ); + + const handleMovePersonal = useCallback( + (card: TaskBoardCard, status: TaskBoardCardStatus) => { + if (!personalBoard) return; + const now = new Date().toISOString(); + const optimistic: TaskBoard = { + ...personalBoard, + cards: personalBoard.cards.map(c => + c.id === card.id ? { ...c, status, updatedAt: now } : c + ), + updatedAt: now, + }; + void mutatePersonal( + optimistic, + () => todosApi.updateStatus(USER_TASKS_THREAD_ID, card.id, status), + personalBoard + ); + }, + [personalBoard, mutatePersonal] + ); + + const handleUpdatePersonal = useCallback( + (card: TaskBoardCard, nextCard: TaskBoardCard) => { + if (!personalBoard) return; + const now = new Date().toISOString(); + const optimistic: TaskBoard = { + ...personalBoard, + cards: personalBoard.cards.map(c => + c.id === card.id ? { ...nextCard, updatedAt: now } : c + ), + updatedAt: now, + }; + void mutatePersonal( + optimistic, + () => + todosApi.edit({ + threadId: USER_TASKS_THREAD_ID, + id: card.id, + content: nextCard.title, + status: nextCard.status, + objective: nextCard.objective ?? null, + notes: nextCard.notes ?? null, + blocker: nextCard.blocker ?? null, + assignedAgent: nextCard.assignedAgent ?? null, + approvalMode: nextCard.approvalMode ?? null, + plan: nextCard.plan ?? [], + allowedTools: nextCard.allowedTools ?? [], + acceptanceCriteria: nextCard.acceptanceCriteria ?? [], + evidence: nextCard.evidence ?? [], + }), + personalBoard + ); + }, + [personalBoard, mutatePersonal] + ); + + const handleDeletePersonal = useCallback( + (card: TaskBoardCard) => { + if (!personalBoard) return; + const optimistic: TaskBoard = { + ...personalBoard, + cards: personalBoard.cards.filter(c => c.id !== card.id), + updatedAt: new Date().toISOString(), + }; + void mutatePersonal( + optimistic, + () => todosApi.remove(USER_TASKS_THREAD_ID, card.id), + personalBoard + ); + }, + [personalBoard, mutatePersonal] + ); + + // ── derived agent board list (read-only) ───────────────────────────── - // Build the merged, deduplicated board list. Live boards take priority - // over persisted ones for the same thread (they reflect the latest agent - // turn in progress). const threadMap = new Map(threads.map(th => [th.id, th])); const allThreadIds = new Set([...Object.keys(liveBoards), ...Object.keys(persistedBoards)]); const boardEntries: ThreadTaskBoard[] = []; for (const threadId of allThreadIds) { + if (threadId === USER_TASKS_THREAD_ID) continue; // personal board rendered separately const liveBoard = liveBoards[threadId]; const persistedBoard = persistedBoards[threadId]; const board = liveBoard ?? persistedBoard; @@ -110,50 +235,77 @@ export default function IntelligenceTasksTab() { boardEntries.push({ threadId, title, board, live: Boolean(liveBoard) }); } - // Sort: live boards first, then by most-recently-updated. boardEntries.sort((a, b) => { if (a.live !== b.live) return a.live ? -1 : 1; return b.board.updatedAt.localeCompare(a.board.updatedAt); }); - if (loading) { - return ( -
-
- {t('intelligence.tasks.loadingBoards')} -
- ); - } - - if (error) { - return ( -
- {t('intelligence.tasks.failedToLoad')}: {error} -
- ); - } - - if (boardEntries.length === 0) { - return ( -
-
📋
-

{t('intelligence.tasks.empty')}

-

- {t('intelligence.tasks.emptyHint')} -

-
- ); - } - - const boardCount = boardEntries.length; - const boardCountLabel = - boardCount === 1 - ? t('intelligence.tasks.activeBoardOne') - : t('intelligence.tasks.activeBoardOther').replace('{count}', String(boardCount)); + const personalCards = personalBoard?.cards ?? []; return (
-

{boardCountLabel}

+
+

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

+ +
+ + {actionError && ( +
+ {actionError} +
+ )} + + {/* Personal board — always present so users can manage their own tasks. */} +
+
+

+ {t('intelligence.tasks.personalBoardTitle')} +

+
+ {personalCards.length > 0 ? ( + + ) : ( +
+

{t('intelligence.tasks.personalEmpty')}

+ +
+ )} +
+ + {loading && ( +
+
+ {t('intelligence.tasks.loadingBoards')} +
+ )} + + {error && ( +
+ {t('intelligence.tasks.failedToLoad')}: {error} +
+ )} + + {/* Agent / conversation boards — read-only. */} {boardEntries.map(entry => (
@@ -169,9 +321,14 @@ export default function IntelligenceTasksTab() { )}
- + +
))} + + {composerOpen && ( + setComposerOpen(false)} /> + )}
); } diff --git a/app/src/components/intelligence/UserTaskComposer.test.tsx b/app/src/components/intelligence/UserTaskComposer.test.tsx new file mode 100644 index 000000000..78d8b5088 --- /dev/null +++ b/app/src/components/intelligence/UserTaskComposer.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi'; +import { UserTaskComposer } from './UserTaskComposer'; + +vi.mock('../../store/hooks', () => ({ + useAppSelector: (sel: (state: unknown) => unknown) => + sel({ + thread: { + threads: [ + { id: 't-1', title: 'Plan trip' }, + { id: 'worker-1', title: 'Worker', parentThreadId: 't-1' }, + ], + }, + }), +})); + +vi.mock('../../services/api/todosApi', () => ({ + USER_TASKS_THREAD_ID: 'user-tasks', + todosApi: { add: vi.fn() }, +})); + +const mockAdd = vi.mocked(todosApi.add); + +function emptyBoard(threadId: string) { + return { threadId, cards: [], updatedAt: '' }; +} + +describe('UserTaskComposer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('disables Create until a title is entered', () => { + render(); + const createBtn = screen.getByRole('button', { name: 'Create task' }); + expect(createBtn).toBeDisabled(); + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Buy milk' }, + }); + expect(createBtn).toBeEnabled(); + }); + + it('creates a task on the personal board by default', async () => { + mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID)); + const onCreated = vi.fn(); + const onClose = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Buy milk' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + + await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1)); + expect(mockAdd).toHaveBeenCalledWith({ + threadId: USER_TASKS_THREAD_ID, + content: 'Buy milk', + status: 'todo', + objective: null, + notes: null, + }); + expect(onCreated).toHaveBeenCalledWith(USER_TASKS_THREAD_ID, expect.any(Object)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('attaches the task to a chosen conversation', async () => { + mockAdd.mockResolvedValueOnce(emptyBoard('t-1')); + render(); + + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Book hotel' }, + }); + // The attach selector lists user-initiated threads (worker threads excluded). + expect(screen.queryByRole('option', { name: 'Worker' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByDisplayValue('Personal (no conversation)'), { + target: { value: 't-1' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + + await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1)); + expect(mockAdd.mock.calls[0][0].threadId).toBe('t-1'); + }); + + it('surfaces an error and keeps the modal open on failure', async () => { + mockAdd.mockRejectedValueOnce(new Error('boom')); + const onClose = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), { + target: { value: 'Buy milk' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + + await waitFor(() => expect(screen.getByText(/Couldn't create the task/)).toBeInTheDocument()); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/intelligence/UserTaskComposer.tsx b/app/src/components/intelligence/UserTaskComposer.tsx new file mode 100644 index 000000000..53fb25390 --- /dev/null +++ b/app/src/components/intelligence/UserTaskComposer.tsx @@ -0,0 +1,198 @@ +/** + * UserTaskComposer — modal form for creating a user-owned task. + * + * Tasks default to the personal board ({@link USER_TASKS_THREAD_ID}) and + * are *optionally* attachable to an existing conversation thread. On + * submit it calls `todosApi.add` and hands the resulting board back to the + * parent via `onCreated` so the Tasks tab can refresh in place. + */ +import debug from 'debug'; +import { useState } from 'react'; +import { LuX } from 'react-icons/lu'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi'; +import { useAppSelector } from '../../store/hooks'; +import type { TaskBoard, TaskBoardCardStatus } from '../../types/turnState'; + +const log = debug('intelligence:task-composer'); + +// Tasks use three states only: Pending / Working / Done. +const STATUS_OPTIONS: { value: TaskBoardCardStatus; labelKey: string }[] = [ + { value: 'todo', labelKey: 'conversations.taskKanban.pending' }, + { value: 'in_progress', labelKey: 'conversations.taskKanban.working' }, + { value: 'done', labelKey: 'conversations.taskKanban.done' }, +]; + +interface UserTaskComposerProps { + /** Called with the updated board for the thread the task landed on. */ + onCreated: (threadId: string, board: TaskBoard) => void; + onClose: () => void; +} + +export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps) { + const { t } = useT(); + const threads = useAppSelector(state => state.thread.threads ?? []); + + const [title, setTitle] = useState(''); + const [status, setStatus] = useState('todo'); + const [objective, setObjective] = useState(''); + const [notes, setNotes] = useState(''); + const [attachThreadId, setAttachThreadId] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Only user-initiated conversations are attachable; background + // worker/subagent threads (those with a parent) would be confusing + // targets for a manual task. + const attachableThreads = threads.filter(thread => !thread.parentThreadId); + + const canSubmit = title.trim().length > 0 && !submitting; + + const handleSubmit = async () => { + const trimmedTitle = title.trim(); + if (!trimmedTitle || submitting) return; + const threadId = attachThreadId || USER_TASKS_THREAD_ID; + setSubmitting(true); + setError(null); + log('submit threadId=%s status=%s', threadId, status); + try { + const board = await todosApi.add({ + threadId, + content: trimmedTitle, + status, + objective: objective.trim() || null, + notes: notes.trim() || null, + }); + onCreated(threadId, board); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('submit failed: %s', msg); + setError(msg); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

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

+ +
+ +
+ + +
+ + + +
+ + + +