refactor: remove plan polling, add message-driven detection via usePlanDetection hook
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
|||||||
useSessionStatus,
|
useSessionStatus,
|
||||||
} from '@/sync/sync-context';
|
} from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
|
|
||||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||||
@@ -320,6 +321,9 @@ export const ChatContainer: React.FC = () => {
|
|||||||
// Sessions from sync system
|
// Sessions from sync system
|
||||||
const sessions = useSessions();
|
const sessions = useSessions();
|
||||||
|
|
||||||
|
// Plan detection - watches messages for plan creation and signals store
|
||||||
|
usePlanDetection(currentSessionId ?? '');
|
||||||
|
|
||||||
// Session status from sync system
|
// Session status from sync system
|
||||||
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS;
|
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 { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
||||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { useGitBranchLabel } from '@/stores/useGitStore';
|
import { useGitBranchLabel } from '@/stores/useGitStore';
|
||||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
@@ -569,33 +568,6 @@ const normalize = (value: string): string => {
|
|||||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
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: {
|
const getActiveContextMode = (panelState: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
@@ -695,7 +667,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||||
const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode);
|
const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode);
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||||
@@ -1154,11 +1125,41 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
}, [actionDirectory, activeProjectRef]);
|
}, [actionDirectory, activeProjectRef]);
|
||||||
|
|
||||||
|
|
||||||
const [planTabAvailable, setPlanTabAvailable] = React.useState(false);
|
|
||||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
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>('');
|
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) => {
|
const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => {
|
||||||
if (!accountId || isSwitchingGitHubAccount) return;
|
if (!accountId || isSwitchingGitHubAccount) return;
|
||||||
setIsSwitchingGitHubAccount(true);
|
setIsSwitchingGitHubAccount(true);
|
||||||
@@ -1191,84 +1192,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]);
|
}, [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(() => {
|
const blurActiveElement = React.useCallback(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export const PlanView: React.FC = () => {
|
|||||||
}, [currentTheme, resolvedPath]);
|
}, [currentTheme, resolvedPath]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// Early exit if plan mode is disabled - don't load anything
|
||||||
if (!planModeEnabled) {
|
if (!planModeEnabled) {
|
||||||
setResolvedPath(null);
|
setResolvedPath(null);
|
||||||
setContent('');
|
setContent('');
|
||||||
@@ -278,11 +279,9 @@ export const PlanView: React.FC = () => {
|
|||||||
return response.text();
|
return response.text();
|
||||||
};
|
};
|
||||||
|
|
||||||
const run = async (showLoading: boolean) => {
|
const run = async () => {
|
||||||
if (showLoading) {
|
setResolvedPath(null);
|
||||||
setResolvedPath(null);
|
setContent('');
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session?.slug || !session?.time?.created || !sessionDirectory) {
|
if (!session?.slug || !session?.time?.created || !sessionDirectory) {
|
||||||
setResolvedPath(null);
|
setResolvedPath(null);
|
||||||
@@ -290,9 +289,7 @@ export const PlanView: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showLoading) {
|
setLoading(true);
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug);
|
const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug);
|
||||||
@@ -332,19 +329,14 @@ export const PlanView: React.FC = () => {
|
|||||||
setResolvedPath(null);
|
setResolvedPath(null);
|
||||||
setContent('');
|
setContent('');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled && showLoading) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void run(true);
|
void run();
|
||||||
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
void run(false);
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
|
||||||
};
|
};
|
||||||
}, [planModeEnabled, sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]);
|
}, [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>
|
abortControllers: Map<string, AbortController>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
lastLoadedDirectory: string | null
|
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
|
// Actions — UI state management
|
||||||
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
|
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
|
||||||
@@ -378,6 +382,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
abortControllers: new Map(),
|
abortControllers: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastLoadedDirectory: null,
|
lastLoadedDirectory: null,
|
||||||
|
sessionPlanAvailable: new Map(),
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// setCurrentSession
|
// setCurrentSession
|
||||||
@@ -1147,4 +1152,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
// Session directory is owned by sync child stores via SSE events.
|
// Session directory is owned by sync child stores via SSE events.
|
||||||
// This is now a no-op — kept for interface compatibility during migration.
|
// 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