refactor: remove plan polling, add message-driven detection via usePlanDetection hook

This commit is contained in:
Bohdan Triapitsyn
2026-04-06 20:44:13 +03:00
parent a516650f96
commit 8de1884f5c
5 changed files with 111 additions and 124 deletions
@@ -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;
+32 -109
View File
@@ -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<HeaderProps> = ({
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<HeaderProps> = ({
}, [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<string>('');
// 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<HeaderProps> = ({
}
}, [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<boolean> => {
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;
+7 -15
View File
@@ -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]);
+48
View File
@@ -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]);
};
+20
View File
@@ -177,6 +177,10 @@ export type SessionUIState = {
abortControllers: Map<string, AbortController>
isLoading: boolean
lastLoadedDirectory: string | null
// Plan mode - per-session plan file availability (set when plan_enter tool creates a plan)
sessionPlanAvailable: Map<string, boolean>
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<SessionUIState>()((set, get) => ({
abortControllers: new Map(),
isLoading: false,
lastLoadedDirectory: null,
sessionPlanAvailable: new Map(),
// ---------------------------------------------------------------------------
// setCurrentSession
@@ -1147,4 +1152,19 @@ export const useSessionUIStore = create<SessionUIState>()((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
},
}))