From 8de1884f5cdcddaef33b78eab20c64fae805c155 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Apr 2026 20:44:13 +0300 Subject: [PATCH] refactor: remove plan polling, add message-driven detection via usePlanDetection hook --- .../ui/src/components/chat/ChatContainer.tsx | 4 + packages/ui/src/components/layout/Header.tsx | 141 ++++-------------- packages/ui/src/components/views/PlanView.tsx | 22 +-- packages/ui/src/hooks/usePlanDetection.ts | 48 ++++++ packages/ui/src/sync/session-ui-store.ts | 20 +++ 5 files changed, 111 insertions(+), 124 deletions(-) create mode 100644 packages/ui/src/hooks/usePlanDetection.ts diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 8f40a244..50836781 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -38,6 +38,7 @@ import { useSessionStatus, } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; +import { usePlanDetection } from '@/hooks/usePlanDetection'; import { getAllSyncSessions } from '@/sync/sync-refs'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; @@ -320,6 +321,9 @@ export const ChatContainer: React.FC = () => { // Sessions from sync system const sessions = useSessions(); + // Plan detection - watches messages for plan creation and signals store + usePlanDetection(currentSessionId ?? ''); + // Session status from sync system const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index cfc47012..448ea740 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -25,7 +25,6 @@ import { useSession, useSessionMessagesResolved } from '@/sync/sync-context'; import { getAllSyncSessions } from '@/sync/sync-refs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitBranchLabel } from '@/stores/useGitStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; @@ -569,33 +568,6 @@ const normalize = (value: string): string => { 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; -}; - const getActiveContextMode = (panelState: { isOpen: boolean; activeTabId: string | null; @@ -695,7 +667,6 @@ export const Header: React.FC = ({ const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); - const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { isMobile } = useDeviceInfo(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); @@ -1154,11 +1125,41 @@ export const Header: React.FC = ({ }, [actionDirectory, activeProjectRef]); - const [planTabAvailable, setPlanTabAvailable] = React.useState(false); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); - const showPlanTab = planModeEnabled && planTabAvailable; + const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable); + const planTabAvailable = planModeEnabled && currentSessionId ? isSessionPlanAvailable(currentSessionId) : false; + const showPlanTab = planTabAvailable; const lastPlanSessionKeyRef = React.useRef(''); + // Reset plan tab availability when session changes + React.useEffect(() => { + if (!planModeEnabled) { + if (useUIStore.getState().activeMainTab === 'plan') { + useUIStore.getState().setActiveMainTab('chat'); + } + return; + } + + if (!currentSessionId) return; + + const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.time?.created || 0}:${currentSession?.slug || 'none'}`; + if (lastPlanSessionKeyRef.current !== sessionKey) { + lastPlanSessionKeyRef.current = sessionKey; + } + + // If plan is not available but user is on plan tab, switch them back to chat + if (!planTabAvailable && useUIStore.getState().activeMainTab === 'plan') { + useUIStore.getState().setActiveMainTab('chat'); + } + }, [ + planModeEnabled, + planTabAvailable, + currentSession?.slug, + currentSession?.time?.created, + currentSessionId, + sessionDirectory, + ]); + const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => { if (!accountId || isSwitchingGitHubAccount) return; setIsSwitchingGitHubAccount(true); @@ -1191,84 +1192,6 @@ export const Header: React.FC = ({ } }, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]); - React.useEffect(() => { - if (!planModeEnabled) { - setPlanTabAvailable(false); - if (useUIStore.getState().activeMainTab === 'plan') { - useUIStore.getState().setActiveMainTab('chat'); - } - return; - } - - 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); - }; - }, [ - planModeEnabled, - sessionDirectory, - currentSession?.slug, - currentSession?.time?.created, - currentSessionId, - homeDirectory, - runtimeApis.files, - ]); - const blurActiveElement = React.useCallback(() => { if (typeof document === 'undefined') { return; diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 83b8326b..9c396032 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -256,6 +256,7 @@ export const PlanView: React.FC = () => { }, [currentTheme, resolvedPath]); React.useEffect(() => { + // Early exit if plan mode is disabled - don't load anything if (!planModeEnabled) { setResolvedPath(null); setContent(''); @@ -278,11 +279,9 @@ export const PlanView: React.FC = () => { return response.text(); }; - const run = async (showLoading: boolean) => { - if (showLoading) { - setResolvedPath(null); - setContent(''); - } + const run = async () => { + setResolvedPath(null); + setContent(''); if (!session?.slug || !session?.time?.created || !sessionDirectory) { setResolvedPath(null); @@ -290,9 +289,7 @@ export const PlanView: React.FC = () => { return; } - if (showLoading) { - setLoading(true); - } + setLoading(true); try { const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug); @@ -332,19 +329,14 @@ export const PlanView: React.FC = () => { setResolvedPath(null); setContent(''); } finally { - if (!cancelled && showLoading) setLoading(false); + if (!cancelled) setLoading(false); } }; - void run(true); - - const interval = window.setInterval(() => { - void run(false); - }, 3000); + void run(); return () => { cancelled = true; - window.clearInterval(interval); }; }, [planModeEnabled, sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]); diff --git a/packages/ui/src/hooks/usePlanDetection.ts b/packages/ui/src/hooks/usePlanDetection.ts new file mode 100644 index 00000000..109fc950 --- /dev/null +++ b/packages/ui/src/hooks/usePlanDetection.ts @@ -0,0 +1,48 @@ +import React from 'react'; +import { useSessionMessages } from '@/sync/sync-context'; +import { getSyncParts } from '@/sync/sync-refs'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; + +/** + * Watches session messages for plan creation and marks sessions as plan-available. + * + * This is the single source of truth for plan detection. When a plan_enter tool + * executes, it creates a synthetic message like "The plan at ${path}" or + * "User has requested to enter plan mode". We detect these and signal availability. + * + * The Header component subscribes to sessionPlanAvailable map to show/hide the Plan tab. + */ +export const usePlanDetection = (sessionId: string, directory?: string) => { + const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); + const markSessionPlanAvailable = useSessionUIStore((state) => state.markSessionPlanAvailable); + const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable); + const messages = useSessionMessages(sessionId, directory); + + React.useEffect(() => { + // Early exit if plan mode is disabled - don't parse messages + if (!planModeEnabled) return; + if (!sessionId) return; + + // Already marked as available - no need to check again + if (isSessionPlanAvailable(sessionId)) return; + + // Scan messages for plan references + for (const message of messages) { + // Only check assistant messages for plan references + if (message.role !== 'assistant') continue; + + const parts = getSyncParts(message.id, directory); + for (const part of parts) { + if (part.type !== 'text') continue; + const text = (part as { text?: string }).text || ''; + + // Check for plan file reference in synthetic messages + if (text.includes('The plan at ') || text.includes('User has requested to enter plan mode')) { + markSessionPlanAvailable(sessionId); + return; + } + } + } + }, [planModeEnabled, sessionId, directory, messages, markSessionPlanAvailable, isSessionPlanAvailable]); +}; diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index e3003c7a..cd373485 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -177,6 +177,10 @@ export type SessionUIState = { abortControllers: Map isLoading: boolean lastLoadedDirectory: string | null + // Plan mode - per-session plan file availability (set when plan_enter tool creates a plan) + sessionPlanAvailable: Map + markSessionPlanAvailable: (sessionId: string) => void + isSessionPlanAvailable: (sessionId: string) => boolean // Actions — UI state management setCurrentSession: (id: string | null, directoryHint?: string | null) => void @@ -378,6 +382,7 @@ export const useSessionUIStore = create()((set, get) => ({ abortControllers: new Map(), isLoading: false, lastLoadedDirectory: null, + sessionPlanAvailable: new Map(), // --------------------------------------------------------------------------- // setCurrentSession @@ -1147,4 +1152,19 @@ export const useSessionUIStore = create()((set, get) => ({ // Session directory is owned by sync child stores via SSE events. // This is now a no-op — kept for interface compatibility during migration. }, + + // --------------------------------------------------------------------------- + // Plan mode availability tracking + // --------------------------------------------------------------------------- + markSessionPlanAvailable: (sessionId) => { + set((state) => { + const next = new Map(state.sessionPlanAvailable) + next.set(sessionId, true) + return { sessionPlanAvailable: next } + }) + }, + + isSessionPlanAvailable: (sessionId) => { + return get().sessionPlanAvailable.get(sessionId) ?? false + }, }))