mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 16:12:53 +08:00
Remove duplicate command grouping from PR 929
This commit is contained in:
@@ -129,8 +129,16 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
// a chevron that expands to show the output inline. The combined view lives on
|
// a chevron that expands to show the output inline. The combined view lives on
|
||||||
// the input render; the separate result section is suppressed in MessageComponent.
|
// the input render; the separate result section is suppressed in MessageComponent.
|
||||||
if (toolName === 'Bash' && mode === 'input') {
|
if (toolName === 'Bash' && mode === 'input') {
|
||||||
const command = parsedData?.command || '';
|
const command = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData
|
||||||
const description = parsedData?.description;
|
? String(parsedData.command || '')
|
||||||
|
: typeof toolInput === 'string'
|
||||||
|
? toolInput
|
||||||
|
: typeof rawToolInput === 'string'
|
||||||
|
? rawToolInput
|
||||||
|
: '';
|
||||||
|
const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData
|
||||||
|
? String(parsedData.description || '')
|
||||||
|
: undefined;
|
||||||
const output = typeof toolResult?.content === 'string'
|
const output = typeof toolResult?.content === 'string'
|
||||||
? toolResult.content
|
? toolResult.content
|
||||||
: toolResult?.content != null
|
: toolResult?.content != null
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
||||||
title="Copy command"
|
title="Copy command"
|
||||||
aria-label="Copy command"
|
aria-label="Copy command"
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { ChevronRight, Terminal } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '../../../../lib/utils';
|
|
||||||
import type { ChatMessage } from '../../types/types';
|
|
||||||
import { BashCommandDisplay } from './BashCommandDisplay';
|
|
||||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
|
||||||
import type { ToolStatus } from './ToolStatusBadge';
|
|
||||||
|
|
||||||
interface CommandRunGroupProps {
|
|
||||||
messages: ChatMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExtractedCommand = {
|
|
||||||
key: string;
|
|
||||||
command: string;
|
|
||||||
description?: string;
|
|
||||||
output: string;
|
|
||||||
isError: boolean;
|
|
||||||
status: ToolStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractCommand(message: ChatMessage, index: number): ExtractedCommand {
|
|
||||||
let command = '';
|
|
||||||
let description: string | undefined;
|
|
||||||
try {
|
|
||||||
const parsed =
|
|
||||||
typeof message.toolInput === 'string' ? JSON.parse(message.toolInput) : message.toolInput;
|
|
||||||
command = parsed?.command || '';
|
|
||||||
description = parsed?.description;
|
|
||||||
} catch {
|
|
||||||
command = typeof message.toolInput === 'string' ? message.toolInput : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = message.toolResult;
|
|
||||||
const rawContent = result?.content;
|
|
||||||
const output =
|
|
||||||
typeof rawContent === 'string' ? rawContent : rawContent != null ? String(rawContent) : '';
|
|
||||||
const isError = Boolean(result?.isError);
|
|
||||||
const status: ToolStatus = !result ? 'running' : isError ? 'error' : 'completed';
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: message.toolId || `${command}-${index}`,
|
|
||||||
command,
|
|
||||||
description,
|
|
||||||
output,
|
|
||||||
isError,
|
|
||||||
status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Groups a run of consecutive shell commands under a single collapsible header
|
|
||||||
* (Codex-in-VSCode style). Collapsed by default so long command runs stay tidy;
|
|
||||||
* expanding reveals every command in the run, each independently expandable for
|
|
||||||
* its own output.
|
|
||||||
*/
|
|
||||||
export const CommandRunGroup: React.FC<CommandRunGroupProps> = ({ messages }) => {
|
|
||||||
const commands = messages.map(extractCommand);
|
|
||||||
const count = commands.length;
|
|
||||||
const anyRunning = commands.some((c) => c.status === 'running');
|
|
||||||
const anyError = commands.some((c) => c.isError);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Surface failed runs without a click: open once when an error first appears.
|
|
||||||
const autoAppliedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoAppliedRef.current && anyError) {
|
|
||||||
autoAppliedRef.current = true;
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}, [anyError]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'overflow-hidden rounded-xl border bg-muted/30 transition-all duration-200',
|
|
||||||
anyError ? 'border-red-500/30' : 'border-border/60',
|
|
||||||
open && 'shadow-sm',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
|
||||||
open && 'rotate-90',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
|
||||||
<Terminal className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-xs font-medium text-foreground">
|
|
||||||
{anyRunning ? 'Running' : 'Ran'} <span className="text-muted-foreground">{count} commands</span>
|
|
||||||
</span>
|
|
||||||
{anyRunning && (
|
|
||||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
|
||||||
)}
|
|
||||||
{anyError && <ToolStatusBadge status="error" className="flex-shrink-0" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="settings-content-enter space-y-1 border-t border-border/50 p-2">
|
|
||||||
{commands.map((cmd) => (
|
|
||||||
<BashCommandDisplay
|
|
||||||
key={cmd.key}
|
|
||||||
command={cmd.command}
|
|
||||||
description={cmd.description}
|
|
||||||
output={cmd.output}
|
|
||||||
isError={cmd.isError}
|
|
||||||
status={cmd.status !== 'completed' ? cmd.status : undefined}
|
|
||||||
defaultOpen={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,6 @@ export { CollapsibleSection } from './CollapsibleSection';
|
|||||||
export { ToolDiffViewer } from './ToolDiffViewer';
|
export { ToolDiffViewer } from './ToolDiffViewer';
|
||||||
export { OneLineDisplay } from './OneLineDisplay';
|
export { OneLineDisplay } from './OneLineDisplay';
|
||||||
export { BashCommandDisplay } from './BashCommandDisplay';
|
export { BashCommandDisplay } from './BashCommandDisplay';
|
||||||
export { CommandRunGroup } from './CommandRunGroup';
|
|
||||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||||
export { SubagentContainer } from './SubagentContainer';
|
export { SubagentContainer } from './SubagentContainer';
|
||||||
export * from './ContentRenderers';
|
export * from './ContentRenderers';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
|
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { ChatMessage } from '../../types/types';
|
import type { ChatMessage } from '../../types/types';
|
||||||
import type {
|
import type {
|
||||||
@@ -13,7 +13,6 @@ import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
|||||||
|
|
||||||
import MessageComponent from './MessageComponent';
|
import MessageComponent from './MessageComponent';
|
||||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||||
import { CommandRunGroup } from '../../tools';
|
|
||||||
|
|
||||||
interface ChatMessagesPaneProps {
|
interface ChatMessagesPaneProps {
|
||||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||||
@@ -253,81 +252,25 @@ export default function ChatMessagesPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{visibleMessages.map((message, index) => {
|
||||||
const isBashCommand = (m: ChatMessage | null | undefined) =>
|
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||||||
Boolean(m && m.isToolUse && m.toolName === 'Bash' && !m.isSubagentContainer);
|
return (
|
||||||
// Messages that render nothing (e.g. thinking hidden when showThinking
|
<MessageComponent
|
||||||
// is off) shouldn't break a visual run of commands.
|
key={getMessageKey(message)}
|
||||||
const isRendered = (m: ChatMessage) => !(m.isThinking && !showThinking);
|
message={message}
|
||||||
|
prevMessage={prevMessage}
|
||||||
const items: ReactNode[] = [];
|
createDiff={createDiff}
|
||||||
|
onFileOpen={onFileOpen}
|
||||||
for (let index = 0; index < visibleMessages.length; index++) {
|
onShowSettings={onShowSettings}
|
||||||
const message = visibleMessages[index];
|
onGrantToolPermission={onGrantToolPermission}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
// Collapse a run of 2+ consecutive shell commands under a single
|
showRawParameters={showRawParameters}
|
||||||
// header so long command runs stay tidy (Codex-in-VSCode style).
|
showThinking={showThinking}
|
||||||
// Skip over non-rendered messages (e.g. hidden reasoning that Codex
|
selectedProject={selectedProject}
|
||||||
// interleaves between commands) so they don't split the run.
|
provider={provider}
|
||||||
if (isBashCommand(message)) {
|
/>
|
||||||
const runIndices = [index];
|
);
|
||||||
let cursor = index + 1;
|
})}
|
||||||
while (cursor < visibleMessages.length) {
|
|
||||||
const candidate = visibleMessages[cursor];
|
|
||||||
if (!isRendered(candidate)) {
|
|
||||||
cursor++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isBashCommand(candidate)) {
|
|
||||||
runIndices.push(cursor);
|
|
||||||
cursor++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (runIndices.length >= 2) {
|
|
||||||
const groupMessages = runIndices.map((i) => visibleMessages[i]);
|
|
||||||
items.push(
|
|
||||||
<CommandRunGroup key={getMessageKey(groupMessages[0])} messages={groupMessages} />,
|
|
||||||
);
|
|
||||||
// Consume everything up to the last command in the run (any
|
|
||||||
// trailing skipped messages render nothing anyway).
|
|
||||||
index = runIndices[runIndices.length - 1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk back past messages that are not actually rendered (e.g. thinking
|
|
||||||
// messages hidden when showThinking is off). Otherwise a hidden thinking
|
|
||||||
// message would make the following message look "grouped" and suppress its
|
|
||||||
// provider header/icon — which is why Claude turns lost their icon.
|
|
||||||
let prevMessage: ChatMessage | null = null;
|
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
|
||||||
const candidate = visibleMessages[i];
|
|
||||||
if (candidate.isThinking && !showThinking) continue;
|
|
||||||
prevMessage = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<MessageComponent
|
|
||||||
key={getMessageKey(message)}
|
|
||||||
message={message}
|
|
||||||
prevMessage={prevMessage}
|
|
||||||
createDiff={createDiff}
|
|
||||||
onFileOpen={onFileOpen}
|
|
||||||
onShowSettings={onShowSettings}
|
|
||||||
onGrantToolPermission={onGrantToolPermission}
|
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
|
||||||
showThinking={showThinking}
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
provider={provider}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
})()}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user