From 8adc27004da3fb3353f8d7a23a2fa69b0102d2c9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 07:40:20 +0000 Subject: [PATCH] feat(chat): add copilot quick links --- backend/src/services/apiServer.ts | 158 ++++++++++++++++++-- web/src/components/ChatControl.dom.test.tsx | 45 +++++- web/src/components/ChatControl.tsx | 50 +++++++ web/src/views/SettingsView.dom.test.tsx | 29 +++- web/src/views/SettingsView.tsx | 23 ++- web/src/views/tradePlansRoutes.ts | 8 + 6 files changed, 287 insertions(+), 26 deletions(-) diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 4cec9d2..dd982bd 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -233,12 +233,20 @@ type ChatAction = | 'create_profile' | 'update_profile' | 'recommend_profile_change' + | 'recommend_trade_plan' + | 'recommend_reconciliation_followup' + | 'review_recent_trades' | 'explain' | 'explain_position' | 'explain_waiting' | 'explain_blocker' | 'summarize_reconciliation'; +type ChatQuickLink = + | { kind: 'portfolio'; label: string; tradeId?: string; symbol?: string } + | { kind: 'plans'; label: string; symbol?: string; tradeId?: string; setupId?: string; mode?: 'sell' | 'view' } + | { kind: 'settings'; label: string; section?: 'Account' | 'Bot Config' | 'Admin Panel' }; + interface ChatRuntimeContextPosition { symbol?: string; side?: 'BUY' | 'SELL'; @@ -356,6 +364,7 @@ interface ChatResponsePayload { summary: string; reasoning: string; nextActions?: string[]; + quickLinks?: ChatQuickLink[]; fallback?: 'local_deterministic'; } @@ -1106,6 +1115,10 @@ export class ApiServer { nextActions: [ 'Open Trade Plans if you want to attach, pause, or resume automated exits.', 'Review Portfolio for the linked holding and current P&L before changing the plan.', + ], + quickLinks: [ + { kind: 'portfolio', label: 'Open Portfolio', tradeId: target.tradeId, symbol: target.symbol }, + { kind: 'plans', label: 'Manage in Plans', mode: 'sell', symbol: target.symbol, tradeId: target.tradeId }, ] }; } @@ -1160,6 +1173,10 @@ export class ApiServer { 'Check the latest operational event and order failure reason for the affected symbol.', 'If the blocker is persistent, inspect the Admin reconciliation surfaces before retrying.', 'If the issue is configuration-driven, ask the copilot to recommend a profile change.', + ], + quickLinks: [ + { kind: 'portfolio', label: 'Open Portfolio', tradeId: recentFailure?.tradeId, symbol: recentFailure?.symbol || recentEvent?.symbol }, + { kind: 'settings', label: 'Open Admin Panel', section: 'Admin Panel' }, ] }; } @@ -1188,6 +1205,9 @@ export class ApiServer { nextActions: [ 'Review the active profile rules and recent symbol signal panel to see which rule or guardrail is not aligned.', 'If the waiting state persists, ask the copilot to recommend a profile change for this setup.', + ], + quickLinks: [ + { kind: 'portfolio', label: 'Open Portfolio', symbol: target.symbol }, ] }; } @@ -1223,6 +1243,14 @@ export class ApiServer { ] : [ 'No immediate action is needed. Continue monitoring normal runtime health and recent operational events.', + ], + quickLinks: degraded + ? [ + { kind: 'settings', label: 'Open Admin Panel', section: 'Admin Panel' }, + { kind: 'portfolio', label: 'Open Portfolio' }, + ] + : [ + { kind: 'portfolio', label: 'Open Portfolio' }, ] }; } @@ -1277,6 +1305,73 @@ export class ApiServer { nextActions: [ 'Review the suggested profile draft below before applying it.', 'After applying, monitor the next signal cycle to see whether the waiting or blocker state improves.', + ], + quickLinks: [ + { kind: 'portfolio', label: 'Open Portfolio' }, + ] + }; + } + + private buildTradePlanRecommendation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null { + const symbolHint = this.extractPrimaryMentionedSymbol(message); + const position = (chatContext.runtime.positions || []).find((entry) => + !symbolHint || String(entry.symbol || '').toUpperCase() === symbolHint + ); + if (!position?.symbol) return null; + + return { + action: 'recommend_trade_plan', + summary: `A Trade Plan would help manage ${position.symbol} more explicitly from here.`, + reasoning: `You already have a live holding on ${position.symbol}. The safest automation step is to open Plans and attach or refine an exit plan tied to trade ${position.tradeId || 'the current holding'}.`, + nextActions: [ + 'Open Plans to attach or adjust a profit exit for this holding.', + 'If you want to stop automation, convert the position to a long-term hold in Plans.', + ], + quickLinks: [ + { kind: 'plans', label: 'Manage in Plans', mode: 'sell', symbol: position.symbol, tradeId: position.tradeId }, + { kind: 'portfolio', label: 'Open Portfolio', tradeId: position.tradeId, symbol: position.symbol }, + ] + }; + } + + private buildRecentTradeReview(chatContext: ChatRequestContext): ChatResponsePayload | null { + const trades = (chatContext.runtime.recentHistory || []).slice(0, 5); + if (trades.length === 0) return null; + const winners = trades.filter((trade) => Number(trade.pnl || 0) > 0).length; + const losers = trades.filter((trade) => Number(trade.pnl || 0) < 0).length; + const totalPnl = trades.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0); + const topReason = trades[0]?.reason ? ` Most recent exit reason: ${trades[0].reason}.` : ''; + + return { + action: 'review_recent_trades', + summary: `Your last ${trades.length} closed trades show ${winners} winners, ${losers} losers, and net P&L of ${totalPnl.toFixed(2)}.${topReason}`, + reasoning: 'Use the most recent closed trades as the fastest feedback loop for whether your current profile settings and Trade Plans are producing the outcomes you expect.', + nextActions: [ + 'Open Portfolio trade history to inspect the latest winners and losers in detail.', + 'If one profile is underperforming repeatedly, ask the copilot to recommend a profile change for it.', + ], + quickLinks: [ + { kind: 'portfolio', label: 'Open Portfolio' }, + ] + }; + } + + private buildReconciliationFollowup(chatContext: ChatRequestContext): ChatResponsePayload { + const health = chatContext.runtime.health || {}; + const mismatchCount = Number(health.reconciliationMismatchCount || 0); + const noGoCount = Number(health.reconciliationNoGoTrades || 0); + const quarantinedCount = Number(health.reconciliationParityQuarantinedTrades || 0); + return { + action: 'recommend_reconciliation_followup', + summary: `The safest next remediation step is to inspect reconciliation before forcing more execution. Current counts: mismatches=${mismatchCount}, no_go=${noGoCount}, quarantined=${quarantinedCount}.`, + reasoning: 'When reconciliation is degraded, fixing the underlying mismatch is safer than retrying trades blindly because it prevents lifecycle drift and duplicate execution.', + nextActions: [ + 'Open the Admin reconciliation panel and inspect the newest mismatches first.', + 'Use the affected symbol and profile from the latest operational events to narrow the problem quickly.', + ], + quickLinks: [ + { kind: 'settings', label: 'Open Admin Panel', section: 'Admin Panel' }, + { kind: 'portfolio', label: 'Open Portfolio' }, ] }; } @@ -1295,17 +1390,48 @@ export class ApiServer { private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload { const lower = String(message || '').toLowerCase(); const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower); + const asksForReconciliationFollowup = /\b(fix reconciliation|what should i do about reconciliation|how do i fix this mismatch|reconciliation followup|reconciliation next step)\b/i.test(lower); const asksForRecommendation = /\b(recommend|improve|tune|optimi[sz]e|adjust my profile|suggest a profile change)\b/i.test(lower); + const asksForTradePlan = /\b(trade plan|exit plan|manage this holding|manage in plans|convert to long term|long-term hold|long term hold)\b/i.test(lower); + const asksForTradeReview = /\b(review my trades|recent trades|last trades|trade review|post-trade|post trade|what went wrong)\b/i.test(lower); const asksForWaiting = /\b(why no trade|why.*no trade|why didn'?t.*trade|why did not.*trade|why.*enter|waiting for|no entry|no signal)\b/i.test(lower); const asksForBlocker = /\b(why.*(blocked|stuck|failed|not execute|didn'?t execute|did not execute)|blocker|blocked|manual trader unavailable|unauthorized|forbidden|failed order)\b/i.test(lower); const asksForPosition = /\b(position|holding|portfolio|pnl|profit|loss|exit|take profit|stop loss)\b/i.test(lower); const asksForExplain = /(what|how|why|help|explain|suggest)/i.test(lower) && !/(create|build|make|generate|new profile|strategy|setup|configure|update|modify)/i.test(lower); + if (asksForReconciliationFollowup) { + return this.buildReconciliationFollowup(context); + } + if (asksForReconciliation) { return this.buildReconciliationSummary(context); } + if (asksForTradeReview) { + return this.buildRecentTradeReview(context) + || { + action: 'review_recent_trades', + summary: 'I could not find recent closed trades in the scoped runtime context yet.', + reasoning: 'Once trade history is available, I can summarize win/loss balance, recent exit reasons, and where to focus next.', + quickLinks: [ + { kind: 'portfolio', label: 'Open Portfolio' }, + ] + }; + } + + if (asksForTradePlan) { + return this.buildTradePlanRecommendation(message, context) + || { + action: 'explain', + summary: 'I could not find a live scoped holding to attach a Trade Plan recommendation to right now.', + reasoning: 'Trade Plan guidance works best when there is an active holding or saved plan to anchor the recommendation.', + quickLinks: [ + { kind: 'plans', label: 'Open Plans', mode: 'view' }, + ] + }; + } + if (asksForRecommendation) { return this.buildProfileChangeRecommendation(message, context) || { @@ -3082,7 +3208,7 @@ AVAILABLE RULES (use these exact ruleId values): PROFILE SCHEMA: { - "action": "create_profile" | "update_profile" | "recommend_profile_change" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation", + "action": "create_profile" | "update_profile" | "recommend_profile_change" | "recommend_trade_plan" | "recommend_reconciliation_followup" | "review_recent_trades" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation", "profile": { "name": string, "allocated_capital": number, @@ -3097,7 +3223,8 @@ PROFILE SCHEMA: }, "summary": string (1-2 sentence human-readable summary of what you did), "reasoning": string (brief explanation of why you chose these parameters or what operational state means), - "nextActions": string[] (optional safe next steps for the user) + "nextActions": string[] (optional safe next steps for the user), + "quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations) } CURRENT CONTEXT (existing profiles): @@ -3110,16 +3237,20 @@ RULES: 1. For "create_profile": generate a complete profile with sensible defaults based on the user's description. 2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same. 3. For "recommend_profile_change": include a profile draft with the recommended adjustments and explain why. -4. For "explain": use it for general educational answers. No profile needed. -5. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed. -6. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed. -7. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed. -8. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed. -9. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change. -10. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice. -11. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. -12. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. -13. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; +4. For "recommend_trade_plan": recommend the safest next Trade Plan action for a live holding. +5. For "recommend_reconciliation_followup": recommend the safest next reconciliation follow-up action. +6. For "review_recent_trades": summarize recent closed trade performance and the likely next review focus. +7. For "explain": use it for general educational answers. No profile needed. +8. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed. +9. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed. +10. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed. +11. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed. +12. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change. +13. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice. +14. Include safe "nextActions" and "quickLinks" whenever they would help the user move to the right app surface. +15. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. +16. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. +17. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; try { let aiResponse: string | null = null; @@ -3160,6 +3291,9 @@ RULES: 'create_profile', 'update_profile', 'recommend_profile_change', + 'recommend_trade_plan', + 'recommend_reconciliation_followup', + 'review_recent_trades', 'explain', 'explain_position', 'explain_waiting', diff --git a/web/src/components/ChatControl.dom.test.tsx b/web/src/components/ChatControl.dom.test.tsx index c4cb86f..6b7b54b 100644 --- a/web/src/components/ChatControl.dom.test.tsx +++ b/web/src/components/ChatControl.dom.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; import { ChatControl } from './ChatControl'; import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; import type { BotState } from '../hooks/useWebSocket'; @@ -74,6 +75,13 @@ const botStateFixture: BotState = { }; describe('ChatControl DOM flow', () => { + const renderControl = (onApplyProfile = vi.fn(async () => ({ success: true }))) => + render( + + + + ); + beforeEach(() => { getPlatformAccessTokenMock.mockReset(); writeTextMock.mockReset(); @@ -90,7 +98,7 @@ describe('ChatControl DOM flow', () => { const onApplyProfile = vi.fn(async () => ({ success: true })); const user = userEvent.setup(); - render(); + renderControl(onApplyProfile); await user.click(screen.getAllByRole('button')[0]); @@ -130,7 +138,7 @@ describe('ChatControl DOM flow', () => { const onApplyProfile = vi.fn(async () => ({ success: true })); const user = userEvent.setup(); - render(); + renderControl(onApplyProfile); await user.click(screen.getAllByRole('button')[0]); @@ -182,7 +190,7 @@ describe('ChatControl DOM flow', () => { const user = userEvent.setup(); - render( ({ success: true }))} />); + renderControl(vi.fn(async () => ({ success: true }))); await user.click(screen.getAllByRole('button')[0]); await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Create profile{enter}'); @@ -212,7 +220,7 @@ describe('ChatControl DOM flow', () => { const user = userEvent.setup(); - render( ({ success: true }))} />); + renderControl(vi.fn(async () => ({ success: true }))); await user.click(screen.getAllByRole('button')[0]); await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Explain my current BTC holding{enter}'); @@ -288,7 +296,7 @@ describe('ChatControl DOM flow', () => { const onApplyProfile = vi.fn(async () => ({ success: true })); const user = userEvent.setup(); - render(); + renderControl(onApplyProfile); await user.click(screen.getAllByRole('button')[0]); await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Recommend a profile change for High Risk Scalper ⚡{enter}'); @@ -311,4 +319,31 @@ describe('ChatControl DOM flow', () => { ); }); }); + + it('renders safe quick links for copilot follow-up actions', async () => { + getPlatformAccessTokenMock.mockResolvedValue('token-5'); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + summary: 'A Trade Plan would help manage BTC/USDT more explicitly from here.', + reasoning: 'You already have a live holding on BTC/USDT.', + action: 'recommend_trade_plan', + nextActions: ['Open Plans to attach or adjust a profit exit for this holding.'], + quickLinks: [ + { kind: 'plans', label: 'Manage in Plans', mode: 'sell', symbol: 'BTC/USDT', tradeId: 'trade-1' }, + { kind: 'portfolio', label: 'Open Portfolio', tradeId: 'trade-1', symbol: 'BTC/USDT' } + ] + }) + } as any); + + const user = userEvent.setup(); + renderControl(); + await user.click(screen.getAllByRole('button')[0]); + await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Recommend a trade plan for BTC/USDT{enter}'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Manage in Plans/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Open Portfolio/i })).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/ChatControl.tsx b/web/src/components/ChatControl.tsx index 788188f..037d55b 100644 --- a/web/src/components/ChatControl.tsx +++ b/web/src/components/ChatControl.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import type { ChangeEvent } from 'react'; import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; import { tradingRuntime } from '../lib/runtime'; import { getPlatformAccessToken } from '../lib/authSession'; import { createRequestId } from '../../../shared/request-id.js'; @@ -12,6 +13,7 @@ import { } from 'lucide-react'; import { Button, Input, Select, Textarea } from './ui/Primitives'; import { cn } from '../lib/utils'; +import { buildCreateExitPlanUrl, buildPlanDrillInUrl, buildPlansHomeUrl, buildSettingsSectionUrl } from '../views/tradePlansRoutes'; interface ChatMessage { id: number; @@ -20,6 +22,7 @@ interface ChatMessage { profileData?: any; action?: ChatAssistantAction; nextActions?: string[]; + quickLinks?: ChatQuickLink[]; timestamp: Date; } @@ -39,6 +42,11 @@ type ChatAssistantAction = | 'explain_blocker' | 'summarize_reconciliation'; +type ChatQuickLink = + | { kind: 'portfolio'; label: string; tradeId?: string; symbol?: string } + | { kind: 'plans'; label: string; symbol?: string; tradeId?: string; setupId?: string; mode?: 'sell' | 'view' } + | { kind: 'settings'; label: string; section?: 'Account' | 'Bot Config' | 'Admin Panel' }; + export interface QuickAction { label: string; prompt: string; @@ -278,6 +286,7 @@ const RobotIcon = ({ size = 32 }: { size?: number }) => ( ); export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlProps) => { + const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const [messages, setMessages] = useState([ { @@ -298,6 +307,29 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP const quickActions = useMemo(() => buildQuickActions(profiles), [profiles]); + const openQuickLink = (link: ChatQuickLink) => { + if (link.kind === 'portfolio') { + navigate('/portfolio'); + setIsOpen(false); + return; + } + if (link.kind === 'plans') { + if (link.setupId) { + navigate(buildPlanDrillInUrl(link.setupId)); + } else if (link.mode === 'sell' && link.symbol) { + navigate(buildCreateExitPlanUrl(link.symbol, link.tradeId)); + } else { + navigate(buildPlansHomeUrl()); + } + setIsOpen(false); + return; + } + if (link.kind === 'settings') { + navigate(buildSettingsSectionUrl(link.section || 'Account')); + setIsOpen(false); + } + }; + const openDraftEditor = (msg: ChatMessage) => { if (!msg.profileData) return; setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) })); @@ -392,6 +424,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP profileData: data.profile || null, action: data.action as ChatAssistantAction | undefined, nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined, + quickLinks: Array.isArray(data.quickLinks) ? data.quickLinks as ChatQuickLink[] : undefined, timestamp: new Date(), }; @@ -671,6 +704,23 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP ) : null} + {msg.quickLinks && msg.quickLinks.length > 0 ? ( +
+ {msg.quickLinks.map((link, index) => ( + + ))} +
+ ) : null} + {/* Profile preview card */} {msg.profileData && isProfileMutationAction(msg.action) && (() => { const activeProfileData = draftProfiles[msg.id] || msg.profileData; diff --git a/web/src/views/SettingsView.dom.test.tsx b/web/src/views/SettingsView.dom.test.tsx index 18693d0..1c32304 100644 --- a/web/src/views/SettingsView.dom.test.tsx +++ b/web/src/views/SettingsView.dom.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; import { AppContext, type AppContextValue } from '../context/AppContext'; import { SettingsView } from './SettingsView'; @@ -44,9 +45,11 @@ describe('SettingsView legacy surface contrast', () => { it('contains legacy settings sections inside a dark contrast surface', async () => { const user = userEvent.setup(); const { container } = render( - - - , + + + + + , ); const surface = container.querySelector('.settings-legacy-surface') as HTMLDivElement; @@ -63,13 +66,27 @@ describe('SettingsView legacy surface contrast', () => { it('hides admin-only sections for non-admin users', () => { render( - - - , + + + + + , ); expect(screen.getByRole('button', { name: 'Account' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Bot Config' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Admin Panel' })).not.toBeInTheDocument(); }); + + it('opens the requested section from the url query string', () => { + render( + + + + + , + ); + + expect(screen.getByText('Admin panel content')).toBeInTheDocument(); + }); }); diff --git a/web/src/views/SettingsView.tsx b/web/src/views/SettingsView.tsx index 461a34b..f1a9643 100644 --- a/web/src/views/SettingsView.tsx +++ b/web/src/views/SettingsView.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useAppContext } from '../context/AppContext'; import { SettingsTab } from '../tabs/SettingsTab'; import { AdminTab } from '../tabs/AdminTab'; @@ -9,12 +10,28 @@ type SettingsSection = 'Account' | 'Bot Config' | 'Admin Panel'; export function SettingsView() { const { botState, isAdmin, socket } = useAppContext(); + const [searchParams, setSearchParams] = useSearchParams(); const sections: SettingsSection[] = [ 'Account', ...(isAdmin ? ['Bot Config' as SettingsSection] : []), ...(isAdmin ? ['Admin Panel' as SettingsSection] : []), ]; - const [section, setSection] = useState('Account'); + const requestedSection = searchParams.get('section'); + const initialSection = useMemo(() => { + if (requestedSection === 'Bot Config' && isAdmin) return 'Bot Config'; + if (requestedSection === 'Admin Panel' && isAdmin) return 'Admin Panel'; + return 'Account'; + }, [requestedSection, isAdmin]); + const [section, setSection] = useState(initialSection); + + useEffect(() => { + setSection(initialSection); + }, [initialSection]); + + const handleSectionChange = (nextSection: SettingsSection) => { + setSection(nextSection); + setSearchParams(nextSection === 'Account' ? {} : { section: nextSection }); + }; return (
@@ -27,7 +44,7 @@ export function SettingsView() { {sections.map(s => (