Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions electron/command/queryLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,6 +42,7 @@ let _modulePromise: Promise<OpenCowAgentModule> | null = null
async function loadSdkModule(): Promise<OpenCowAgentModule> {
if (!_modulePromise) {
_modulePromise = (async () => {
ensureSdkCompatEnv()
const entryPath = require.resolve('@opencow-ai/opencow-agent-sdk/dist/sdk.js')
return import(pathToFileURL(entryPath).href) as Promise<OpenCowAgentModule>
})()
Expand Down
16 changes: 16 additions & 0 deletions electron/command/sdkCompatEnv.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 6 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

155 changes: 86 additions & 69 deletions src/renderer/components/DetailPanel/SessionPanel/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) => (
<ContentBlockRenderer
key={`${block.type}-${index}`}
block={block}
sessionId={sessionId}
isLastTextBlock={index === lastTextBlockIndex}
isStreaming={textStreaming}
isMessageStreaming={isStreaming}
activeToolUseId={activeToolUseId}
/>
)

if (!shouldCollapseInMessageTools) {
const renderedBlocks = filteredContent.map(renderBlock)
return (
<div data-msg-id={id} data-msg-role="assistant" className="py-0.5 break-words min-w-0">
{filteredContent.map((block, index) => (
<ContentBlockRenderer
key={`${block.type}-${index}`}
block={block}
sessionId={sessionId}
isLastTextBlock={index === lastTextBlockIndex}
isStreaming={textStreaming}
isMessageStreaming={isStreaming}
activeToolUseId={activeToolUseId}
/>
))}
<div data-msg-id={id} data-msg-role="assistant" className={rootClassName}>
{compactStackClassName ? (
<div className={compactStackClassName}>
{renderedBlocks}
</div>
) : renderedBlocks}
</div>
)
}

const segments = splitToolAndNonToolSegments(filteredContent)

return (
<div data-msg-id={id} data-msg-role="assistant" className="py-0.5 break-words min-w-0">
{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 (
<div key={`${id}-tool-segment-raw-${segmentIndex}-${segment.blocks[0]?.index ?? 0}`}>
{segment.blocks.map(({ block, index }) => (
<ContentBlockRenderer
key={`${block.type}-${index}`}
block={block}
sessionId={sessionId}
isLastTextBlock={index === lastTextBlockIndex}
isStreaming={textStreaming}
isMessageStreaming={isStreaming}
activeToolUseId={activeToolUseId}
/>
))}
</div>
)
}

const segmentMessage: ManagedSessionMessage = {
id: `${id}-tool-segment-${segmentIndex}`,
role: 'assistant',
content: segmentContent,
timestamp: msg.timestamp,
isStreaming,
activeToolUseId,
}
return (
<ToolBatchCollapsible
key={`${id}-tool-segment-${segmentIndex}-${segment.blocks[0]?.index ?? 0}`}
messages={[segmentMessage]}
sessionId={sessionId}
/>
)
}
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 (
<div key={`${id}-other-segment-${segmentIndex}-${segment.blocks[0]?.index ?? 0}`}>
{segment.blocks.map(({ block, index }) => (
<ContentBlockRenderer
key={`${block.type}-${index}`}
block={block}
sessionId={sessionId}
isLastTextBlock={index === lastTextBlockIndex}
isStreaming={textStreaming}
isMessageStreaming={isStreaming}
activeToolUseId={activeToolUseId}
/>
))}
<div key={`${id}-tool-segment-raw-${segmentIndex}-${segment.blocks[0]?.index ?? 0}`}>
{segment.blocks.map(({ block, index }) => renderBlock(block, index))}
</div>
)
})}
}

const segmentMessage: ManagedSessionMessage = {
id: `${id}-tool-segment-${segmentIndex}`,
role: 'assistant',
content: segmentContent,
timestamp: msg.timestamp,
isStreaming,
activeToolUseId,
}
return (
<ToolBatchCollapsible
key={`${id}-tool-segment-${segmentIndex}-${segment.blocks[0]?.index ?? 0}`}
messages={[segmentMessage]}
sessionId={sessionId}
/>
)
}
return (
<div key={`${id}-other-segment-${segmentIndex}-${segment.blocks[0]?.index ?? 0}`}>
{segment.blocks.map(({ block, index }) => renderBlock(block, index))}
</div>
)
}

return (
<div data-msg-id={id} data-msg-role="assistant" className={rootClassName}>
{compactStackClassName ? (
<div className={compactStackClassName}>
{segments.map(renderSegment)}
</div>
) : segments.map(renderSegment)}
</div>
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -138,6 +140,9 @@ export const ToolResultUserMessage = memo(function ToolResultUserMessage({
content: ContentBlock[]
sessionId?: string
}) {
const toolLifecycleMap = useToolLifecycleMap()
if (!hasVisibleToolResultUserMessageContent(content, toolLifecycleMap)) return null

return (
<div data-msg-id={id} data-msg-role="user-tool-result" className="py-0.5 break-words min-w-0">
{content.map((block, i) => (
Expand Down Expand Up @@ -171,3 +176,21 @@ export const ChatBubbleUserMessage = memo(function ChatBubbleUserMessage({ id, c
</div>
)
})

/**
* 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
}
Expand Down
Loading
Loading