diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 9b7183f7..8f52f2d5 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -40,12 +40,12 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { VoiceProvider } from '@/components/voice'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import type { RuntimeAPIs } from '@/lib/api/types'; import { TooltipProvider } from '@/components/ui/tooltip'; const CLI_MISSING_ERROR_REGEX = /ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i; -const CLI_ONBOARDING_HEALTH_POLL_MS = 1500; const AboutDialogWrapper: React.FC = () => { const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen); @@ -176,6 +176,7 @@ function App({ apis }: AppProps) { const [showCliOnboarding, setShowCliOnboarding] = React.useState(false); const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true); const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []); + const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const appReadyDispatchedRef = React.useRef(false); const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; @@ -256,6 +257,28 @@ function App({ apis }: AppProps) { return () => clearTimeout(fallbackTimer); }, [isInitialized]); + React.useEffect(() => { + let cancelled = false; + + const run = async () => { + const res = await fetch('/health', { method: 'GET' }).catch(() => null); + if (!res || !res.ok || cancelled) return; + const data = (await res.json().catch(() => null)) as null | { + planModeExperimentalEnabled?: unknown; + }; + if (!data || cancelled) return; + const raw = data.planModeExperimentalEnabled; + const enabled = raw === true || raw === 1 || raw === '1' || raw === 'true'; + setPlanModeEnabled(enabled); + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [setPlanModeEnabled]); + React.useEffect(() => { const init = async () => { // VS Code runtime bootstraps config + sessions after the managed OpenCode instance reports "connected". @@ -480,13 +503,9 @@ function App({ apis }: AppProps) { }; void run(); - const interval = window.setInterval(() => { - void run(); - }, CLI_ONBOARDING_HEALTH_POLL_MS); return () => { cancelled = true; - window.clearInterval(interval); }; }, [embeddedSessionChat]); diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 32d48551..ec76418b 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -5,6 +5,7 @@ import { useShallow } from 'zustand/react/shallow'; import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { useConfigStore } from '@/stores/useConfigStore'; +import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useContextStore } from '@/stores/contextStore'; import { useStreamingStore } from '@/sync/streaming'; @@ -214,6 +215,7 @@ const ChatMessage: React.FC = ({ const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow; const sessionId = message.info.sessionID; + const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); // Keep non-active-turn rows detached from context-store churn. const { currentContextAgent, savedSessionAgentSelection } = useContextStore( @@ -228,8 +230,8 @@ const ChatMessage: React.FC = ({ return message.parts; } - return normalizeUserDisplayParts(message.parts); - }, [isUser, message.parts]); + return normalizeUserDisplayParts(message.parts, { planModeEnabled }); + }, [isUser, message.parts, planModeEnabled]); const previousUserMetadata = React.useMemo(() => { if (isUser || !previousMessage) { @@ -269,6 +271,7 @@ const ChatMessage: React.FC = ({ }, [isUser, previousMessage]); const previousIsModeSwitchMessage = React.useMemo(() => { + if (!planModeEnabled) return false; if (isUser || !previousMessage) return false; const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : []; for (let i = 0; i < parts.length; i++) { @@ -281,7 +284,7 @@ const ChatMessage: React.FC = ({ } } return false; - }, [isUser, previousMessage]); + }, [isUser, planModeEnabled, previousMessage]); const agentName = React.useMemo(() => { if (isUser) return undefined; diff --git a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts index d9954b42..26b52758 100644 --- a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts +++ b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts @@ -81,15 +81,16 @@ const buildGitHubAttachmentPart = (text: string): Part | null => { return null; }; -const shouldKeepSyntheticUserText = (text: string): boolean => { +const shouldKeepSyntheticUserText = (text: string, planModeEnabled: boolean): 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; + if (planModeEnabled && trimmed.startsWith('User has requested to enter plan mode')) return true; + if (planModeEnabled && trimmed.startsWith('The plan at ')) return true; if (trimmed.startsWith('The following tool was executed by the user')) return true; return false; }; -export const normalizeUserDisplayParts = (parts: Part[]): Part[] => { +export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEnabled?: boolean }): Part[] => { + const planModeEnabled = options?.planModeEnabled === true; return parts .filter((part) => { const synthetic = (part as { synthetic?: boolean }).synthetic === true; @@ -101,7 +102,7 @@ export const normalizeUserDisplayParts = (parts: Part[]): Part[] => { } const normalizedText = text.trimStart(); - return shouldKeepSyntheticUserText(text) + return shouldKeepSyntheticUserText(text, planModeEnabled) || normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX) || normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX); }) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 11329d24..4432cb49 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -30,6 +30,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitStore } from '@/stores/useGitStore'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -807,7 +808,8 @@ export const Header: React.FC = ({ const [planTabAvailable, setPlanTabAvailable] = React.useState(false); - const showPlanTab = planTabAvailable; + const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); + const showPlanTab = planModeEnabled && planTabAvailable; const lastPlanSessionKeyRef = React.useRef(''); const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => { @@ -843,6 +845,14 @@ 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 => { @@ -903,6 +913,7 @@ export const Header: React.FC = ({ window.clearInterval(interval); }; }, [ + planModeEnabled, sessionDirectory, currentSession?.slug, currentSession?.time?.created, diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 4ab7e4af..939d2d07 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -18,6 +18,7 @@ import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react' import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; import { copyTextToClipboard } from '@/lib/clipboard'; @@ -84,6 +85,7 @@ export const PlanView: React.FC = () => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const runtimeApis = useRuntimeAPIs(); useUIStore(); const { isMobile } = useDeviceInfo(); @@ -256,6 +258,13 @@ export const PlanView: React.FC = () => { }, [currentTheme, resolvedPath]); React.useEffect(() => { + if (!planModeEnabled) { + setResolvedPath(null); + setContent(''); + setLoading(false); + return; + } + let cancelled = false; const readText = async (path: string): Promise => { @@ -339,7 +348,7 @@ export const PlanView: React.FC = () => { cancelled = true; window.clearInterval(interval); }; - }, [sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]); + }, [planModeEnabled, sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]); React.useEffect(() => { return () => { diff --git a/packages/ui/src/stores/useFeatureFlagsStore.ts b/packages/ui/src/stores/useFeatureFlagsStore.ts new file mode 100644 index 00000000..c29305e6 --- /dev/null +++ b/packages/ui/src/stores/useFeatureFlagsStore.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand'; + +type FeatureFlagsStore = { + planModeEnabled: boolean; + setPlanModeEnabled: (enabled: boolean) => void; +}; + +export const useFeatureFlagsStore = create((set) => ({ + planModeEnabled: false, + setPlanModeEnabled: (enabled) => set({ planModeEnabled: enabled }), +})); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 976367a0..faf54815 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -98,6 +98,18 @@ const OPENCHAMBER_VERSION = (() => { } return 'unknown'; })(); + +const isEnvFlagEnabled = (value) => { + if (value === true || value === 1) return true; + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true'; +}; + +const PLAN_MODE_EXPERIMENT_ENABLED = + isEnvFlagEnabled(process.env.OPENCODE_EXPERIMENTAL_PLAN_MODE) + || isEnvFlagEnabled(process.env.OPENCODE_EXPERIMENTAL); + const fsPromises = fs.promises; const settingsNormalizationRuntime = createSettingsNormalizationRuntime({ @@ -850,6 +862,7 @@ async function main(options = {}) { opencodeWslDistro: resolvedWslDistro || null, nodeBinaryResolved: resolvedNodeBinary || null, bunBinaryResolved: resolvedBunBinary || null, + planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED, }), uiPassword, tunnelAuthController,