feat(chat): add copilot quick links

This commit is contained in:
root 2026-05-07 07:40:20 +00:00
parent 7b772494fc
commit 8adc27004d
6 changed files with 287 additions and 26 deletions

View File

@ -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',

View File

@ -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(
<MemoryRouter>
<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />
</MemoryRouter>
);
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(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
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(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
renderControl(onApplyProfile);
await user.click(screen.getAllByRole('button')[0]);
@ -182,7 +190,7 @@ describe('ChatControl DOM flow', () => {
const user = userEvent.setup();
render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={vi.fn(async () => ({ 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(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={vi.fn(async () => ({ 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(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
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();
});
});
});

View File

@ -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<ChatMessage[]>([
{
@ -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
</div>
) : null}
{msg.quickLinks && msg.quickLinks.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{msg.quickLinks.map((link, index) => (
<Button
key={`${msg.id}-link-${index}`}
type="button"
onClick={() => openQuickLink(link)}
variant="outline"
size="sm"
className="h-8 rounded-full px-3 text-[11px]"
>
{link.label}
</Button>
))}
</div>
) : null}
{/* Profile preview card */}
{msg.profileData && isProfileMutationAction(msg.action) && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData;

View File

@ -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(
<AppContext.Provider value={appContext}>
<SettingsView />
</AppContext.Provider>,
<MemoryRouter>
<AppContext.Provider value={appContext}>
<SettingsView />
</AppContext.Provider>
</MemoryRouter>,
);
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(
<AppContext.Provider value={{ ...appContext, isAdmin: false, profile: { role: 'user' } as any }}>
<SettingsView />
</AppContext.Provider>,
<MemoryRouter>
<AppContext.Provider value={{ ...appContext, isAdmin: false, profile: { role: 'user' } as any }}>
<SettingsView />
</AppContext.Provider>
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={['/settings?section=Admin%20Panel']}>
<AppContext.Provider value={appContext}>
<SettingsView />
</AppContext.Provider>
</MemoryRouter>,
);
expect(screen.getByText('Admin panel content')).toBeInTheDocument();
});
});

View File

@ -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<SettingsSection>('Account');
const requestedSection = searchParams.get('section');
const initialSection = useMemo<SettingsSection>(() => {
if (requestedSection === 'Bot Config' && isAdmin) return 'Bot Config';
if (requestedSection === 'Admin Panel' && isAdmin) return 'Admin Panel';
return 'Account';
}, [requestedSection, isAdmin]);
const [section, setSection] = useState<SettingsSection>(initialSection);
useEffect(() => {
setSection(initialSection);
}, [initialSection]);
const handleSectionChange = (nextSection: SettingsSection) => {
setSection(nextSection);
setSearchParams(nextSection === 'Account' ? {} : { section: nextSection });
};
return (
<div>
@ -27,7 +44,7 @@ export function SettingsView() {
{sections.map(s => (
<button
key={s}
onClick={() => setSection(s)}
onClick={() => handleSectionChange(s)}
className="tab-button"
data-active={section === s}
>

View File

@ -21,3 +21,11 @@ export function buildCreateExitPlanUrl(symbol: string, tradeId?: string | null)
if (tradeId) params.set('tradeId', tradeId);
return `${PLANS_ROUTE}?${params.toString()}`;
}
export function buildPlansHomeUrl() {
return PLANS_ROUTE;
}
export function buildSettingsSectionUrl(section: 'Account' | 'Bot Config' | 'Admin Panel') {
return `/settings?${new URLSearchParams({ section }).toString()}`;
}