From f4da45a8ad73ab004c522bcd242953cdc5dd24ad Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 25 Jan 2026 15:52:02 +0200 Subject: [PATCH] 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 --- .../src-tauri/src/assistant_notifications.rs | 46 +- .../ui/src/components/chat/ChatMessage.tsx | 85 ++- .../ui/src/components/chat/ModelControls.tsx | 142 ++++- .../ui/src/components/chat/QuestionCard.tsx | 10 + .../chat/message/ToolOutputDialog.tsx | 8 +- .../src/components/chat/message/partUtils.ts | 8 + .../chat/message/parts/ToolPart.tsx | 16 +- packages/ui/src/components/layout/Header.tsx | 170 ++++- .../ui/src/components/layout/MainLayout.tsx | 4 +- .../ui/src/components/views/FilesView.tsx | 44 +- packages/ui/src/components/views/PlanView.tsx | 591 ++++++++++++++++++ packages/ui/src/components/views/index.ts | 1 + packages/ui/src/hooks/useAssistantStatus.ts | 2 + packages/ui/src/hooks/useEventStream.ts | 189 +++++- packages/ui/src/index.css | 6 + .../src/lib/codemirror/languageByExtension.ts | 24 +- packages/ui/src/lib/toolHelpers.ts | 30 +- packages/ui/src/stores/contextStore.ts | 44 +- packages/ui/src/stores/messageStore.ts | 20 +- packages/ui/src/stores/useUIStore.ts | 2 +- packages/ui/src/types/question.ts | 8 + packages/web/server/index.js | 15 +- 22 files changed, 1353 insertions(+), 112 deletions(-) create mode 100644 packages/ui/src/components/views/PlanView.tsx diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs index 6b426baa..088c33ac 100644 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ b/packages/desktop/src-tauri/src/assistant_notifications.rs @@ -294,11 +294,53 @@ async fn handle_question_asked( .unwrap_or(true); if should_notify { + let (title, body) = properties + .get("questions") + .and_then(Value::as_array) + .and_then(|questions| questions.first()) + .and_then(Value::as_object) + .map(|first| { + let header = first + .get("header") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let question = first + .get("question") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + + let title = if header.to_ascii_lowercase().contains("plan mode") { + "Switch to plan mode".to_string() + } else if header.to_ascii_lowercase().contains("build agent") { + "Switch to build mode".to_string() + } else if !header.is_empty() { + header.to_string() + } else { + "Input needed".to_string() + }; + + let body = if !question.is_empty() { + question.to_string() + } else { + "Agent is waiting for your response".to_string() + }; + + (title, body) + }) + .unwrap_or_else(|| { + ( + "Input needed".to_string(), + "Agent is waiting for your response".to_string(), + ) + }); + let _ = app .notification() .builder() - .title("Input needed") - .body("Agent is waiting for your response") + .title(title) + .body(body) .sound("Glass") .show(); } diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 33c4ec2b..ed9335fc 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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(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 = ({ 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 = ({ lifecyclePhase, isStreamingMessage, currentSessionId, - getCurrentAgent, - getSessionAgentSelection, getAgentModelForSession, getSessionModelSelection, } = sessionState; @@ -134,12 +133,37 @@ const ChatMessage: React.FC = ({ 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; if (rawPart.type === 'compaction') { return { type: 'text', text: '/compact' } as Part; @@ -161,10 +185,14 @@ const ChatMessage: React.FC = ({ } 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 = ({ }; }, [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 = ({ return isMessageCompleted ? visibleParts : []; }, [isUser, isMessageCompleted, visibleParts]); + const assistantTextParts = React.useMemo(() => { if (isUser) { return []; @@ -835,12 +888,13 @@ const ChatMessage: React.FC = ({ >
{isUser ? ( + displayParts.length === 0 ? null : (
= ({
+ ) ) : (
{shouldShowHeader && ( diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index f0485ea7..90e83e9d 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -295,7 +295,6 @@ export const ModelControls: React.FC = ({ className }) => { currentSessionId, messages, saveSessionAgentSelection, - getSessionAgentSelection, saveAgentModelForSession, getAgentModelForSession, saveAgentModelVariantForSession, @@ -306,6 +305,28 @@ export const ModelControls: React.FC = ({ className }) => { } = useSessionStore(); const contextHydrated = useContextStore((state) => state.hasHydrated); + + const sessionSavedAgentName = useContextStore((state) => + currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null + ); + + const stickySessionAgentRef = React.useRef(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 = ({ 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 = { @@ -535,6 +556,23 @@ export const ModelControls: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ className }) => { } } + if (isCancelled) { + return; + } + applyFallbackAgent(); finalize(); } catch (error) { @@ -755,7 +832,6 @@ export const ModelControls: React.FC = ({ className }) => { currentSessionMessageCount, agents, currentAgentName, - getSessionAgentSelection, getAgentModelForSession, setAgent, tryApplyModelSelection, @@ -763,6 +839,7 @@ export const ModelControls: React.FC = ({ className }) => { saveSessionAgentSelection, contextHydrated, providers, + sessionSavedAgentName, ]); React.useEffect(() => { @@ -770,8 +847,7 @@ export const ModelControls: React.FC = ({ 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 = ({ className }) => { currentModelId, providers, agents, - getSessionAgentSelection, getAgentModelForSession, tryApplyModelSelection, setAgent, + sessionSavedAgentName, ]); React.useEffect(() => { @@ -1008,14 +1084,14 @@ export const ModelControls: React.FC = ({ 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 = ({ className }) => { >
{primaryAgents.map((agent) => { - const isSelected = agent.name === currentAgentName; + const isSelected = agent.name === uiAgentName; const agentColor = getAgentColor(agent.name); return ( ); }; diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index 2bfef6ce..1714dc40 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -26,6 +26,11 @@ export const QuestionCard: React.FC = ({ 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 = ({ question }) => {
Input needed + {activeHeader ? ( + + {activeHeader} + + ) : null}
diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index e0e5e01d..cbc699a3 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -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 ; } + if (tool === 'plan_enter') { + return ; + } + if (tool === 'plan_exit') { + return ; + } if (tool.startsWith('git')) { return ; } diff --git a/packages/ui/src/components/chat/message/partUtils.ts b/packages/ui/src/components/chat/message/partUtils.ts index 2e1c7651..7a557671 100644 --- a/packages/ui/src/components/chat/message/partUtils.ts +++ b/packages/ui/src/components/chat/message/partUtils.ts @@ -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('')) { + 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) { diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 5623de6d..b6f988cc 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -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 ; } + if (tool === 'plan_enter') { + return ; + } + if (tool === 'plan_exit') { + return ; + } if (tool.startsWith('git')) { return ; } @@ -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 : ''; }; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 71a8827b..794b8300 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -5,17 +5,52 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; -import { RiArrowLeftSLine, RiChat4Line, RiCodeLine, RiCommandLine, RiFolder6Line, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiCodeLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, hasModifier } from '@/lib/utils'; import { useDiffFileCount } from '@/components/views/DiffView'; import { McpDropdown } from '@/components/mcp/McpDropdown'; +const normalize = (value: string): string => { + if (!value) return ''; + const replaced = value.replace(/\\/g, '/'); + return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); +}; + +const joinPath = (base: string, segment: string): string => { + const normalizedBase = normalize(base); + const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + if (!normalizedBase || normalizedBase === '/') { + return `/${cleanSegment}`; + } + return `${normalizedBase}/${cleanSegment}`; +}; + +const buildRepoPlansDirectory = (directory: string): string => { + return joinPath(joinPath(directory, '.opencode'), 'plans'); +}; + +const buildHomePlansDirectory = (): string => { + return '~/.opencode/plans'; +}; + +const resolveTilde = (path: string, homeDir: string | null): string => { + const trimmed = path.trim(); + if (!trimmed.startsWith('~')) return trimmed; + if (trimmed === '~') return homeDir || trimmed; + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return homeDir ? `${homeDir}${trimmed.slice(1)}` : trimmed; + } + return trimmed; +}; + interface TabConfig { id: MainTab; label: string; @@ -34,8 +69,12 @@ export const Header: React.FC = () => { const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const { getCurrentModel } = useConfigStore(); + const runtimeApis = useRuntimeAPIs(); const getContextUsage = useSessionStore((state) => state.getContextUsage); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessions = useSessionStore((state) => state.sessions); + const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { isMobile } = useDeviceInfo(); const diffFileCount = useDiffFileCount(); const updateAvailable = useUpdateStore((state) => state.available); @@ -73,6 +112,90 @@ export const Header: React.FC = () => { const contextUsage = getContextUsage(contextLimit, outputLimit); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); + const currentSession = React.useMemo(() => { + if (!currentSessionId) return null; + return sessions.find((s) => s.id === currentSessionId) ?? null; + }, [currentSessionId, sessions]); + + const sessionDirectory = React.useMemo(() => { + const raw = typeof currentSession?.directory === 'string' ? currentSession.directory : ''; + return normalize(raw || ''); + }, [currentSession?.directory]); + + + const [planTabAvailable, setPlanTabAvailable] = React.useState(false); + const showPlanTab = planTabAvailable; + const lastPlanSessionKeyRef = React.useRef(''); + + React.useEffect(() => { + let cancelled = false; + + const checkExists = async (directory: string, fileName: string): Promise => { + if (!directory || !fileName) return false; + if (!runtimeApis.files?.listDirectory) return false; + + try { + const listing = await runtimeApis.files.listDirectory(directory); + const entries = Array.isArray(listing?.entries) ? listing.entries : []; + return entries.some((entry) => entry?.name === fileName && !entry?.isDirectory); + } catch { + return false; + } + }; + + const runOnce = async () => { + if (cancelled) return; + + if (!currentSession?.slug || !currentSession?.time?.created || !sessionDirectory) { + setPlanTabAvailable(false); + if (useUIStore.getState().activeMainTab === 'plan') { + useUIStore.getState().setActiveMainTab('chat'); + } + return; + } + + const fileName = `${currentSession.time.created}-${currentSession.slug}.md`; + const repoDir = buildRepoPlansDirectory(sessionDirectory); + const homeDir = resolveTilde(buildHomePlansDirectory(), homeDirectory || null); + + const [repoExists, homeExists] = await Promise.all([ + checkExists(repoDir, fileName), + checkExists(homeDir, fileName), + ]); + + if (cancelled) return; + + const available = repoExists || homeExists; + setPlanTabAvailable(available); + if (!available && useUIStore.getState().activeMainTab === 'plan') { + useUIStore.getState().setActiveMainTab('chat'); + } + }; + + const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.time?.created || 0}:${currentSession?.slug || 'none'}`; + if (lastPlanSessionKeyRef.current !== sessionKey) { + lastPlanSessionKeyRef.current = sessionKey; + setPlanTabAvailable(false); + } + void runOnce(); + + const interval = window.setInterval(() => { + void runOnce(); + }, 3000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [ + sessionDirectory, + currentSession?.slug, + currentSession?.time?.created, + currentSessionId, + homeDirectory, + runtimeApis.files, + ]); + const blurActiveElement = React.useCallback(() => { if (typeof document === 'undefined') { return; @@ -193,23 +316,34 @@ export const Header: React.FC = () => { } }, [isDesktopApp]); - const tabs: TabConfig[] = React.useMemo(() => [ - { id: 'chat', label: 'Chat', icon: RiChat4Line }, - { - id: 'diff', - label: 'Diff', - icon: RiCodeLine, - badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined, - }, - { id: 'files', label: 'Files', icon: RiFolder6Line }, - { id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine }, - { - id: 'git', - label: 'Git', - icon: RiGitBranchLine, - showDot: isMobile && diffFileCount > 0, - }, - ], [diffFileCount, isMobile]); + const tabs: TabConfig[] = React.useMemo(() => { + const base: TabConfig[] = [ + { id: 'chat', label: 'Chat', icon: RiChat4Line }, + ]; + + if (showPlanTab) { + base.push({ id: 'plan', label: 'Plan', icon: RiFileTextLine }); + } + + base.push( + { + id: 'diff', + label: 'Diff', + icon: RiCodeLine, + badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined, + }, + { id: 'files', label: 'Files', icon: RiFolder6Line }, + { id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine }, + { + id: 'git', + label: 'Git', + icon: RiGitBranchLine, + showDot: isMobile && diffFileCount > 0, + }, + ); + + return base; + }, [diffFileCount, isMobile, showPlanTab]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index e8f83392..fdfe8678 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -15,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device'; import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; import { cn } from '@/lib/utils'; -import { ChatView, GitView, DiffView, TerminalView, FilesView, SettingsView } from '@/components/views'; +import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView } from '@/components/views'; export const MainLayout: React.FC = () => { const { @@ -298,6 +298,8 @@ export const MainLayout: React.FC = () => { const secondaryView = React.useMemo(() => { switch (activeMainTab) { + case 'plan': + return ; case 'git': return ; case 'diff': diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index e2f7660e..979972d4 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -8,6 +8,7 @@ import { RiFileImageLine, RiFileTextLine, RiFileCopy2Line, + RiCheckLine, RiFolder3Fill, RiFolderOpenFill, RiLoader4Line, @@ -285,12 +286,16 @@ export const FilesView: React.FC = () => { const pendingSelectFileRef = React.useRef(null); const pendingTabRef = React.useRef(null); const skipDirtyOnceRef = React.useRef(false); + const copiedContentTimeoutRef = React.useRef(null); + const copiedPathTimeoutRef = React.useRef(null); const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); const [dialogInputValue, setDialogInputValue] = React.useState(''); const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); const [contextMenuPath, setContextMenuPath] = React.useState(null); + const [copiedContent, setCopiedContent] = React.useState(false); + const [copiedPath, setCopiedPath] = React.useState(false); const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); @@ -341,6 +346,17 @@ export const FilesView: React.FC = () => { setIsSaving(false); }, [selectedFile?.path, setMainTabGuard]); + React.useEffect(() => { + return () => { + if (copiedContentTimeoutRef.current !== null) { + window.clearTimeout(copiedContentTimeoutRef.current); + } + if (copiedPathTimeoutRef.current !== null) { + window.clearTimeout(copiedPathTimeoutRef.current); + } + }; + }, []); + // Click outside to dismiss selection React.useEffect(() => { if (!lineSelection) return; @@ -1403,7 +1419,13 @@ export const FilesView: React.FC = () => { onClick={async () => { try { await navigator.clipboard.writeText(fileContent); - toast.success('Copied'); + setCopiedContent(true); + if (copiedContentTimeoutRef.current !== null) { + window.clearTimeout(copiedContentTimeoutRef.current); + } + copiedContentTimeoutRef.current = window.setTimeout(() => { + setCopiedContent(false); + }, 1200); } catch { toast.error('Copy failed'); } @@ -1412,7 +1434,11 @@ export const FilesView: React.FC = () => { title="Copy file contents" aria-label="Copy file contents" > - + {copiedContent ? ( + + ) : ( + + )} )} @@ -1423,7 +1449,13 @@ export const FilesView: React.FC = () => { onClick={async () => { try { await navigator.clipboard.writeText(displaySelectedPath); - toast.success('Copied'); + setCopiedPath(true); + if (copiedPathTimeoutRef.current !== null) { + window.clearTimeout(copiedPathTimeoutRef.current); + } + copiedPathTimeoutRef.current = window.setTimeout(() => { + setCopiedPath(false); + }, 1200); } catch { toast.error('Copy failed'); } @@ -1432,7 +1464,11 @@ export const FilesView: React.FC = () => { title={`Copy file path (${displaySelectedPath})`} aria-label={`Copy file path (${displaySelectedPath})`} > - + {copiedPath ? ( + + ) : ( + + )} )}
diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx new file mode 100644 index 00000000..72ce9c46 --- /dev/null +++ b/packages/ui/src/components/views/PlanView.tsx @@ -0,0 +1,591 @@ +import React from 'react'; +import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useContextStore } from '@/stores/contextStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { cn, getModifierLabel } from '@/lib/utils'; +import { getLanguageFromExtension } from '@/lib/toolHelpers'; +import { useDeviceInfo } from '@/lib/device'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; +import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; +import { languageByExtension } from '@/lib/codemirror/languageByExtension'; +import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiSendPlane2Line } from '@remixicon/react'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { EditorView } from '@codemirror/view'; + +const normalize = (value: string): string => { + if (!value) return ''; + const replaced = value.replace(/\\/g, '/'); + return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); +}; + +const joinPath = (base: string, segment: string): string => { + const normalizedBase = normalize(base); + const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + if (!normalizedBase || normalizedBase === '/') { + return `/${cleanSegment}`; + } + return `${normalizedBase}/${cleanSegment}`; +}; + +const buildRepoPlanPath = (directory: string, created: number, slug: string): string => { + return joinPath(joinPath(joinPath(directory, '.opencode'), 'plans'), `${created}-${slug}.md`); +}; + +const buildHomePlanPath = (created: number, slug: string): string => { + return `~/.opencode/plans/${created}-${slug}.md`; +}; + +const resolveTilde = (path: string, homeDir: string | null): string => { + const trimmed = path.trim(); + if (!trimmed.startsWith('~')) return trimmed; + if (trimmed === '~') return homeDir || trimmed; + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return homeDir ? `${homeDir}${trimmed.slice(1)}` : trimmed; + } + return trimmed; +}; + +const toDisplayPath = (resolvedPath: string, options: { currentDirectory: string; homeDirectory: string }): string => { + const current = normalize(options.currentDirectory); + const home = normalize(options.homeDirectory); + const normalized = normalize(resolvedPath); + + if (current && normalized.startsWith(current + '/')) { + return normalized.slice(current.length + 1); + } + + if (home && normalized === home) { + return '~'; + } + + if (home && normalized.startsWith(home + '/')) { + return `~${normalized.slice(home.length)}`; + } + + return normalized; +}; + +type SelectedLineRange = { + start: number; + end: number; +}; + +export const PlanView: React.FC = () => { + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessions = useSessionStore((state) => state.sessions); + const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const runtimeApis = useRuntimeAPIs(); + const sendMessage = useSessionStore((state) => state.sendMessage); + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore(); + const getSessionAgentSelection = useContextStore((state) => state.getSessionAgentSelection); + const getAgentModelForSession = useContextStore((state) => state.getAgentModelForSession); + const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const { inputBarOffset, isKeyboardOpen } = useUIStore(); + const { isMobile } = useDeviceInfo(); + const { currentTheme } = useThemeSystem(); + React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); + + const session = React.useMemo(() => { + if (!currentSessionId) return null; + return sessions.find((s) => s.id === currentSessionId) ?? null; + }, [currentSessionId, sessions]); + + const sessionDirectory = React.useMemo(() => { + const raw = typeof session?.directory === 'string' ? session.directory : ''; + return normalize(raw || ''); + }, [session?.directory]); + + const [resolvedPath, setResolvedPath] = React.useState(null); + const displayPath = React.useMemo(() => { + if (!resolvedPath || !sessionDirectory || !homeDirectory) { + return resolvedPath; + } + return toDisplayPath(resolvedPath, { currentDirectory: sessionDirectory, homeDirectory }); + }, [resolvedPath, sessionDirectory, homeDirectory]); + const [content, setContent] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [copiedPath, setCopiedPath] = React.useState(false); + const [copiedContent, setCopiedContent] = React.useState(false); + const copiedTimeoutRef = React.useRef(null); + const copiedContentTimeoutRef = React.useRef(null); + + const [lineSelection, setLineSelection] = React.useState(null); + const [commentText, setCommentText] = React.useState(''); + const isSelectingRef = React.useRef(false); + const selectionStartRef = React.useRef(null); + + React.useEffect(() => { + const handleGlobalMouseUp = () => { + isSelectingRef.current = false; + selectionStartRef.current = null; + }; + document.addEventListener('mouseup', handleGlobalMouseUp); + return () => document.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + React.useEffect(() => { + setLineSelection(null); + setCommentText(''); + }, [content]); + + React.useEffect(() => { + if (!lineSelection) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const commentUI = document.querySelector('[data-comment-ui]'); + if (commentUI?.contains(target)) return; + if (target.closest('.cm-gutterElement')) return; + if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; + setLineSelection(null); + setCommentText(''); + }; + + const timeoutId = window.setTimeout(() => { + document.addEventListener('click', handleClickOutside); + }, 100); + + return () => { + window.clearTimeout(timeoutId); + document.removeEventListener('click', handleClickOutside); + }; + }, [lineSelection]); + + const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => { + const lines = text.split('\n'); + const startLine = Math.max(1, range.start); + const endLine = Math.min(lines.length, range.end); + if (startLine > endLine) return ''; + return lines.slice(startLine - 1, endLine).join('\n'); + }, []); + + const handleSendComment = React.useCallback(async () => { + if (!lineSelection || !commentText.trim()) return; + if (!currentSessionId) return; + + const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName; + const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null; + const effectiveProviderId = sessionModel?.providerId || currentProviderId; + const effectiveModelId = sessionModel?.modelId || currentModelId; + + if (!effectiveProviderId || !effectiveModelId) { + return; + } + + const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId + ? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant + : currentVariant; + + const startLine = lineSelection.start; + const endLine = lineSelection.end; + const code = extractSelectedCode(content, lineSelection); + const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan'; + const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown'; + + const message = `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`; + + setCommentText(''); + setLineSelection(null); + setActiveMainTab('chat'); + + void sendMessage( + message, + effectiveProviderId, + effectiveModelId, + sessionAgent, + undefined, + undefined, + undefined, + effectiveVariant + ).catch(() => { + // ignore + }); + }, [ + lineSelection, + commentText, + currentSessionId, + currentProviderId, + currentModelId, + currentAgentName, + currentVariant, + content, + resolvedPath, + displayPath, + extractSelectedCode, + sendMessage, + setActiveMainTab, + getSessionAgentSelection, + getAgentModelForSession, + getAgentModelVariantForSession, + ]); + + const editorExtensions = React.useMemo(() => { + const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; + const language = languageByExtension(resolvedPath || 'plan.md'); + if (language) { + extensions.push(language); + } + extensions.push(EditorView.lineWrapping); + return extensions; + }, [currentTheme, resolvedPath]); + + React.useEffect(() => { + let cancelled = false; + + const readText = async (path: string): Promise => { + if (runtimeApis.files?.readFile) { + const result = await runtimeApis.files.readFile(path); + return result?.content ?? ''; + } + + const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); + if (!response.ok) { + throw new Error(`Failed to read plan file (${response.status})`); + } + return response.text(); + }; + + const run = async (showLoading: boolean) => { + if (showLoading) { + setResolvedPath(null); + setContent(''); + } + + if (!session?.slug || !session?.time?.created || !sessionDirectory) { + setResolvedPath(null); + setContent(''); + return; + } + + if (showLoading) { + setLoading(true); + } + + try { + const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug); + const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null); + + let resolved: string | null = null; + let text: string | null = null; + + try { + text = await readText(repoPath); + resolved = repoPath; + } catch { + // ignore + } + + if (!resolved) { + try { + text = await readText(homePath); + resolved = homePath; + } catch { + // ignore + } + } + + if (cancelled) return; + + if (!resolved || text === null) { + setResolvedPath(null); + setContent(''); + return; + } + + setResolvedPath(resolved); + setContent(text); + } catch { + if (cancelled) return; + setResolvedPath(null); + setContent(''); + } finally { + if (!cancelled && showLoading) setLoading(false); + } + }; + + void run(true); + + const interval = window.setInterval(() => { + void run(false); + }, 3000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]); + + React.useEffect(() => { + return () => { + if (copiedTimeoutRef.current !== null) { + window.clearTimeout(copiedTimeoutRef.current); + } + if (copiedContentTimeoutRef.current !== null) { + window.clearTimeout(copiedContentTimeoutRef.current); + } + }; + }, []); + + const renderCommentUI = () => { + if (!lineSelection) return null; + return ( +
+
+