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 ( +
+
+