Add plan mode and plan view with per-session agent context (#210)

* feat: add plan/build mode switching and Plan view tab

Add PlanView view and integrate plan tab into main layout
Introduce plan_enter and plan_exit tools with icons and status strings
Persist per-session agent selections in context to reduce UI flicker during mode switches

* feat: enchanced plan discovery checks in header and plan view

* fix(ui): align PlanView and Header with sessionDirectory logic

Remove dependency on context store in header and compute sessionDirectory from session or currentDirectory
Compute display and repo paths using sessionDirectory for PlanView and update path resolution
Poll plan content every 3 seconds to reflect changes without heavy retries

* feat(ui): improve header tab switching and add copy buttons for plan

Switch header tab navigation to central UI store and auto-switch when plan is unavailable
Show checkmark icon after copying file contents or path with auto-hide timeout
Extend plan view path resolution to prefer session directory when present
This commit is contained in:
Bohdan Triapitsyn
2026-01-25 15:52:02 +02:00
committed by GitHub
parent 75f781f940
commit f4da45a8ad
22 changed files with 1353 additions and 112 deletions
+70 -15
View File
@@ -8,6 +8,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useContextStore } from '@/stores/contextStore';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -34,8 +35,10 @@ const isDetailedDefaultTool = (toolName: unknown): boolean =>
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined });
if (!ref.current.hasValue && value !== undefined && value !== null) {
ref.current = { hasValue: true, value };
if (value !== undefined && value !== null) {
if (!ref.current.hasValue || ref.current.value !== value) {
ref.current = { hasValue: true, value };
}
}
return ref.current.hasValue ? ref.current.value : value;
@@ -91,8 +94,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
})(),
currentSessionId: state.currentSessionId,
getCurrentAgent: state.getCurrentAgent,
getSessionAgentSelection: state.getSessionAgentSelection,
getAgentModelForSession: state.getAgentModelForSession,
getSessionModelSelection: state.getSessionModelSelection,
}))
@@ -102,8 +103,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
lifecyclePhase,
isStreamingMessage,
currentSessionId,
getCurrentAgent,
getSessionAgentSelection,
getAgentModelForSession,
getSessionModelSelection,
} = sessionState;
@@ -134,12 +133,37 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const sessionId = message.info.sessionID;
// Subscribe to context changes so badges update immediately on mode switches.
const currentContextAgent = useContextStore(
(state) => (sessionId ? state.currentAgentContext.get(sessionId) : undefined)
);
const savedSessionAgentSelection = useContextStore(
(state) => (sessionId ? state.sessionAgentSelections.get(sessionId) : undefined)
);
const normalizedParts = React.useMemo(() => {
if (!isUser) {
return message.parts;
}
return message.parts.map((part) => {
const keepSyntheticUserText = (text: string): boolean => {
const trimmed = text.trim();
if (trimmed.startsWith('User has requested to enter plan mode')) return true;
if (trimmed.startsWith('The plan at ')) return true;
return false;
};
return message.parts
.filter((part) => {
const synthetic = (part as unknown as { synthetic?: boolean })?.synthetic === true;
if (!synthetic) return true;
if (part.type !== 'text') return false;
const text = (part as unknown as { text?: unknown })?.text;
return typeof text === 'string' ? keepSyntheticUserText(text) : false;
})
.map((part) => {
const rawPart = part as Record<string, unknown>;
if (rawPart.type === 'compaction') {
return { type: 'text', text: '/compact' } as Part;
@@ -161,10 +185,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}
const mode = getMessageInfoProp(previousMessage.info, 'mode');
const agent = getMessageInfoProp(previousMessage.info, 'agent');
const providerID = getMessageInfoProp(previousMessage.info, 'providerID');
const modelID = getMessageInfoProp(previousMessage.info, 'modelID');
const variant = getMessageInfoProp(previousMessage.info, 'variant');
const resolvedAgent = typeof mode === 'string' && mode.trim().length > 0 ? mode : undefined;
const resolvedAgent =
typeof mode === 'string' && mode.trim().length > 0
? mode
: (typeof agent === 'string' && agent.trim().length > 0 ? agent : undefined);
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
@@ -181,33 +209,57 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
};
}, [isUser, previousMessage]);
const previousIsModeSwitchMessage = React.useMemo(() => {
if (isUser || !previousMessage) return false;
const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i] as unknown as { type?: string; text?: string; synthetic?: boolean };
if (part?.type !== 'text') continue;
if (part?.synthetic !== true) continue;
const text = typeof part.text === 'string' ? part.text.trim() : '';
if (text.startsWith('User has requested to enter plan mode') || text.startsWith('The plan at ')) {
return true;
}
}
return false;
}, [isUser, previousMessage]);
const agentName = React.useMemo(() => {
if (isUser) return undefined;
// While the assistant message is streaming, if the immediately previous user message is a
// synthetic mode switch, trust that mode for the badge.
const timeInfo = message.info.time as { completed?: number } | undefined;
const isCompleted = typeof timeInfo?.completed === 'number' && timeInfo.completed > 0;
if (!isCompleted && previousIsModeSwitchMessage && previousUserMetadata?.agentName) {
return previousUserMetadata.agentName;
}
const messageMode = getMessageInfoProp(message.info, 'mode');
if (typeof messageMode === 'string' && messageMode.trim().length > 0) {
return messageMode;
}
const messageAgent = getMessageInfoProp(message.info, 'agent');
if (typeof messageAgent === 'string' && messageAgent.trim().length > 0) {
return messageAgent;
}
if (previousUserMetadata?.agentName) {
return previousUserMetadata.agentName;
}
const sessionId = message.info.sessionID;
if (!sessionId) {
return undefined;
}
const currentContextAgent = getCurrentAgent(sessionId);
if (currentContextAgent) {
return currentContextAgent;
}
const savedSelection = getSessionAgentSelection(sessionId);
return savedSelection ?? undefined;
}, [isUser, message.info, previousUserMetadata, getCurrentAgent, getSessionAgentSelection]);
return savedSessionAgentSelection ?? undefined;
}, [isUser, message.info, previousIsModeSwitchMessage, previousUserMetadata, sessionId, currentContextAgent, savedSessionAgentSelection]);
const sessionId = message.info.sessionID;
const messageProviderID = !isUser ? getMessageInfoProp(message.info, 'providerID') : null;
const messageModelID = !isUser ? getMessageInfoProp(message.info, 'modelID') : null;
@@ -324,6 +376,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return isMessageCompleted ? visibleParts : [];
}, [isUser, isMessageCompleted, visibleParts]);
const assistantTextParts = React.useMemo(() => {
if (isUser) {
return [];
@@ -835,12 +888,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
>
<div className="chat-message-column relative">
{isUser ? (
displayParts.length === 0 ? null : (
<FadeInOnReveal>
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-primary/10 dark:bg-primary/10 px-5 py-3 shadow-sm border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={visibleParts}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
@@ -869,6 +923,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
</div>
</div>
</FadeInOnReveal>
)
) : (
<div className="relative pl-4 ml-1">
{shouldShowHeader && (
+109 -33
View File
@@ -295,7 +295,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
currentSessionId,
messages,
saveSessionAgentSelection,
getSessionAgentSelection,
saveAgentModelForSession,
getAgentModelForSession,
saveAgentModelVariantForSession,
@@ -306,6 +305,28 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
} = useSessionStore();
const contextHydrated = useContextStore((state) => state.hasHydrated);
const sessionSavedAgentName = useContextStore((state) =>
currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null
);
const stickySessionAgentRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!currentSessionId) {
stickySessionAgentRef.current = null;
return;
}
if (sessionSavedAgentName) {
stickySessionAgentRef.current = sessionSavedAgentName;
}
}, [currentSessionId, sessionSavedAgentName]);
const stickySessionAgentName = currentSessionId ? stickySessionAgentRef.current : null;
// Prefer per-session selection over global config to avoid flicker during server-driven mode switches.
const uiAgentName = currentSessionId
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
: currentAgentName;
const { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore();
const { favoriteModelsList, recentModelsList } = useModelLists();
@@ -416,9 +437,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const { cascadeDefaultMode, modeAvailability, autoApproveAvailable } = permissionUiState;
const selectionContextReady = Boolean(currentSessionId && currentAgentName);
const sessionMode = selectionContextReady && currentSessionId && currentAgentName
? getSessionAgentEditMode(currentSessionId, currentAgentName, cascadeDefaultMode)
const selectionContextReady = Boolean(currentSessionId && uiAgentName);
const sessionMode = selectionContextReady && currentSessionId && uiAgentName
? getSessionAgentEditMode(currentSessionId, uiAgentName, cascadeDefaultMode)
: cascadeDefaultMode;
const editModeShortLabels: Record<EditPermissionMode, string> = {
@@ -535,6 +556,23 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
inFlight: boolean;
} | null>(null);
// If we have an explicit per-session agent selection (eg. server-injected mode switch),
// treat the session as resolved and don't run inference/fallback that could cause flicker.
React.useEffect(() => {
if (!currentSessionId) {
return;
}
const refState = sessionInitializationRef.current;
if (!refState || refState.sessionId !== currentSessionId) {
return;
}
if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) {
refState.resolved = true;
refState.inFlight = false;
}
}, [agents, currentSessionId, sessionSavedAgentName]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
if (!providerId || !modelId) {
@@ -597,7 +635,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
};
const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => {
const savedAgentName = getSessionAgentSelection(currentSessionId);
const savedAgentName = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
@@ -627,7 +667,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
setAgent(agent.name);
}
saveSessionAgentSelection(currentSessionId, agent.name);
const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
return 'resolved';
@@ -645,13 +688,33 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
return;
}
const existingSelection = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null;
// If we already have a valid agent selected (often from server-injected mode switch),
// don't override it with a fallback.
const preferred =
(currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null) ||
currentAgentName;
if (preferred && agents.some((agent) => agent.name === preferred)) {
if (currentAgentName !== preferred) {
setAgent(preferred);
}
return;
}
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
const fallbackAgent = agents.find(agent => agent.name === 'build') || primaryAgents[0] || agents[0];
if (!fallbackAgent) {
return;
}
saveSessionAgentSelection(currentSessionId, fallbackAgent.name);
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, fallbackAgent.name);
}
if (currentAgentName !== fallbackAgent.name) {
setAgent(fallbackAgent.name);
@@ -697,7 +760,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}
if (latestAgent) {
saveSessionAgentSelection(currentSessionId, latestAgent);
// If server/user already selected an agent for this session, don't override
// with heuristic inference mid-stream.
const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (latestSaved && latestSaved !== latestAgent) {
finalize();
return;
}
if (!latestSaved) {
saveSessionAgentSelection(currentSessionId, latestAgent);
}
if (currentAgentName !== latestAgent) {
setAgent(latestAgent);
}
@@ -736,6 +809,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}
}
if (isCancelled) {
return;
}
applyFallbackAgent();
finalize();
} catch (error) {
@@ -755,7 +832,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
currentSessionMessageCount,
agents,
currentAgentName,
getSessionAgentSelection,
getAgentModelForSession,
setAgent,
tryApplyModelSelection,
@@ -763,6 +839,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
saveSessionAgentSelection,
contextHydrated,
providers,
sessionSavedAgentName,
]);
React.useEffect(() => {
@@ -770,8 +847,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
return;
}
const savedAgentName = getSessionAgentSelection(currentSessionId);
const preferredAgent = savedAgentName || currentAgentName;
const preferredAgent = sessionSavedAgentName || currentAgentName;
if (!preferredAgent) {
return;
}
@@ -812,10 +888,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
currentModelId,
providers,
agents,
getSessionAgentSelection,
getAgentModelForSession,
tryApplyModelSelection,
setAgent,
sessionSavedAgentName,
]);
React.useEffect(() => {
@@ -1008,14 +1084,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
};
const getAgentDisplayName = () => {
if (!currentAgentName) {
if (!uiAgentName) {
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find(agent => agent.name === 'build');
const defaultAgent = buildAgent || primaryAgents[0];
return defaultAgent ? capitalizeAgentName(defaultAgent.name) : 'Select Agent';
}
const agent = agents.find(a => a.name === currentAgentName);
return agent ? capitalizeAgentName(agent.name) : capitalizeAgentName(currentAgentName);
const agent = agents.find(a => a.name === uiAgentName);
return agent ? capitalizeAgentName(agent.name) : capitalizeAgentName(uiAgentName);
};
const capitalizeAgentName = (name: string) => {
@@ -1630,7 +1706,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
>
<div className="flex flex-col gap-1.5">
{primaryAgents.map((agent) => {
const isSelected = agent.name === currentAgentName;
const isSelected = agent.name === uiAgentName;
const agentColor = getAgentColor(agent.name);
return (
<button
@@ -2418,10 +2494,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
className={cn(
controlIconSize,
'flex-shrink-0',
currentAgentName ? '' : 'text-muted-foreground'
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
/>
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
@@ -2429,7 +2505,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[220px]' : undefined
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
@@ -2573,14 +2649,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
'cursor-pointer hover:opacity-70',
)}
>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
currentAgentName ? '' : 'text-muted-foreground'
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
/>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
@@ -2588,10 +2664,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
'font-medium truncate min-w-0',
isMobile && 'max-w-[60px]'
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</button>
);
};
@@ -26,6 +26,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const isSummaryTab = activeTab === SUMMARY_TAB;
const activeIndex = isSummaryTab ? -1 : Math.max(0, Math.min(questions.length - 1, Number(activeTab) || 0));
const activeQuestion = isSummaryTab ? null : questions[activeIndex];
const activeHeader = React.useMemo(() => {
if (isSummaryTab) return null;
const header = activeQuestion?.header?.trim();
return header && header.length > 0 ? header : null;
}, [activeQuestion?.header, isSummaryTab]);
React.useEffect(() => {
setActiveTab('0');
@@ -163,6 +168,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
<div className="flex items-center gap-2">
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
{activeHeader ? (
<span className="ml-auto typography-micro font-medium text-foreground/70 px-1.5 py-0.5 rounded bg-muted/30 border border-border/20">
{activeHeader}
</span>
) : null}
</div>
</div>
@@ -1,6 +1,6 @@
import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { RiBrainAi3Line, RiFileImageLine, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { RiBrainAi3Line, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { cn } from '@/lib/utils';
@@ -69,6 +69,12 @@ const getToolIcon = (toolName: string) => {
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool === 'plan_enter') {
return <RiFileList2Line className={iconClass} />;
}
if (tool === 'plan_exit') {
return <RiTaskLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
@@ -37,6 +37,14 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
return parts.filter((part) => {
const partWithSynthetic = part as PartWithSynthetic;
const isSynthetic = Boolean(partWithSynthetic.synthetic);
if (isSynthetic && part.type === 'text') {
const text = extractTextContent(part);
if (text.includes('<system-reminder>')) {
return false;
}
}
// Only filter out synthetic parts if there are non-synthetic parts present
// Otherwise, show synthetic parts so the message is displayed
if (isSynthetic && hasNonSynthetic) {
@@ -1,7 +1,7 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { 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';
@@ -88,6 +88,12 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'question') {
return <RiSurveyLine className={iconClass} />;
}
if (tool === 'plan_enter') {
return <RiFileList2Line className={iconClass} />;
}
if (tool === 'plan_exit') {
return <RiTaskLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
@@ -221,6 +227,14 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
return input.name;
}
if (part.tool === 'plan_enter') {
return 'Switching to planning';
}
if (part.tool === 'plan_exit') {
return 'Switching to building';
}
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
return typeof desc === 'string' ? desc : '';
};