refactor: remove plan polling, add message-driven detection via usePlanDetection hook
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user