opentradex 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,22 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState, useCallback, useRef } from "react";
4
- import { TopBar } from "@/components/TopBar";
5
- import { PortfolioStrip } from "@/components/PortfolioStrip";
6
- import { PositionsPanel } from "@/components/PositionsPanel";
7
- import { MarketScanner } from "@/components/MarketScanner";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
8
4
  import { LiveStream } from "@/components/LiveStream";
5
+ import { MarketScanner } from "@/components/MarketScanner";
9
6
  import { NewsFeed } from "@/components/NewsFeed";
7
+ import { PortfolioStrip } from "@/components/PortfolioStrip";
8
+ import { PositionsPanel } from "@/components/PositionsPanel";
9
+ import { TopBar } from "@/components/TopBar";
10
10
  import type {
11
- Portfolio,
12
- Trade,
13
- NewsArticle,
14
11
  Market,
15
- StreamLine,
12
+ NewsArticle,
13
+ Portfolio,
16
14
  PositionPrice,
15
+ PromptEntry,
17
16
  SocialPost,
17
+ StreamLine,
18
+ Trade,
19
+ WorkspaceSummary,
18
20
  } from "@/lib/types";
19
21
 
20
22
  const SHOW_PNL = false;
@@ -25,6 +27,7 @@ export function DashboardApp() {
25
27
  const [news, setNews] = useState<NewsArticle[]>([]);
26
28
  const [liveNews, setLiveNews] = useState<NewsArticle[]>([]);
27
29
  const [markets, setMarkets] = useState<Market[]>([]);
30
+ const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null);
28
31
  const [rationale, setRationale] = useState("");
29
32
  const [customPrompt, setCustomPrompt] = useState("");
30
33
  const [agentStatus, setAgentStatus] = useState("");
@@ -40,8 +43,19 @@ export function DashboardApp() {
40
43
  const [loadingTruth, setLoadingTruth] = useState(true);
41
44
  const [loadingReddit, setLoadingReddit] = useState(true);
42
45
  const [loadingTiktok, setLoadingTiktok] = useState(true);
46
+ const [promptHistory, setPromptHistory] = useState<PromptEntry[]>([]);
43
47
  const streamOffset = useRef(0);
44
48
 
49
+ const fetchWorkspace = useCallback(async () => {
50
+ try {
51
+ const res = await fetch("/api/workspace");
52
+ const data = await res.json();
53
+ setWorkspace(data);
54
+ } catch {
55
+ // ignore
56
+ }
57
+ }, []);
58
+
45
59
  const fetchAll = useCallback(async () => {
46
60
  try {
47
61
  const [p, t, n, m] = await Promise.all([
@@ -101,11 +115,11 @@ export function DashboardApp() {
101
115
  setLoadingTweets(false);
102
116
  tweetRetries.current = 0;
103
117
  } else {
104
- tweetRetries.current++;
118
+ tweetRetries.current += 1;
105
119
  if (tweetRetries.current > 12) setLoadingTweets(false);
106
120
  }
107
121
  } catch {
108
- tweetRetries.current++;
122
+ tweetRetries.current += 1;
109
123
  if (tweetRetries.current > 12) setLoadingTweets(false);
110
124
  }
111
125
  }, []);
@@ -132,11 +146,11 @@ export function DashboardApp() {
132
146
  setLoadingReddit(false);
133
147
  redditRetries.current = 0;
134
148
  } else {
135
- redditRetries.current++;
149
+ redditRetries.current += 1;
136
150
  if (redditRetries.current > 12) setLoadingReddit(false);
137
151
  }
138
152
  } catch {
139
- redditRetries.current++;
153
+ redditRetries.current += 1;
140
154
  if (redditRetries.current > 12) setLoadingReddit(false);
141
155
  }
142
156
  }, []);
@@ -151,16 +165,17 @@ export function DashboardApp() {
151
165
  setLoadingTiktok(false);
152
166
  tiktokRetries.current = 0;
153
167
  } else {
154
- tiktokRetries.current++;
168
+ tiktokRetries.current += 1;
155
169
  if (tiktokRetries.current > 12) setLoadingTiktok(false);
156
170
  }
157
171
  } catch {
158
- tiktokRetries.current++;
172
+ tiktokRetries.current += 1;
159
173
  if (tiktokRetries.current > 12) setLoadingTiktok(false);
160
174
  }
