clawport-ui 0.7.0 → 0.8.0

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.
package/.env.example CHANGED
@@ -29,6 +29,10 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token-here
29
29
  # Optional
30
30
  # ---------------------------------------------------------------------------
31
31
 
32
+ # OpenClaw gateway port (default: 18789).
33
+ # Change this if you configured a custom port in openclaw.json (gateway.http.port).
34
+ # OPENCLAW_GATEWAY_PORT=18789
35
+
32
36
  # ElevenLabs API key — enables voice indicators on agent profiles.
33
37
  # Get one at: https://elevenlabs.io
34
38
  # Leave blank or remove this line if you don't need voice features.
package/README.md CHANGED
@@ -42,7 +42,7 @@ After onboarding, verify the gateway is running:
42
42
  openclaw gateway status
43
43
  ```
44
44
 
45
- You should see your gateway URL (`localhost:18789`) and auth token. See the [OpenClaw docs](https://docs.openclaw.ai/getting-started) for more detail.
45
+ You should see your gateway URL (default `localhost:18789`) and auth token. If you use a custom port, `clawport setup` will detect it automatically. See the [OpenClaw docs](https://docs.openclaw.ai/getting-started) for more detail.
46
46
 
47
47
  ### 2. Install ClawPort
48
48
 
@@ -100,7 +100,7 @@ npm run dev
100
100
  ClawPort reads your OpenClaw workspace to discover agents, then connects to the gateway for all AI operations:
101
101
 
102
102
  ```
103
- Browser --> ClawPort (Next.js) --> OpenClaw Gateway (localhost:18789) --> Claude
103
+ Browser --> ClawPort (Next.js) --> OpenClaw Gateway (localhost:18789 default) --> Claude
104
104
  | |
105
105
  | Text: /v1/chat/completions (streaming SSE)
106
106
  | Vision: openclaw gateway call chat.send (CLI)
@@ -4,10 +4,11 @@ import { getAgent } from '@/lib/agents'
4
4
  import { validateChatMessages } from '@/lib/validation'
5
5
  import { hasImageContent, extractImageAttachments, buildTextPrompt, sendViaOpenClaw } from '@/lib/anthropic'
6
6
  import OpenAI from 'openai'
7
+ import { gatewayBaseUrl } from '@/lib/env'
7
8
 
8
9
  // Route through the OpenClaw gateway — no separate API key needed
9
10
  const openai = new OpenAI({
10
- baseURL: 'http://localhost:18789/v1',
11
+ baseURL: gatewayBaseUrl(),
11
12
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
12
13
  })
13
14
 
@@ -2,9 +2,10 @@ export const runtime = 'nodejs'
2
2
 
3
3
  import { getAgent } from '@/lib/agents'
4
4
  import OpenAI from 'openai'
5
+ import { gatewayBaseUrl } from '@/lib/env'
5
6
 
6
7
  const openai = new OpenAI({
7
- baseURL: 'http://localhost:18789/v1',
8
+ baseURL: gatewayBaseUrl(),
8
9
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
9
10
  })
10
11
 
@@ -1,9 +1,10 @@
1
1
  export const runtime = 'nodejs'
2
2
 
3
3
  import OpenAI from 'openai'
4
+ import { gatewayBaseUrl } from '@/lib/env'
4
5
 
5
6
  const openai = new OpenAI({
6
- baseURL: 'http://localhost:18789/v1',
7
+ baseURL: gatewayBaseUrl(),
7
8
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
8
9
  })
9
10
 
@@ -1,9 +1,10 @@
1
1
  export const runtime = 'nodejs'
2
2
 
3
3
  import OpenAI from 'openai'
4
+ import { gatewayBaseUrl } from '@/lib/env'
4
5
 
5
6
  const openai = new OpenAI({
6
- baseURL: 'http://localhost:18789/v1',
7
+ baseURL: gatewayBaseUrl(),
7
8
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
8
9
  })
9
10
 
package/bin/clawport.mjs CHANGED
@@ -84,9 +84,13 @@ function run(cmd, args = []) {
84
84
  child.on('close', (code) => process.exit(code ?? 0))
85
85
  }
