@bibobonking/opensol 0.0.0-stage → 3.0.1

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.
Files changed (114) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,189 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Activity, CalendarDays, Clock3, Cpu, MessageSquare, Sparkles } from 'lucide-react'
3
+ import { useChats } from '../store/chats'
4
+ import { useSettings } from '../store/settings'
5
+ import { useWorkspace } from '../store/workspace'
6
+ import { cn } from '../lib/utils'
7
+ import { computeUsage, formatTokens, shortModelName, type UsageRange } from '../lib/usage'
8
+ import { PixleSol } from './PixleSol'
9
+
10
+ const RANGES: Array<{ value: UsageRange; label: string }> = [
11
+ { value: 'all', label: 'All' },
12
+ { value: '30d', label: '30d' },
13
+ { value: '7d', label: '7d' },
14
+ ]
15
+
16
+ const HEAT_LEVELS = [
17
+ 'bg-raised',
18
+ 'bg-accent/20',
19
+ 'bg-accent/35',
20
+ 'bg-accent/55',
21
+ 'bg-accent/80',
22
+ ]
23
+
24
+ function heatLevel(count: number, busiest: number) {
25
+ if (count <= 0) return 0
26
+ if (busiest <= 1) return 2
27
+ return Math.min(4, 1 + Math.floor((count / busiest) * 3))
28
+ }
29
+
30
+ function Stat({ icon: Icon, label, value }: { icon: typeof Activity; label: string; value: string }) {
31
+ return (
32
+ <div className="min-w-0 rounded-lg border border-line/70 bg-canvas px-3 py-2.5">
33
+ <p className="flex items-center gap-1.5 text-[11.5px] text-muted">
34
+ <Icon size={12} className="shrink-0 text-faint" />
35
+ <span className="truncate">{label}</span>
36
+ </p>
37
+ <p className="mt-0.5 truncate text-[20px] font-semibold tracking-[-0.03em] text-ink" title={value}>
38
+ {value}
39
+ </p>
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export function HomeGreeting() {
45
+ const daemon = useSettings((state) => state.daemon)
46
+ const workspace = useWorkspace((state) => state.info)
47
+ const home = daemon?.home ?? ''
48
+ const name = home.split(/[\\/]/).filter(Boolean).pop() || workspace.name || 'there'
49
+ return (
50
+ <div className="flex flex-col items-center gap-4 text-center">
51
+ <div className="relative">
52
+ <span className="absolute inset-0 -z-10 m-auto h-16 w-16 rounded-full bg-accent/20 blur-2xl" aria-hidden="true" />
53
+ <PixleSol mood="wave" size={60} />
54
+ </div>
55
+ <h1 className="text-[28px] font-semibold leading-tight tracking-[-0.035em] text-ink sm:text-[34px]">
56
+ What should we build, <span className="text-solar">{name}</span>?
57
+ </h1>
58
+ <p className="-mt-2 text-[13.5px] text-muted">Local-first agent. Your folder, your keys, your machine.</p>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ export function HomeDashboard() {
64
+ const sessions = useChats((state) => state.sessions)
65
+ const [range, setRange] = useState<UsageRange>('30d')
66
+ const [tab, setTab] = useState<'overview' | 'models'>('overview')
67
+ const stats = useMemo(() => computeUsage(sessions, range), [sessions, range])
68
+
69
+ const columns = useMemo(() => {
70
+ const weeks: typeof stats.heatmap[] = []
71
+ for (let index = 0; index < stats.heatmap.length; index += 7) {
72
+ weeks.push(stats.heatmap.slice(index, index + 7))
73
+ }
74
+ return weeks
75
+ }, [stats.heatmap])
76
+
77
+ const topModelTokens = stats.models[0]?.tokens ?? 0
78
+
79
+ return (
80
+ <div className="relative w-full">
81
+ <section className="relative overflow-hidden rounded-xl border border-line bg-panel">
82
+ <div className="relative flex flex-wrap items-center justify-between gap-2 border-b border-line/70 px-3 py-2.5">
83
+ <div className="inline-flex items-center rounded-lg bg-raised p-0.5">
84
+ {(['overview', 'models'] as const).map((value) => (
85
+ <button
86
+ key={value}
87
+ type="button"
88
+ onClick={() => setTab(value)}
89
+ className={cn(
90
+ 'h-6 rounded-md px-2.5 text-[11.5px] font-medium capitalize transition-colors',
91
+ tab === value ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
92
+ )}
93
+ >
94
+ {value}
95
+ </button>
96
+ ))}
97
+ </div>
98
+ <div className="inline-flex items-center gap-0.5 rounded-lg bg-raised p-0.5">
99
+ {RANGES.map((entry) => (
100
+ <button
101
+ key={entry.value}
102
+ type="button"
103
+ onClick={() => setRange(entry.value)}
104
+ className={cn(
105
+ 'h-6 rounded-md px-2.5 text-[11.5px] font-medium transition-colors',
106
+ range === entry.value ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
107
+ )}
108
+ >
109
+ {entry.label}
110
+ </button>
111
+ ))}
112
+ </div>
113
+ </div>
114
+
115
+ {tab === 'overview' ? (
116
+ <div className="relative p-3">
117
+ <div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
118
+ <Stat icon={Activity} label="Sessions" value={String(stats.sessions)} />
119
+ <Stat icon={MessageSquare} label="Messages" value={String(stats.messages)} />
120
+ <Stat icon={Sparkles} label="Total tokens" value={stats.tokens ? formatTokens(stats.tokens) : '0'} />
121
+ <Stat icon={CalendarDays} label="Active days" value={String(stats.activeDays)} />
122
+ <Stat icon={Clock3} label="Peak hour" value={stats.peakHour} />
123
+ <Stat icon={Cpu} label="Favorite model" value={shortModelName(stats.favoriteModel)} />
124
+ </div>
125
+
126
+ <div className="mt-3 overflow-x-auto">
127
+ <div className="flex w-max gap-[3px]">
128
+ {columns.map((week, index) => (
129
+ <div key={index} className="grid grid-rows-7 gap-[3px]">
130
+ {week.map((day) => (
131
+ <span
132
+ key={day.key}
133
+ className={cn('h-[11px] w-[11px] rounded-[2px]', HEAT_LEVELS[heatLevel(day.count, stats.busiestDay)])}
134
+ title={`${day.key} · ${day.count} message${day.count === 1 ? '' : 's'}`}
135
+ />
136
+ ))}
137
+ </div>
138
+ ))}
139
+ </div>
140
+ </div>
141
+
142
+ <p className="mt-2.5 text-[11px] leading-relaxed text-muted">
143
+ {stats.messages === 0
144
+ ? 'No activity in this range yet. Send a message to start the graph.'
145
+ : `You've used ~${formatTokens(stats.tokens)} tokens across ${stats.messages} messages.`}
146
+ {stats.favoriteModel !== '—' && (
147
+ <>
148
+ <span className="px-1.5 text-line">·</span>
149
+ {stats.favoriteShare}% on {shortModelName(stats.favoriteModel)}
150
+ </>
151
+ )}
152
+ </p>
153
+ </div>
154
+ ) : (
155
+ <div className="relative p-3">
156
+ {stats.models.length === 0 ? (
157
+ <div className="rounded-xl border border-dashed border-line px-4 py-8 text-center">
158
+ <Cpu size={18} className="mx-auto text-faint" />
159
+ <p className="mt-2 text-[12px] font-medium text-ink">No model usage yet</p>
160
+ <p className="mt-1 text-[11px] text-muted">Models appear here after you send a message.</p>
161
+ </div>
162
+ ) : (
163
+ <div className="space-y-2.5">
164
+ {stats.models.slice(0, 6).map((model) => (
165
+ <div key={model.model}>
166
+ <div className="flex items-baseline justify-between gap-3">
167
+ <span className="min-w-0 truncate text-[12px] font-medium text-ink" title={model.model}>
168
+ {shortModelName(model.model)}
169
+ </span>
170
+ <span className="shrink-0 text-[10.5px] tabular-nums text-faint">
171
+ {formatTokens(model.tokens)} · {model.messages} msg
172
+ </span>
173
+ </div>
174
+ <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-raised">
175
+ <div
176
+ className="h-full rounded-full bg-accent/70"
177
+ style={{ width: `${topModelTokens ? Math.max(4, (model.tokens / topModelTokens) * 100) : 4}%` }}
178
+ />
179
+ </div>
180
+ </div>
181
+ ))}
182
+ </div>
183
+ )}
184
+ </div>
185
+ )}
186
+ </section>
187
+ </div>
188
+ )
189
+ }
@@ -0,0 +1,41 @@
1
+ import { cn } from '../lib/utils'
2
+
3
+ /** The OpenSol sun: a solid core with eight round-capped rays. Inherits `currentColor`. */
4
+ export function LogoMark({ size = 28, className, spin = false }: { size?: number; className?: string; spin?: boolean }) {
5
+ return (
6
+ <span
7
+ className={cn('inline-flex shrink-0 items-center justify-center text-accent', className)}
8
+ style={{ width: size, height: size }}
9
+ aria-hidden="true"
10
+ >
11
+ <svg viewBox="0 0 32 32" width={size} height={size} fill="none" className={cn(spin && 'animate-spin-slow')}>
12
+ <circle cx="16" cy="16" r="6.2" fill="currentColor" />
13
+ <g stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
14
+ <path d="M16 3.6v3.2M16 25.2v3.2M3.6 16h3.2M25.2 16h3.2" />
15
+ <path d="M7.2 7.2l2.2 2.2M22.6 22.6l2.2 2.2M7.2 24.8l2.2-2.2M22.6 9.4l2.2-2.2" opacity="0.55" />
16
+ </g>
17
+ </svg>
18
+ </span>
19
+ )
20
+ }
21
+
22
+ export function LogoLockup({ compact = false }: { compact?: boolean }) {
23
+ return (
24
+ <div className="group/logo flex items-center gap-2">
25
+ <LogoMark size={compact ? 20 : 22} className="transition-transform duration-500 ease-out group-hover/logo:rotate-90" />
26
+ {!compact && (
27
+ <span className="text-[15px] font-semibold leading-none tracking-[-0.025em] text-ink">
28
+ Open<span className="text-accent">Sol</span>
29
+ </span>
30
+ )}
31
+ </div>
32
+ )
33
+ }
34
+
35
+ export function BrandFooter() {
36
+ return (
37
+ <p className="text-[11px] tracking-[0.01em] text-faint">
38
+ OpenSol <span className="px-1 text-line">/</span> by OpenSoft
39
+ </p>
40
+ )
41
+ }
@@ -0,0 +1,232 @@
1
+ import { isValidElement, memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+ import rehypeHighlight from 'rehype-highlight'
6
+ import { Check, Copy, Download, Maximize2, RotateCw, X } from 'lucide-react'
7
+ import { cn, downloadText } from '../lib/utils'
8
+
9
+ function extractLanguage(children: ReactNode): string {
10
+ const first = Array.isArray(children) ? children[0] : children
11
+ if (first && typeof first === 'object' && 'props' in first) {
12
+ const props = (first as { props?: { className?: string } }).props
13
+ const match = /language-([\w-]+)/.exec(props?.className ?? '')
14
+ if (match) return match[1]
15
+ }
16
+ return ''
17
+ }
18
+
19
+ /** Plain text of a (syntax-highlighted) React subtree. */
20
+ function nodeText(node: ReactNode): string {
21
+ if (node === null || node === undefined || typeof node === 'boolean') return ''
22
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
23
+ if (Array.isArray(node)) return node.map(nodeText).join('')
24
+ if (isValidElement(node)) return nodeText((node.props as { children?: ReactNode }).children)
25
+ return ''
26
+ }
27
+
28
+ /** Whether a code block is something the browser can run as a page. */
29
+ function canPreview(language: string, code: string): boolean {
30
+ if (language === 'svg') return /<svg[\s>]/i.test(code)
31
+ if (language === 'html' || language === 'htm') {
32
+ return /<(html|body|canvas|script|style|svg|main|section|div|h1|button)\b/i.test(code)
33
+ }
34
+ return false
35
+ }
36
+
37
+ /** Turns a snippet into a complete document, so fragments and bare SVG still render. */
38
+ function toDocument(language: string, code: string): string {
39
+ if (/<!doctype|<html[\s>]/i.test(code)) return code
40
+ const head =
41
+ '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
42
+ if (language === 'svg') {
43
+ return `${head}<style>html,body{margin:0;min-height:100%}body{display:grid;place-items:center;background:#fff}svg{max-width:100%;height:auto}</style></head><body>${code}</body></html>`
44
+ }
45
+ return `${head}</head><body>${code}</body></html>`
46
+ }
47
+
48
+ function PreviewFrame({ html, reloadKey, className }: { html: string; reloadKey: number; className?: string }) {
49
+ const frameRef = useRef<HTMLIFrameElement>(null)
50
+
51
+ useEffect(() => {
52
+ const frame = frameRef.current
53
+ if (!frame) return
54
+ const onMessage = (event: MessageEvent) => {
55
+ if (event.source !== frame.contentWindow) return
56
+ if ((event.data as { type?: string } | null)?.type !== 'maxcode-preview-ready') return
57
+ frame.contentWindow?.postMessage({ type: 'maxcode-preview', html }, '*')
58
+ }
59
+ window.addEventListener('message', onMessage)
60
+ return () => window.removeEventListener('message', onMessage)
61
+ }, [html, reloadKey])
62
+
63
+ return (
64
+ <iframe
65
+ key={reloadKey}
66
+ ref={frameRef}
67
+ src="/preview-host.html"
68
+ title="Live preview"
69
+ // No allow-same-origin: the page cannot reach OpenSol, its cookies or the local daemon.
70
+ sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals"
71
+ allow="fullscreen"
72
+ className={cn('block h-full w-full border-0 bg-white', className)}
73
+ />
74
+ )
75
+ }
76
+
77
+ function PreviewOverlay({ html, onClose }: { html: string; onClose: () => void }) {
78
+ const [reloadKey, setReloadKey] = useState(0)
79
+
80
+ useEffect(() => {
81
+ const onKey = (event: KeyboardEvent) => {
82
+ if (event.key === 'Escape') onClose()
83
+ }
84
+ window.addEventListener('keydown', onKey)
85
+ return () => window.removeEventListener('keydown', onKey)
86
+ }, [onClose])
87
+
88
+ return createPortal(
89
+ <div className="fixed inset-0 z-[90] flex flex-col bg-canvas/95 p-3 backdrop-blur-sm sm:p-5" role="dialog" aria-label="Live preview">
90
+ <div className="mb-3 flex items-center justify-between">
91
+ <p className="text-[13.5px] font-medium text-ink">Live preview</p>
92
+ <div className="flex items-center gap-1">
93
+ <button type="button" className="icon-button" onClick={() => setReloadKey((value) => value + 1)} title="Restart" aria-label="Restart preview">
94
+ <RotateCw size={16} />
95
+ </button>
96
+ <button type="button" className="icon-button" onClick={onClose} title="Close (Esc)" aria-label="Close preview">
97
+ <X size={18} />
98
+ </button>
99
+ </div>
100
+ </div>
101
+ <div className="min-h-0 flex-1 overflow-hidden rounded-2xl border border-line bg-white shadow-float">
102
+ <PreviewFrame html={html} reloadKey={reloadKey} />
103
+ </div>
104
+ </div>,
105
+ document.body,
106
+ )
107
+ }
108
+
109
+ function CodeBlock({ children, streaming }: { children?: ReactNode; streaming?: boolean }) {
110
+ const [copied, setCopied] = useState(false)
111
+ const [view, setView] = useState<'code' | 'preview'>('code')
112
+ const [reloadKey, setReloadKey] = useState(0)
113
+ const [expanded, setExpanded] = useState(false)
114
+ const language = extractLanguage(children) || 'text'
115
+ const code = useMemo(() => nodeText(children), [children])
116
+ const previewable = !streaming && canPreview(language, code)
117
+ const html = useMemo(() => (previewable ? toDocument(language, code) : ''), [previewable, language, code])
118
+ const showingPreview = previewable && view === 'preview'
119
+
120
+ const copy = async () => {
121
+ try {
122
+ await navigator.clipboard.writeText(code)
123
+ setCopied(true)
124
+ window.setTimeout(() => setCopied(false), 1600)
125
+ } catch {
126
+ // Clipboard access can be blocked; leave the button unchanged.
127
+ }
128
+ }
129
+
130
+ return (
131
+ <div className="code-shell group/code">
132
+ <div className="code-head">
133
+ {previewable ? (
134
+ <div className="inline-flex items-center rounded-lg bg-raised/80 p-0.5" role="tablist" aria-label="Code or preview">
135
+ {(['code', 'preview'] as const).map((value) => (
136
+ <button
137
+ key={value}
138
+ type="button"
139
+ role="tab"
140
+ aria-selected={view === value}
141
+ onClick={() => setView(value)}
142
+ className={cn(
143
+ 'h-6 rounded-md px-2.5 text-[11.5px] font-medium capitalize transition-colors',
144
+ view === value ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
145
+ )}
146
+ >
147
+ {value}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ ) : (
152
+ <span className="code-lang">{language}</span>
153
+ )}
154
+ <div className="flex items-center gap-0.5">
155
+ {showingPreview && (
156
+ <>
157
+ <button
158
+ type="button"
159
+ onClick={() => setReloadKey((value) => value + 1)}
160
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-panel hover:text-ink"
161
+ title="Restart"
162
+ aria-label="Restart preview"
163
+ >
164
+ <RotateCw size={13} />
165
+ </button>
166
+ <button
167
+ type="button"
168
+ onClick={() => setExpanded(true)}
169
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-panel hover:text-ink"
170
+ title="Full screen"
171
+ aria-label="Open preview full screen"
172
+ >
173
+ <Maximize2 size={13} />
174
+ </button>
175
+ </>
176
+ )}
177
+ {previewable && (
178
+ <button
179
+ type="button"
180
+ onClick={() => downloadText(language === 'svg' ? 'image.svg' : 'index.html', language === 'svg' ? code : html, language === 'svg' ? 'image/svg+xml' : 'text/html')}
181
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-panel hover:text-ink"
182
+ title="Download file"
183
+ aria-label="Download file"
184
+ >
185
+ <Download size={13} />
186
+ </button>
187
+ )}
188
+ <button
189
+ type="button"
190
+ onClick={copy}
191
+ className="inline-flex h-7 items-center gap-1 rounded-md px-1.5 text-[11.5px] font-medium text-faint transition-colors hover:bg-panel hover:text-ink"
192
+ aria-label="Copy code"
193
+ >
194
+ {copied ? <Check size={13} /> : <Copy size={13} />}
195
+ {copied ? 'Copied' : 'Copy'}
196
+ </button>
197
+ </div>
198
+ </div>
199
+ {showingPreview ? (
200
+ <div className="h-[420px] min-h-[220px] resize-y overflow-hidden border-t border-line/60 bg-white">
201
+ <PreviewFrame html={html} reloadKey={reloadKey} />
202
+ </div>
203
+ ) : (
204
+ <pre className="code-pre thin-scroll">{children}</pre>
205
+ )}
206
+ {expanded && <PreviewOverlay html={html} onClose={() => setExpanded(false)} />}
207
+ </div>
208
+ )
209
+ }
210
+
211
+ function MarkdownImpl({ content, streaming }: { content: string; streaming?: boolean }) {
212
+ return (
213
+ <div className={cn('md', streaming && 'is-streaming')}>
214
+ <ReactMarkdown
215
+ remarkPlugins={[remarkGfm]}
216
+ rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
217
+ components={{
218
+ pre: ({ children }) => <CodeBlock streaming={streaming}>{children}</CodeBlock>,
219
+ a: ({ children, href }) => (
220
+ <a href={href} target="_blank" rel="noreferrer noopener">
221
+ {children}
222
+ </a>
223
+ ),
224
+ }}
225
+ >
226
+ {content}
227
+ </ReactMarkdown>
228
+ </div>
229
+ )
230
+ }
231
+
232
+ export const Markdown = memo(MarkdownImpl)