feat(chat): align command, shell, and subtask UX (#444)
* feat: reload interface after skills operations - Adds configurable delay before interface reload after skills changes - Introduces polling to wait for application health after reload - Updates UI to show reload message when installing or modifying skills * feat: distinguish skills from commands in UI - Displays skill badge for commands that are registered skills - Prevents editing skills through command management interface - Triggers interface reload after skill operations to reflect changes * feat(chat): align command and subtask UX with opencode parity Route commands/shell via parity paths, render delegated subtasks cleanly, and surface child-session permission/question prompts in parent chat. * fix(ProjectEditDialog): improve layout consistency * fix: update task icon and session handling in ToolPart * feat(chat): add shell-mode input and collapse shell bridge output Switch leading ! to shell mode UX and fold synthetic shell bridge assistant messages into the user shell bubble with inline output actions. * fix: remove AI agent icon from file mention autocomplete * fix: remove unused icon import from file mention component
This commit is contained in:
committed by
GitHub
parent
e4a2486312
commit
85f21cb945
@@ -28,6 +28,235 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
command?: unknown;
|
||||
agent?: unknown;
|
||||
prompt?: unknown;
|
||||
taskSessionID?: unknown;
|
||||
model?: {
|
||||
providerID?: unknown;
|
||||
modelID?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ShellActionPartLike = Part & {
|
||||
type: 'text';
|
||||
shellAction?: {
|
||||
command?: unknown;
|
||||
output?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const isSubtaskPart = (part: Part): part is SubtaskPartLike => {
|
||||
return part.type === 'subtask';
|
||||
};
|
||||
|
||||
const isShellActionPart = (part: Part): part is ShellActionPartLike => {
|
||||
const textPart = part as unknown as { type?: unknown; shellAction?: unknown };
|
||||
return textPart.type === 'text' && typeof textPart.shellAction === 'object' && textPart.shellAction !== null;
|
||||
};
|
||||
|
||||
const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null => {
|
||||
if (!model || typeof model !== 'object') return null;
|
||||
const providerID = typeof model.providerID === 'string' ? model.providerID.trim() : '';
|
||||
const modelID = typeof model.modelID === 'string' ? model.modelID.trim() : '';
|
||||
if (!providerID || !modelID) return null;
|
||||
return `${providerID}/${modelID}`;
|
||||
};
|
||||
|
||||
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
|
||||
const description = typeof part.description === 'string' ? part.description.trim() : '';
|
||||
const command = typeof part.command === 'string' ? part.command.trim() : '';
|
||||
const agent = typeof part.agent === 'string' ? part.agent.trim() : '';
|
||||
const prompt = typeof part.prompt === 'string' ? part.prompt.trim() : '';
|
||||
const taskSessionID = typeof part.taskSessionID === 'string' ? part.taskSessionID.trim() : '';
|
||||
const model = normalizeSubtaskModel(part.model);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="typography-meta font-semibold text-foreground">Delegated task</span>
|
||||
{command ? (
|
||||
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
||||
/{command}
|
||||
</span>
|
||||
) : null}
|
||||
{agent ? (
|
||||
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
||||
@{agent}
|
||||
</span>
|
||||
) : null}
|
||||
{model ? (
|
||||
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
||||
{model}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<div className="typography-ui-label text-foreground/90 mt-1.5">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{prompt ? (
|
||||
<div className="mt-2 border-t border-border/60 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Hide prompt' : 'Show prompt'}
|
||||
</button>
|
||||
{expanded ? (
|
||||
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/85">
|
||||
{prompt}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{taskSessionID ? (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
onClick={() => {
|
||||
void setCurrentSession(taskSessionID);
|
||||
}}
|
||||
>
|
||||
Open subtask session
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [copiedOutput, setCopiedOutput] = React.useState(false);
|
||||
const copiedResetTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
|
||||
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
|
||||
const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
|
||||
const hasOutput = output.trim().length > 0;
|
||||
|
||||
const clearCopiedResetTimeout = React.useCallback(() => {
|
||||
if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(copiedResetTimeoutRef.current);
|
||||
copiedResetTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearCopiedResetTimeout();
|
||||
};
|
||||
}, [clearCopiedResetTimeout]);
|
||||
|
||||
const copyOutputToClipboard = React.useCallback(async () => {
|
||||
if (!hasOutput) return;
|
||||
|
||||
let succeeded = false;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
succeeded = true;
|
||||
} catch {
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!succeeded && typeof document !== 'undefined') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = output;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.left = '-1000px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
succeeded = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
if (!succeeded) return;
|
||||
|
||||
clearCopiedResetTimeout();
|
||||
setCopiedOutput(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
copiedResetTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedOutput(false);
|
||||
copiedResetTimeoutRef.current = null;
|
||||
}, 2000);
|
||||
}
|
||||
}, [clearCopiedResetTimeout, hasOutput, output]);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="typography-meta font-semibold text-foreground">Shell command</span>
|
||||
{status ? (
|
||||
<span className={cn(
|
||||
'inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none',
|
||||
status === 'error'
|
||||
? 'bg-[var(--status-error-background)] text-[var(--status-error)]'
|
||||
: 'bg-foreground/5 text-muted-foreground'
|
||||
)}>
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{command ? (
|
||||
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/90 font-mono">
|
||||
{command}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{hasOutput ? (
|
||||
<div className="mt-2 border-t border-border/60 pt-1.5">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Hide output' : 'Show output'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
void copyOutputToClipboard();
|
||||
}}
|
||||
aria-label={copiedOutput ? 'Copied' : 'Copy output'}
|
||||
title={copiedOutput ? 'Copied' : 'Copy output'}
|
||||
>
|
||||
{copiedOutput ? <RiCheckLine className="h-3.5 w-3.5" /> : <RiFileCopyLine className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<pre className="typography-meta mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-words text-foreground/85 font-mono">
|
||||
{output}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTurnDuration = (durationMs: number): string => {
|
||||
const totalSeconds = durationMs / 1000;
|
||||
if (totalSeconds < 60) {
|
||||
@@ -94,10 +323,18 @@ const UserMessageBody: React.FC<{
|
||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
const textParts = React.useMemo(() => {
|
||||
const userContentParts = React.useMemo(() => {
|
||||
return parts.filter((part) => {
|
||||
if (part.type !== 'text') return false;
|
||||
return !isEmptyTextPart(part);
|
||||
if (part.type === 'text') {
|
||||
return !isEmptyTextPart(part);
|
||||
}
|
||||
if (isSubtaskPart(part)) {
|
||||
return true;
|
||||
}
|
||||
if (isShellActionPart(part)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [parts]);
|
||||
|
||||
@@ -160,7 +397,23 @@ const UserMessageBody: React.FC<{
|
||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||
>
|
||||
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
|
||||
{textParts.map((part, index) => {
|
||||
{userContentParts.map((part, index) => {
|
||||
if (isSubtaskPart(part)) {
|
||||
return (
|
||||
<FadeInOnReveal key={part.id ?? `user-subtask-${index}`}>
|
||||
<UserSubtaskPart part={part} />
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
}
|
||||
|
||||
if (isShellActionPart(part)) {
|
||||
return (
|
||||
<FadeInOnReveal key={part.id ?? `user-shell-${index}`}>
|
||||
<UserShellActionPart part={part} />
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
}
|
||||
|
||||
let mentionForPart: AgentMentionInfo | undefined;
|
||||
if (agentMention && mentionToken && !mentionInjected) {
|
||||
const candidateText = extractTextContent(part);
|
||||
@@ -170,7 +423,7 @@ const UserMessageBody: React.FC<{
|
||||
}
|
||||
}
|
||||
return (
|
||||
<FadeInOnReveal key={`user-text-${index}`}>
|
||||
<FadeInOnReveal key={part.id ?? `user-text-${index}`}>
|
||||
<UserTextPart
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
||||
@@ -11,6 +11,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
|
||||
@@ -87,6 +88,9 @@ export const getToolIcon = (toolName: string) => {
|
||||
if (tool === 'skill') {
|
||||
return <RiBookLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'task') {
|
||||
return <RiAiAgentLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'question') {
|
||||
return <RiSurveyLine className={iconClass} />;
|
||||
}
|
||||
@@ -277,6 +281,66 @@ type TaskToolSummaryEntry = {
|
||||
};
|
||||
};
|
||||
|
||||
type SessionMessageWithParts = {
|
||||
info?: {
|
||||
role?: string;
|
||||
};
|
||||
parts?: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
tool?: string;
|
||||
state?: {
|
||||
status?: string;
|
||||
title?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = [];
|
||||
|
||||
const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
|
||||
if (typeof output !== 'string' || output.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedMetadata = parseTaskMetadataBlock(output);
|
||||
if (parsedMetadata.sessionId) {
|
||||
return parsedMetadata.sessionId;
|
||||
}
|
||||
const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
|
||||
const candidate = match?.[1];
|
||||
return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined;
|
||||
};
|
||||
|
||||
const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
|
||||
const entries: TaskToolSummaryEntry[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message?.info?.role !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||
for (const part of parts) {
|
||||
if (part?.type !== 'tool') {
|
||||
continue;
|
||||
}
|
||||
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
|
||||
if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
id: part.id,
|
||||
tool: part.tool,
|
||||
state: {
|
||||
status: part.state?.status,
|
||||
title: part.state?.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||
const title = entry.state?.title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -293,6 +357,97 @@ const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||
};
|
||||
|
||||
const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized: TaskToolSummaryEntry[] = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry === 'string') {
|
||||
normalized.push({
|
||||
tool: 'tool',
|
||||
state: { status: 'completed', title: entry },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = entry as {
|
||||
id?: unknown;
|
||||
tool?: unknown;
|
||||
title?: unknown;
|
||||
status?: unknown;
|
||||
state?: { status?: unknown; title?: unknown };
|
||||
};
|
||||
|
||||
const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined;
|
||||
const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined;
|
||||
const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined);
|
||||
const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined);
|
||||
|
||||
normalized.push({
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
tool: typeof record.tool === 'string' ? record.tool : 'tool',
|
||||
state: {
|
||||
status,
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const parseTaskMetadataBlock = (output: string | undefined): {
|
||||
sessionId?: string;
|
||||
summaryEntries: TaskToolSummaryEntry[];
|
||||
} => {
|
||||
if (typeof output !== 'string' || output.trim().length === 0) {
|
||||
return { summaryEntries: [] };
|
||||
}
|
||||
|
||||
const blockMatch = output.match(/<task_metadata>\s*([\s\S]*?)\s*<\/task_metadata>/i);
|
||||
if (!blockMatch?.[1]) {
|
||||
return { summaryEntries: [] };
|
||||
}
|
||||
|
||||
const raw = blockMatch[1].trim();
|
||||
if (!raw) {
|
||||
return { summaryEntries: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
sessionId?: unknown;
|
||||
sessionID?: unknown;
|
||||
summary?: unknown;
|
||||
entries?: unknown;
|
||||
tools?: unknown;
|
||||
calls?: unknown;
|
||||
};
|
||||
|
||||
const summaryEntries = normalizeTaskSummaryEntries(
|
||||
parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls
|
||||
);
|
||||
|
||||
const sessionId =
|
||||
(typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0
|
||||
? parsed.sessionId.trim()
|
||||
: undefined) ??
|
||||
(typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0
|
||||
? parsed.sessionID.trim()
|
||||
: undefined);
|
||||
|
||||
return { sessionId, summaryEntries };
|
||||
} catch {
|
||||
return { summaryEntries: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const TaskToolSummary: React.FC<{
|
||||
entries: TaskToolSummaryEntry[];
|
||||
isExpanded: boolean;
|
||||
@@ -302,8 +457,9 @@ const TaskToolSummary: React.FC<{
|
||||
sessionId?: string;
|
||||
}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId }) => {
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
const completedEntries = React.useMemo(() => {
|
||||
return entries.filter((entry) => entry.state?.status === 'completed');
|
||||
const displayEntries = React.useMemo(() => {
|
||||
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
|
||||
return nonPending.length > 0 ? nonPending : entries;
|
||||
}, [entries]);
|
||||
|
||||
const trimmedOutput = typeof output === 'string'
|
||||
@@ -319,12 +475,12 @@ const TaskToolSummary: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
if (completedEntries.length === 0 && !hasOutput && !sessionId) {
|
||||
if (displayEntries.length === 0 && !hasOutput && !sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleEntries = isExpanded ? completedEntries : completedEntries.slice(-6);
|
||||
const hiddenCount = Math.max(0, completedEntries.length - visibleEntries.length);
|
||||
const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
|
||||
const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -335,7 +491,7 @@ const TaskToolSummary: React.FC<{
|
||||
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
|
||||
)}
|
||||
>
|
||||
{completedEntries.length > 0 ? (
|
||||
{displayEntries.length > 0 ? (
|
||||
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
|
||||
<div className="w-full min-w-0 space-y-1">
|
||||
{hiddenCount > 0 ? (
|
||||
@@ -345,6 +501,7 @@ const TaskToolSummary: React.FC<{
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool';
|
||||
const label = getTaskSummaryLabel(entry);
|
||||
const status = entry.state?.status;
|
||||
|
||||
const displayName = getToolMetadata(toolName).displayName;
|
||||
|
||||
@@ -352,7 +509,10 @@ const TaskToolSummary: React.FC<{
|
||||
<div key={entry.id ?? `${toolName}-${idx}`} className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
|
||||
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
|
||||
<span className="typography-meta text-muted-foreground/70 truncate">{label}</span>
|
||||
<span className={cn(
|
||||
'typography-meta truncate',
|
||||
status === 'error' ? 'text-[var(--status-error)]' : 'text-muted-foreground/70'
|
||||
)}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -373,7 +533,7 @@ const TaskToolSummary: React.FC<{
|
||||
)}
|
||||
|
||||
{hasOutput ? (
|
||||
<div className={cn('space-y-1', (completedEntries.length > 0 || sessionId) && 'pt-1')}
|
||||
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1043,24 +1203,105 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start;
|
||||
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end;
|
||||
|
||||
const taskOutputString = React.useMemo(() => {
|
||||
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
|
||||
}, [stateWithData.output]);
|
||||
|
||||
const parsedTaskMetadata = React.useMemo(() => {
|
||||
return parseTaskMetadataBlock(taskOutputString);
|
||||
}, [taskOutputString]);
|
||||
|
||||
const taskSessionId = React.useMemo<string | undefined>(() => {
|
||||
if (!isTaskTool) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = metadata as { sessionId?: string } | undefined;
|
||||
return typeof candidate?.sessionId === 'string' ? candidate.sessionId : undefined;
|
||||
}, [isTaskTool, metadata]);
|
||||
if (typeof candidate?.sessionId === 'string' && candidate.sessionId.trim().length > 0) {
|
||||
return candidate.sessionId;
|
||||
}
|
||||
if (parsedTaskMetadata.sessionId) {
|
||||
return parsedTaskMetadata.sessionId;
|
||||
}
|
||||
return readTaskSessionIdFromOutput(taskOutputString);
|
||||
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, taskOutputString]);
|
||||
|
||||
const taskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||
const childSessionMessages = useSessionStore(
|
||||
React.useCallback((store) => {
|
||||
if (!taskSessionId) {
|
||||
return EMPTY_SESSION_MESSAGES;
|
||||
}
|
||||
return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES;
|
||||
}, [taskSessionId])
|
||||
);
|
||||
|
||||
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||
if (!isTaskTool) {
|
||||
return [];
|
||||
}
|
||||
const candidate = (metadata as { summary?: unknown } | undefined)?.summary;
|
||||
if (!Array.isArray(candidate)) {
|
||||
const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined);
|
||||
const normalized = normalizeTaskSummaryEntries(
|
||||
candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls
|
||||
);
|
||||
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return parsedTaskMetadata.summaryEntries;
|
||||
}, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]);
|
||||
|
||||
const childSessionTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||
if (!isTaskTool || !taskSessionId) {
|
||||
return [];
|
||||
}
|
||||
return candidate.filter((entry): entry is TaskToolSummaryEntry => typeof entry === 'object' && entry !== null) as TaskToolSummaryEntry[];
|
||||
}, [isTaskTool, metadata]);
|
||||
if (!Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return buildTaskSummaryEntriesFromSession(childSessionMessages);
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const taskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||
if (childSessionTaskSummaryEntries.length > 0) {
|
||||
return childSessionTaskSummaryEntries;
|
||||
}
|
||||
return metadataTaskSummaryEntries;
|
||||
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
|
||||
|
||||
const fetchedTaskSessionsRef = React.useRef<Set<string>>(new Set());
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool || !taskSessionId) {
|
||||
return;
|
||||
}
|
||||
if (childSessionTaskSummaryEntries.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (fetchedTaskSessionsRef.current.has(taskSessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchedTaskSessionsRef.current.add(taskSessionId);
|
||||
let cancelled = false;
|
||||
|
||||
void opencodeClient
|
||||
.getSessionMessages(taskSessionId, 500)
|
||||
.then((messages) => {
|
||||
if (cancelled || !Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
fetchedTaskSessionsRef.current.delete(taskSessionId);
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().syncMessages(taskSessionId, messages);
|
||||
})
|
||||
.catch(() => {
|
||||
fetchedTaskSessionsRef.current.delete(taskSessionId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [childSessionTaskSummaryEntries.length, isTaskTool, taskSessionId]);
|
||||
|
||||
|
||||
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
||||
@@ -1211,7 +1452,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
isExpanded={isExpanded}
|
||||
hasPrevTool={hasPrevTool}
|
||||
hasNextTool={hasNextTool}
|
||||
output={typeof stateWithData.output === 'string' ? stateWithData.output : undefined}
|
||||
output={taskOutputString}
|
||||
sessionId={taskSessionId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user