86
86
 
87
+ function getGatewayPort() {
88
+ return parseInt(process.env.OPENCLAW_GATEWAY_PORT || '18789', 10)
89
+ }
90
+
87
91
  async function checkGateway() {
88
92
  try {
89
- const res = await fetch('http://127.0.0.1:18789/', {
93
+ const res = await fetch(`http://127.0.0.1:${getGatewayPort()}/`, {
90
94
  signal: AbortSignal.timeout(3000),
91
95
  })
92
96
  return res.ok || res.status > 0
@@ -187,10 +191,11 @@ async function cmdStatus() {
187
191
  // Check gateway
188
192
  const gatewayUp = await checkGateway()
189
193
 
194
+ const gwPort = getGatewayPort()
190
195
  if (gatewayUp) {
191
- console.log(` ${green('+')} Gateway reachable at ${dim('localhost:18789')}`)
196
+ console.log(` ${green('+')} Gateway reachable at ${dim(`localhost:${gwPort}`)}`)
192
197
  } else {
193
- console.log(` ${red('x')} Gateway not responding at ${dim('localhost:18789')}`)
198
+ console.log(` ${red('x')} Gateway not responding at ${dim(`localhost:${gwPort}`)}`)
194
199
  console.log(` ${dim('Start it with: openclaw gateway run')}`)
195
200
  }
196
201
 
@@ -254,7 +259,7 @@ async function cmdDoctor() {
254
259
 
255
260
  // 4. Gateway reachable
256
261
  const gatewayUp = await checkGateway()
257
- check(gatewayUp, 'Gateway reachable at localhost:18789', 'Start it with: openclaw gateway run')
262
+ check(gatewayUp, `Gateway reachable at localhost:${getGatewayPort()}`, 'Start it with: openclaw gateway run')
258
263
 
259
264
  // 5. Configuration -- .env.local with required vars (package root or ~/.config/clawport-ui)
260
265
  const envPath = getEnvLocalPath()
@@ -442,7 +442,7 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
442
442
  </div>
443
443
  {cronsStatus === 'ok' && (
444
444
  <div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
445
- Connected at localhost:18789
445
+ Connected to gateway
446
446
  </div>
447
447
  )}
448
448
  {cronsError && (
@@ -3,7 +3,7 @@
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import type { Agent, CostSummary, CronJob, RunCost, ClaudeCodeUsage } from '@/lib/types'
5
5
  import { Skeleton } from '@/components/ui/skeleton'
6
- import { AlertTriangle, TrendingDown, TrendingUp, Activity, MessageSquare, ChevronDown, ChevronUp } from 'lucide-react'
6
+ import { AlertTriangle, TrendingDown, TrendingUp, Activity, MessageSquare, ChevronDown } from 'lucide-react'
7
7
  import { generateId } from '@/lib/id'
8
8
  import { buildCostAnalysisPrompt } from '@/lib/costs'
9
9
  import { renderMarkdown } from '@/lib/sanitize'
@@ -13,7 +13,7 @@ import { DailyCostChart } from './DailyCostChart'
13
13
  import { TokenDonut } from './TokenDonut'
14
14
  import { TopCrons } from './TopCrons'
15
15
  import { RunDetailTable } from './RunDetailTable'
16
- import { OptScoreRing, InsightCard } from './OptimizationPanel'
16
+ import { OptimizationCard } from './OptimizationPanel'
17
17
  import { ClaudeUsageRow } from './ClaudeUsageRow'
18
18
 
19
19
  /* ── Chat message type ───────────────────────────────────────── */
@@ -38,8 +38,6 @@ export function CostsPage() {
38
38
  const [analysisOpen, setAnalysisOpen] = useState(false)
39
39
  const [analysisStreaming, setAnalysisStreaming] = useState(false)
40
40
  const [analysisContent, setAnalysisContent] = useState('')
41
- const analysisRef = useRef<HTMLDivElement>(null)
42
- const chatEndRef = useRef<HTMLDivElement>(null)
43
41
  const chatTextareaRef = useRef<HTMLTextAreaElement>(null)
44
42
  const [chatMessages, setChatMessages] = useState<CostChatMessage[]>([])
45
43
  const [chatInput, setChatInput] = useState('')
@@ -48,9 +46,6 @@ export function CostsPage() {
48
46
  // Claude Code usage state
49
47
  const [claudeUsage, setClaudeUsage] = useState<ClaudeCodeUsage | null>(null)
50
48
 
51
- // Insights collapse
52
- const [insightsExpanded, setInsightsExpanded] = useState(false)
53
-
54
49
  const rootAgent = useMemo(
55
50
  () => agents.find(a => a.reportsTo === null) || agents[0] || null,
56
51
  [agents],
@@ -109,14 +104,6 @@ export function CostsPage() {
109
104
  return () => es.close()
110
105
  }, [])
111
106
 
112
- // Auto-scroll analysis
113
- useEffect(() => {
114
- if (analysisRef.current) analysisRef.current.scrollTop = analysisRef.current.scrollHeight
115
- }, [analysisContent])
116
-
117
- useEffect(() => {
118
- if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
119
- }, [chatMessages])
120
107
 
121
108
  const jobName = (id: string) => jobNames[id] || id
122
109
 
@@ -375,8 +362,8 @@ export function CostsPage() {
375
362
  {/* ── Claude Code Usage ──────────────────────────────── */}
376
363
  {claudeUsage && <ClaudeUsageRow usage={claudeUsage} />}
377
364
 
378
- {/* ── Summary cards (4-col) ──────────────────────────── */}
379
- <div className="costs-summary-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--space-3)', marginBottom: 'var(--space-4)' }}>
365
+ {/* ── Summary cards ────────────────────────────────── */}
366
+ <div className="costs-summary-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)', marginBottom: 'var(--space-4)' }}>
380
367
  {/* Total Estimated Cost */}
381
368
  <SummaryCard label="Total Estimated Cost">
382
369
  <div className="flex items-center" style={{ gap: 'var(--space-2)' }}>
@@ -442,153 +429,41 @@ export function CostsPage() {
442
429
  </div>
443
430
 
444
431
  {/* ── Optimization Score + Insights ─────────────────── */}
445
- <div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}
446
- className="opt-row">
447
-
448
- {/* Score card */}
449
- <div style={{
450
- background: 'var(--material-regular)',
451
- border: '1px solid var(--separator)',
452
- borderRadius: 'var(--radius-md)',
453
- padding: 'var(--space-4)',
454
- display: 'flex',
455
- flexDirection: 'column',
456
- alignItems: 'center',
457
- gap: 'var(--space-3)',
458
- }}>
459
- <div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)' }}>
460
- Optimization Score
461
- </div>
462
- <OptScoreRing score={data.optimizationScore.overall} size={80} />
463
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px', width: '100%' }}>
464
- {([
465
- ['Cache', data.optimizationScore.cacheScore],
466
- ['Tiering', data.optimizationScore.tieringScore],
467
- ['Anomaly', data.optimizationScore.anomalyScore],
468
- ['Efficiency', data.optimizationScore.efficiencyScore],
469
- ] as [string, number][]).map(([label, score]) => (
470
- <div key={label} className="flex items-center" style={{ gap: 4, fontSize: 'var(--text-caption2)' }}>
471
- <div style={{
472
- width: 32, height: 4, borderRadius: 2,
473
- background: 'var(--fill-tertiary)', overflow: 'hidden', flexShrink: 0,
474
- }}>
475
- <div style={{
476
- width: `${score}%`, height: '100%', borderRadius: 2,
477
- background: score >= 75 ? 'var(--system-green)' : score >= 50 ? 'var(--system-orange)' : 'var(--system-red)',
478
- transition: 'width 600ms ease',
479
- }} />
480
- </div>
481
- <span style={{ color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>{label}</span>
482
- <span style={{ color: 'var(--text-secondary)', fontWeight: 600, marginLeft: 'auto' }}>{score}</span>
483
- </div>
484
- ))}
485
- </div>
486
- {totalProjectedSavings > 0 && (
487
- <div style={{
488
- marginTop: 'var(--space-1)',
489
- padding: '4px 10px',
490
- borderRadius: 'var(--radius-sm)',
491
- background: 'rgba(48,209,88,0.10)',
492
- fontSize: 'var(--text-caption1)',
493
- fontWeight: 600,
494
- color: 'var(--system-green)',
495
- textAlign: 'center',
496
- }}>
497
- Potential savings: {fmtCost(totalProjectedSavings)}/period
498
- </div>
499
- )}
500
- </div>
501
-
502
- {/* Insights list */}
503
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
504
- <div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)', marginBottom: 2 }}>
505
- Optimization Insights
506
- </div>
507
- {data.insights.length === 0 ? (
508
- <div style={{
509
- padding: 'var(--space-4)',
510
- background: 'var(--material-regular)',
511
- border: '1px solid var(--separator)',
512
- borderRadius: 'var(--radius-md)',
513
- textAlign: 'center',
514
- fontSize: 'var(--text-footnote)',
515
- color: 'var(--system-green)',
516
- }}>
517
- All clear -- no optimization issues detected
518
- </div>
519
- ) : (
520
- <>
521
- {(insightsExpanded ? data.insights : data.insights.slice(0, 2)).map(insight => (
522
- <div key={insight.id} style={{ opacity: 1, transition: 'opacity 150ms ease' }}>
523
- <InsightCard insight={insight} onAction={handleInsightAction} />
524
- </div>
525
- ))}
526
- {data.insights.length > 2 && (
527
- <button
528
- onClick={() => setInsightsExpanded(prev => !prev)}
529
- style={{
530
- display: 'inline-flex',
531
- alignItems: 'center',
532
- gap: 4,
533
- padding: '6px 0',
534
- background: 'none',
535
- border: 'none',
536
- cursor: 'pointer',
537
- fontSize: 'var(--text-caption1)',
538
- fontWeight: 'var(--weight-medium)',
539
- color: 'var(--accent)',
540
- }}
541
- >
542
- {insightsExpanded ? (
543
- <><ChevronUp size={12} /> Show less</>
544
- ) : (
545
- <><ChevronDown size={12} /> Show all {data.insights.length} insights</>
546
- )}
547
- </button>
548
- )}
549
- </>
550
- )}
551
- </div>
552
- </div>
553
-
554
- {/* ── AI Cost Analysis ────────────────────────────────── */}
432
+ <OptimizationCard
433
+ score={data.optimizationScore}
434
+ insights={data.insights}
435
+ totalSavings={totalProjectedSavings}
436
+ jobName={jobName}
437
+ onAction={handleInsightAction}
438
+ />
439
+
440
+ {/* ── Agent Optimizer ─────────────────────────────────── */}
555
441
  <div style={{
556
442
  background: 'var(--material-regular)',
557
443
  border: '1px solid var(--separator)',
558
- borderRadius: 'var(--radius-md)',
444
+ borderRadius: 12,
559
445
  marginBottom: 'var(--space-4)',
560
446
  overflow: 'hidden',
561
447
  }}>
562
- <button
563
- onClick={() => {
564
- if (!analysisOpen) {
565
- setAnalysisOpen(true)
566
- if (!analysisContent && !analysisStreaming) runAnalysis()
567
- } else {
568
- setAnalysisOpen(!analysisOpen)
569
- }
570
- }}
571
- className="focus-ring"
572
- style={{
573
- width: '100%',
574
- display: 'flex',
575
- alignItems: 'center',
576
- gap: 'var(--space-3)',
577
- padding: 'var(--space-3) var(--space-4)',
578
- background: 'none',
579
- border: 'none',
580
- cursor: 'pointer',
581
- fontSize: 'var(--text-footnote)',
582
- fontWeight: 'var(--weight-semibold)',
583
- color: 'var(--text-primary)',
584
- }}
585
- >
586
- <Activity size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
587
- AI Cost Analysis
448
+ {/* Header */}
449
+ <div style={{
450
+ padding: '16px 20px',
451
+ display: 'flex', alignItems: 'center', gap: 12,
452
+ borderBottom: analysisOpen ? '1px solid var(--separator)' : undefined,
453
+ }}>
454
+ <Activity size={18} style={{ color: 'var(--accent)', flexShrink: 0 }} />
455
+ <div style={{ flex: 1, minWidth: 0 }}>
456
+ <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
457
+ Agent Optimizer
458
+ </div>
459
+ <div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2 }}>
460
+ AI-powered analysis of your agent costs and throughput
461
+ </div>
462
+ </div>
588
463
  {analysisStreaming && (
589
464
  <span style={{
590
465
  display: 'inline-flex', alignItems: 'center', gap: 6,
591
- fontSize: 'var(--text-caption1)', color: 'var(--accent)', fontWeight: 500,
466
+ fontSize: 12, color: 'var(--accent)', fontWeight: 500,
592
467
  }}>
593
468
  <span style={{
594
469
  width: 6, height: 6, borderRadius: '50%', background: 'var(--accent)',
@@ -597,33 +472,46 @@ export function CostsPage() {
597
472
  Analyzing...
598
473
  </span>
599
474
  )}
600
- {analysisContent && !analysisStreaming && (
601
- <span style={{
602
- fontSize: 'var(--text-caption2)', fontWeight: 600,
603
- padding: '1px 8px', borderRadius: 10,
604
- background: 'rgba(48,209,88,0.12)', color: 'var(--system-green)',
605
- }}>
606
- Complete
607
- </span>
475
+ {!analysisOpen && !analysisContent && !analysisStreaming && (
476
+ <button
477
+ onClick={() => { setAnalysisOpen(true); runAnalysis() }}
478
+ className="btn-ghost focus-ring"
479
+ style={{
480
+ padding: '6px 16px', borderRadius: 8,
481
+ fontSize: 13, fontWeight: 600,
482
+ background: 'var(--accent)', color: 'white',
483
+ border: 'none', cursor: 'pointer',
484
+ }}
485
+ >
486
+ Analyze
487
+ </button>
608
488
  )}
609
- <ChevronDown
610
- size={14}
611
- style={{
612
- marginLeft: 'auto', color: 'var(--text-tertiary)',
613
- transform: analysisOpen ? 'rotate(180deg)' : 'rotate(0deg)',
614
- transition: 'transform 200ms ease',
615
- }}
616
- />
617
- </button>
489
+ {(analysisOpen || analysisContent) && (
490
+ <button
491
+ onClick={() => setAnalysisOpen(!analysisOpen)}
492
+ className="focus-ring"
493
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
494
+ >
495
+ <ChevronDown
496
+ size={16}
497
+ style={{
498
+ color: 'var(--text-tertiary)',
499
+ transform: analysisOpen ? 'rotate(180deg)' : 'rotate(0deg)',
500
+ transition: 'transform 200ms ease',
501
+ }}
502
+ />
503
+ </button>
504
+ )}
505
+ </div>
618
506
 
619
507
  {analysisOpen && (
620
- <div style={{ borderTop: '1px solid var(--separator)' }}>
621
- {/* Analysis content */}
508
+ <div>
509
+ {/* Loading skeleton */}
622
510
  {analysisStreaming && !analysisContent && (
623
- <div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', gap: 10 }}>
624
- {[180, 220, 160, 200].map((w, i) => (
511
+ <div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 10 }}>
512
+ {[180, 240, 160, 220, 140].map((w, i) => (
625
513
  <div key={i} style={{
626
- width: w, height: 12, borderRadius: 4,
514
+ width: w, maxWidth: '100%', height: 12, borderRadius: 4,
627
515
  background: 'var(--fill-tertiary)',
628
516
  animation: `shimmer 1.6s ease-in-out ${i * 0.15}s infinite`,
629
517
  }} />
@@ -631,42 +519,76 @@ export function CostsPage() {
631
519
  </div>
632
520
  )}
633
521
 
522
+ {/* Analysis content */}
634
523
  {analysisContent && (
635
524
  <div
636
- ref={analysisRef}
637
525
  className="markdown-body"
638
526
  style={{
639
- padding: 'var(--space-4)',
640
- maxHeight: 480,
527
+ padding: '16px 20px',
528
+ maxHeight: 520,
641
529
  overflowY: 'auto',
642
- fontSize: 'var(--text-footnote)',
643
- lineHeight: 1.6,
530
+ fontSize: 14,
531
+ lineHeight: 1.65,
644
532
  color: 'var(--text-primary)',
645
533
  }}
646
534
  dangerouslySetInnerHTML={{ __html: renderMarkdown(analysisContent) }}
647
535
  />
648
536
  )}
649
537
 
538
+ {/* Suggested actions (before first analysis or after completion) */}
539
+ {!analysisContent && !analysisStreaming && (
540
+ <div style={{ padding: '12px 20px 16px' }}>
541
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 8 }}>
542
+ Ask about
543
+ </div>
544
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
545
+ {[
546
+ 'Which agents should switch to Haiku?',
547
+ 'How do I reduce my 5-hour window usage?',
548
+ 'Show me my most expensive agent and how to fix it',
549
+ 'What thinking effort should each agent use?',
550
+ ].map(q => (
551
+ <button
552
+ key={q}
553
+ onClick={() => { setAnalysisOpen(true); runAnalysis(); }}
554
+ className="btn-ghost focus-ring"
555
+ style={{
556
+ padding: '5px 12px', borderRadius: 14,
557
+ fontSize: 12, fontWeight: 500,
558
+ background: 'var(--fill-secondary)',
559
+ border: '1px solid var(--separator)',
560
+ color: 'var(--text-secondary)',
561
+ cursor: 'pointer',
562
+ whiteSpace: 'nowrap',
563
+ }}
564
+ >
565
+ {q}
566
+ </button>
567
+ ))}
568
+ </div>
569
+ </div>
570
+ )}
571
+
650
572
  {/* Inline chat (after analysis complete) */}
651
573
  {analysisContent && !analysisStreaming && (
652
574
  <>
653
- <div style={{ borderTop: '1px solid var(--separator)' }} />
575
+ <div style={{ height: 1, background: 'var(--separator)' }} />
654
576
 
655
577
  {/* Chat messages */}
656
578
  {chatMessages.length > 0 && (
657
- <div style={{ maxHeight: 300, overflowY: 'auto', padding: 'var(--space-3) var(--space-4)' }}>
579
+ <div style={{ maxHeight: 320, overflowY: 'auto', padding: '12px 20px' }}>
658
580
  {chatMessages.map(msg => (
659
581
  <div key={msg.id} style={{
660
- marginBottom: 'var(--space-3)',
582
+ marginBottom: 12,
661
583
  display: 'flex',
662
584
  justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
663
585
  }}>
664
586
  <div style={{
665
587
  maxWidth: '85%',
666
- padding: 'var(--space-2) var(--space-3)',
667
- borderRadius: 'var(--radius-md, 10px)',
668
- fontSize: 'var(--text-footnote)',
669
- lineHeight: 1.5,
588
+ padding: '8px 14px',
589
+ borderRadius: 12,
590
+ fontSize: 14,
591
+ lineHeight: 1.55,
670
592
  ...(msg.role === 'user' ? {
671
593
  background: 'var(--accent)',
672
594
  color: 'white',
@@ -693,17 +615,41 @@ export function CostsPage() {
693
615
  </div>
694
616
  </div>
695
617
  ))}
696
- <div ref={chatEndRef} />
618
+ </div>
619
+ )}
620
+
621
+ {/* Follow-up suggestions */}
622
+ {chatMessages.length === 0 && (
623
+ <div style={{ padding: '8px 20px 4px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
624
+ {[
625
+ 'Show me the config changes',
626
+ 'Which agents need less thinking effort?',
627
+ 'How do I trim agent context?',
628
+ ].map(q => (
629
+ <button
630
+ key={q}
631
+ onClick={() => sendChatMessage(q)}
632
+ className="btn-ghost focus-ring"
633
+ style={{
634
+ padding: '4px 10px', borderRadius: 12,
635
+ fontSize: 11, fontWeight: 500,
636
+ background: 'var(--fill-secondary)',
637
+ border: '1px solid var(--separator)',
638
+ color: 'var(--text-secondary)',
639
+ cursor: 'pointer', whiteSpace: 'nowrap',
640
+ }}
641
+ >
642
+ {q}
643
+ </button>
644
+ ))}
697
645
  </div>
698
646
  )}
699
647
 
700
648
  {/* Chat input */}
701
649
  <div style={{
702
- display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)',
703
- padding: 'var(--space-3) var(--space-4)',
704
- borderTop: chatMessages.length > 0 ? '1px solid var(--separator)' : undefined,
650
+ display: 'flex', alignItems: 'flex-end', gap: 8,
651
+ padding: '10px 20px 16px',
705
652
  }}>
706
- <MessageSquare size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0, marginBottom: 6 }} />
707
653
  <textarea
708
654
  ref={chatTextareaRef}
709
655
  value={chatInput}
@@ -714,16 +660,16 @@ export function CostsPage() {
714
660
  sendChatMessage()
715
661
  }
716
662
  }}
717
- placeholder="Ask about cost optimization..."
663
+ placeholder="Ask a follow-up..."
718
664
  disabled={chatStreaming}
719
665
  rows={1}
720
666
  style={{
721
667
  flex: 1, resize: 'none',
722
668
  background: 'var(--fill-tertiary)',
723
669
  border: '1px solid var(--separator)',
724
- borderRadius: 'var(--radius-sm)',
725
- padding: '6px 10px',
726
- fontSize: 'var(--text-footnote)',
670
+ borderRadius: 8,
671
+ padding: '8px 12px',
672
+ fontSize: 13,
727
673
  color: 'var(--text-primary)',
728
674
  outline: 'none',
729
675
  lineHeight: 1.4,
@@ -735,9 +681,9 @@ export function CostsPage() {
735
681
  disabled={chatStreaming || !chatInput.trim()}
736
682
  className="btn-ghost focus-ring"
737
683
  style={{
738
- padding: '6px 12px',
739
- borderRadius: 'var(--radius-sm)',
740
- fontSize: 'var(--text-caption1)',
684
+ padding: '8px 14px',
685
+ borderRadius: 8,
686
+ fontSize: 13,
741
687
  fontWeight: 600,
742
688
  background: 'var(--accent)',
743
689
  color: 'white',
@@ -759,7 +705,7 @@ export function CostsPage() {
759
705
  <TopCrons jobCosts={data.jobCosts} jobName={jobName} />
760
706
 
761
707
  {/* ── Charts row: daily cost + token donut ────────────── */}
762
- <div className="charts-row" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}>
708
+ <div className="charts-row" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}>
763
709
  <DailyCostChart dailyCosts={data.dailyCosts} />
764
710
  <TokenDonut data={data} />
765
711
  </div>
@@ -873,26 +819,14 @@ export function CostsPage() {
873
819
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
874
820
  }
875
821
  @media (max-width: 768px) {
876
- .costs-summary-grid {
877
- grid-template-columns: repeat(2, 1fr) !important;
878
- }
879
822
  .top-crons-grid {
880
823
  grid-template-columns: 1fr !important;
881
824
  }
882
- .charts-row {
883
- grid-template-columns: 1fr !important;
884
- }
885
- .opt-row {
886
- grid-template-columns: 1fr !important;
887
- }
888
825
  .usage-row {
889
826
  grid-template-columns: 1fr !important;
890
827
  }
891
828
  }
892
829
  @media (max-width: 640px) {
893
- .costs-summary-grid {
894
- grid-template-columns: 1fr !important;
895
- }
896
830
  .hidden-mobile { display: none !important; }
897
831
  }
898
832
  `}</style>