161
175
  }, []);
162
176
 
163
177
  useEffect(() => {
178
+ fetchWorkspace();
164
179
  fetchAll();
165
180
  fetchLiveNews();
166
181
  fetchPrices();
@@ -168,9 +183,10 @@ export function DashboardApp() {
168
183
  const redditDelay = setTimeout(fetchRedditPosts, 5_000);
169
184
  const tweetDelay = setTimeout(fetchTweets, 15_000);
170
185
  const tiktokDelay = setTimeout(fetchTiktokPosts, 25_000);
171
- const id = setInterval(fetchAll, 5000);
186
+ const dataId = setInterval(fetchAll, 5_000);
172
187
  const newsId = setInterval(fetchLiveNews, 120_000);
173
188
  const pricesId = setInterval(fetchPrices, 30_000);
189
+ const workspaceId = setInterval(fetchWorkspace, 60_000);
174
190
  const tweetsId = setInterval(() => {
175
191
  if (tweetRetries.current > 0 && tweetRetries.current <= 12) {
176
192
  fetchTweets();
@@ -190,10 +206,12 @@ export function DashboardApp() {
190
206
  }
191
207
  }, 5_000);
192
208
  const tiktokSlowId = setInterval(fetchTiktokPosts, 300_000);
209
+
193
210
  return () => {
194
- clearInterval(id);
211
+ clearInterval(dataId);
195
212
  clearInterval(newsId);
196
213
  clearInterval(pricesId);
214
+ clearInterval(workspaceId);
197
215
  clearTimeout(tweetDelay);
198
216
  clearTimeout(redditDelay);
199
217
  clearTimeout(tiktokDelay);
@@ -205,16 +223,29 @@ export function DashboardApp() {
205
223
  clearInterval(tiktokRetryId);
206
224
  clearInterval(tiktokSlowId);
207
225
  };
208
- }, [fetchAll, fetchLiveNews, fetchPrices, fetchTweets, fetchTruthPosts, fetchRedditPosts, fetchTiktokPosts]);
226
+ }, [
227
+ fetchAll,
228
+ fetchLiveNews,
229
+ fetchPrices,
230
+ fetchRedditPosts,
231
+ fetchTiktokPosts,
232
+ fetchTruthPosts,
233
+ fetchTweets,
234
+ fetchWorkspace,
235
+ ]);
209
236
 
210
237
  useEffect(() => {
211
238
  const pollStream = async () => {
212
239
  try {
213
- const res = await fetch(
214
- `/api/agent/stream?offset=${streamOffset.current}`
215
- );
240
+ const res = await fetch(`/api/agent/stream?offset=${streamOffset.current}`);
216
241
  const data = await res.json();
217
242
  setLiveStatus(data.status?.status || "idle");
243
+
244
+ if (data.offset < streamOffset.current) {
245
+ setStreamLines([]);
246
+ streamOffset.current = 0;
247
+ }
248
+
218
249
  if (data.lines.length > 0) {
219
250
  setStreamLines((prev) => {
220
251
  const next = [...prev, ...data.lines];
@@ -222,28 +253,42 @@ export function DashboardApp() {
222
253
  });
223
254
  streamOffset.current = data.offset;
224
255
  }
225
- if (data.offset < streamOffset.current) {
226
- setStreamLines([]);
227
- streamOffset.current = 0;
228
- }
229
256
  } catch {
230
257
  // ignore
231
258
  }
232
259
  };
233
- const id = setInterval(pollStream, 1000);
260
+
261
+ const id = setInterval(pollStream, 1_000);
234
262
  return () => clearInterval(id);
235
263
  }, []);
236
264
 
237
265
  const mergedNews = mergeNews(news, liveNews);
238
266
 
239
- const runCycle = async (prompt?: string) => {
240
- setAgentStatus("Starting cycle...");
267
+ const runCycle = async (prompt?: string, channel = "command") => {
268
+ const cleanPrompt = prompt?.trim();
269
+ if (cleanPrompt) {
270
+ setPromptHistory((prev) => [
271
+ ...prev.slice(-7),
272
+ {
273
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
274
+ text: cleanPrompt,
275
+ channel,
276
+ createdAt: new Date().toISOString(),
277
+ },
278
+ ]);
279
+ }
280
+
281
+ setAgentStatus(cleanPrompt ? `Routing ${channel} request...` : "Starting cycle...");
241
282
  setStreamLines([]);
242
283
  streamOffset.current = 0;
284
+
243
285
  const res = await fetch("/api/agent", {
244
286
  method: "POST",
245
287
  headers: { "Content-Type": "application/json" },
246
- body: JSON.stringify({ action: "run_cycle", prompt }),
288
+ body: JSON.stringify({
289
+ action: "run_cycle",
290
+ prompt: cleanPrompt ? buildChannelPrompt(cleanPrompt, channel, workspace) : undefined,
291
+ }),
247
292
  });
248
293
  const data = await res.json();
249
294
  setAgentStatus(data.message || data.status);
@@ -262,11 +307,23 @@ export function DashboardApp() {
262
307
 
263
308
  const submitRationale = async () => {
264
309
  if (!rationale.trim()) return;
310
+
311
+ const thesis = rationale.trim();
312
+ setPromptHistory((prev) => [
313
+ ...prev.slice(-7),
314
+ {
315
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
316
+ text: thesis,
317
+ channel: "command",
318
+ createdAt: new Date().toISOString(),
319
+ },
320
+ ]);
265
321
  setAgentStatus("Researching thesis...");
322
+
266
323
  const res = await fetch("/api/agent", {
267
324
  method: "POST",
268
325
  headers: { "Content-Type": "application/json" },
269
- body: JSON.stringify({ action: "submit_rationale", rationale }),
326
+ body: JSON.stringify({ action: "submit_rationale", rationale: thesis }),
270
327
  });
271
328
  const data = await res.json();
272
329
  setAgentStatus(data.message || data.status);
@@ -274,64 +331,73 @@ export function DashboardApp() {
274
331
  };
275
332
 
276
333
  return (
277
- <div className="h-screen w-screen max-w-full flex flex-col overflow-hidden bg-background">
278
- <TopBar
279
- liveStatus={liveStatus}
280
- agentStatus={agentStatus}
281
- rationale={rationale}
282
- loopInterval={loopInterval}
283
- onRationaleChange={setRationale}
284
- onSubmitRationale={submitRationale}
285
- onRunCycle={() => runCycle()}
286
- onStartLoop={startLoop}
287
- onRefresh={() => {
288
- fetchAll();
289
- fetchLiveNews();
290
- }}
291
- onLoopIntervalChange={setLoopInterval}
292
- />
293
-
294
- <PortfolioStrip portfolio={portfolio} unrealizedPnl={prices.reduce((s, p) => s + p.unrealized_pnl, 0)} />
295
-
296
- <div className="flex-1 grid grid-cols-1 md:grid-cols-[minmax(0,280px)_minmax(0,1fr)] lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)_minmax(0,340px)] min-h-0 overflow-hidden">
297
- <div className="hidden md:flex flex-col border-r border-border min-h-0 overflow-hidden">
298
- <div className="flex-1 min-h-0 overflow-hidden">
299
- <PositionsPanel
300
- positions={portfolio?.open_positions ?? []}
301
- trades={trades}
302
- prices={prices}
303
- />
304
- </div>
305
- <div className="h-[40%] min-h-0 overflow-hidden border-t border-border">
306
- <MarketScanner markets={markets} />
334
+ <div className="dashboard-shell h-screen w-screen max-w-full overflow-hidden bg-background">
335
+ <div className="flex h-full min-h-0 flex-col">
336
+ <TopBar
337
+ workspace={workspace}
338
+ liveStatus={liveStatus}
339
+ agentStatus={agentStatus}
340
+ rationale={rationale}
341
+ loopInterval={loopInterval}
342
+ onRationaleChange={setRationale}
343
+ onSubmitRationale={submitRationale}
344
+ onRunCycle={() => runCycle()}
345
+ onStartLoop={startLoop}
346
+ onRefresh={() => {
347
+ fetchWorkspace();
348
+ fetchAll();
349
+ fetchLiveNews();
350
+ }}
351
+ onLoopIntervalChange={setLoopInterval}
352
+ />
353
+
354
+ <PortfolioStrip
355
+ portfolio={portfolio}
356
+ unrealizedPnl={prices.reduce((sum, price) => sum + price.unrealized_pnl, 0)}
357
+ />
358
+
359
+ <div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,280px)_minmax(0,1fr)] lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)_minmax(0,360px)]">
360
+ <div className="hidden min-h-0 flex-col overflow-hidden border-r border-border/70 bg-white/40 backdrop-blur md:flex">
361
+ <div className="flex-1 min-h-0 overflow-hidden">
362
+ <PositionsPanel
363
+ positions={portfolio?.open_positions ?? []}
364
+ trades={trades}
365
+ prices={prices}
366
+ />
367
+ </div>
368
+ <div className="h-[40%] min-h-0 overflow-hidden border-t border-border/70">
369
+ <MarketScanner markets={markets} />
370
+ </div>
307
371
  </div>
308
- </div>
309
372
 
310
- <div className="flex flex-col min-h-0">
311
- <LiveStream
312
- lines={streamLines}
313
- liveStatus={liveStatus}
314
- customPrompt={customPrompt}
315
- onCustomPromptChange={setCustomPrompt}
316
- onSendCommand={(prompt) => {
317
- runCycle(prompt);
318
- setCustomPrompt("");
319
- }}
320
- />
321
- </div>
373
+ <div className="min-h-0 overflow-hidden">
374
+ <LiveStream
375
+ lines={streamLines}
376
+ liveStatus={liveStatus}
377
+ customPrompt={customPrompt}
378
+ prompts={promptHistory}
379
+ workspace={workspace}
380
+ onCustomPromptChange={setCustomPrompt}
381
+ onSendCommand={(prompt, channel) => {
382
+ runCycle(prompt, channel);
383
+ setCustomPrompt("");
384
+ }}
385
+ />
386
+ </div>
322
387
 
323
- <div className="hidden lg:flex flex-col border-l border-border min-h-0 overflow-hidden">
324
- <NewsFeed
325
- news={mergedNews}
326
- tweets={tweets}
327
- truthPosts={truthPosts}
328
- redditPosts={redditPosts}
329
- tiktokPosts={tiktokPosts}
330
- loadingTweets={loadingTweets}
331
- loadingTruth={loadingTruth}
332
- loadingReddit={loadingReddit}
333
- loadingTiktok={loadingTiktok}
334
- />
388
+ <div className="hidden min-h-0 flex-col overflow-hidden border-l border-border/70 bg-white/40 backdrop-blur lg:flex">
389
+ <NewsFeed
390
+ news={mergedNews}
391
+ tweets={tweets}
392
+ truthPosts={truthPosts}
393
+ redditPosts={redditPosts}
394
+ tiktokPosts={tiktokPosts}
395
+ loadingTweets={loadingTweets}
396
+ loadingTruth={loadingTruth}
397
+ loadingReddit={loadingReddit}
398
+ loadingTiktok={loadingTiktok}
399
+ />
400
+ </div>
335
401
  </div>
336
402
  </div>
337
403
  </div>
@@ -348,6 +414,7 @@ function mergeNews(dbNews: NewsArticle[], liveNews: NewsArticle[]): NewsArticle[
348
414
  merged.push(item);
349
415
  }
350
416
  }
417
+
351
418
  for (const item of liveNews) {
352
419
  if (!seen.has(item.title)) {
353
420
  seen.add(item.title);
@@ -355,8 +422,45 @@ function mergeNews(dbNews: NewsArticle[], liveNews: NewsArticle[]): NewsArticle[
355
422
  }
356
423
  }
357
424
 
358
- merged.sort(
359
- (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
360
- );
425
+ merged.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
361
426
  return merged.slice(0, 60);
362
427
  }
428
+
429
+ function buildChannelPrompt(
430
+ prompt: string,
431
+ channel: string,
432
+ workspace: WorkspaceSummary | null
433
+ ) {
434
+ const rails = workspace?.enabledMarkets.join(", ") || "kalshi";
435
+ const feeds = workspace?.integrations.join(", ") || "apify, rss";
436
+ const watchlist = workspace?.tradingview.watchlist.join(", ") || "SPY, QQQ, BTCUSD, NQ1!";
437
+
438
+ if (channel === "markets") {
439
+ return `Channel: markets.\nFocus on the enabled market rails (${rails}). Compare prices, scan only liquid contracts, and explain the best surviving setup.\n\nUser request: ${prompt}`;
440
+ }
441
+
442
+ if (channel === "feeds") {
443
+ return `Channel: feeds.\nFocus on the enabled news and social feeds (${feeds}). Summarize what changed recently, what is already priced in, and what deserves a market follow-up.\n\nUser request: ${prompt}`;
444
+ }
445
+
446
+ if (channel === "risk") {
447
+ return `Channel: risk.\nReview open exposure first. Prioritize thesis validation, exit conditions, bankroll preservation, and hard passes over new trades.\n\nUser request: ${prompt}`;
448
+ }
449
+
450
+ if (channel === "execution") {
451
+ return `Channel: execution.\nFocus on the most executable idea only. Confirm supported rails, size, entry level, and why the trade should or should not actually fire.\n\nUser request: ${prompt}`;
452
+ }
453
+
454
+ if (channel === "tradingview") {
455
+ const connectorLine =
456
+ workspace?.tradingview.connectorMode === "mcp" && workspace.tradingview.mcpEnabled
457
+ ? workspace.tradingview.configured
458
+ ? "A TradingView MCP connector is configured for this workspace. Use it if the local Claude session has the server available."
459
+ : "TradingView MCP is enabled in the workspace profile but still incomplete. Fall back to the watchlist unless the connector becomes available."
460
+ : "TradingView is running in watchlist-only mode for this workspace.";
461
+
462
+ return `Channel: tradingview.\nFocus on the TradingView watchlist (${watchlist}). ${connectorLine}\nUse this lane for symbol triage, cross-asset context, and chart-aware market research.\n\nUser request: ${prompt}`;
463
+ }
464
+
465
+ return prompt;
466
+ }
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import type { WorkspaceSummary } from "@/lib/types";
4
+
5
+ const cockpitLanes = [
6
+ {
7
+ id: "command",
8
+ name: "Command",
9
+ detail: "Direct the harness, launch scans, and ask for a clean next move.",
10
+ },
11
+ {
12
+ id: "markets",
13
+ name: "Markets",
14
+ detail: "Cross-market comparison across Kalshi, Polymarket, and active rails.",
15
+ },
16
+ {
17
+ id: "feeds",
18
+ name: "Feeds",
19
+ detail: "News and social context for catalysts, narrative shifts, and timing.",
20
+ },
21
+ {
22
+ id: "risk",
23
+ name: "Risk",
24
+ detail: "Position review, sizing discipline, and reasons to pass.",
25
+ },
26
+ {
27
+ id: "execution",
28
+ name: "Execution",
29
+ detail: "Supported trade routing, order readiness, and cycle recap.",
30
+ },
31
+ {
32
+ id: "tradingview",
33
+ name: "TradingView",
34
+ detail: "Watchlist or MCP-backed chart context for symbols, macro, and timing.",
35
+ },
36
+ ];
37
+
38
+ function buildMissions(workspace: WorkspaceSummary | null) {
39
+ const watchlist = workspace?.tradingview.watchlist.join(", ") || "SPY, QQQ, BTCUSD, NQ1!";
40
+
41
+ return [
42
+ {
43
+ label: "Connector audit",
44
+ prompt:
45
+ "Audit this OpenTradex workspace. Tell me which rails, feeds, channels, and credentials are configured, what is still missing, and the smartest next local step.",
46
+ },
47
+ {
48
+ label: "Cross-market scan",
49
+ prompt:
50
+ "Scan the enabled market rails, compare overlapping themes, and surface the best 3 setups before recommending one paper trade or pass.",
51
+ },
52
+ {
53
+ label: "TradingView pass",
54
+ prompt: `Use the TradingView lane and focus on this watchlist: ${watchlist}. Tell me which symbols or macro instruments deserve attention right now and why.`,
55
+ },
56
+ {
57
+ label: "Risk review",
58
+ prompt:
59
+ "Run a strict risk-manager review of the portfolio and open theses. Cut weak ideas, flag unsupported execution paths, and list only what survives.",
60
+ },
61
+ {
62
+ label: "News recap",
63
+ prompt:
64
+ "Summarize the latest feeds, separate what is new from what is already priced in, and point me to one market worth deeper research.",
65
+ },
66
+ {
67
+ label: "Explain setup",
68
+ prompt:
69
+ "Explain this OpenTradex setup like an operator boot briefing: runtime, rails, dashboard surface, channels, and where live execution is actually supported.",
70
+ },
71
+ ];
72
+ }
73
+
74
+ export function HarnessBootPanel({
75
+ workspace,
76
+ onLaunchMission,
77
+ }: {
78
+ workspace: WorkspaceSummary | null;
79
+ onLaunchMission: (prompt: string) => void;
80
+ }) {
81
+ const missions = buildMissions(workspace);
82
+
83
+ return (
84
+ <div className="relative overflow-hidden rounded-[1.6rem] border border-[#553249] bg-[linear-gradient(180deg,#321126_0%,#240b1c_100%)] p-5 text-[#f7e6ee] shadow-[0_30px_120px_rgba(24,6,14,0.45)]">
85
+ <div className="absolute inset-0 pointer-events-none bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[size:22px_22px] opacity-40" />
86
+
87
+ <div className="relative flex flex-wrap items-start justify-between gap-4">
88
+ <div className="max-w-2xl">
89
+ <p className="font-mono text-[0.68rem] uppercase tracking-[0.28em] text-[#f3a96f]">
90
+ OpenTradex onboarding
91
+ </p>
92
+ <h3 className="mt-3 text-3xl font-semibold tracking-tight text-white">
93
+ Start in chat, route by channel, keep the rails honest.
94
+ </h3>
95
+ <p className="mt-3 max-w-xl text-sm leading-7 text-[#e9c9d9]/80">
96
+ This cockpit is meant to feel like an operator interface, not a flat log.
97
+ Pick a lane, send a mission, and use the right connector for the job.
98
+ </p>
99
+ </div>
100
+
101
+ <div className="rounded-full border border-[#724b62] bg-[#4a2038]/80 px-3 py-1.5 font-mono text-[0.68rem] uppercase tracking-[0.24em] text-[#ffd9a4]">
102
+ {workspace?.dashboardSurface === "chat" ? "Chat cockpit active" : "Stream surface active"}
103
+ </div>
104
+ </div>
105
+
106
+ <div className="relative mt-5 grid gap-3 xl:grid-cols-5">
107
+ {cockpitLanes
108
+ .filter((lane) => (lane.id === "tradingview" ? workspace?.tradingview.enabled : true))
109
+ .map((lane) => (
110
+ <div
111
+ key={lane.id}
112
+ className="rounded-[1.2rem] border border-[#6f475f] bg-[#40172f]/70 p-3"
113
+ >
114
+ <p className="font-mono text-[0.66rem] uppercase tracking-[0.24em] text-[#f3a96f]">
115
+ {lane.name}
116
+ </p>
117
+ <p className="mt-2 text-[0.82rem] leading-6 text-[#f4dfea]/78">{lane.detail}</p>
118
+ </div>
119
+ ))}
120
+ </div>
121
+
122
+ <div className="relative mt-5 flex flex-wrap gap-2">
123
+ {workspace?.enabledMarkets.map((market) => (
124
+ <span
125
+ key={market}
126
+ className="rounded-full border border-[#724b62] bg-[#4a2038]/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-[#f7d3a7]"
127
+ >
128
+ {market}
129
+ </span>
130
+ ))}
131
+ {workspace?.tradingview.enabled ? (
132
+ <span className="rounded-full border border-[#724b62] bg-[#4a2038]/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-[#f7d3a7]">
133
+ {workspace.tradingview.connectorMode === "mcp"
134
+ ? workspace.tradingview.configured
135
+ ? "tradingview mcp ready"
136
+ : "tradingview mcp incomplete"
137
+ : "tradingview watchlist"}
138
+ </span>
139
+ ) : null}
140
+ </div>
141
+
142
+ <div className="relative mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
143
+ {missions.map((mission) => (
144
+ <button
145
+ key={mission.label}
146
+ onClick={() => onLaunchMission(mission.prompt)}
147
+ className="rounded-[1.2rem] border border-[#724b62] bg-[#4a2038]/58 px-4 py-3 text-left transition-all hover:-translate-y-0.5 hover:border-[#9e6985] hover:bg-[#572542]/72"
148
+ >
149
+ <p className="font-mono text-[0.66rem] uppercase tracking-[0.24em] text-[#f7d3a7]">
150
+ {mission.label}
151
+ </p>
152
+ <p className="mt-2 text-[0.82rem] leading-6 text-[#f4dfea]/78">
153
+ {mission.prompt}
154
+ </p>
155
+ </button>
156
+ ))}
157
+ </div>
158
+ </div>
159
+ );
160
+ }