From f90a337bc0a8e0393eab752b484c8f8c0494dd7b Mon Sep 17 00:00:00 2001 From: Zavian Wang <36817799+Zavianx@users.noreply.github.com> Date: Sat, 16 May 2026 12:36:08 +0800 Subject: [PATCH] Add agent task orchestration (#1768) Co-authored-by: Steven Enamakel --- .github/workflows/coverage.yml | 7 +- .github/workflows/e2e-agent-review.yml | 15 +- app/scripts/e2e-build.sh | 6 + app/scripts/e2e-run-session.sh | 33 +- .../intelligence/IntelligenceTasksTab.tsx | 171 ++++ .../__tests__/IntelligenceTasksTab.test.tsx | 184 ++++ .../__tests__/DeveloperOptionsPanel.test.tsx | 2 + app/src/pages/Conversations.tsx | 249 ++++- app/src/pages/Intelligence.tsx | 6 +- .../__tests__/Conversations.render.test.tsx | 157 ++++ .../components/TaskKanbanBoard.tsx | 107 +++ .../__tests__/TaskKanbanBoard.test.tsx | 52 ++ app/src/providers/ChatRuntimeProvider.tsx | 6 + .../__tests__/ChatRuntimeProvider.test.tsx | 23 + .../services/__tests__/chatService.test.ts | 47 +- app/src/services/api/agentProfilesApi.test.ts | 78 ++ app/src/services/api/agentProfilesApi.ts | 50 + app/src/services/api/threadApi.test.ts | 33 + app/src/services/api/threadApi.ts | 28 +- app/src/services/chatService.ts | 31 +- .../store/__tests__/agentProfileSlice.test.ts | 240 +++++ .../store/__tests__/chatRuntimeSlice.test.ts | 44 + app/src/store/agentProfileSlice.ts | 128 +++ app/src/store/chatRuntimeSlice.ts | 19 + app/src/store/coreModeSlice.test.ts | 14 + app/src/store/coreModeSlice.ts | 6 + app/src/store/index.ts | 2 + app/src/test/setup.ts | 2 + app/src/types/agentProfile.ts | 16 + app/src/types/turnState.ts | 27 + app/src/utils/config.ts | 4 + app/src/vite-env.d.ts | 2 + app/test/e2e/helpers/shared-flows.ts | 32 +- .../e2e/specs/auth-access-control.spec.ts | 130 +-- app/test/e2e/specs/login-flow.spec.ts | 37 +- scripts/mock-api/routes/integrations.mjs | 34 + src/api/config.rs | 13 +- src/core/socketio.rs | 6 + src/openhuman/accessibility/mod.rs | 2 + .../accessibility/permissions_tests.rs | 3 +- .../agent/agents/orchestrator/agent.toml | 1 + .../agent/harness/session/builder.rs | 58 +- src/openhuman/agent/mod.rs | 2 + src/openhuman/agent/profiles.rs | 708 ++++++++++++++ src/openhuman/agent/progress.rs | 6 + src/openhuman/agent/schemas.rs | 389 ++++++++ src/openhuman/agent/task_board.rs | 470 ++++++++++ src/openhuman/channels/bus.rs | 2 +- src/openhuman/channels/proactive.rs | 1 + .../channels/providers/presentation.rs | 3 + src/openhuman/channels/providers/web.rs | 121 ++- src/openhuman/channels/providers/web_tests.rs | 12 +- src/openhuman/composio/auth_retry.rs | 4 + src/openhuman/composio/client.rs | 71 +- src/openhuman/config/schema/load.rs | 14 +- src/openhuman/config/schema/load_tests.rs | 4 - src/openhuman/threads/schemas.rs | 144 +++ src/openhuman/threads/schemas_tests.rs | 105 +++ src/openhuman/threads/turn_state/mirror.rs | 5 + .../threads/turn_state/mirror_tests.rs | 29 + src/openhuman/threads/turn_state/types.rs | 5 + src/openhuman/tools/impl/agent/mod.rs | 2 + .../tools/impl/agent/spawn_parallel_agents.rs | 868 ++++++++++++++++++ src/openhuman/tools/impl/agent/todo_write.rs | 451 ++++++++- src/openhuman/tools/ops.rs | 1 + src/openhuman/tools/ops_tests.rs | 36 + 66 files changed, 5332 insertions(+), 226 deletions(-) create mode 100644 app/src/components/intelligence/IntelligenceTasksTab.tsx create mode 100644 app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx create mode 100644 app/src/pages/conversations/components/TaskKanbanBoard.tsx create mode 100644 app/src/pages/conversations/components/__tests__/TaskKanbanBoard.test.tsx create mode 100644 app/src/services/api/agentProfilesApi.test.ts create mode 100644 app/src/services/api/agentProfilesApi.ts create mode 100644 app/src/store/__tests__/agentProfileSlice.test.ts create mode 100644 app/src/store/agentProfileSlice.ts create mode 100644 app/src/types/agentProfile.ts create mode 100644 src/openhuman/agent/profiles.rs create mode 100644 src/openhuman/agent/task_board.rs create mode 100644 src/openhuman/tools/impl/agent/spawn_parallel_agents.rs diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1a14b6511..4f320ec2b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -65,10 +65,9 @@ jobs: runs-on: ubuntu-22.04 # See test.yml `rust-core-tests` — same shared-singleton flake risk in # the lib suite. Coverage instrumentation roughly doubles wall time vs. - # the plain test runs, so cap at 30 min (vs. 20 in test.yml) so a - # deadlock still surfaces logs without prematurely killing a healthy - # coverage build. - timeout-minutes: 30 + # the plain test runs; give it enough headroom for cold/warm cache + # variance while still surfacing deadlocks with logs. + timeout-minutes: 45 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: diff --git a/.github/workflows/e2e-agent-review.yml b/.github/workflows/e2e-agent-review.yml index 2ef8cdfef..dc953f626 100644 --- a/.github/workflows/e2e-agent-review.yml +++ b/.github/workflows/e2e-agent-review.yml @@ -80,8 +80,19 @@ jobs: - name: Install Appium and chromium driver if: steps.gate.outputs.present == 'true' run: | - npm install -g appium@3 - appium driver install --source=npm appium-chromium-driver + APPIUM_ROOT="$(printf '%s' "$RUNNER_TEMP" | tr '\\' '/')/openhuman-appium" + APPIUM_BIN_DIR="$(printf '%s' "$RUNNER_TEMP" | tr '\\' '/')/openhuman-e2e-bin" + mkdir -p "$APPIUM_ROOT" "$APPIUM_BIN_DIR" + npm install --no-audit --no-fund --prefix "$APPIUM_ROOT" appium@3 + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"' \ + 'APPIUM_ROOT="$(cd "$SCRIPT_DIR/../openhuman-appium" && pwd)"' \ + 'exec node "$APPIUM_ROOT/node_modules/appium/index.js" "$@"' \ + > "$APPIUM_BIN_DIR/appium" + chmod +x "$APPIUM_BIN_DIR/appium" + echo "$APPIUM_BIN_DIR" >> "$GITHUB_PATH" + "$APPIUM_BIN_DIR/appium" driver install --source=npm appium-chromium-driver - name: Install JS dependencies if: steps.gate.outputs.present == 'true' run: pnpm install --frozen-lockfile diff --git a/app/scripts/e2e-build.sh b/app/scripts/e2e-build.sh index b38b0de7d..2dd08b84b 100755 --- a/app/scripts/e2e-build.sh +++ b/app/scripts/e2e-build.sh @@ -17,8 +17,12 @@ cd "$APP_DIR" [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}" +export VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE="local" +export VITE_OPENHUMAN_E2E_RESTART_APP_AS_RELOAD="true" echo "Building E2E app with VITE_BACKEND_URL=$VITE_BACKEND_URL" +echo "Building E2E app with VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE=$VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE" +echo "Building E2E app with VITE_OPENHUMAN_E2E_RESTART_APP_AS_RELOAD=$VITE_OPENHUMAN_E2E_RESTART_APP_AS_RELOAD" if [ -n "${E2E_FORCE_CARGO_CLEAN:-}" ]; then echo "Forcing cargo clean (E2E_FORCE_CARGO_CLEAN is set)." @@ -38,6 +42,8 @@ else fi export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}" +export VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE="local" +export VITE_OPENHUMAN_E2E_RESTART_APP_AS_RELOAD="true" # Core is compiled in-process into the Tauri shell as of PR #1061; the old # scripts/stage-core-sidecar.mjs staging step is no longer needed. diff --git a/app/scripts/e2e-run-session.sh b/app/scripts/e2e-run-session.sh index 187a628bb..7df1efe43 100755 --- a/app/scripts/e2e-run-session.sh +++ b/app/scripts/e2e-run-session.sh @@ -169,8 +169,9 @@ esac # Mock URL must reach the core sidecar — XCUITest doesn't inherit env, # and CEF child processes won't either. Pinning via config.toml works -# on every platform. -E2E_CONFIG_DIR="$HOME/.openhuman" +# on every platform. The runner always sets OPENHUMAN_WORKSPACE above; +# Config::load_or_init gives that path precedence over $HOME/.openhuman. +E2E_CONFIG_DIR="${OPENHUMAN_WORKSPACE:-$HOME/.openhuman}" E2E_CONFIG_FILE="$E2E_CONFIG_DIR/config.toml" mkdir -p "$E2E_CONFIG_DIR" if [ -f "$E2E_CONFIG_FILE" ]; then @@ -326,28 +327,40 @@ APP_LOG="$LOG_DIR/openhuman-e2e-app-${LOG_SUFFIX}.log" APP_ARGS=() # CEF/Chromium needs extra coaxing in headless / containerized Linux runs: # -# --no-sandbox crbug.com/638180 — refuses to start as root -# without this. The openhuman_ci docker image -# runs as uid 0. +# --no-sandbox crbug.com/638180 — needed only when the runner is +# uid 0 or the cached CEF chrome-sandbox helper is +# missing/misconfigured. Non-root Linux runs with a +# valid helper keep Chromium sandboxing. # --disable-dev-shm-usage docker /dev/shm is often 64 MB; Chromium # assumes ≥2 GB and crashes mid-startup # ("Failed global descriptor lookup: 7" in the # zygote helper). # --disable-gpu no GPU in the CI container. -# --no-zygote skips the zygote launcher that wants dbus. +# --no-zygote skips the zygote launcher that wants dbus; Chromium +# only permits this when sandboxing is also disabled. # # Apply only on Linux. macOS/Windows runners are unprivileged users with a # real display / GPU; leaving the sandbox on there is correct. case "$OS" in Linux) - if [ "$(id -u 2>/dev/null || echo 0)" = "0" ]; then - APP_ARGS+=("--no-sandbox") - fi APP_ARGS+=( "--disable-dev-shm-usage" "--disable-gpu" - "--no-zygote" ) + NO_SANDBOX_REASON="" + if [ "$(id -u)" -eq 0 ]; then + NO_SANDBOX_REASON="runner is uid 0" + elif [ -n "${CEF_DIST_DIR:-}" ]; then + SANDBOX_HELPER="$CEF_DIST_DIR/chrome-sandbox" + SANDBOX_HELPER_MODE="$(stat -c '%u:%a' "$SANDBOX_HELPER" 2>/dev/null || true)" + if [ "$SANDBOX_HELPER_MODE" != "0:4755" ]; then + NO_SANDBOX_REASON="chrome-sandbox helper is not root-owned mode 4755" + fi + fi + if [ -n "$NO_SANDBOX_REASON" ]; then + APP_ARGS+=("--no-sandbox" "--no-zygote") + echo "[runner] Linux CEF sandbox disabled: $NO_SANDBOX_REASON" + fi echo "[runner] Linux CEF args: ${APP_ARGS[*]}" ;; esac diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx new file mode 100644 index 000000000..6210427a5 --- /dev/null +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -0,0 +1,171 @@ +/** + * IntelligenceTasksTab — shows all agent task boards across conversations. + * + * 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`). + * + * 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. + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { TaskKanbanBoard } from '../../pages/conversations/components/TaskKanbanBoard'; +import { threadApi } from '../../services/api/threadApi'; +import { useAppSelector } from '../../store/hooks'; +import type { TaskBoard } from '../../types/turnState'; + +const log = debug('intelligence:tasks'); + +interface ThreadTaskBoard { + threadId: string; + title: string; + board: TaskBoard; + live: boolean; +} + +function shortId(threadId: string): string { + return threadId.length > 8 ? `…${threadId.slice(-8)}` : threadId; +} + +export default function IntelligenceTasksTab() { + const liveBoards = useAppSelector(state => state.chatRuntime.taskBoardByThread); + const threads = useAppSelector(state => state.thread.threads ?? []); + + const [persistedBoards, setPersistedBoards] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const fetchPersistedBoards = useCallback(async () => { + log('fetchPersistedBoards: entry'); + setLoading(true); + setError(null); + try { + const turnStates = await threadApi.listTurnStates(); + log('fetchPersistedBoards: received %d turn states', turnStates.length); + const boards: Record = {}; + 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) { + setPersistedBoards(boards); + log('fetchPersistedBoards: done boards=%d', Object.keys(boards).length); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('fetchPersistedBoards: error %s', msg); + if (mountedRef.current) { + setError(msg); + } + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, []); + + useEffect(() => { + mountedRef.current = true; + fetchPersistedBoards(); + return () => { + mountedRef.current = false; + }; + }, [fetchPersistedBoards]); + + // 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(t => [t.id, t])); + const allThreadIds = new Set([...Object.keys(liveBoards), ...Object.keys(persistedBoards)]); + + const boardEntries: ThreadTaskBoard[] = []; + for (const threadId of allThreadIds) { + const liveBoard = liveBoards[threadId]; + const persistedBoard = persistedBoards[threadId]; + const board = liveBoard ?? persistedBoard; + if (!board || board.cards.length === 0) continue; + + const thread = threadMap.get(threadId); + const title = + thread?.title && thread.title.trim().length > 0 + ? thread.title + : `Thread ${shortId(threadId)}`; + + 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 ( +
+
+ Loading task boards… +
+ ); + } + + if (error) { + return ( +
+ Failed to load task boards: {error} +
+ ); + } + + if (boardEntries.length === 0) { + return ( +
+
📋
+

No agent task boards yet

+

+ Start a conversation and ask the agent to manage tasks — boards will appear here as cards + are created. +

+
+ ); + } + + return ( +
+

+ {boardEntries.length} active board{boardEntries.length !== 1 ? 's' : ''} across + conversations +

+ {boardEntries.map(entry => ( +
+
+

+ {entry.title} +

+ {entry.live && ( + + + live + + )} +
+ +
+ ))} +
+ ); +} diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx new file mode 100644 index 000000000..50123f227 --- /dev/null +++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx @@ -0,0 +1,184 @@ +/** + * Vitest for IntelligenceTasksTab. + * + * Covers: + * - Loading state while listTurnStates is in-flight. + * - Error state when listTurnStates rejects. + * - Empty state when no boards have any cards. + * - Board aggregation: persisted boards from turn-state list are shown. + * - Live boards from Redux take priority and render a "live" badge. + * - Thread title resolution: threads with a title use it; unknown threads + * fall back to a shortened thread id. + */ +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + listTurnStates: vi.fn(), + selectorResult: { + chatRuntime: { taskBoardByThread: {} as Record }, + thread: { threads: [] as unknown[] }, + }, +})); + +vi.mock('../../../services/api/threadApi', () => ({ + threadApi: { listTurnStates: hoisted.listTurnStates }, +})); + +vi.mock('../../../store/hooks', () => ({ + useAppSelector: (selector: (state: typeof hoisted.selectorResult) => unknown) => + selector(hoisted.selectorResult), + useAppDispatch: () => vi.fn(), +})); + +// TaskKanbanBoard is exercised by its own test; stub it to a simple +// table so we can assert on title/card-count without rendering the +// full kanban grid. +vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({ + TaskKanbanBoard: ({ board }: { board: { cards: { title: string }[] } }) => ( +
+ {board.cards.map(c => ( + {c.title} + ))} +
+ ), +})); + +async function importTab() { + const mod = await import('../IntelligenceTasksTab'); + return mod.default; +} + +function makeBoard(threadId: string, cardTitles: string[]) { + return { + threadId, + cards: cardTitles.map((title, i) => ({ + id: `card-${i}`, + title, + status: 'todo' as const, + order: i, + updatedAt: '2026-01-01T00:00:00Z', + })), + updatedAt: '2026-01-01T00:00:00Z', + }; +} + +function renderTab(Tab: React.ComponentType) { + const { render } = require('@testing-library/react'); + render(); +} + +describe('IntelligenceTasksTab', () => { + beforeEach(() => { + vi.resetModules(); + hoisted.listTurnStates.mockReset(); + hoisted.selectorResult.chatRuntime.taskBoardByThread = {}; + hoisted.selectorResult.thread.threads = []; + }); + + test('shows loading spinner while fetching', async () => { + // Never resolves during this test + hoisted.listTurnStates.mockReturnValue(new Promise(() => {})); + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + expect(screen.getByText(/loading task boards/i)).toBeInTheDocument(); + }); + + test('shows error message when listTurnStates rejects', async () => { + hoisted.listTurnStates.mockRejectedValue(new Error('rpc failed')); + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + expect(screen.getByText(/rpc failed/i)).toBeInTheDocument(); + }); + }); + + test('shows empty-state when no boards have cards', async () => { + hoisted.listTurnStates.mockResolvedValue([ + { threadId: 'thread-a', taskBoard: null }, + { threadId: 'thread-b', taskBoard: makeBoard('thread-b', []) }, + ]); + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + expect(screen.getByText(/no agent task boards yet/i)).toBeInTheDocument(); + }); + }); + + test('renders persisted boards from turn-state list', async () => { + hoisted.listTurnStates.mockResolvedValue([ + { threadId: 'thread-x', taskBoard: makeBoard('thread-x', ['Write docs', 'Fix bug']) }, + ]); + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + expect(screen.getByTestId('kanban-stub')).toBeInTheDocument(); + }); + expect(screen.getByText('Write docs')).toBeInTheDocument(); + expect(screen.getByText('Fix bug')).toBeInTheDocument(); + }); + + test('resolves thread title from thread list', async () => { + hoisted.listTurnStates.mockResolvedValue([ + { threadId: 'thread-y', taskBoard: makeBoard('thread-y', ['Task A']) }, + ]); + hoisted.selectorResult.thread.threads = [ + { id: 'thread-y', title: 'Research sprint', labels: [] }, + ]; + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + expect(screen.getByText('Research sprint')).toBeInTheDocument(); + }); + }); + + test('falls back to shortened thread id when title is missing', async () => { + hoisted.listTurnStates.mockResolvedValue([ + { threadId: 'abcdef1234567890', taskBoard: makeBoard('abcdef1234567890', ['Plan']) }, + ]); + // No entry in thread list + hoisted.selectorResult.thread.threads = []; + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + // Shortened id — last 8 chars are "34567890" + expect(screen.getByText(/34567890/)).toBeInTheDocument(); + }); + }); + + test('live boards from Redux take priority and show "live" badge', async () => { + hoisted.listTurnStates.mockResolvedValue([ + { threadId: 'thread-live', taskBoard: makeBoard('thread-live', ['Old card']) }, + ]); + hoisted.selectorResult.chatRuntime.taskBoardByThread = { + 'thread-live': makeBoard('thread-live', ['Live card']), + }; + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + expect(screen.getByText('Live card')).toBeInTheDocument(); + }); + // The live badge is present + expect(screen.getByText('live')).toBeInTheDocument(); + }); + + test('shows count of active boards', async () => { + hoisted.listTurnStates.mockResolvedValue([ + { threadId: 'ta', taskBoard: makeBoard('ta', ['A']) }, + { threadId: 'tb', taskBoard: makeBoard('tb', ['B']) }, + ]); + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + await waitFor(() => { + expect(screen.getByText(/2 active boards/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx b/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx index 83131e310..7b8415c3c 100644 --- a/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx @@ -27,6 +27,8 @@ vi.mock('../../../../utils/config', () => ({ // Pulled transitively via `resetWalkthrough` → configPersistence. CORE_RPC_URL: 'http://127.0.0.1:7788/rpc', BACKEND_URL: 'http://localhost:5005', + // Required by coreModeSlice (pulled transitively via renderWithProviders). + E2E_DEFAULT_CORE_MODE: '', })); vi.mock('../../../walkthrough/AppWalkthrough', () => ({ diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index e1c910208..278e0a5c2 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -1,4 +1,5 @@ import { convertFileSrc } from '@tauri-apps/api/core'; +import debugFactory from 'debug'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -17,16 +18,25 @@ import { useStickToBottom } from '../hooks/useStickToBottom'; import { useUsageState } from '../hooks/useUsageState'; import { useT } from '../lib/i18n/I18nContext'; import { trackEvent } from '../services/analytics'; +import { threadApi } from '../services/api/threadApi'; // [#1123] getCoreStateSnapshot and isWelcomeLocked commented out — welcome-agent onboarding replaced by Joyride walkthrough // import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store'; // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // import { useCoreState } from '../providers/CoreStateProvider'; import { chatCancel, chatSend, useRustChat } from '../services/chatService'; import { store } from '../store'; +import { + loadAgentProfiles, + selectActiveAgentProfileId, + selectAgentProfile, + selectAgentProfiles, + upsertAgentProfile, +} from '../store/agentProfileSlice'; import { beginInferenceTurn, clearRuntimeForThread, fetchAndHydrateTurnState, + setTaskBoardForThread, setToolTimelineForThread, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; @@ -42,8 +52,10 @@ import { setSelectedThread, THREAD_NOT_FOUND_MESSAGE, } from '../store/threadSlice'; +import type { AgentProfile } from '../types/agentProfile'; import type { ConfirmationModal as ConfirmationModalType } from '../types/intelligence'; import type { ThreadMessage } from '../types/thread'; +import type { TaskBoardCard, TaskBoardCardStatus } from '../types/turnState'; import { splitAgentMessageIntoBubbles } from '../utils/agentMessageBubbles'; import { BILLING_DASHBOARD_URL } from '../utils/links'; import { openUrl } from '../utils/openUrl'; @@ -60,6 +72,7 @@ import { formatTimelineEntry } from '../utils/toolTimelineFormatting'; import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble'; import { CitationChips, type MessageCitation } from './conversations/components/CitationChips'; import { LimitPill } from './conversations/components/LimitPill'; +import { TaskKanbanBoard } from './conversations/components/TaskKanbanBoard'; import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock'; import { evaluateComposerSend, @@ -87,6 +100,13 @@ type InputMode = 'text' | 'voice'; type ReplyMode = 'text' | 'voice'; const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320; const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3; +const debug = debugFactory('conversations'); +const DEFAULT_PROFILE_DRAFT = { + name: '', + agentId: 'orchestrator', + systemPromptSuffix: '', + allowedTools: '', +}; interface ConversationsProps { /** @@ -150,6 +170,14 @@ export function formatThreadLoadError(err: unknown): string { return String(err); } +function formatAgentProfileAgentLabel(agentId: string): string { + return agentId + .split(/[_-]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // function WelcomeThinkingTypewriter() { // const text = 'Your agent is thinking...'; @@ -223,8 +251,13 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro const [inlineSuggestionValue, setInlineSuggestionValue] = useState(''); const [sendError, setSendError] = useState(null); const [sendAdvisory, setSendAdvisory] = useState(null); + const [profileDraftOpen, setProfileDraftOpen] = useState(false); + const [profileDraft, setProfileDraft] = useState(DEFAULT_PROFILE_DRAFT); const socketStatus = useAppSelector(selectSocketStatus); + const agentProfiles = useAppSelector(selectAgentProfiles); + const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId); const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread); + const taskBoardByThread = useAppSelector(state => state.chatRuntime.taskBoardByThread); const inferenceStatusByThread = useAppSelector( state => state.chatRuntime.inferenceStatusByThread ); @@ -258,6 +291,29 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro onConfirm: () => {}, onCancel: () => {}, }); + const agentProfileAgentOptions = useMemo(() => { + const seen = new Set(); + const options: Array<{ id: string; label: string }> = []; + for (const profile of agentProfiles) { + const id = profile.agentId.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + options.push({ + id, + label: profile.builtIn ? profile.name : formatAgentProfileAgentLabel(id), + }); + } + if (profileDraft.agentId && !seen.has(profileDraft.agentId)) { + options.push({ + id: profileDraft.agentId, + label: formatAgentProfileAgentLabel(profileDraft.agentId), + }); + } + if (options.length === 0) { + options.push({ id: 'orchestrator', label: 'Orchestrator' }); + } + return options; + }, [agentProfiles, profileDraft.agentId]); const textInputRef = useRef(null); const isComposingTextRef = useRef(false); @@ -293,6 +349,50 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro void dispatch(loadThreadMessages(thread.id)); }; + const handleSelectAgentProfile = async (profileId: string) => { + try { + await dispatch(selectAgentProfile(profileId)).unwrap(); + } catch (error) { + debug('agent profile select failed: %o', error); + } + }; + + const handleCreateAgentProfile = async () => { + const name = profileDraft.name.trim(); + if (!name) return; + const duplicate = agentProfiles.some( + profile => profile.name.trim().toLowerCase() === name.toLowerCase() + ); + if (duplicate) { + setSendAdvisory(`Agent profile "${name}" already exists.`); + return; + } + const id = `profile-${globalThis.crypto.randomUUID().slice(0, 8)}`; + const allowedTools = profileDraft.allowedTools + .split(',') + .map(tool => tool.trim()) + .filter(Boolean); + const profile: AgentProfile = { + id, + name, + description: 'Custom agent profile', + agentId: profileDraft.agentId, + systemPromptSuffix: profileDraft.systemPromptSuffix.trim() || null, + allowedTools: allowedTools.length > 0 ? allowedTools : null, + builtIn: false, + }; + try { + await dispatch(upsertAgentProfile(profile)).unwrap(); + await dispatch(selectAgentProfile(id)).unwrap(); + setProfileDraftOpen(false); + setProfileDraft(DEFAULT_PROFILE_DRAFT); + setSendAdvisory(null); + } catch (error) { + debug('agent profile create failed: %o', error); + setSendAdvisory('Could not create agent profile.'); + } + }; + useEffect(() => { let cancelled = false; @@ -339,7 +439,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro }) .catch(err => { if (cancelled) return; - console.warn('[conversations] loadThreads failed on mount:', formatThreadLoadError(err)); + debug('loadThreads failed on mount: %s', formatThreadLoadError(err)); }); return () => { @@ -352,9 +452,27 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro if (selectedThreadId) { void dispatch(loadThreadMessages(selectedThreadId)); void dispatch(fetchAndHydrateTurnState(selectedThreadId)); + void threadApi + .getTaskBoard(selectedThreadId) + .then(board => { + if (board) { + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board })); + } + }) + .catch(error => { + debug('getTaskBoard failed: %o', error); + }); } }, [selectedThreadId, dispatch]); + useEffect(() => { + void dispatch(loadAgentProfiles()) + .unwrap() + .catch(error => { + debug('agent profiles load failed: %o', error); + }); + }, [dispatch]); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown unlock (#883) — when `chatOnboardingCompleted` // transitions from `false` → `true` (the welcome agent just called @@ -422,7 +540,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro if (sendingTimeoutRef.current) clearTimeout(sendingTimeoutRef.current); sendingThreadIdRef.current = threadId; sendingTimeoutRef.current = setTimeout(() => { - console.warn('[chat] silence timeout: no inference signal for 120s'); + debug('armSilenceTimer: no inference signal for 120s — clearing runtime'); setSendError(chatSendError('safety_timeout', t('chat.safetyTimeout'))); dispatch(clearRuntimeForThread({ threadId })); dispatch(setActiveThread(null)); @@ -635,7 +753,12 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro // Local model (Ollama) is used only for supplementary features // (auto-react, autocomplete, etc.) — never as a primary chat path. try { - await chatSend({ threadId: sendingThreadId, message: trimmed, model: CHAT_MODEL_ID }); + await chatSend({ + threadId: sendingThreadId, + message: trimmed, + model: CHAT_MODEL_ID, + profileId: selectedAgentProfileId, + }); trackEvent('chat_message_sent'); // Active-thread reset happens in the global ChatRuntimeProvider events. @@ -892,6 +1015,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro const selectedThreadToolTimeline = selectedThreadId ? (toolTimelineByThread[selectedThreadId] ?? []) : []; + const selectedTaskBoard = selectedThreadId ? (taskBoardByThread[selectedThreadId] ?? null) : null; + const hasTaskBoard = Boolean(selectedTaskBoard?.cards.length); const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); const hasVisibleMessages = visibleMessages.length > 0; const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; @@ -963,6 +1088,33 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro const shouldRenderTimelineBeforeLatestAgentMessage = selectedThreadToolTimeline.length > 0 && !isSending && Boolean(latestVisibleAgentMessage); + const handleMoveTaskCard = async ( + card: TaskBoardCard, + nextStatus: TaskBoardCardStatus + ): Promise => { + if (!selectedThreadId || !selectedTaskBoard) return; + const now = new Date().toISOString(); + const nextBoard = { + ...selectedTaskBoard, + cards: selectedTaskBoard.cards.map(existing => + existing.id === card.id ? { ...existing, status: nextStatus, updatedAt: now } : existing + ), + updatedAt: now, + }; + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: nextBoard })); + try { + const saved = await threadApi.putTaskBoard(selectedThreadId, nextBoard.cards); + if (!saved) { + throw new Error('Task board update returned no board'); + } + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: saved })); + } catch (error) { + debug('putTaskBoard failed: %o', error); + setSendAdvisory('Could not move task; changes were not saved.'); + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: selectedTaskBoard })); + } + }; + const filteredThreads = useMemo(() => { // Worker/subagent threads (any thread with `parentThreadId`) are // surfaced through two intentional paths (issue #1624): @@ -1239,6 +1391,27 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
{/* [#1123] welcomeLocked guard removed — always show token usage + new thread button */} <> +
+ + +