diff --git a/electron/command/queryLifecycle.ts b/electron/command/queryLifecycle.ts index 3a412a9..e391cab 100644 --- a/electron/command/queryLifecycle.ts +++ b/electron/command/queryLifecycle.ts @@ -8,6 +8,7 @@ import type { ClaudeSessionLaunchOptions } from './sessionLaunchOptions' import { toSdkOptions } from './sessionLaunchOptions' import { mapManagedMessagesToSdkInitialMessages } from './sdkHistoryMapper' import { adaptClaudeSdkMessage } from '../conversation/runtime/claudeRuntimeAdapter' +import { ensureSdkCompatEnv } from './sdkCompatEnv' import { createRuntimeEventEnvelope, isTurnScopedRuntimeEventKind, @@ -41,6 +42,7 @@ let _modulePromise: Promise | null = null async function loadSdkModule(): Promise { if (!_modulePromise) { _modulePromise = (async () => { + ensureSdkCompatEnv() const entryPath = require.resolve('@opencow-ai/opencow-agent-sdk/dist/sdk.js') return import(pathToFileURL(entryPath).href) as Promise })() diff --git a/electron/command/sdkCompatEnv.ts b/electron/command/sdkCompatEnv.ts new file mode 100644 index 0000000..19e6e86 --- /dev/null +++ b/electron/command/sdkCompatEnv.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * OpenCow SDK compatibility env defaults. + * + * The published npm SDK package currently does not ship the vendored + * `dist/vendor/ripgrep` fallback, so forcing the SDK onto system `rg` + * avoids guaranteed `Glob`/`Grep` failures when a working ripgrep is on PATH. + * + * Respect explicit user overrides. + */ +export function ensureSdkCompatEnv(): void { + if (process.env.USE_BUILTIN_RIPGREP === undefined) { + process.env.USE_BUILTIN_RIPGREP = '0' + } +} diff --git a/package.json b/package.json index b9131bc..bf0dba9 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@nivo/bar": "^0.99.0", "@nivo/calendar": "^0.99.0", "@nivo/core": "^0.99.0", - "@opencow-ai/opencow-agent-sdk": "^0.1.9", + "@opencow-ai/opencow-agent-sdk": "^0.1.10", "@pydantic/genai-prices": "0.0.56", "@tailwindcss/vite": "^4.2.0", "@tiptap/core": "^3.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c93595e..a379709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^0.99.0 version: 0.99.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@opencow-ai/opencow-agent-sdk': - specifier: ^0.1.9 - version: 0.1.9(encoding@0.1.13) + specifier: ^0.1.10 + version: 0.1.10(encoding@0.1.13) '@pydantic/genai-prices': specifier: 0.0.56 version: 0.0.56 @@ -1444,8 +1444,8 @@ packages: resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} engines: {node: '>= 20'} - '@opencow-ai/opencow-agent-sdk@0.1.9': - resolution: {integrity: sha512-FaYXbsSR3U54XPFYiXl31K8MDDwgKpSg8KOqDj4wBfJsOvhJeO2pW8jX37ohtTzqgJ0/iOFgGvJhbVJCFg6DXA==} + '@opencow-ai/opencow-agent-sdk@0.1.10': + resolution: {integrity: sha512-4u91hMGjJOZXVstkETY20TkQvGIM5mRf42ntFTwGlUDaTzPEPdfs/9/qssEBAEcYwZhXunmuhBiPIaFhGWIgjw==} engines: {node: '>=20.0.0'} hasBin: true @@ -4961,10 +4961,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -6364,7 +6360,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -6808,7 +6804,7 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/webhooks-methods': 6.0.0 - '@opencow-ai/opencow-agent-sdk@0.1.9(encoding@0.1.13)': + '@opencow-ai/opencow-agent-sdk@0.1.10(encoding@0.1.13)': dependencies: '@alcalzone/ansi-tokenize': 0.3.0 '@anthropic-ai/sandbox-runtime': 0.0.46 @@ -10979,10 +10975,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 diff --git a/src/renderer/components/DetailPanel/SessionPanel/AssistantMessage.tsx b/src/renderer/components/DetailPanel/SessionPanel/AssistantMessage.tsx index cb3ab82..dec8b0d 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/AssistantMessage.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/AssistantMessage.tsx @@ -14,6 +14,7 @@ import { useRef, useMemo, memo } from 'react' import { ContentBlockRenderer } from './ContentBlockRenderer' import { ToolBatchCollapsible } from './ToolBatchCollapsible' import { useCommandStore, selectStreamingMessage } from '@/stores/commandStore' +import { cn } from '@/lib/utils' import { NativeCapabilityTools } from '@shared/nativeCapabilityToolNames' import { isEvoseToolName } from '@shared/evoseNames' import type { ManagedSessionMessage, ContentBlock } from '@shared/types' @@ -86,6 +87,21 @@ function extractLastTextBlockIndex(blocks: ContentBlock[]): number { return -1 } +/** + * Compact assistant messages are made entirely of lightweight console rows + * (tool pills/results and collapsed thinking toggles) with no prose/image/ + * document content. Their vertical rhythm should come from the stack + * container rather than per-message padding. + */ +export function isCompactAssistantContent(blocks: readonly ContentBlock[]): boolean { + if (blocks.length === 0) return false + return blocks.every((block) => + block.type === 'tool_use' || + block.type === 'tool_result' || + block.type === 'thinking', + ) +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -202,86 +218,87 @@ export const AssistantMessage = memo(function AssistantMessage({ const lastTextBlockIndex = extractLastTextBlockIndex(filteredContent) const hasToolUseInMessage = toolCallCount > 0 const textStreaming = isStreaming && !hasToolUseInMessage + const isCompactOnly = isCompactAssistantContent(filteredContent) + const rootClassName = cn( + isCompactOnly ? 'py-px' : 'py-0.5', + 'break-words min-w-0', + ) + const compactStackClassName = isCompactOnly && filteredContent.length > 1 ? 'space-y-1' : undefined + + const renderBlock = (block: ContentBlock, index: number) => ( + + ) if (!shouldCollapseInMessageTools) { + const renderedBlocks = filteredContent.map(renderBlock) return ( -
- {filteredContent.map((block, index) => ( - - ))} +
+ {compactStackClassName ? ( +
+ {renderedBlocks} +
+ ) : renderedBlocks}
) } const segments = splitToolAndNonToolSegments(filteredContent) - - return ( -
- {segments.map((segment, segmentIndex) => { - if (segment.kind === 'tool') { - const segmentContent = segment.blocks.map(({ block }) => block) - const segmentToolCallCount = countToolUseBlocks(segmentContent) - const shouldKeepExpanded = - segmentToolCallCount < IN_MESSAGE_TOOL_COLLAPSE_THRESHOLD || - hasNonBatchableToolUse(segmentContent) - if (shouldKeepExpanded) { - return ( -
- {segment.blocks.map(({ block, index }) => ( - - ))} -
- ) - } - - const segmentMessage: ManagedSessionMessage = { - id: `${id}-tool-segment-${segmentIndex}`, - role: 'assistant', - content: segmentContent, - timestamp: msg.timestamp, - isStreaming, - activeToolUseId, - } - return ( - - ) - } + const renderSegment = ( + segment: { kind: 'tool' | 'other'; blocks: IndexedContentBlock[] }, + segmentIndex: number, + ) => { + if (segment.kind === 'tool') { + const segmentContent = segment.blocks.map(({ block }) => block) + const segmentToolCallCount = countToolUseBlocks(segmentContent) + const shouldKeepExpanded = + segmentToolCallCount < IN_MESSAGE_TOOL_COLLAPSE_THRESHOLD || + hasNonBatchableToolUse(segmentContent) + if (shouldKeepExpanded) { return ( -
- {segment.blocks.map(({ block, index }) => ( - - ))} +
+ {segment.blocks.map(({ block, index }) => renderBlock(block, index))}
) - })} + } + + const segmentMessage: ManagedSessionMessage = { + id: `${id}-tool-segment-${segmentIndex}`, + role: 'assistant', + content: segmentContent, + timestamp: msg.timestamp, + isStreaming, + activeToolUseId, + } + return ( + + ) + } + return ( +
+ {segment.blocks.map(({ block, index }) => renderBlock(block, index))} +
+ ) + } + + return ( +
+ {compactStackClassName ? ( +
+ {segments.map(renderSegment)} +
+ ) : segments.map(renderSegment)}
) }) diff --git a/src/renderer/components/DetailPanel/SessionPanel/MessageRenderers.tsx b/src/renderer/components/DetailPanel/SessionPanel/MessageRenderers.tsx index 5d59b91..fe40231 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/MessageRenderers.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/MessageRenderers.tsx @@ -11,6 +11,8 @@ import { memo } from 'react' import { LinkifiedText } from '@/components/ui/LinkifiedText' import { ContentBlockRenderer } from './ContentBlockRenderer' +import { useToolLifecycleMap, type ToolLifecycleMap } from './ToolLifecycleContext' +import { shouldRenderToolResultBlock } from './ToolResultBlockView' import { ContextFileChips } from '@/components/ui/ContextFileChips' import { parseContextFiles } from '@/lib/contextFilesParsing' import { getSlashDisplayLabel } from '@shared/slashDisplay' @@ -138,6 +140,9 @@ export const ToolResultUserMessage = memo(function ToolResultUserMessage({ content: ContentBlock[] sessionId?: string }) { + const toolLifecycleMap = useToolLifecycleMap() + if (!hasVisibleToolResultUserMessageContent(content, toolLifecycleMap)) return null + return (
{content.map((block, i) => ( @@ -171,3 +176,21 @@ export const ChatBubbleUserMessage = memo(function ChatBubbleUserMessage({ id, c
) }) + +/** + * Engine-emitted user tool-result messages should only reserve space when at + * least one block actually renders visible UI. + */ +export function hasVisibleToolResultUserMessageContent( + content: readonly ContentBlock[], + toolLifecycleMap: ToolLifecycleMap, +): boolean { + for (const block of content) { + if (block.type === 'image' && block.toolUseId) return true + if (block.type === 'tool_result') { + const toolName = toolLifecycleMap.get(block.toolUseId)?.name + if (shouldRenderToolResultBlock(block, toolName)) return true + } + } + return false +} diff --git a/src/renderer/components/DetailPanel/SessionPanel/SessionMessageList.tsx b/src/renderer/components/DetailPanel/SessionPanel/SessionMessageList.tsx index aa24739..dfb829f 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/SessionMessageList.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/SessionMessageList.tsx @@ -4,7 +4,12 @@ import { useEffect, useRef, useCallback, useMemo, useState, startTransition, mem import { useTranslation } from 'react-i18next' import { Virtuoso, type VirtuosoHandle, type ListRange } from 'react-virtuoso' import { ArrowDown, GitCompare } from 'lucide-react' -import { UserMessage, ChatBubbleUserMessage, ToolResultUserMessage } from './MessageRenderers' +import { + UserMessage, + ChatBubbleUserMessage, + ToolResultUserMessage, + hasVisibleToolResultUserMessageContent, +} from './MessageRenderers' import { AssistantMessage } from './AssistantMessage' import { INCREASE_VIEWPORT_BY, FooterNodeContext, VIRTUOSO_COMPONENTS } from './VirtuosoShell' import type { VirtuosoContext, MessageListVariant } from './VirtuosoShell' @@ -162,6 +167,7 @@ function scanNavAnchors( for (const msg of newMsgs) { if (msg.role === 'user') { + if (isToolResultOnlyUserMessage(msg.content)) continue inAssistantTurn = false const info = getUserMessageDisplayInfo(msg.content) if (info.isEmpty) continue @@ -357,6 +363,8 @@ function SessionMessageList({ scanToolLifecycle, INIT_TOOL_MAP, ) + const toolLifecycleMapRef = useRef(toolLifecycleMap) + toolLifecycleMapRef.current = toolLifecycleMap // --------------------------------------------------------------------------- // RC3: Incremental messageGroups — O(delta) per append instead of O(N). @@ -404,6 +412,13 @@ function SessionMessageList({ // Filter consumed task events from the delta const filtered = newMsgs.filter((msg) => { if (msg.role === 'system' && isConsumedTaskEvent(msg.event, consumedIdsRef.current)) return false + if ( + msg.role === 'user' && + isToolResultOnlyUserMessage(msg.content) && + !hasVisibleToolResultUserMessageContent(msg.content, toolLifecycleMapRef.current) + ) { + return false + } return true }) if (filtered.length === 0) return prev // copy-on-write: no change @@ -643,9 +658,11 @@ function SessionMessageList({ // If in 'browsing' state (user had scrolled up), `followOutput` returned // `false` so no Virtuoso scroll was initiated. We need `engage()` to // transition to 'following' and issue the scroll manually. - const userMsgCountRef = useRef(messages.filter((m) => m.role === 'user').length) + const userMsgCountRef = useRef( + messages.filter((m) => m.role === 'user' && !isToolResultOnlyUserMessage(m.content)).length, + ) useEffect(() => { - const count = messages.filter((m) => m.role === 'user').length + const count = messages.filter((m) => m.role === 'user' && !isToolResultOnlyUserMessage(m.content)).length if (count > userMsgCountRef.current) { reengageIfBrowsing('smooth') } diff --git a/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx b/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx index 5411a88..82496d6 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx @@ -9,9 +9,10 @@ import type { SessionHistoryContext } from './sessionHistoryTypes' import { ContextWindowRing } from '../../ui/ContextWindowRing' import { Tooltip } from '../../ui/Tooltip' import { PillDropdown } from '../../ui/PillDropdown' +import { TodoStatusPill } from './TodoWidgets' import { formatDuration, computeActiveDuration } from '@/lib/sessionHelpers' import { isProcessCorruptedError } from '../../../lib/sessionErrors' -import { useStreamingSessionMetrics, useCommandStore } from '@/stores/commandStore' +import { selectLatestOpenTodos, useStreamingSessionMetrics, useCommandStore } from '@/stores/commandStore' import { useStoreWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/shallow' import { resolveContextDisplayState } from '@shared/contextDisplay' @@ -137,6 +138,7 @@ export const SessionStatusBar = React.memo(function SessionStatusBar({ const metrics = useStreamingSessionMetrics(sessionId) const activeDurationMs = metrics?.activeDurationMs ?? 0 const activeStartedAt = metrics?.activeStartedAt ?? null + const latestTodos = useCommandStore((s) => selectLatestOpenTodos(s, sessionId)) // Context display — resolves from the full session snapshot. // Uses useStoreWithEqualityFn + shallow to avoid re-renders when @@ -175,6 +177,7 @@ export const SessionStatusBar = React.memo(function SessionStatusBar({ state === 'stopped' || state === 'error' ) && !!onNewSession && !!onNewBlankSession + const isTodoPaused = state === 'idle' || state === 'stopped' || state === 'error' return (
+ {latestTodos && ( + + )} {showStop && ( ) } diff --git a/src/renderer/components/DetailPanel/SessionPanel/TodoWidgets.tsx b/src/renderer/components/DetailPanel/SessionPanel/TodoWidgets.tsx index 78d8434..4bd7a3a 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/TodoWidgets.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/TodoWidgets.tsx @@ -113,6 +113,7 @@ export const TodoCard = memo(function TodoCard({ todos: TodoItem[] isPaused?: boolean }): React.JSX.Element { + const { t } = useTranslation('sessions') const [expanded, setExpanded] = useState(false) const [showCompleted, setShowCompleted] = useState(false) @@ -120,11 +121,11 @@ export const TodoCard = memo(function TodoCard({ const activeTodos: TodoItem[] = [] const pendingTodos: TodoItem[] = [] const completedTodos: TodoItem[] = [] - for (const t of todos) { - const eff = effectiveStatus(t.status, isPaused) - if (eff === 'completed') completedTodos.push(t) - else if (eff === 'in_progress' || eff === 'paused') activeTodos.push(t) - else pendingTodos.push(t) + for (const todo of todos) { + const eff = effectiveStatus(todo.status, isPaused) + if (eff === 'completed') completedTodos.push(todo) + else if (eff === 'in_progress' || eff === 'paused') activeTodos.push(todo) + else pendingTodos.push(todo) } // The currently active task name (for collapsed summary) @@ -137,7 +138,7 @@ export const TodoCard = memo(function TodoCard({
{/* Header — always visible, clickable to expand/collapse */} {showCompleted && ( @@ -211,6 +212,7 @@ export const TodoStatusPill = memo(function TodoStatusPill({ todos: TodoItem[] isPaused?: boolean }): React.JSX.Element { + const { t } = useTranslation('sessions') const [popoverOpen, setPopoverOpen] = useState(false) return ( @@ -223,7 +225,7 @@ export const TodoStatusPill = memo(function TodoStatusPill({ trigger={