Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 12 additions & 46 deletions frontend/src/components/UnifiedChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { useIsMobile } from "@/utils/utils";
import { fileToDataURL } from "@/utils/file";
import { truncateMarkdownPreservingLinks } from "@/utils/markdown";
import { useOpenAI } from "@/ai/useOpenAi";
import { DEFAULT_MODEL_ID } from "@/state/LocalStateContext";
import { DEFAULT_MODEL_ID, getInitialWebSearchEnabled } from "@/state/LocalStateContext";
import { Markdown, ThinkingBlock } from "@/components/markdown";
import { ModelSelector } from "@/components/ModelSelector";
import { useLocalState } from "@/state/useLocalState";
Expand All @@ -61,7 +61,6 @@ import { UpgradePromptDialog } from "@/components/UpgradePromptDialog";
import { DocumentPlatformDialog } from "@/components/DocumentPlatformDialog";
import { ContextLimitDialog } from "@/components/ContextLimitDialog";
import { RecordingOverlay } from "@/components/RecordingOverlay";
import { WebSearchInfoDialog } from "@/components/WebSearchInfoDialog";
import { TTSDownloadDialog } from "@/components/TTSDownloadDialog";
import { useTTS } from "@/services/tts/TTSContext";
import { Alert, AlertDescription } from "@/components/ui/alert";
Expand Down Expand Up @@ -936,7 +935,6 @@ export function UnifiedChat() {
>("image");
const [documentPlatformDialogOpen, setDocumentPlatformDialogOpen] = useState(false);
const [contextLimitDialogOpen, setContextLimitDialogOpen] = useState(false);
const [webSearchInfoDialogOpen, setWebSearchInfoDialogOpen] = useState(false);
const [ttsSetupDialogOpen, setTtsSetupDialogOpen] = useState(false);

// Audio recording states
Expand All @@ -945,10 +943,8 @@ export function UnifiedChat() {
const [isProcessingSend, setIsProcessingSend] = useState(false);
const [audioError, setAudioError] = useState<string | null>(null);

// Web search toggle state - persisted in localStorage
const [isWebSearchEnabled, setIsWebSearchEnabled] = useState(() => {
return localStorage.getItem("webSearchEnabled") === "true";
});
// Web search toggle state - persisted in localStorage, billing-aware initial default
const [isWebSearchEnabled, setIsWebSearchEnabled] = useState(getInitialWebSearchEnabled);

// Fullscreen mode for power users - persisted in localStorage
const [isFullscreen, setIsFullscreen] = useState(() => {
Expand All @@ -966,6 +962,15 @@ export function UnifiedChat() {
localStorage.setItem("webSearchEnabled", isWebSearchEnabled.toString());
}, [isWebSearchEnabled]);

// Sync web search state when billing status changes (handles the one-time paid defaults flip).
// When setBillingStatus writes "webSearchEnabled" to localStorage, we need to pick that up.
useEffect(() => {
if (localState.billingStatus) {
const storedValue = localStorage.getItem("webSearchEnabled") === "true";
setIsWebSearchEnabled(storedValue);
}
}, [localState.billingStatus]);

// Toggle fullscreen with animation
const toggleFullscreen = useCallback(() => {
setIsFullscreenAnimating(true);
Expand Down Expand Up @@ -2993,24 +2998,11 @@ export function UnifiedChat() {
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
// Step 1: Check if user has access (free/starter users see upsell)
if (!canUseWebSearch) {
setUpgradeFeature("websearch");
setUpgradeDialogOpen(true);
return;
}

// Step 2: Check if this is their first time (enable web search, set flag, show popup)
const hasSeenWebSearchInfo =
localStorage.getItem("hasSeenWebSearchInfo") === "true";
if (!hasSeenWebSearchInfo) {
localStorage.setItem("hasSeenWebSearchInfo", "true");
setIsWebSearchEnabled(true);
setWebSearchInfoDialogOpen(true);
return;
}

// Step 3: Toggle web search directly
setIsWebSearchEnabled(!isWebSearchEnabled);
}}
aria-label={
Expand Down Expand Up @@ -3236,24 +3228,11 @@ export function UnifiedChat() {
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
// Step 1: Check if user has access (free/starter users see upsell)
if (!canUseWebSearch) {
setUpgradeFeature("websearch");
setUpgradeDialogOpen(true);
return;
}

// Step 2: Check if this is their first time (enable web search, set flag, show popup)
const hasSeenWebSearchInfo =
localStorage.getItem("hasSeenWebSearchInfo") === "true";
if (!hasSeenWebSearchInfo) {
localStorage.setItem("hasSeenWebSearchInfo", "true");
setIsWebSearchEnabled(true);
setWebSearchInfoDialogOpen(true);
return;
}

// Step 3: Toggle web search directly
setIsWebSearchEnabled(!isWebSearchEnabled);
}}
aria-label={
Expand Down Expand Up @@ -3404,19 +3383,6 @@ export function UnifiedChat() {
hasDocument={!!documentName}
/>

{/* Web search info dialog for first-time paid users */}
<WebSearchInfoDialog
open={webSearchInfoDialogOpen}
onOpenChange={(open) => {
// When dialog is closed via X or backdrop, just dismiss - web search already enabled on click
setWebSearchInfoDialogOpen(open);
}}
onConfirm={() => {
// "Got it" button - just close (web search already enabled on click)
setWebSearchInfoDialogOpen(false);
}}
/>

{/* TTS setup dialog */}
<TTSDownloadDialog open={ttsSetupDialogOpen} onOpenChange={setTtsSetupDialogOpen} />

Expand Down
77 changes: 0 additions & 77 deletions frontend/src/components/WebSearchInfoDialog.tsx

This file was deleted.

124 changes: 107 additions & 17 deletions frontend/src/state/LocalStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@ export {
} from "./LocalStateContextDef";

export const DEFAULT_MODEL_ID = "gpt-oss-120b";
export const PAID_DEFAULT_MODEL_ID = "kimi-k2-5";

// Check if a plan name corresponds to a pro/max/team plan
function isProMaxOrTeamPlan(planName: string): boolean {
return planName.includes("pro") || planName.includes("max") || planName.includes("team");
}

// Check if paid defaults have already been applied for this user.
// The value is an ISO date string indicating when defaults were last applied.
function hasPaidDefaultsBeenApplied(): boolean {
return localStorage.getItem("paidDefaultsApplied") !== null;
}

// Helper to get the initial web search state from localStorage, billing-aware
export function getInitialWebSearchEnabled(): boolean {
try {
// If user has explicitly toggled web search before, respect that
const webSearchSetting = localStorage.getItem("webSearchEnabled");
if (webSearchSetting !== null) {
return webSearchSetting === "true";
}

// No explicit setting — check if paid defaults were applied (web search would be on)
if (hasPaidDefaultsBeenApplied()) {
return true;
}

// Check cached billing for fast initial load (before billing fetch completes)
const cachedBillingStr = localStorage.getItem("cachedBillingStatus");
if (cachedBillingStr) {
const cachedBilling = JSON.parse(cachedBillingStr) as BillingStatus;
const planName = cachedBilling.product_name?.toLowerCase() || "";
if (isProMaxOrTeamPlan(planName)) {
return true;
}
}
} catch (error) {
console.error("Failed to get initial web search state:", error);
}
return false;
}

// Helper to get default model based on cached billing status
function getInitialModel(): string {
Expand All @@ -21,28 +62,35 @@ function getInitialModel(): string {
}

try {
// Priority 1: Check local storage for last used model
// Priority 1: Check local storage for user's explicit model choice
const selectedModel = localStorage.getItem("selectedModel");
if (selectedModel) {
return aliasModelName(selectedModel);
}

// Priority 2: Check cached billing status for pro/max/team users
// Priority 2: Check if paid defaults were already applied
// (user is returning paid user who got the one-time flip but then
// cleared selectedModel somehow — unlikely but safe fallback)
if (hasPaidDefaultsBeenApplied()) {
return PAID_DEFAULT_MODEL_ID;
}

// Priority 3: Check cached billing status for pro/max/team users
const cachedBillingStr = localStorage.getItem("cachedBillingStatus");
if (cachedBillingStr) {
const cachedBilling = JSON.parse(cachedBillingStr) as BillingStatus;
const planName = cachedBilling.product_name?.toLowerCase() || "";

// Pro, Max, or Team users get default model
if (planName.includes("pro") || planName.includes("max") || planName.includes("team")) {
return DEFAULT_MODEL_ID;
// Pro, Max, or Team users get the powerful reasoning model
if (isProMaxOrTeamPlan(planName)) {
return PAID_DEFAULT_MODEL_ID;
}
}
} catch (error) {
console.error("Failed to load initial model:", error);
}

// Priority 3: Default to free model
// Priority 4: Default to free model
return DEFAULT_MODEL_ID;
}

Expand Down Expand Up @@ -162,8 +210,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode })
planName.includes("team") ||
planName.includes("starter");

const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isProMaxOrTeam = isProMaxOrTeamPlan(planName);

// Check if billing plan changed from cached version
let billingChanged = false;
Expand All @@ -190,17 +237,43 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode })
console.error("Failed to cache billing status:", error);
}

// Update model if: 1) no custom selectedModel OR 2) billing plan changed
// One-time paid defaults: when a user is on pro/max/team and we haven't
// applied paid defaults yet, flip model to "Powerful" and web search ON.
// This handles both new signups and free-to-paid upgrades.
try {
const selectedModel = localStorage.getItem("selectedModel");
const shouldUpdateModel = !selectedModel || billingChanged;
if (isProMaxOrTeam && !hasPaidDefaultsBeenApplied()) {
// Apply paid defaults — set model to Powerful reasoning model
setModelInternal(PAID_DEFAULT_MODEL_ID);

// Enable web search
localStorage.setItem("webSearchEnabled", "true");

// Mark when we applied paid defaults (ISO date) so we never override again.
// Future defaults can check this date to decide whether to re-apply newer defaults.
localStorage.setItem("paidDefaultsApplied", new Date().toISOString());

return;
}
Comment thread
AnthonyRonning marked this conversation as resolved.
} catch (error) {
console.error("Failed to apply paid defaults:", error);
}

if (shouldUpdateModel) {
// For users who already had defaults applied: handle plan changes
try {
if (billingChanged) {
if (isProMaxOrTeam) {
setModel(DEFAULT_MODEL_ID);
} else if (billingChanged) {
// User downgraded, switch back to free model
setModel(DEFAULT_MODEL_ID);
// Plan changed but still pro-tier — only update model if user
// hasn't manually chosen one (selectedModel not in localStorage)
const selectedModel = localStorage.getItem("selectedModel");
if (!selectedModel) {
setModelInternal(PAID_DEFAULT_MODEL_ID);
}
} else {
// User downgraded to free/starter — switch back to free model
// and clear paid defaults so they get re-applied if they upgrade again
setModelInternal(DEFAULT_MODEL_ID);
localStorage.removeItem("paidDefaultsApplied");
localStorage.removeItem("selectedModel");
}
}
} catch (error) {
Expand Down Expand Up @@ -375,12 +448,29 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode })
});
}

// Internal model setter — updates state and localStorage but does NOT mark as
// a user's explicit choice. Used by billing/system logic.
function setModelInternal(modelId: string) {
const aliasedModel = aliasModelName(modelId);
setLocalState((prev) => {
if (prev.model === aliasedModel) return prev;
return { ...prev, model: aliasedModel };
});
try {
localStorage.setItem("selectedModel", aliasedModel);
} catch (error) {
console.error("Failed to save model to localStorage:", error);
}
}

// Public model setter — records the choice as a user-initiated selection.
// After this, we won't auto-override their model choice.
function setModel(model: string) {
const aliasedModel = aliasModelName(model);
setLocalState((prev) => {
if (prev.model === aliasedModel) return prev;

// Save to localStorage when model changes
// Save to localStorage as user's explicit choice
try {
localStorage.setItem("selectedModel", aliasedModel);
} catch (error) {
Expand Down
Loading