diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml new file mode 100644 index 000000000..39972626f --- /dev/null +++ b/.github/workflows/backend-tests.yml @@ -0,0 +1,71 @@ +name: backend-tests + +# The backend pytest suite, run on every pull request and on pushes to the mainline branches. Until +# now nothing ran it in CI, so a regression only surfaced when someone ran it by hand. +on: + pull_request: + paths: + - 'backend/**' + - '.github/workflows/backend-tests.yml' + push: + branches: [main, dev] + paths: + - 'backend/**' + - '.github/workflows/backend-tests.yml' + workflow_dispatch: + +# Runs checked-out project code on pull_request: the token stays read-only. +permissions: + contents: read + +concurrency: + group: backend-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: pytest (ubuntu) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: | + backend/requirements.lock + backend/requirements-dev.txt + - name: Install backend deps (locked runtime + dev) + run: | + python -m pip install --require-hashes --only-binary=:all: -r backend/requirements.lock + python -m pip install -r backend/requirements-dev.txt + # Two numbers that must agree. A test process that dies mid-run can exit 0 with no summary + # (a hard-exit shutdown path did exactly that once and silently skipped ~42% of the suite), so + # the job also asserts that every collected test was actually run: junit testcase count == + # collect-only count. Green then means green, not "green as far as it got". + - name: Count the suite + run: | + python -m pytest backend/tests --ignore=backend/tests/formal --collect-only -q -p no:cacheprovider \ + | tail -1 | tee collected.txt + # --timeout: a test that blocks forever (a bare ws.receive_json() waiting for an event that never + # comes, say) otherwise stalls the run at 99% until timeout-minutes with no summary and no junit. + # With the cap it fails by name, the rest of the suite runs, and the assertion below still holds. + - name: Run the backend suite + run: | + python -m pytest backend/tests --ignore=backend/tests/formal -q -p no:cacheprovider \ + --timeout=300 \ + --junitxml "${RUNNER_TEMP}/pytest.xml" + - name: Every collected test ran + # Runs after a red suite too, so a failure report also says whether the run was complete. + if: ${{ !cancelled() }} + run: | + python - "${RUNNER_TEMP}/pytest.xml" collected.txt <<'PY' + import re, sys, xml.etree.ElementTree as ET + ran = sum(1 for _ in ET.parse(sys.argv[1]).getroot().iter('testcase')) + m = re.search(r'(\d+) tests? collected', open(sys.argv[2]).read()) + collected = int(m.group(1)) if m else -1 + print(f'collected={collected} ran={ran}') + if collected < 1 or ran != collected: + sys.exit(f'FAIL: {ran} of {collected} collected tests reached the report; the run was truncated') + PY diff --git a/.github/workflows/edge-tests.yml b/.github/workflows/edge-tests.yml new file mode 100644 index 000000000..82ef10d9d --- /dev/null +++ b/.github/workflows/edge-tests.yml @@ -0,0 +1,44 @@ +name: edge-tests + +# The openswarm-edge pytest suite, on every pull request that touches it and on pushes to the +# mainline branches. +on: + pull_request: + paths: + - 'openswarm-edge/**' + - '.github/workflows/edge-tests.yml' + push: + branches: [main, dev] + paths: + - 'openswarm-edge/**' + - '.github/workflows/edge-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: edge-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: pytest (openswarm-edge) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: openswarm-edge + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: openswarm-edge/requirements.txt + - name: Install edge deps + run: | + python -m pip install -r requirements.txt + python -m pip install pytest pytest-asyncio + - name: Run the edge suite + run: python -m pytest tests -q -p no:cacheprovider diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 000000000..00578bb8f --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,43 @@ +name: frontend-tests + +# Typecheck plus the renderer's node:test suite, on every pull request and on pushes to the mainline +# branches. Until now neither ran in CI; the tests were run by hand, one file at a time. +on: + pull_request: + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + push: + branches: [main, dev] + paths: + - 'frontend/**' + - '.github/workflows/frontend-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + typecheck-and-tests: + name: tsc + node:test + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.18.1' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - name: Typecheck + run: npx tsc --noEmit -p tsconfig.json + - name: Unit tests (node:test via tsx) + run: node scripts/run-tests.mjs diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index fe1535065..710b74dc3 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -9,6 +9,9 @@ pytest==8.3.4 pytest-asyncio==0.25.2 +# Per-test wall-clock cap for CI (see .github/workflows/backend-tests.yml): a test that +# blocks forever fails by name instead of stalling the whole run until the job cap. +pytest-timeout==2.4.0 # Used by linter/lint.py (the vulture dead-code check). watchfiles, also # needed by lint.py, already comes in transitively via uvicorn[standard]. diff --git a/backend/tests/test_ws_integration.py b/backend/tests/test_ws_integration.py index 741cc223f..33f86210e 100644 --- a/backend/tests/test_ws_integration.py +++ b/backend/tests/test_ws_integration.py @@ -6,6 +6,8 @@ The SDK and WS auth are mocked; everything else is the real running stack.""" import asyncio +import queue +import threading import pytest @@ -17,6 +19,29 @@ import backend.main as main_mod from backend.apps.agents.agent_manager import agent_manager from backend.apps.agents.core.models import AgentSession +import backend.apps.agents.manager.run.RunOptions as run_options_mod + + +def p_receive_json(ws, timeout: float = 5.0): + """ws.receive_json() blocks forever when the event never comes; a regression in the loop then + hangs the whole pytest run instead of failing this test. Bound every receive.""" + out = queue.Queue(maxsize=1) + + def p_recv(): + try: + out.put((True, ws.receive_json())) + except BaseException as exc: + out.put((False, exc)) + + thread = threading.Thread(target=p_recv, daemon=True) + thread.start() + try: + ok, value = out.get(timeout=timeout) + except queue.Empty as exc: + raise AssertionError(f"timed out waiting for websocket event after {timeout}s") from exc + if ok: + return value + raise value def p_assistant(): @@ -33,6 +58,20 @@ def p_result(): def test_ws_endpoint_streams_a_full_turn_end_to_end(monkeypatch): monkeypatch.setattr(main_mod, "p_ws_auth_ok", lambda ws: True, raising=True) + # The contract of this test is "SDK and WS auth mocked, everything else real", but two things on + # the turn path reach outside the process and must not decide the outcome: configure_provider_env + # can wander into 9Router revival (spawn/npm install, serialized on a module-level lock) whenever + # earlier tests left provider evidence behind, and the background turn-label aux call does the + # same. Pin both out; the persistent-client path is pinned off suite-wide in conftest. + async def p_noop_provider_env(*args, **kwargs): + return None + + async def p_noop_turn_label(*args, **kwargs): + return None + + monkeypatch.setattr(run_options_mod, "configure_provider_env", p_noop_provider_env, raising=True) + monkeypatch.setattr(agent_manager, "generate_turn_label", p_noop_turn_label, raising=True) + async def fake_query(*args, **kwargs): yield p_assistant() yield p_result() @@ -49,10 +88,15 @@ async def fake_query(*args, **kwargs): ws.send_json({"event": "agent:send_message", "data": {"prompt": "hi"}}) seen = [] for _ in range(40): - ev = ws.receive_json() + ev = p_receive_json(ws) seen.append(ev.get("event")) - if ev.get("event") == "agent:message" and "hello from the loop" in str(ev.get("data", {})): + if ( + ev.get("event") == "agent:status" + and ev.get("data", {}).get("status") == "completed" + ): break + else: + raise AssertionError(f"did not receive completed status; saw events={seen}") # the real loop's assistant reply made it all the way back over the WS assert "agent:message" in seen assert any(m.role == "assistant" and "hello from the loop" in str(m.content) diff --git a/frontend/scripts/css-stub.cjs b/frontend/scripts/css-stub.cjs new file mode 100644 index 000000000..581e57e5d --- /dev/null +++ b/frontend/scripts/css-stub.cjs @@ -0,0 +1,5 @@ +// Run: node --import tsx --require ./scripts/css-stub.cjs --test +// A stylesheet import resolves to an empty module under node:test. Vite handles CSS imports in the +// renderer bundle; under node (tsx compiles these modules to CommonJS) they would be parsed as +// JavaScript and throw. +require.extensions['.css'] = () => {}; diff --git a/frontend/scripts/run-tests.mjs b/frontend/scripts/run-tests.mjs new file mode 100644 index 000000000..0ea0f7b6a --- /dev/null +++ b/frontend/scripts/run-tests.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// Runs every renderer unit test (src/**/*.test.ts, *.test.tsx) under node:test, with tsx doing the +// TypeScript. One command for CI and for a dev machine: `node scripts/run-tests.mjs`, optionally +// followed by file paths to run a subset. Exits non-zero if any test fails or nothing was found. +import { spawnSync } from 'node:child_process'; +import { readdirSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const testFile = (p) => /\.test\.tsx?$/.test(p); + +function walk(dir, out) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'dist') continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (testFile(p)) out.push(p); + } + return out; +} + +const files = process.argv.length > 2 ? process.argv.slice(2) : walk(join(root, 'src'), []).sort(); +if (files.length === 0) { + console.error('run-tests: no test files found under src/'); + process.exit(1); +} +// --require css-stub: some units live in component files that import a stylesheet; under node those +// imports must resolve to nothing (Vite handles them in the bundle). +const result = spawnSync( + process.execPath, + ['--import', 'tsx', '--require', './scripts/css-stub.cjs', '--test', ...files], + { cwd: root, stdio: 'inherit' }, +); +process.exit(result.status ?? 1); diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index ec2a347ab..cc001c033 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1,223 +1,45 @@ -import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react'; +import React, { useRef, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import TextField from '@mui/material/TextField'; import ClickAwayListener from '@mui/material/ClickAwayListener'; -import { getMinimizedShot } from '@/app/pages/Dashboard/desktop/minimizedShots'; -import Fade from '@mui/material/Fade'; -import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import CloseIcon from '@mui/icons-material/Close'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import CheckIcon from '@mui/icons-material/Check'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { friendlyStatusLabel } from '@/shared/statusLabel'; -import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; -import { dismissMcpSuggestion } from '@/shared/state/settingsSlice'; -import { openSettingsCard } from '@/shared/state/dashboardLayoutSlice'; -import { API_BASE, getAuthToken } from '@/shared/config'; -import { - sendMessage as sendMessageThunk, - launchAndSendFirstMessage, - generateTitle, - generateGroupMeta, - stopAgent, - handleApproval, - editMessage, - switchBranch, - duplicateSession, - setActiveSession, - updateSessionModel, - clearContextOverflow, - updateSessionMode, - updateSessionThinkingLevel, - updateThinkingLevel, - fetchSession, - AgentMessage, - clearSessionMessages, - clearMcpSuggestions, -} from '@/shared/state/agentsSlice'; -import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; -import { Typewriter } from '@/app/components/feedback/Animated'; -import { store } from '@/shared/state/store'; -import { fetchModes } from '@/shared/state/modesSlice'; -import { createSessionWs, acquireSessionWs, releaseSessionWs, seedSessionSeq } from '@/shared/ws/WebSocketManager'; -import StreamingBubble from './bubbles/StreamingBubble'; -import WelcomeQuickReplies from './WelcomeQuickReplies'; -import InlineSurfaceEmbeds from './shell/InlineSurfaceEmbeds'; +import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; +import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; +import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import { useWelcomeGreeting } from './useWelcomeGreeting'; -import { THINKING_LABELS } from './thinkingLabels'; -import MessageBubble from './bubbles/MessageBubble'; -import BurstRevealBubble from './bubbles/BurstRevealBubble'; -import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; -import CompactionMarker from './bubbles/CompactionMarker'; -import MessageActionBar from './shell/MessageActionBar'; -import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; -import ToolGroupBubble, { RenderItem, ToolGroup, ToolGroupEntry, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; -import ToolUiBubble from './tool-ui/ToolUiBubble'; -import AskUiBubble from './tool-ui/AskUiBubble'; -import { isShowUiPair, isAskUiPair, extractPendingAskUi, callToolUseId, resultToolUseId, isDeadAskResult } from './tool-ui/showUiPayload'; import { composerPlaceholder } from './composerPlaceholder'; -import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; +import { useBurstRevealTracking } from './bubbles/useBurstRevealTracking'; +import { useDockedBrowserSlot } from './shell/useDockedBrowserSlot'; +import { useJustStreamed } from './streaming/useJustStreamed'; +import { WorkflowModelNotice, FreeTrialModelNotice } from './model/ModelNotices'; +import { useMcpActivation } from './model/useMcpActivation'; +import { useMessageQueue, type QueuedMessage } from './queue/useMessageQueue'; +import { QueuePanel } from './queue/QueuePanel'; +import { AgentChatHeader } from './render/AgentChatHeader'; +import { useMessageScroll } from './scroll/useMessageScroll'; +import { FULLSCREEN_READING_MAX_W, MessageListBody } from './scroll/MessageListBody'; +import { TranscriptItem, type TranscriptItemVm } from './transcript/TranscriptItem'; +import { TranscriptFooter } from './transcript/TranscriptFooter'; +import { ContextOverflowCard } from './transcript/ContextOverflowCard'; +import { useTranscriptDerivations } from './transcript/useTranscriptDerivations'; +import { useToolGroupMeta } from './transcript/useToolGroupMeta'; +import { useBranchActions } from './transcript/useBranchActions'; +import { useSessionWs } from './session/useSessionWs'; +import { useSendPipeline } from './session/useSendPipeline'; +import { useModeModel } from './session/useModeModel'; +import { useWorkflowSidecar } from './session/useWorkflowSidecar'; +import { HaikuMcpWarning } from './composer/HaikuMcpWarning'; +import { McpSuggestionsBanner } from './composer/McpSuggestionsBanner'; +import { ContinueChatGlow } from './composer/ContinueChatGlow'; +import { PendingApprovalBars } from './shell/PendingApprovalBars'; import ForceStopAgentBar from './ForceStopAgentBar'; import { ProviderRetryPill, RateLimitPill } from './shell/RateLimitPill'; import { ContextRecoveredPill } from './shell/ContextRecoveredPill'; import ChatInput, { ChatInputHandle } from './ChatInput'; +import { useInitialContextPaths } from './ChatInput/hooks/useInitialContextPaths'; import FollowupChips from './FollowupChips'; import ContextDrawer from './shell/ContextDrawer'; -import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; -import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; -import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice'; -import { openCardContextMenu, isNativeMenuTarget, type CardMenuRow } from '../Dashboard/desktop/openCardContextMenu'; -import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; -import { setCardSidecar, commitDraft, updateWorkflowCard, controlWorkflowRun } from '@/shared/state/workflowsSlice'; -import { shallowEqual } from 'react-redux'; -import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; -import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta'; -import { isNarration } from './parsing/isNarration'; -import { openMarketplace } from '@/app/pages/Directory/openMarketplace'; - -const CONTEXT_WINDOWS: Record = { - 'opus-4-8': 1_000_000, - 'opus-4-7': 1_000_000, - opus: 1_000_000, - sonnet: 1_000_000, - haiku: 200_000, -}; - -// Full size view reads like a chat page, not a card: the transcript + composer center in one -// column at a comfortable measure (assistant-ui parks at 44rem, LibreChat 48rem; 760 splits them). -const FULLSCREEN_READING_MAX_W = 760; - -// Only a fallback for never-rendered items; real heights are measured once on screen. -const RENDER_ITEM_ESTIMATED_HEIGHT = 140; -// Conservative estimate for an unmeasured tool row: tool groups/pairs render collapsed (~40-50px) far more often than expanded. Leaning low keeps scrollHeight (and the scrollbar thumb) from jumping when a tool row measures shorter. -const COLLAPSED_TOOL_ROW_HEIGHT = 44; -// How many screens of real content to keep mounted on EACH side of the viewport. Beyond it, items unmount and are replaced by a measured-height spacer, so render/memory stays bounded no matter how long the transcript is. -const WINDOW_BUFFER_SCREENS_PER_SIDE = 3; -// Below this item count the transcript renders WHOLE, no spacers, no windowing. Virtualization only earns its keep on huge chats; on a normal chat the spacer-height recompute just fights the scroll position (the "jumps up and down" glitch), so we skip it entirely until a chat is genuinely long. -const WINDOW_MIN_ITEMS = 60; -// Floor on the mounted item count so a single very tall item can't strand us with an effectively empty window. -const MIN_WINDOW_BUFFER_ITEMS = 6; - -// Bootstrap count for the initial bottom-anchored slice (on open and on scroll-to-bottom): ONE screen's worth, so first paint mounts the minimum that fills the viewport. The post-settle recompute (scheduleWindowRecompute after the pin) widens to the full pixel band, so the buffer arrives a few frames later instead of taxing open-to-paint. -function initialSeedItems(viewportHeight: number): number { - const fillPx = 1.25 * Math.max(1, viewportHeight); - return Math.max(MIN_WINDOW_BUFFER_ITEMS, Math.ceil(fillPx / RENDER_ITEM_ESTIMATED_HEIGHT)); -} - -// Pure window solver: given the current scroll position and a per-index height accessor (measured where known, estimated otherwise), return the [start, end) slice of render items that should be mounted. The buffer is measured in PIXELS (N screens of real content on each side of the viewport), not item count, so a few very tall messages can't blow the mounted set up to the whole transcript. A huge viewport naturally yields start=0/end=total (mount all). -function computeDesiredWindow( - scrollTop: number, - clientHeight: number, - total: number, - heightOf: (index: number) => number, - bufferPx: number, -): { start: number; end: number } { - if (total <= 0) return { start: 0, end: 0 }; - const keepTop = scrollTop - bufferPx; - const keepBottom = scrollTop + clientHeight + bufferPx; - let offset = 0; - let start = -1; - let end = total; - for (let i = 0; i < total; i++) { - const h = heightOf(i); - const itemTop = offset; - const itemBottom = offset + h; - if (start === -1 && itemBottom > keepTop) start = i; - if (itemTop < keepBottom) { - end = i + 1; - } else { - // Everything past here starts below the keep band. - break; - } - offset += h; - } - if (start === -1) start = Math.max(0, total - 1); - end = Math.min(total, Math.max(end, start + 1)); - // Always keep at least a small floor of items mounted around the viewport so a single under-measured item can't strand us with an empty window. - if (end - start < MIN_WINDOW_BUFFER_ITEMS) { - start = Math.max(0, Math.min(start, end - MIN_WINDOW_BUFFER_ITEMS)); - } - return { start: Math.max(0, start), end }; -} - -function stringifyContent(content: any): string { - if (content == null) return ''; - if (typeof content === 'string') return content; - return JSON.stringify(content); -} - -// Content-aware height estimate for a render item that has never been measured. Tool rows and tiny system/thinking rows keep the flat fallback; message bubbles scale with their FULL text length (messages render in full once on-screen, so the estimate matches both the rendered bubble and MessageBubble's placeholder fallback). -function estimateItemHeight(item: RenderItem, viewportWidth: number): number { - if (isToolGroup(item) || isToolPair(item)) return COLLAPSED_TOOL_ROW_HEIGHT; - const msg = item as AgentMessage; - if (msg.role === 'thinking' || msg.role === 'system') return 60; - return estimateRenderedTextHeight(stringifyContent(msg.content), viewportWidth); -} - -const thinkingShimmerKeyframes = ` -@keyframes thinking-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} -`; - -const ThinkingBubble: React.FC<{ label?: string | null }> = ({ label }) => { - const c = useClaudeTokens(); - const shimmerBase = c.text.tertiary; - const shimmerHighlight = c.text.primary; - // Aux-LLM label wins; otherwise the pill stays plain "Thinking". The whimsical verbs read as personality - // in the per-message thinking bubble (MessageBubble), but as a vague, confusing status on a working card. - const display = label ? `${label}…` : `${THINKING_LABELS[0].live}…`; - // A quiet shimmer LINE, not a bordered card: status shares one visual language with the - // per-message thinking row, so only real content gets bubbles (the ChatGPT/Claude pattern). - return ( - - - - - {display} - - - - ); -}; - -interface QueuedMessage { - prompt: string; - images?: Array<{ data: string; media_type: string }>; - contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>; - forcedTools?: string[]; - attachedSkills?: Array<{ id: string; name: string; content: string }>; - selectedBrowserIds?: string[]; - selectedAppIds?: string[]; - attachedRunId?: string; - selectedSettingIds?: string[]; -} interface AgentChatProps { sessionId?: string; @@ -242,657 +64,68 @@ interface AgentChatProps { onSendRunQuestion?: (prompt: string, runId: string) => Promise; } +// The chat orchestrator (AGENTCHAT_SPLIT_PLAN done-state): wires the session/ hooks (transport, send pipeline, mode/model, workflow sidecar), +// the transcript/ derivations + actions, and the scroll/ mechanism into the header / transcript / approvals / composer render. Composition only. const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch, workflowEditId, readOnly, fullscreenChat, prefillPrompt, runContext, onClearRunContext, onSendRunQuestion }) => { const c = useClaudeTokens(); - // Fullscreen is a flat theme ground, same as Claude's: the old accent wash from the top read as - // decoration and dated the whole surface. + // Fullscreen is a flat theme ground, same as Claude's: the old accent wash from the top read as decoration and dated the whole surface. const { mode: themeMode } = useThemeMode(); const fullscreenWash = fullscreenChat ? (themeMode === 'dark' ? '#1a1918' : '#F5F5F0') : undefined; - const STATUS_STYLES: Record = { - running: { color: c.status.success, bg: c.status.successBg }, - waiting_approval: { color: c.status.warning, bg: c.status.warningBg }, - completed: { color: c.text.tertiary, bg: c.bg.secondary }, - error: { color: c.status.error, bg: c.status.errorBg }, - stopped: { color: c.text.tertiary, bg: c.bg.secondary }, - }; const { id: routeId } = useParams<{ id: string }>(); const id = sessionIdProp || routeId; - // True while a spawned surface (browser or built app) calls this chat home; gates the dock slot the real card overlays. - const hasDockedBrowser = useAppSelector((st) => - Object.values(st.dashboardLayout.browserCards).some((bc) => bc.docked_to === (sessionIdProp || routeId)) || - Object.values(st.dashboardLayout.viewCards).some((vc) => vc.docked_to === (sessionIdProp || routeId))); - // The docked surface's aspect ratio, so the inline slot hugs the browser's shape instead of reserving a fixed letterbox band (primitive selectors so no fresh-object rerenders). Highest z wins, mirroring BrowserCard's dock-owner election, so a dead rival's stale dock never feeds dims or shots. - const pickTopDocked = (st: { dashboardLayout: { zOrders: Record; browserCards: Record } }) => { - let best: { browser_id: string; width: number; height: number; zOrder: number } | null = null; - let bestZ = -1; - const zOf = (b: { browser_id: string; zOrder: number }): number => st.dashboardLayout.zOrders[b.browser_id] ?? b.zOrder ?? 0; - for (const b of Object.values(st.dashboardLayout.browserCards)) { - if (b.docked_to !== (sessionIdProp || routeId)) continue; - if (!best || zOf(b) > bestZ) { best = b; bestZ = zOf(b); } - } - return best; - }; - const dockedSurfaceW = useAppSelector((st) => pickTopDocked(st)?.width ?? 0); - const dockedSurfaceH = useAppSelector((st) => pickTopDocked(st)?.height ?? 0); - const dockedSurfaceId = useAppSelector((st) => pickTopDocked(st)?.browser_id ?? null); - // Shared by the anchor slot and the fallback slot so both read as the same framed block. - const browserSlotSx = { - position: 'relative', - // When the height cap bites, the WIDTH shrinks to keep the slot at the page's exact aspect (a maxHeight that broke the ratio left the live overlay letterboxed inside its own frame). - width: dockedSurfaceW > 0 && dockedSurfaceH > 0 ? `min(100%, calc(min(480px, 52vh) * ${dockedSurfaceW / dockedSurfaceH}))` : '100%', - aspectRatio: dockedSurfaceW > 0 && dockedSurfaceH > 0 ? `${dockedSurfaceW} / ${dockedSurfaceH}` : undefined, - height: dockedSurfaceW > 0 && dockedSurfaceH > 0 ? 'auto' : 'min(360px, 38vh)', - minHeight: 140, - mx: 'auto', - mt: 1, - mb: 0.5, - borderRadius: '12px', - overflow: 'hidden', - border: `1px solid ${c.border.medium}`, - // The live overlay stamps data-mini-live; while it paints, the frozen-shot backdrop must not (the clamped overlay leaves margins where a misaligned second copy of the page peeked through). - '&[data-mini-live="1"] img': { opacity: 0 }, - } as const; - // The slot announces its own (re)mount so the docked mini re-measures exactly then, instead of polling rects on a timer (the windowed transcript remounts it with no resize/pan event firing). - const announceBrowserSlot = useCallback((el: HTMLElement | null) => { - if (el) window.dispatchEvent(new CustomEvent('openswarm:browser-slot-mounted', { detail: { id } })); - }, [id]); - // A live webview cannot be clipped by the scroller, so the OVERLAY only shows while fully in view; this frozen shot is what scrolls and clips underneath it, ChatGPT-style. - const dockedShot = dockedSurfaceId ? getMinimizedShot(dockedSurfaceId) : undefined; - const browserSlotBody = dockedShot ? ( - - ) : null; - // A card linked as a workflow sidecar (Test Agent, or a watched run) swaps its composer for a Force Stop button: continuing the chat is meaningless, but killing the run is the common need. Once a Test Agent finishes, the button flips to a green "close" (see workflow_test_state + ForceStopAgentBar). - const linkedSidecar = useAppSelector((s) => { - const found = Object.values(s.workflows.openCards).find( - (cd) => cd.sidecarSessionId === id && (cd.sidecarKind === 'testing' || cd.sidecarKind === 'watching'), - ); - return found - ? { workflowId: found.workflowId, runId: found.runId ?? null, kind: found.sidecarKind ?? null } - : null; - }, shallowEqual); - const linkedWorkflowId = linkedSidecar?.workflowId ?? null; - const isStoppableSidecar = !!linkedWorkflowId; - // A live workflow run being watched owns pause/resume from its workflow card, so the chat's own "Resume Agent Response" bubble is redundant and would go stale against the card's Resume. Suppress it for any workflow-run sidecar, not just the fragile exact "watching" value. Test-run sidecars keep their chat-level resume behavior. - const isWorkflowRunSidecar = useAppSelector((s) => { - if (!id) return false; - for (const cd of Object.values(s.workflows.openCards)) { - if (cd.sidecarSessionId !== id || cd.sidecarKind === 'testing') continue; - if (cd.runId) { - const run = (s.workflows.runs[cd.workflowId] || []).find((r) => r.id === cd.runId); - if (!run || run.session_id === id) return true; - } - if (cd.sidecarKind === 'watching' || cd.sidecarKind === 'viewing-completed' || cd.sidecarKind === 'viewing-error') return true; - } - return Object.values(s.workflows.runs).some((runs) => - runs.some((r) => r.session_id === id && r.status === 'running'), - ); - }); - const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null); const dispatch = useAppDispatch(); const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); - const modesMap = useAppSelector((state) => state.modes.items); - const modelsByProvider = useAppSelector((state) => state.models.byProvider); const connectionMode = useAppSelector((state) => state.settings.data.connection_mode); + const { isStoppableSidecar, isWorkflowRunSidecar, testState, handleStop, onTestContinueEditing, onTestSaveWorkflow } = useWorkflowSidecar(id); - // Stored value → curated picker label, with a tidy fallback for unknowns. - const resolveModelLabel = useCallback((value: string | null | undefined): string => { - if (!value) return ''; - for (const models of Object.values(modelsByProvider)) { - for (const m of models as any[]) { - if (m.value === value) return m.label; - } - } - let s = String(value); - if (s.startsWith('or:')) s = s.slice(3); - if (s.includes('/')) s = s.split('/').pop() || s; - return s; - }, [modelsByProvider]); - // Used by the "too many connected apps for Haiku" warning rendered above ChatInput. Each connected MCP adds a meaningful chunk of tool-schema tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them. - const toolItems = useAppSelector((state) => state.tools.items); - const scrollContainerRef = useRef(null); - const lastVisibleItemRef = useRef(null); const chatInputRef = useRef(null); - const isAtBottomRef = useRef(true); - const pendingInitialBottomScrollRef = useRef(false); - const initialBottomScrollSettledRef = useRef(false); - const renderItemsLengthRef = useRef(0); - const renderItemsRef = useRef([]); - const itemHeightsRef = useRef>(new Map()); - const estimateCacheRef = useRef>(new Map()); - const viewportWidthRef = useRef(0); - const windowStartRef = useRef(0); - const windowEndRef = useRef(0); - const windowScrollRafRef = useRef(null); - const [viewportHeight, setViewportHeight] = useState(0); - const [viewportWidth, setViewportWidth] = useState(0); - const [scrollRoot, setScrollRoot] = useState(null); - const [windowStart, setWindowStart] = useState(0); - const [windowEnd, setWindowEnd] = useState(0); - const [heightVersion, setHeightVersion] = useState(0); - const [showScrollButton, setShowScrollButton] = useState(false); - const [showResumeBubble, setShowResumeBubble] = useState(false); - useEffect(() => { - if (isWorkflowRunSidecar) setShowResumeBubble(false); - }, [isWorkflowRunSidecar]); - const [awaitingResponse, setAwaitingResponse] = useState(false); - const [preSendActivityLabel, setPreSendActivityLabel] = useState(null); - const [activatingMcp, setActivatingMcp] = useState(null); - const [activateError, setActivateError] = useState(null); - // Holds the last non-empty suggestions so the docked banner's exit fade renders them instead of going blank the instant the array is cleared. - const mcpSnapshotRef = useRef>([]); - const [mode, setMode] = useState('agent'); - const [model, setModel] = useState('opus-5'); - // Workflow build chat only: brief "this model now runs the workflow" notice when the user switches models, so the run-model change isn't silent. - const [workflowModelNotice, setWorkflowModelNotice] = useState(null); - const workflowModelNoticeTimer = useRef | null>(null); - const [freeTrialModelNotice, setFreeTrialModelNotice] = useState<{ kind: 'connect' | 'spent'; label: string } | null>(null); - const freeTrialModelNoticeTimer = useRef | null>(null); - const freeTrialRemaining = useAppSelector((s) => s.settings.data.free_trial_remaining); - - // Read live in the stable handleSend/dispatchMessage closures without busting their memo (ChatInput leans on handleSend identity holding across renders). - const runContextRef = useRef(runContext); - runContextRef.current = runContext; - const onSendRunQuestionRef = useRef(onSendRunQuestion); - onSendRunQuestionRef.current = onSendRunQuestion; - - const wsRef = useRef | null>(null); - // Current status for the WS-cleanup closure (effect deps can't include it). - const statusRef = useRef(undefined); - const initialContextApplied = useRef(false); - const messageQueueRef = useRef([]); - const [queueLength, setQueueLength] = useState(0); - const [queueExpanded, setQueueExpanded] = useState(false); - const [editingQueueIdx, setEditingQueueIdx] = useState(null); - const [editingQueueText, setEditingQueueText] = useState(''); - const [dragIdx, setDragIdx] = useState(null); - const [dropTargetIdx, setDropTargetIdx] = useState(null); + const mcpActivation = useMcpActivation(dispatch, id); + const queue = useMessageQueue(); const isDraft = session?.status === 'draft'; const { greetingDone: welcomeGreetingDone } = useWelcomeGreeting(session, isDraft); - useEffect(() => { - if (!id || isDraft) return; - let cancelled = false; - let ws: ReturnType | null = null; - // Order matters: hydrate the persisted message list from REST FIRST, THEN connect the WS. The WS resume protocol replays buffered events starting at last_seq=0, which includes every stream_* event for messages that finished before the disconnect. The replay-skip guard in WebSocketManager._messageAlreadyComplete checks `session.messages` to decide whether to drop deltas, so if we connect first, the slice is empty when the replay arrives, the guard returns false, and the user sees the chat type itself out again. Awaiting fetchSession before connect makes the slice authoritative before any replay event lands. - (async () => { - // The await exists so the slice isn't EMPTY at replay time. A warm store (remount after a hop) already satisfies that, so connect immediately and let the fetch reconcile in the background; awaiting serialized a slow round trip in front of the live stream on every reopen. - const warm = !!store.getState().agents.sessions[id]?.messages?.length; - if (warm) { - dispatch(fetchSession(id)); - } else { - try { - const action = await dispatch(fetchSession(id)); - // Seed the resume cursor from the snapshot's seq so the connect below doesn't replay the whole ring buffer we just hydrated over REST. - if (fetchSession.fulfilled.match(action)) { - const seq = (action.payload as { event_seq?: number }).event_seq; - if (typeof seq === 'number') seedSessionSeq(id, seq); - } - } catch { - // Even if the REST hydrate fails, still connect, the WS resume protocol can hydrate from buffered events as a fallback. - } - } - if (cancelled) return; - // acquireSessionWs reuses a still-open socket kept alive from the last hop, so an active agent's stream resumes with no reconnect handshake. connect() is a no-op when the reused socket is already open. - ws = acquireSessionWs(id); - ws.connect(); - wsRef.current = ws; - })(); - return () => { - cancelled = true; - if (ws) { - const st = statusRef.current; - const active = st === 'running' || st === 'waiting_approval'; - releaseSessionWs(id, ws, active); - } - wsRef.current = null; - }; - }, [id, isDraft, dispatch]); - - useEffect(() => { - if (initialContextApplied.current || !initialContextPaths?.length) return; - const timer = setTimeout(() => { - chatInputRef.current?.setContent('', initialContextPaths); - initialContextApplied.current = true; - }, 50); - return () => clearTimeout(timer); - }, [initialContextPaths]); - - useEffect(() => { - if (session) setMode(session.mode); - }, [session?.mode]); - - useEffect(() => { - if (session) setModel(session.model); - }, [session?.model]); - - useEffect(() => { - if (Object.keys(modesMap).length === 0) dispatch(fetchModes()); - }, [dispatch, modesMap]); + const { + mode, setMode, model, modesMap, resolveModelLabel, + handleModeChange, handleModelChange, handleThinkingLevelChange, + workflowNotice, freeTrialNotice, + } = useModeModel({ id, isDraft, session, workflowEditId, connectionMode }); - const dispatchMessage = useCallback((msg: QueuedMessage) => { - if (!id) return; - setShowResumeBubble(false); - setAwaitingResponse(true); - if (isDraft) { - const config: Record = { model, mode }; - if (session?.system_prompt) config.system_prompt = session.system_prompt; - if (session?.target_directory) config.target_directory = session.target_directory; - // Carry the draft's dashboard so the launched session stays ON this dashboard; without it the session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes the instant you send (looked like "the chat quit when I clicked an option"). - if (session?.dashboard_id) config.dashboard_id = session.dashboard_id; - // Editing an existing app: bind the launch to it so the backend edits in place instead of seeding a duplicate empty app (App Builder mode only). - if (msg.selectedAppIds?.length) config.selected_app_output_ids = msg.selectedAppIds; - dispatch( - launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds, selectedSettingIds: msg.selectedSettingIds }) - ).then((action) => { - if (launchAndSendFirstMessage.fulfilled.match(action)) { - const realId = action.payload.session.id; - dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt })); - if (msg.selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' })); - } - } - }); - } else if (msg.attachedRunId && onSendRunQuestionRef.current) { - // Run-context question: the backend folds the run transcript into this one turn and echoes the user bubble + answer over WS, so no optimistic thunk. - onSendRunQuestionRef.current(msg.prompt, msg.attachedRunId).catch(() => setAwaitingResponse(false)); - } else { - if (msg.selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); - } - dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds, selectedSettingIds: msg.selectedSettingIds })) - .then((action) => { - if (sendMessageThunk.rejected.match(action)) { - setAwaitingResponse(false); - } - }); - } - }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, session?.dashboard_id, dispatch]); - - statusRef.current = session?.status; - - const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval')); - - const prevStatusRef = useRef(session?.status); - useEffect(() => { - const prev = prevStatusRef.current; - const curr = session?.status; - prevStatusRef.current = curr; - let didDispatchQueued = false; - - const wasActive = prev === 'running' || prev === 'waiting_approval'; - const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error'; - - if (wasActive && isTerminal) { - if (id) { - dispatch(fadeGlowingBrowserCards(id)); - setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 600); - } - - const nextQueued = messageQueueRef.current.shift(); - if (nextQueued) { - setQueueLength(messageQueueRef.current.length); - dispatchMessage(nextQueued); - didDispatchQueued = true; - } else { - if (curr === 'stopped') { - setShowResumeBubble(!isWorkflowRunSidecar); - } - } - - const currentMode = modesMap[mode]; - if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) { - setMode(currentMode.default_next_mode); - if (id && !isDraft) { - dispatch(updateSessionMode({ sessionId: id, mode: currentMode.default_next_mode as any })); - } - } - } - if (curr === 'running') { - setShowResumeBubble(false); - } - if (curr !== 'draft' && !didDispatchQueued) { - setAwaitingResponse(false); - } - }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage, isWorkflowRunSidecar]); - - // A reload remounts past the live running->stopped transition that first shows the resume button, so re-derive it once from the persisted 'stopped' status (transcript-gated so a cleared chat can't resurrect it). - const resumeHydratedRef = useRef(false); - useEffect(() => { - if (resumeHydratedRef.current) return; - if (session?.status === 'stopped' && (session?.messages?.length ?? 0) > 0) { - resumeHydratedRef.current = true; - setShowResumeBubble(true); - } - }, [session?.status, session?.messages?.length]); - - // Idle reconcile: if the session has been 'running' for 5s with no WebSocket activity (no new messages, no streaming updates), do a single GET to fetch the real status from the backend. Catches the case where the completion WebSocket event was dropped (network blip, sleep/wake, SDK subprocess dying). Resets on every activity signal so it never fires during normal streaming. - const reconcileTimer = useRef | null>(null); - const messageCount = session?.messages?.length ?? 0; - // Subscribe only to the streaming MESSAGE ID (stable across the 30Hz delta updates), never to the content. The actual streaming text renders inside the leaf below, which subscribes to the content itself. This keeps AgentChat's render and useEffects dormant during streaming; only the bubble updates per delta. + // Subscribe only to the streaming MESSAGE ID (stable across the 30Hz delta updates), never to the content. The actual streaming text renders inside the leaf (TranscriptFooter), which subscribes to the content itself. This keeps AgentChat's render and useEffects dormant during streaming; only the bubble updates per delta. const streamingMessageId = useAppSelector((s) => id ? s.streaming.bySession[id]?.id ?? null : null); const hasStreaming = !!streamingMessageId; + const justStreamedId = useJustStreamed(streamingMessageId); - useEffect(() => { - if ( - streamingMessageId || - session?.turn_label?.label || - session?.status === 'completed' || - session?.status === 'error' || - session?.status === 'stopped' - ) { - setPreSendActivityLabel(null); - } - }, [streamingMessageId, session?.turn_label?.label, session?.status]); - - useEffect(() => { - if (reconcileTimer.current) { - clearTimeout(reconcileTimer.current); - reconcileTimer.current = null; - } - - if (!id || session?.status !== 'running') return; - - reconcileTimer.current = setTimeout(() => { - reconcileTimer.current = null; - dispatch(fetchSession(id)); - }, 5000); - - return () => { - if (reconcileTimer.current) { - clearTimeout(reconcileTimer.current); - reconcileTimer.current = null; - } - }; - }, [id, session?.status, messageCount, hasStreaming, dispatch]); - - const SCROLL_THRESHOLD = 50; - - // Reserved pixel height for a render item: the measured height once we have one, otherwise a content-aware estimate (cached per id). The spacer math and the window solver both go through this so unmounted spacers, freshly-mounted placeholders, and the real rendered bubble all reserve the same space. - const reservedHeightForItem = useCallback((item: RenderItem | undefined): number => { - if (!item) return RENDER_ITEM_ESTIMATED_HEIGHT; - const measured = itemHeightsRef.current.get(item.id); - if (measured != null) return measured; - const cached = estimateCacheRef.current.get(item.id); - if (cached != null) return cached; - const est = estimateItemHeight(item, viewportWidthRef.current); - estimateCacheRef.current.set(item.id, est); - return est; - }, []); - - // Measured-or-estimated pixel height of render item at `index`, for the window solver (reads the renderItems ref so it is valid inside rAF callbacks). - const heightOf = useCallback((index: number): number => { - return reservedHeightForItem(renderItemsRef.current[index]); - }, [reservedHeightForItem]); - - // Solve the mounted window from the live scroll position and push it to state when it changes. Scroll position itself is preserved by the container's overflow-anchor plus the measured-height spacers, so we never touch scrollTop here. Following (pinned to bottom) always keeps the newest item. - const applyWindowFromScroll = useCallback(() => { - const el = scrollContainerRef.current; - if (!el) return; - if (!initialBottomScrollSettledRef.current) return; - const total = renderItemsLengthRef.current; - // Below the windowing threshold the whole transcript is mounted; recomputing a window here would only churn the spacers and shift scroll. Leave it alone. - if (total < WINDOW_MIN_ITEMS) return; - const clientHeight = Math.max(1, el.clientHeight); - const tightPx = WINDOW_BUFFER_SCREENS_PER_SIDE * clientHeight; - // Mount with the tight buffer, but keep already-mounted items until they drift a full extra screen past it. Without this, an item sitting right on the buffer edge flip-flops mounted/unmounted forever: mounting it shifts content above the viewport, overflow-anchor nudges scrollTop a few px, that re-runs the solver, which now excludes it, and round it goes. - const loosePx = tightPx + clientHeight; - const tight = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, tightPx); - const loose = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, loosePx); - const curStart = windowStartRef.current; - const curEnd = windowEndRef.current; - // Must-mount the tight band; keep current edges only while still inside loose. - let start = Math.max(loose.start, Math.min(curStart, tight.start)); - let end = Math.min(loose.end, Math.max(curEnd, tight.end)); - if (isAtBottomRef.current) end = total; - start = Math.max(0, Math.min(start, Math.max(0, end - 1))); - if (start === curStart && end === curEnd) return; - windowStartRef.current = start; - windowEndRef.current = end; - setWindowStart(start); - setWindowEnd(end); - }, [heightOf]); - - const scheduleWindowRecompute = useCallback(() => { - if (windowScrollRafRef.current != null) return; - windowScrollRafRef.current = requestAnimationFrame(() => { - windowScrollRafRef.current = null; - applyWindowFromScroll(); - }); - }, [applyWindowFromScroll]); + useSessionWs(id, isDraft, session?.status, { messageCount: session?.messages?.length ?? 0, hasStreaming }); - useEffect(() => { - const el = scrollContainerRef.current; - if (!el) return; - setScrollRoot(el); + useInitialContextPaths(chatInputRef, initialContextPaths); - const updateViewport = () => { - setViewportHeight(el.clientHeight); - setViewportWidth(el.clientWidth); - // Width drives the char-per-line estimate; drop cached estimates so they recompute at the new width (measured heights are unaffected and kept). - estimateCacheRef.current.clear(); - // Resize changes the budgets and how many items fit; re-solve the window off the current scroll position WITHOUT resetting it (only session / branch changes reset). overflow-anchor holds the visible content. - scheduleWindowRecompute(); - }; + const { + dispatchMessage, agentBusy, awaitingResponse, showResumeBubble, + preSendActivityLabel, setPreSendActivityLabel, handleResume, handleResetHistory, + } = useSendPipeline({ id, isDraft, session, mode, model, setMode, modesMap, queue, isWorkflowRunSidecar, streamingMessageId, onSendRunQuestion }); - updateViewport(); - const observer = new ResizeObserver(updateViewport); - observer.observe(el); - return () => { - observer.disconnect(); - if (windowScrollRafRef.current != null) { - cancelAnimationFrame(windowScrollRafRef.current); - windowScrollRafRef.current = null; - } - setScrollRoot(null); - }; - }, [id, session?.id, scheduleWindowRecompute]); - - // Burst-reveal bookkeeping: ids present at open (or after a session/branch hop) are HISTORY and - // never animate; only ids that appear later, while the agent is working, type themselves out. - const seenMessageIdsRef = useRef | null>(null); - - React.useLayoutEffect(() => { - seenMessageIdsRef.current = null; - const seed = initialSeedItems(viewportHeight); - const total = renderItemsLengthRef.current; - const end = total; - const start = Math.max(0, end - seed); - windowStartRef.current = start; - windowEndRef.current = end; - setWindowStart(start); - setWindowEnd(end); - itemHeightsRef.current.clear(); - estimateCacheRef.current.clear(); - if (initialPinRafRef.current != null) { - cancelAnimationFrame(initialPinRafRef.current); - initialPinRafRef.current = null; - } - pendingInitialBottomScrollRef.current = true; - initialBottomScrollSettledRef.current = false; - isAtBottomRef.current = true; - setShowScrollButton(false); - }, [id, session?.active_branch_id]); - - const handleScroll = useCallback(() => { - const el = scrollContainerRef.current; - if (!el) return; - // Measure against the real content bottom, not the locked-height pad below it. - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; - isAtBottomRef.current = atBottom; - setShowScrollButton(!atBottom); - // Slide the mounted window to follow the viewport (loads newer/older items and unloads ones that drifted past the buffer on either side). - scheduleWindowRecompute(); - }, [scheduleWindowRecompute]); - - // Prevent scroll from leaking into the dashboard canvas when at boundaries - useEffect(() => { - const el = scrollContainerRef.current; - if (!el) return; - const onWheel = (e: WheelEvent) => { - // Pinch-to-zoom (ctrl/meta + wheel) must reach the canvas viewport so the dashboard zooms when the cursor is over an agent's chat panel. Without this early-out the unconditional stopPropagation below kills ctrl+wheel and the canvas listener never fires. - if (e.ctrlKey || e.metaKey) return; - // Horizontal-dominant gestures must also reach the canvas so a sideways swipe pans the dashboard (chat has no horizontal scroll to absorb). - if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; - // Google Maps model: a plain wheel over a chat you haven't clicked INTO belongs to the canvas, so let it through instead of swallowing it here (this is what made zoom look dead over any chat). - const cardId = el.closest('[data-select-id]')?.getAttribute('data-select-id') ?? null; - if (cardId && cardId !== getScrollFocusedCard()) return; - const atTop = el.scrollTop <= 0; - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; - const scrollingDown = e.deltaY > 0; - const scrollingUp = e.deltaY < 0; - if ((scrollingUp && atTop) || (scrollingDown && atBottom)) { - e.preventDefault(); - } - e.stopPropagation(); - }; - el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); - }, []); - - const scrollToBottomRafRef = useRef(null); - const scrollToBottom = useCallback(() => { - const el = scrollContainerRef.current; - if (!el) return; - isAtBottomRef.current = true; - setShowScrollButton(false); - // When scrolled far up the newest items are unmounted behind the bottom spacer (estimated height). Jump the window to the bottom slice so they actually mount, then pin across several frames: a single scrollTop=scrollHeight lands short because the spacer collapses and the freshly-mounted items replace their estimates with real measured heights, changing scrollHeight. - const total = renderItemsLengthRef.current; - const start = Math.max(0, total - initialSeedItems(el.clientHeight)); - windowStartRef.current = start; - windowEndRef.current = total; - setWindowStart(start); - setWindowEnd(total); - if (scrollToBottomRafRef.current != null) cancelAnimationFrame(scrollToBottomRafRef.current); - let frame = 0; - const FRAMES = 16; // ~260ms, enough for the window + oversized blocks to settle - const pin = () => { - const c = scrollContainerRef.current; - if (!c) { scrollToBottomRafRef.current = null; return; } - c.scrollTop = c.scrollHeight; - lastScrollHeightRef.current = c.scrollHeight; - isAtBottomRef.current = true; - if (++frame < FRAMES) { - scrollToBottomRafRef.current = requestAnimationFrame(pin); - } else { - scrollToBottomRafRef.current = null; - // Jump has settled: re-evaluate oversized message / block visibility synchronously so nothing now in view is stuck as a placeholder. - c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT)); - } - }; - pin(); - }, []); - - const scrollRafRef = useRef(null); - const pinRafRef = useRef(null); - const initialPinRafRef = useRef(null); - const lastScrollHeightRef = useRef(0); - // Shared scroll-stick routine. Used both by the structural-events useEffect below (new message lands / stream starts/ends) and by StreamingBubble's onStreamGrew callback (per-delta growth). RAF + height-grew gate ensures we only set scrollTop when needed. - const stickToBottomIfNeeded = useCallback(() => { - if (!isAtBottomRef.current) return; - if (scrollRafRef.current != null) return; - scrollRafRef.current = requestAnimationFrame(() => { - scrollRafRef.current = null; - if (!isAtBottomRef.current) return; - const el = scrollContainerRef.current; - if (!el) return; - const newHeight = el.scrollHeight; - if (newHeight === lastScrollHeightRef.current) return; - lastScrollHeightRef.current = newHeight; - el.scrollTop = newHeight; - }); - }, []); - useEffect(() => { - stickToBottomIfNeeded(); - // Structural triggers only: a new message lands or a stream starts/ends. Streaming content updates trigger this via instead so AgentChat stays dormant during the 30Hz delta storm. - }, [session?.messages.length, streamingMessageId, stickToBottomIfNeeded]); - - // While a stream is LIVE, pin every frame: smooth-text grows between onStreamGrew callbacks, and the callback's own rAF deferral let the bottom drift up to ~100px for several frames. Parks the moment the stream ends, so idle cost is zero. - useEffect(() => { - if (!streamingMessageId) return undefined; - let raf = 0; - const pin = () => { - const el = scrollContainerRef.current; - if (el && isAtBottomRef.current && el.scrollHeight !== lastScrollHeightRef.current) { - lastScrollHeightRef.current = el.scrollHeight; - el.scrollTop = el.scrollHeight; - } - raf = requestAnimationFrame(pin); - }; - raf = requestAnimationFrame(pin); - return () => cancelAnimationFrame(raf); - }, [streamingMessageId]); - - // Stream-end re-stick. When a stream finishes, the live bubble (smooth-revealed text) is replaced by the committed bubble rendering FULL markdown with contentVisibility placeholders; as those resolve, Chromium's overflow-anchor re-anchors to an EARLIER element (the user message), yanking the view up to "the top of the user input". A single deferred scroll loses the race because that anchor shift fires an onScroll that flips isAtBottomRef false before we run. Fix: snapshot the "was following" intent the moment streaming stops (captured continuously during the stream, before any completion re-render), then pin to bottom across a short multi-frame window that OVERRIDES the layout-induced flip. A genuine user scroll-away (wheel/touch) during that window aborts the pin, honoring "unless the user scrolls up". - const prevStreamingIdRef = useRef(null); - const wasFollowingRef = useRef(true); - const pinAbortRef = useRef(false); - // Keep the follow-intent fresh while streaming so it's accurate at the instant the stream ends (handleScroll updates isAtBottomRef on every real scroll). - if (streamingMessageId) wasFollowingRef.current = isAtBottomRef.current; - useEffect(() => { - const prev = prevStreamingIdRef.current; - prevStreamingIdRef.current = streamingMessageId; - if (!(prev && !streamingMessageId)) return; - if (!wasFollowingRef.current) return; // user had scrolled up; leave them be - pinAbortRef.current = false; - const el = scrollContainerRef.current; - if (!el) return; - // Abort the pin only on a deliberate scroll-away gesture, not the layout-induced onScroll the commit itself triggers. - const onUserScrollAway = (e: Event) => { - if ((e as WheelEvent).deltaY != null && (e as WheelEvent).deltaY < 0) pinAbortRef.current = true; // wheel up - else if (e.type === 'touchmove') pinAbortRef.current = true; - }; - el.addEventListener('wheel', onUserScrollAway, { passive: true }); - el.addEventListener('touchmove', onUserScrollAway, { passive: true }); - let frame = 0; - const FRAMES = 18; // ~300ms at 60fps, long enough for async highlight/layout - const pin = () => { - if (pinAbortRef.current) { cleanup(); return; } - const c = scrollContainerRef.current; - if (c) { c.scrollTop = c.scrollHeight; lastScrollHeightRef.current = c.scrollHeight; isAtBottomRef.current = true; } - if (++frame < FRAMES) { pinRafRef.current = requestAnimationFrame(pin); } - else cleanup(); - }; - const cleanup = () => { - el.removeEventListener('wheel', onUserScrollAway); - el.removeEventListener('touchmove', onUserScrollAway); - if (pinRafRef.current != null) { cancelAnimationFrame(pinRafRef.current); pinRafRef.current = null; } - }; - pinRafRef.current = requestAnimationFrame(pin); - return cleanup; - }, [streamingMessageId]); - - // A tool's live pill is already on screen when it commits, so re-running the mount reveal on the committed bubble flashes the exact same row. Remember the id that just stopped streaming for a beat and let that one bubble skip its entrance, so the hand-off is seamless. 500ms is slack for the commit render to land after the stream clears (they don't always arrive on the same frame). - const [justStreamedId, setJustStreamedId] = useState(null); - const justStreamPrevRef = useRef(null); - useEffect(() => { - const prev = justStreamPrevRef.current; - justStreamPrevRef.current = streamingMessageId; - if (prev && !streamingMessageId) { - setJustStreamedId(prev); - const t = setTimeout(() => setJustStreamedId(null), 500); - return () => clearTimeout(t); - } - }, [streamingMessageId]); + const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval'; + const { activeBranchMessages, renderItems, lastAssistantIdsInTurn, lastPendingAskCallId, contextEstimate, getSiblingBranches } = + useTranscriptDerivations({ session, model, streamingMessageId, sessionRunning }); + const { browserAnchorItemId, browserSlot } = useDockedBrowserSlot({ id, c, renderItems }); + const seenMessageIds = useBurstRevealTracking(id, session?.active_branch_id); + + const scroll = useMessageScroll({ + renderItems, + streamingMessageId, + sessionId: session?.id, + activeBranchId: session?.active_branch_id, + messagesLength: session?.messages?.length, + id, + }); + // Destructure ONLY the stable useCallback'd members that feed dep arrays / memoized children + // (callback-identity rule: never put the hook object itself in a dep array). + const { scrollToBottom, stickToBottomIfNeeded } = scroll; - useEffect(() => () => { - if (scrollRafRef.current != null) { - cancelAnimationFrame(scrollRafRef.current); - scrollRafRef.current = null; - } - if (pinRafRef.current != null) { - cancelAnimationFrame(pinRafRef.current); - pinRafRef.current = null; - } - if (initialPinRafRef.current != null) { - cancelAnimationFrame(initialPinRafRef.current); - initialPinRafRef.current = null; - } - if (scrollToBottomRafRef.current != null) { - cancelAnimationFrame(scrollToBottomRafRef.current); - scrollToBottomRafRef.current = null; - } - }, []); + // Read live in the stable handleSend closure without busting its memo (ChatInput leans on handleSend identity holding across renders). + const runContextRef = useRef(runContext); + runContextRef.current = runContext; // useCallback so ChatInput's memo equality holds across AgentChat re-renders driven by unrelated session state. Captures agentBusy through the dependency so a stale "busy" closure doesn't ever route a message past the queue. const handleSend = useCallback( @@ -910,8 +143,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose scrollToBottom(); const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds, attachedRunId: runContextRef.current?.runId }; if (agentBusy) { - messageQueueRef.current.push(msg); - setQueueLength(messageQueueRef.current.length); + queue.enqueue(msg); return; } dispatchMessage(msg); @@ -919,620 +151,12 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose [id, scrollToBottom, agentBusy, dispatchMessage], ); - const handleModeChange = useCallback((newMode: string) => { - setMode(newMode); - if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode })); - }, [id, isDraft, dispatch]); - - const handleModelChange = useCallback((newModel: string) => { - // On the trial only Haiku is funded; picking anything else needs a connected provider, and once runs are spent nothing local works, so warn and keep the funded model instead of snagging. - if (connectionMode === 'free-trial') { - const kind: 'connect' | 'spent' | null = - (freeTrialRemaining ?? 0) <= 0 ? 'spent' : (newModel !== 'haiku' ? 'connect' : null); - if (kind) { - setFreeTrialModelNotice({ kind, label: resolveModelLabel(newModel) }); - if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current); - freeTrialModelNoticeTimer.current = setTimeout(() => setFreeTrialModelNotice(null), 6000); - return; - } - } - if (workflowEditId && newModel !== model) { - setWorkflowModelNotice(resolveModelLabel(newModel)); - if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); - workflowModelNoticeTimer.current = setTimeout(() => setWorkflowModelNotice(null), 5000); - } - // Picked a usable model: drop any stale notice now (fades out in ~220ms) instead of letting it sit out its timer. - setFreeTrialModelNotice(null); - if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current); - setModel(newModel); - if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); - }, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel, connectionMode, freeTrialRemaining]); - - useEffect(() => () => { - if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); - if (freeTrialModelNoticeTimer.current) clearTimeout(freeTrialModelNoticeTimer.current); - }, []); + useToolGroupMeta(id, isDraft, renderItems, session?.tool_group_meta); - const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => { - if (!id) return; - dispatch(updateSessionThinkingLevel({ sessionId: id, level })); - if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level })); - }, [id, isDraft, dispatch]); - - const handleApprove = (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => { - dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput, trustPattern, setAlwaysAllow: alwaysAllow })); - }; - - const handleDeny = (requestId: string, message?: string) => { - dispatch(handleApproval({ requestId, behavior: 'deny', message })); - }; - - const handleStop = useCallback(() => { - if (!id) return; - // A watched live workflow run mirrors the workflow card's Stop: fully stop the run, not just pause the agent. Test Agent + plain chats stop the session. - if (linkedSidecar?.kind === 'watching' && linkedSidecar.runId) { - dispatch(controlWorkflowRun({ runId: linkedSidecar.runId, action: 'stop' })); - return; - } - dispatch(stopAgent({ sessionId: id })); - }, [id, dispatch, linkedSidecar]); - - // Finished Test Agent card: drop the tether + remove this card, and either commit the workflow draft (Save, same as the edit card's "save now") or leave the draft untouched so the user keeps editing. - const onTestContinueEditing = useCallback(() => { - if (linkedWorkflowId) dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null })); - if (id) dispatch(removeCard(id)); - }, [linkedWorkflowId, id, dispatch]); - - const onTestSaveWorkflow = useCallback(async () => { - if (linkedWorkflowId) { - try { - await dispatch(commitDraft(linkedWorkflowId)).unwrap(); - } catch { - return; - } - dispatch(updateWorkflowCard({ workflowId: linkedWorkflowId, patch: { view: 'saved' } })); - dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null })); - } - if (id) dispatch(removeCard(id)); - }, [linkedWorkflowId, id, dispatch]); - - const handleResume = useCallback(() => { - if (!id) return; - setShowResumeBubble(false); - dispatch(sendMessageThunk({ - sessionId: id, - prompt: "Continue your previous response from exactly where it was cut off. Do not repeat anything you already wrote; pick up mid-sentence if you need to and keep going.", - mode, - model, - hidden: true, - })); - }, [id, mode, model, dispatch]); - - const [editingMessageId, setEditingMessageId] = useState(null); - - const handleSaveEdit = useCallback( - (messageId: string, newContent: string) => { - if (!id) return; - dispatch(editMessage({ sessionId: id, messageId, content: newContent })); - setEditingMessageId(null); - }, - [id, dispatch] - ); - - const handleCancelEdit = useCallback(() => { - setEditingMessageId(null); - }, []); - - const activeBranchMessages = useMemo(() => { - if (!session) return []; - const branchId = session.active_branch_id || 'main'; - const branch = session.branches?.[branchId]; - - if (!branch || !branch.fork_point_message_id) { - return session.messages.filter((m) => m.branch_id === 'main' || m.branch_id === branchId); - } - - const segments: Array<{ branchId: string; upToMessageId?: string }> = []; - let cur = branch; - let curId = branchId; - while (cur && cur.fork_point_message_id) { - segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id }); - curId = cur.parent_branch_id || 'main'; - cur = session.branches?.[curId]; - } - segments.unshift({ branchId: curId }); - - const result: typeof session.messages = []; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - const nextForkMsgId = seg.upToMessageId; - if (nextForkMsgId) { - const forkIdx = session.messages.findIndex((m) => m.id === nextForkMsgId); - const pre = session.messages - .slice(0, forkIdx) - .filter((m) => m.branch_id === seg.branchId); - result.push(...pre); - } else if (i < segments.length - 1) { - const nextFork = segments[i + 1].upToMessageId; - const forkIdx = nextFork - ? session.messages.findIndex((m) => m.id === nextFork) - : session.messages.length; - result.push( - ...session.messages.slice(0, forkIdx).filter((m) => m.branch_id === seg.branchId) - ); - } else { - result.push(...session.messages.filter((m) => m.branch_id === seg.branchId)); - } - } - const leafMsgs = session.messages.filter((m) => m.branch_id === branchId); - if (!result.some((m) => m.branch_id === branchId)) { - result.push(...leafMsgs); - } - return result; - }, [session?.messages, session?.active_branch_id, session?.branches]); - - const handleRegenerate = useCallback( - (assistantMsg: AgentMessage) => { - if (!id) return; - const idx = activeBranchMessages.findIndex((m) => m.id === assistantMsg.id); - for (let i = idx - 1; i >= 0; i--) { - if (activeBranchMessages[i].role === 'user') { - const userMsg = activeBranchMessages[i]; - const content = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content); - dispatch(editMessage({ sessionId: id, messageId: userMsg.id, content })); - break; - } - } - }, - [id, activeBranchMessages, dispatch] - ); - - const handleBranchChat = useCallback(async (upToMessageId: string) => { - if (!id) return; - const dashId = session?.dashboard_id; - const action = await dispatch(duplicateSession({ sessionId: id, dashboardId: dashId, upToMessageId })); - if (duplicateSession.fulfilled.match(action)) { - if (onBranch) { - onBranch(action.payload.id); - } else { - dispatch(setActiveSession(action.payload.id)); - } - } - }, [id, dispatch, onBranch, session?.dashboard_id]); - - const contextEstimate = useMemo(() => { - // Prefer the live API-reported input token count once we have one (session.tokens.input includes the full request: messages + system + tool defs + cached prefix). That number is authoritative because Anthropic counts it against the context window. Before the first turn completes, fall back to a char/4 estimate of visible message content as a rough pre-send hint. - let limit = 0; - for (const ms of Object.values(modelsByProvider)) { - const hit = ms.find((m) => m.value === model); - if (hit?.context_window) { limit = hit.context_window; break; } - } - if (!limit) limit = (session?.context_window) || CONTEXT_WINDOWS[model] || 200_000; - const liveInput = session?.tokens?.input ?? 0; - if (liveInput > 0) { - return { used: liveInput, limit }; - } - let totalChars = 0; - if (session?.system_prompt) totalChars += session.system_prompt.length; - for (const msg of activeBranchMessages) { - totalChars += stringifyContent(msg.content).length; - } - const used = Math.round(totalChars / 4); - return { used, limit }; - // Streaming content's contribution to the context estimate is no longer included here: we'd have to subscribe to the streaming text and re-run this sum on every painted character, defeating the whole point of isolating AgentChat from delta updates. The header gauge will catch up when stream_end commits the message. - }, [activeBranchMessages, session?.system_prompt, session?.tokens?.input, session?.context_window, streamingMessageId, model, modelsByProvider]); - - const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval'; - const lastPendingAskCallId = useMemo( - () => extractPendingAskUi(session?.messages || [])?.call.id ?? null, - [session?.messages], - ); - - const renderItems: RenderItem[] = useMemo(() => { - const items: RenderItem[] = []; - let i = 0; - // Live-updating cards: repeated ShowUI calls with the SAME component+props.id are one card that - // UPDATES IN PLACE at its first position (progress advances, data refreshes), never a stack of - // stale snapshots. Pre-scan maps each id key to its first slot and its latest call+result. - const firstCallIdByKey = new Map(); - const latestByKey = new Map(); - const keyByCallId = new Map(); - for (let s = 0; s < activeBranchMessages.length; s++) { - const m = activeBranchMessages[s]; - const mc = m.content; - if (m.role !== 'tool_call' || typeof mc !== 'object' || !/(^|__)ShowUI$/.test(String(mc?.tool || ''))) continue; - const input = mc?.input as { component?: unknown; props?: { id?: unknown } } | undefined; - const compId = input?.props?.id; - if (!input?.component || typeof compId !== 'string' || !compId) continue; - const key = `${input.component}:${compId}`; - keyByCallId.set(m.id, key); - if (!firstCallIdByKey.has(key)) firstCallIdByKey.set(key, m.id); - const next = activeBranchMessages[s + 1]; - latestByKey.set(key, { call: m, result: next && next.role === 'tool_result' ? next : null }); - } - // Narration that led INTO a tool phase; folds into that phase's group on a finished session. - let leadNotes: typeof activeBranchMessages = []; - while (i < activeBranchMessages.length) { - const msg = activeBranchMessages[i]; - if (msg.role === 'tool_call' || msg.role === 'tool_result') { - const group: typeof activeBranchMessages = []; - // On a finished session the whole tool PHASE folds into one quiet row: short narration - // LEADING INTO or BETWEEN tool runs is absorbed (readable on expand), only the final - // answer stays out. While running, narration streams visibly, so the phase never folds live. - const noteMarks: Array<{ afterCall: number; msg: (typeof activeBranchMessages)[number] }> = - leadNotes.map((m) => ({ afterCall: 0, msg: m })); - leadNotes = []; - let callsSoFar = 0; - while (i < activeBranchMessages.length) { - const m = activeBranchMessages[i]; - if (m.role === 'tool_call' || m.role === 'tool_result') { - group.push(m); - if (m.role === 'tool_call') callsSoFar++; - i++; - continue; - } - if (!sessionRunning && m.role === 'assistant') { - let j = i; - while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++; - const next = activeBranchMessages[j]; - const absorbable = activeBranchMessages.slice(i, j).every((a) => isNarration(a.content)); - // A long or structured message is the answer, not narration. Absorbing it hides the whole deliverable in a grey tool row and strips its markdown, which is worse than showing one redundant line. - if (next && absorbable && (next.role === 'tool_call' || next.role === 'tool_result')) { - for (let k = i; k < j; k++) { - if (!activeBranchMessages[k].hidden) noteMarks.push({ afterCall: callsSoFar, msg: activeBranchMessages[k] }); - } - i = j; - continue; - } - } - break; - } - - const allCalls = group.filter((m) => m.role === 'tool_call'); - const results = group.filter((m) => m.role === 'tool_result'); - // Index pairing mispairs any parallel batch (first-completing result lands on the first call, killing a live AskUI card, ENG-232); pair by tool_use_id when the result carries one, index only for legacy unkeyed results. - const resultById = new Map(); - for (const r of results) { - const rid = resultToolUseId(r); - if (rid && !resultById.has(rid)) resultById.set(rid, r); - } - const allPairs: ToolPair[] = allCalls.map((call, idx) => { - const byId = resultById.get(callToolUseId(call)); - const indexed = results[idx] || null; - return { - type: 'tool_pair' as const, - id: `pair-${call.id}`, - call, - result: byId ?? (indexed && !resultToolUseId(indexed) ? indexed : null), - }; - }); - - // ShowUI/AskUI calls render as inline components, never buried inside a collapsed group. - // They typically cap a run of work, so the quiet group row stays above the widget. - const showUiPairs = allPairs.filter((p) => isShowUiPair(p) || isAskUiPair(p)); - const pairs = allPairs.filter((p) => !isShowUiPair(p) && !isAskUiPair(p)); - const calls = pairs.map((p) => p.call); - - // Folded narration goes back at its original position among the visible pairs. - const groupEntries: ToolGroupEntry[] | undefined = (() => { - if (noteMarks.length === 0) return undefined; - const entries: ToolGroupEntry[] = []; - let noteIdx = 0; - const noteText = (m: (typeof activeBranchMessages)[number]) => - typeof m.content === 'string' ? m.content : ''; - allPairs.forEach((pair, idx) => { - while (noteIdx < noteMarks.length && noteMarks[noteIdx].afterCall <= idx) { - entries.push({ kind: 'note', id: `note-${noteMarks[noteIdx].msg.id}`, text: noteText(noteMarks[noteIdx].msg) }); - noteIdx++; - } - if (!isShowUiPair(pair) && !isAskUiPair(pair)) entries.push({ kind: 'pair', pair }); - }); - while (noteIdx < noteMarks.length) { - entries.push({ kind: 'note', id: `note-${noteMarks[noteIdx].msg.id}`, text: noteText(noteMarks[noteIdx].msg) }); - noteIdx++; - } - return entries; - })(); - - const mcpServers = new Set( - calls.map((m) => { - const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; - const match = tool.match(/^mcp__([^_]+(?:-[^_]+)*)__/); - return match ? match[1] : ''; - }).filter(Boolean) - ); - // Only wrap MCP calls in a group when there's more than one; a lone call would just double up the group header on top of its own (e.g. "Browser Navigation 1/1" over "Opened a browser"), so render it bare instead. - const allSameMcp = mcpServers.size === 1 && pairs.length > 1; - - if (allSameMcp) { - const mcpServer = [...mcpServers][0]; - const toolNames = new Set( - calls.map((m) => (typeof m.content === 'object' ? m.content.tool : '')) - ); - const label = - toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; - items.push({ - type: 'tool_group', - id: `group-${group[0].id}`, - pairs, - label, - callCount: calls.length, - mcpServer, - entries: groupEntries, - } satisfies ToolGroup); - } else if (sessionRunning && pairs.length <= 2 && !groupEntries) { - // Live turns keep bare rows for streaming detail; finished transcripts always rest as the quiet group row. - items.push(...pairs); - } else if (pairs.length > 0) { - const toolNames = new Set( - calls.map((m) => (typeof m.content === 'object' ? m.content.tool : '')) - ); - const label = - toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`; - items.push({ - type: 'tool_group', - id: `group-${group[0].id}`, - pairs, - label, - callCount: calls.length, - entries: groupEntries, - } satisfies ToolGroup); - } else if (noteMarks.length > 0) { - // Phase held only ShowUI/AskUI pairs: narration has no group to fold into, keep it visible. - for (const nm of noteMarks) items.push(nm.msg); - } - for (const p of showUiPairs) { - const key = keyByCallId.get(p.call.id); - if (!key || isAskUiPair(p)) { - items.push(p); - continue; - } - // Later updates render nowhere themselves; the first slot always shows the latest call - // under a STABLE key so React updates the mounted component instead of remounting it. - if (p.call.id !== firstCallIdByKey.get(key)) continue; - const latest = latestByKey.get(key); - items.push({ - type: 'tool_pair' as const, - id: `showui-${key}`, - call: latest ? latest.call : p.call, - result: latest ? latest.result : p.result, - }); - } - } else { - if (!sessionRunning && msg.role === 'assistant') { - let j = i; - while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++; - const next = activeBranchMessages[j]; - if (next && (next.role === 'tool_call' || next.role === 'tool_result')) { - leadNotes = activeBranchMessages.slice(i, j).filter((m) => !m.hidden); - i = j; - continue; - } - } - if (!msg.hidden) { - items.push(msg); - } - i++; - } - } - return items; - }, [activeBranchMessages, sessionRunning]); - - // The docked browser anchors at the LAST browser-agent tool row (one live browser, latest work site wins); no row yet falls back to the end-of-transcript slot. - const browserAnchorItemId = useMemo((): string | null => { - const isBrowserCall = (m: AgentMessage): boolean => - m.role === 'tool_call' && typeof m.content === 'object' && String((m.content as { tool?: string })?.tool || '').toLowerCase().endsWith('browseragent'); - let anchor: string | null = null; - for (const item of renderItems) { - if (isToolGroup(item)) { if (item.pairs.some((p) => isBrowserCall(p.call))) anchor = item.id; } - else if (isToolPair(item)) { if (isBrowserCall(item.call)) anchor = item.id; } - else if (isBrowserCall(item as AgentMessage)) anchor = item.id; - } - return anchor; - }, [renderItems]); - - React.useLayoutEffect(() => { - const total = renderItems.length; - renderItemsLengthRef.current = total; - renderItemsRef.current = renderItems; - const seed = initialSeedItems(viewportHeight); - let start = windowStartRef.current; - let end = windowEndRef.current; - if (isAtBottomRef.current || end === 0) { - // Following the live tail: keep the newest item mounted and unload the oldest beyond a bounded recent slice so memory stays flat as the transcript grows. The pixel solver refines this seed on the next scroll. - end = total; - start = Math.max(0, end - seed); - } else { - // Scrolled up: just keep the existing window valid against the new length. - end = Math.min(end, total); - start = Math.min(start, Math.max(0, end - 1)); - } - if (start !== windowStartRef.current) { windowStartRef.current = start; setWindowStart(start); } - if (end !== windowEndRef.current) { windowEndRef.current = end; setWindowEnd(end); } - }, [id, renderItems, viewportHeight]); - - const total = renderItems.length; - // Small chats render whole (no windowing): forces the full slice so both spacer loops sum to 0, which removes the recompute-driven scroll jump entirely. - const windowingActive = total >= WINDOW_MIN_ITEMS; - const safeWindowEnd = !windowingActive ? total : (windowEnd > 0 ? Math.min(windowEnd, total) : total); - const safeWindowStart = !windowingActive ? 0 : Math.min(Math.max(0, windowStart), Math.max(0, safeWindowEnd - 1)); - const visibleStartIndex = safeWindowStart; - const visibleRenderItems = useMemo( - () => renderItems.slice(safeWindowStart, safeWindowEnd), - [renderItems, safeWindowStart, safeWindowEnd] - ); - const renderedVisibleItems = useMemo( - () => visibleRenderItems.filter((item) => !streamingMessageId || item.id !== streamingMessageId), - [streamingMessageId, visibleRenderItems] - ); - // Keep the ref the height estimator reads in sync with the live viewport width. - viewportWidthRef.current = viewportWidth; - - // Measure mounted item heights so the spacers that stand in for unmounted items keep the scrollbar geometry stable (no jump when unloading above). - React.useLayoutEffect(() => { - const el = scrollContainerRef.current; - if (!el) return; - let changed = false; - el.querySelectorAll('[data-window-item-id]').forEach((node) => { - const itemId = node.dataset.windowItemId; - if (!itemId) return; - const h = node.offsetHeight; - if (h <= 0) return; - const prev = itemHeightsRef.current.get(itemId); - if (prev === undefined || Math.abs(prev - h) > 1) { - itemHeightsRef.current.set(itemId, h); - changed = true; - } - }); - // Guarded so this converges: once heights stop moving, no more version bumps. - if (changed) setHeightVersion((v) => v + 1); - }); - - // Spacers reserve the cumulative height of the unmounted items above/below the window. heightVersion gates recompute off the ref-held measurements; we index the render-scope renderItems directly so id->height stays correct on the frame the transcript changes. - const topSpacerHeight = useMemo(() => { - let h = 0; - for (let i = 0; i < safeWindowStart; i++) h += reservedHeightForItem(renderItems[i]); - return h; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [renderItems, safeWindowStart, heightVersion, reservedHeightForItem]); - const bottomSpacerHeight = useMemo(() => { - let h = 0; - for (let i = safeWindowEnd; i < total; i++) h += reservedHeightForItem(renderItems[i]); - return h; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [renderItems, safeWindowEnd, total, heightVersion, reservedHeightForItem]); - - React.useLayoutEffect(() => { - if (pendingInitialBottomScrollRef.current) { - const el = scrollContainerRef.current; - if (!el || visibleRenderItems.length === 0) return; - let frame = 0; - const FRAMES = 8; - const pin = () => { - const c = scrollContainerRef.current; - if (!c) return; - lastVisibleItemRef.current?.scrollIntoView({ block: 'end' }); - c.scrollTop = Math.max(0, Math.min(c.scrollTop, c.scrollHeight - c.clientHeight)); - lastScrollHeightRef.current = c.scrollHeight; - isAtBottomRef.current = true; - setShowScrollButton(false); - if (++frame < FRAMES) { - initialPinRafRef.current = requestAnimationFrame(pin); - } else { - initialPinRafRef.current = null; - initialBottomScrollSettledRef.current = true; - // The open slice is sized by item COUNT; now that we're settled and measured, trim it down to the pixel-based band so tall messages high in the slice unload instead of sitting fully rendered off-screen. - scheduleWindowRecompute(); - // Re-evaluate visibility now the open jump has settled, so an oversized newest message isn't left stuck as a placeholder. - c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT)); - } - }; - if (initialPinRafRef.current != null) { - cancelAnimationFrame(initialPinRafRef.current); - initialPinRafRef.current = null; - } - pendingInitialBottomScrollRef.current = false; - pin(); - } - }, [id, session?.active_branch_id, renderItems.length, renderedVisibleItems.length, visibleStartIndex]); - - const lastAssistantIdsInTurn = useMemo(() => { - const ids = new Set(); - let lastAssistantId: string | null = null; - for (const item of renderItems) { - if (!isToolGroup(item) && !isToolPair(item)) { - const msg = item as AgentMessage; - if (msg.role === 'assistant') { - lastAssistantId = msg.id; - } else if (msg.role === 'user') { - if (lastAssistantId) ids.add(lastAssistantId); - lastAssistantId = null; - } - } - } - if (lastAssistantId) ids.add(lastAssistantId); - return ids; - }, [renderItems]); - - const groupMetaRequestedRef = useRef>(new Set()); - const groupMetaRefinedRef = useRef>(new Set()); - - useEffect(() => { - if (!id || isDraft) return; - const toolGroups = renderItems.filter(isToolGroup) as ToolGroup[]; - const meta = session?.tool_group_meta ?? {}; - - for (const group of toolGroups) { - const allDone = group.pairs.every((p) => p.result !== null); - - if (!groupMetaRequestedRef.current.has(group.id) && !meta[group.id]) { - groupMetaRequestedRef.current.add(group.id); - const toolCalls = group.pairs.map((p) => { - const c = p.call.content; - const tool = typeof c === 'object' ? c.tool || '' : ''; - const input = typeof c === 'object' ? c.input : ''; - const mcp = parseMcpToolName(tool); - const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : ''; - const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120)); - return { tool, input_summary: summary }; - }); - dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls })); - } - - if (allDone && meta[group.id] && !meta[group.id].is_refined && !groupMetaRefinedRef.current.has(group.id)) { - groupMetaRefinedRef.current.add(group.id); - const toolCalls = group.pairs.map((p) => { - const c = p.call.content; - const tool = typeof c === 'object' ? c.tool || '' : ''; - const input = typeof c === 'object' ? c.input : ''; - const mcp = parseMcpToolName(tool); - const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : ''; - const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120)); - return { tool, input_summary: summary }; - }); - const resultsSummary = group.pairs - .filter((p) => p.result) - .map((p) => { - const rc = p.result!.content; - const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? rc.text : JSON.stringify(rc); - return text.slice(0, 150); - }); - dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls, resultsSummary, isRefinement: true })); - } - } - }, [renderItems, id, isDraft, session?.tool_group_meta, dispatch]); - - const getSiblingBranches = useCallback( - (messageId: string): string[] => { - if (!session?.branches) return []; - - const directForks = Object.values(session.branches) - .filter((b) => b.fork_point_message_id === messageId) - .map((b) => b.id); - if (directForks.length > 0) { - const originalMsg = session.messages.find((m) => m.id === messageId); - const parentBranchId = originalMsg?.branch_id || 'main'; - return [parentBranchId, ...directForks]; - } - - const msg = session.messages.find((m) => m.id === messageId); - if (!msg || msg.role !== 'user') return []; - const msgBranch = session.branches[msg.branch_id]; - if (!msgBranch?.fork_point_message_id) return []; - const branchUserMsgs = session.messages.filter( - (m) => m.branch_id === msg.branch_id && m.role === 'user' - ); - if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return []; - - const forkPointId = msgBranch.fork_point_message_id; - const siblingBranches = Object.values(session.branches) - .filter((b) => b.fork_point_message_id === forkPointId) - .map((b) => b.id); - const parentBranchId = msgBranch.parent_branch_id || 'main'; - return [parentBranchId, ...siblingBranches]; - }, - [session?.branches, session?.messages] - ); + const { + editingMessageId, setEditingMessageId, handleSaveEdit, handleCancelEdit, + handleRegenerate, handleBranchChat, handleSwitchBranch, + } = useBranchActions({ id, session, activeBranchMessages, onBranch }); if (!session) { return ( @@ -1545,1014 +169,86 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } const branchNavLocked = agentBusy || hasStreaming; - const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; + // Structured view model for the per-item transcript render (TranscriptItem); rebuilt each render, + // same staleness semantics as the previously-inline closures. + const itemVm: TranscriptItemVm = { + session, id, c, renderItems, sessionRunning, awaitingResponse, justStreamedId, lastPendingAskCallId, seenMessageIds, + editingMessageId, lastAssistantIdsInTurn, branchNavLocked, + viewportHeight: scroll.viewportHeight, + viewportWidth: scroll.viewportWidth, + scrollRoot: scroll.scrollRoot, + getSiblingBranches, + onSaveEdit: handleSaveEdit, + onCancelEdit: handleCancelEdit, + onStartEdit: setEditingMessageId, + onRegenerate: handleRegenerate, + onBranch: handleBranchChat, + onSwitchBranch: handleSwitchBranch, + onStreamGrew: stickToBottomIfNeeded, + }; return ( {!embedded && ( - - - - - {(t) => {t}} - - {!isDraft && statusStyle && session.status !== 'completed' && session.status !== 'stopped' && ( - // Status speaks only when it needs the user; finished work sits quiet. - - - {friendlyStatusLabel(session.status)} - - - )} - - {(!isDraft || session.is_welcome_draft) && ( - // Welcome draft shows just the model so the header isn't bare; real runs add branch + cost. - - - {resolveModelLabel(session.model)} - - {!isDraft && session.branch_name && ( - - {session.branch_name} - - )} - {(() => { - if (!(session.cost_usd > 0)) return null; - // The SDK reports a per-call $ figure regardless of how the request was routed. For requests that went through a subscription path, that figure is misleading, the user pays flat-rate. Show "subscription" instead in those cases. Show $ only when the call was actually metered (Anthropic API key, OpenAI API key, etc.). Model-id signals (these are short_name values from the BUILTIN_MODELS registry): - `*-api` → pinned Anthropic API key (METERED) - `*-cc` → pinned Claude Pro/Max via 9Router (sub) - plain sonnet/opus/haiku + openswarm-pro mode → Pro proxy (sub) - plain sonnet/opus/haiku + own_key mode → API key (METERED) - gpt-5.4* / gpt-5.3* → ChatGPT Plus/Pro via 9Router (sub) - gemini-* → Gemini Advanced via 9Router (sub) - const m = (session.model || '').toLowerCase(); - const isApiRoute = m.endsWith('-api'); - if (isApiRoute) { - return ( - - ${session.cost_usd.toFixed(4)} - - ); - } - const isCcRoute = m.endsWith('-cc'); - const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku'; - const isProRoute = isPlainAnthropic && connectionMode === 'openswarm-pro'; - const isOwnKeyAnthropic = isPlainAnthropic && connectionMode !== 'openswarm-pro'; - const isOpenAISub = m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4'); - const isGeminiSub = m.startsWith('gemini-'); - const isSubscriptionRouted = isCcRoute || isProRoute || isOpenAISub || isGeminiSub; - if (isSubscriptionRouted) { - return ( - - subscription - - ); - } - // own-key Anthropic OR anything else → real $ figure. - void isOwnKeyAnthropic; - return ( - - ${session.cost_usd.toFixed(4)} - - ); - })()} - {(() => { - const mcpCount = session.active_mcps?.length ?? 0; - if (mcpCount === 0) return null; - return ( - - - {mcpCount} tool{mcpCount === 1 ? '' : 's'} - - - ); - })()} - - )} - - {!isDraft && id && ( - - { - const sid = id; - // Reset local UI state first; clearSessionMessages only touches Redux session.messages, so showResumeBubble/awaitingResponse/the queue otherwise survive and a "thinking" or "Resume agent response" bubble lingers on a now-empty chat. - setShowResumeBubble(false); - setAwaitingResponse(false); - messageQueueRef.current = []; - setQueueLength(0); - setQueueExpanded(false); - setEditingQueueIdx(null); - setEditingQueueText(''); - try { - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (tok) headers['Authorization'] = `Bearer ${tok}`; - await fetch(`${API_BASE}/agents/sessions/${sid}/clear`, { method: 'POST', headers }); - } catch { /* surfaced via context_status */ } - dispatch(clearSessionMessages(sid)); - }} - sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} - > - - - - )} - {onClose && ( - - - - )} - + )} - -