feat(chat): add copilot quick links
This commit is contained in:
parent
7b772494fc
commit
8adc27004d
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user