sparkdesign 0.0.1 → 0.1.10

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 (118) hide show
  1. package/README.md +188 -4
  2. package/dist/commands/add.js +93 -0
  3. package/dist/commands/diff.js +54 -0
  4. package/dist/commands/init.js +96 -0
  5. package/dist/commands/list.js +25 -0
  6. package/dist/index.js +37 -0
  7. package/dist/utils/config.js +53 -0
  8. package/dist/utils/registry.js +34 -0
  9. package/dist/utils/tokens.js +176 -0
  10. package/dist/utils/transform.js +19 -0
  11. package/package.json +33 -10
  12. package/registry/__tests__/basic/button.test.tsx +333 -0
  13. package/registry/__tests__/chat/markdown.test.tsx +387 -0
  14. package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
  15. package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
  16. package/registry/basic/alert-dialog.tsx +180 -0
  17. package/registry/basic/avatar.tsx +120 -0
  18. package/registry/basic/button.tsx +100 -0
  19. package/registry/basic/collapse.tsx +94 -0
  20. package/registry/basic/collapsible-card.tsx +230 -0
  21. package/registry/basic/collapsible.tsx +21 -0
  22. package/registry/basic/dropdown-menu.tsx +254 -0
  23. package/registry/basic/icon-button.tsx +66 -0
  24. package/registry/basic/icons-inline.tsx +206 -0
  25. package/registry/basic/kbd.tsx +50 -0
  26. package/registry/basic/option-list.tsx +125 -0
  27. package/registry/basic/pagination.tsx +132 -0
  28. package/registry/basic/progress.tsx +42 -0
  29. package/registry/basic/radio-group.tsx +69 -0
  30. package/registry/basic/resizable.tsx +67 -0
  31. package/registry/basic/scrollbar.tsx +114 -0
  32. package/registry/basic/select.tsx +177 -0
  33. package/registry/basic/shimmering-text.tsx +115 -0
  34. package/registry/basic/sidebar-menu.tsx +177 -0
  35. package/registry/basic/skeleton.tsx +33 -0
  36. package/registry/basic/slider.tsx +55 -0
  37. package/registry/basic/sonner.tsx +104 -0
  38. package/registry/basic/spinner.tsx +17 -0
  39. package/registry/basic/switch.tsx +49 -0
  40. package/registry/basic/table.tsx +117 -0
  41. package/registry/basic/tabs.tsx +85 -0
  42. package/registry/basic/tag.tsx +161 -0
  43. package/registry/basic/theme-from-document.ts +10 -0
  44. package/registry/basic/toggle.tsx +223 -0
  45. package/registry/basic/tooltip.tsx +80 -0
  46. package/registry/basic/typography.tsx +201 -0
  47. package/registry/chat/ask-user-part.tsx +70 -0
  48. package/registry/chat/browser-action-part.tsx +166 -0
  49. package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
  50. package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
  51. package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
  52. package/registry/chat/chat-input/compound.tsx +334 -0
  53. package/registry/chat/chat-input/context.tsx +189 -0
  54. package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
  55. package/registry/chat/chat-input/index.tsx +123 -0
  56. package/registry/chat/chat-input/types.ts +77 -0
  57. package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
  58. package/registry/chat/code-block-part.tsx +151 -0
  59. package/registry/chat/file-attachment.tsx +44 -0
  60. package/registry/chat/file-card.tsx +68 -0
  61. package/registry/chat/file-review-part.tsx +259 -0
  62. package/registry/chat/folder-button.tsx +169 -0
  63. package/registry/chat/generated-images-grid.tsx +56 -0
  64. package/registry/chat/generation-status-bar.tsx +72 -0
  65. package/registry/chat/hint-banner.tsx +165 -0
  66. package/registry/chat/image-attachment.tsx +166 -0
  67. package/registry/chat/image-generating.tsx +281 -0
  68. package/registry/chat/markdown.tsx +146 -0
  69. package/registry/chat/mermaid-part.tsx +90 -0
  70. package/registry/chat/permission-card.tsx +178 -0
  71. package/registry/chat/plan-part.tsx +168 -0
  72. package/registry/chat/queue-indicator.tsx +234 -0
  73. package/registry/chat/reasoning-step/compound.tsx +336 -0
  74. package/registry/chat/reasoning-step/context.tsx +114 -0
  75. package/registry/chat/reasoning-step/index.tsx +45 -0
  76. package/registry/chat/reasoning-step/types.ts +109 -0
  77. package/registry/chat/related-prompts.tsx +91 -0
  78. package/registry/chat/response/compound.tsx +210 -0
  79. package/registry/chat/response/context.tsx +200 -0
  80. package/registry/chat/response/index.tsx +87 -0
  81. package/registry/chat/response/types.ts +123 -0
  82. package/registry/chat/send-button.tsx +94 -0
  83. package/registry/chat/streaming-markdown-block.tsx +111 -0
  84. package/registry/chat/task-part.tsx +109 -0
  85. package/registry/chat/terminal-code-block-part.tsx +69 -0
  86. package/registry/chat/thinking-indicator.tsx +91 -0
  87. package/registry/chat/tool-invocation-card.tsx +132 -0
  88. package/registry/chat/user-message.tsx +38 -0
  89. package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
  90. package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
  91. package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
  92. package/registry/chat/user-question/compound.tsx +324 -0
  93. package/registry/chat/user-question/context.tsx +456 -0
  94. package/registry/chat/user-question/index.tsx +95 -0
  95. package/registry/chat/user-question/types.ts +61 -0
  96. package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
  97. package/registry/chat/user-question/useUserQuestionState.ts +165 -0
  98. package/registry/chat/user-question-answer.tsx +62 -0
  99. package/registry/lib/file-icon-maps.ts +150 -0
  100. package/registry/lib/use-mermaid-render.ts +76 -0
  101. package/registry/lib/utils.ts +6 -0
  102. package/registry/meta.json +1 -0
  103. package/registry/tokens/index.css +31 -0
  104. package/registry/tokens/scale/computed.css +103 -0
  105. package/registry/tokens/scale/config.css +110 -0
  106. package/registry/tokens/scale/index.css +30 -0
  107. package/registry/tokens/scale/presets/compact.css +30 -0
  108. package/registry/tokens/scale/presets/dense.css +64 -0
  109. package/registry/tokens/scale/presets/sharp.css +40 -0
  110. package/registry/tokens/scale/presets/soft.css +16 -0
  111. package/registry/tokens/scale.css +13 -0
  112. package/registry/tokens/scrollbar-utility.css +35 -0
  113. package/registry/tokens/theme.css +633 -0
  114. package/registry/tokens/themes/dark-parchment.css +132 -0
  115. package/registry/tokens/themes/dark-qoder.css +132 -0
  116. package/registry/tokens/themes/light-parchment.css +123 -0
  117. package/registry/tokens/themes/light-qoder.css +131 -0
  118. package/index.js +0 -5
@@ -0,0 +1,234 @@
1
+ import { memo, useState, useCallback, useEffect } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '@/lib/utils'
4
+ import { motion, AnimatePresence } from 'framer-motion'
5
+ import { Tooltip } from '../basic/tooltip'
6
+ import { IconButton } from '../basic/icon-button'
7
+ import { ArrowUpLine, CloseLine, ArrowDownSLine } from '../basic/icons-inline'
8
+ import { Scrollbar } from '../basic/scrollbar'
9
+
10
+ export interface QueueIndicatorItem {
11
+ id: string
12
+ label: ReactNode
13
+ attachmentCount?: number
14
+ }
15
+
16
+ export interface QueueIndicatorLabels {
17
+ title?: string
18
+ sendNowTip?: string
19
+ removeTip?: string
20
+ fileLabel?: string
21
+ filesLabel?: string
22
+ }
23
+
24
+ const DEFAULT_LABELS: Required<QueueIndicatorLabels> = {
25
+ title: 'In queue',
26
+ sendNowTip: 'Send now',
27
+ removeTip: 'Remove',
28
+ fileLabel: 'file',
29
+ filesLabel: 'files',
30
+ }
31
+
32
+ export interface QueueIndicatorProps {
33
+ items: QueueIndicatorItem[]
34
+ onRemoveItem?: (itemId: string) => void
35
+ onSendNow?: (itemId: string) => void
36
+ isStreaming?: boolean
37
+ hasStatusCardBelow?: boolean
38
+ labels?: QueueIndicatorLabels
39
+ defaultExpanded?: boolean
40
+ storageKey?: string
41
+ arrowUpIcon?: ReactNode
42
+ closeIcon?: ReactNode
43
+ arrowDownSIcon?: ReactNode
44
+ }
45
+
46
+ const arrowUpClass = 'w-3.5 h-3.5'
47
+ const closeClass = 'w-3.5 h-3.5'
48
+ const arrowDownClass = 'w-4 h-4 text-text-tertiary transition-transform duration-200'
49
+
50
+ const QueueItemRow = memo(function QueueItemRow({
51
+ item,
52
+ onRemove,
53
+ onSendNow,
54
+ labels,
55
+ arrowUpIcon,
56
+ closeIcon,
57
+ }: {
58
+ item: QueueIndicatorItem
59
+ onRemove?: (itemId: string) => void
60
+ onSendNow?: (itemId: string) => void
61
+ labels: Required<QueueIndicatorLabels>
62
+ arrowUpIcon: ReactNode
63
+ closeIcon: ReactNode
64
+ }) {
65
+ const handleRemove = useCallback(
66
+ (e: React.MouseEvent) => {
67
+ e.stopPropagation()
68
+ onRemove?.(item.id)
69
+ },
70
+ [item.id, onRemove]
71
+ )
72
+
73
+ const handleSendNow = useCallback(
74
+ (e: React.MouseEvent) => {
75
+ e.stopPropagation()
76
+ onSendNow?.(item.id)
77
+ },
78
+ [item.id, onSendNow]
79
+ )
80
+
81
+ const attachmentCount = item.attachmentCount ?? 0
82
+ const fileWord = attachmentCount === 1 ? labels.fileLabel : labels.filesLabel
83
+
84
+ return (
85
+ <div className="flex items-center gap-2 px-3 py-1.5 text-xs leading-xs hover:bg-fill-tertiary transition-colors cursor-default">
86
+ <span className="truncate flex-1 text-text">{item.label}</span>
87
+ {attachmentCount > 0 && (
88
+ <span className="shrink-0 text-xs leading-xs text-text-tertiary">
89
+ +{attachmentCount} {fileWord}
90
+ </span>
91
+ )}
92
+ <div className="flex items-center gap-0.5">
93
+ {onSendNow && (
94
+ <Tooltip content={labels.sendNowTip} side="top">
95
+ <IconButton
96
+ size="sm"
97
+ variant="ghost"
98
+ icon={arrowUpIcon}
99
+ aria-label={labels.sendNowTip}
100
+ className="shrink-0"
101
+ onClick={handleSendNow}
102
+ />
103
+ </Tooltip>
104
+ )}
105
+ {onRemove && (
106
+ <Tooltip content={labels.removeTip} side="top">
107
+ <IconButton
108
+ size="sm"
109
+ variant="ghost"
110
+ icon={closeIcon}
111
+ aria-label={labels.removeTip}
112
+ className="shrink-0"
113
+ onClick={handleRemove}
114
+ />
115
+ </Tooltip>
116
+ )}
117
+ </div>
118
+ </div>
119
+ )
120
+ })
121
+
122
+ export const QueueIndicator = memo(function QueueIndicator({
123
+ items,
124
+ onRemoveItem,
125
+ onSendNow,
126
+ isStreaming = false,
127
+ hasStatusCardBelow = false,
128
+ labels: labelsProp,
129
+ defaultExpanded = true,
130
+ storageKey,
131
+ arrowUpIcon,
132
+ closeIcon,
133
+ arrowDownSIcon,
134
+ }: QueueIndicatorProps) {
135
+ const labels = { ...DEFAULT_LABELS, ...labelsProp }
136
+
137
+ const defaultArrowUp = arrowUpIcon ?? <ArrowUpLine className={arrowUpClass} />
138
+ const defaultClose = closeIcon ?? <CloseLine className={closeClass} />
139
+ const defaultArrowDown =
140
+ arrowDownSIcon ?? <ArrowDownSLine className={arrowDownClass} />
141
+
142
+ const [isExpanded, setIsExpanded] = useState(() => {
143
+ if (typeof window === 'undefined') return defaultExpanded
144
+ if (storageKey) {
145
+ const saved = localStorage.getItem(storageKey)
146
+ return saved !== null ? saved === 'true' : defaultExpanded
147
+ }
148
+ return defaultExpanded
149
+ })
150
+
151
+ useEffect(() => {
152
+ if (storageKey) {
153
+ localStorage.setItem(storageKey, String(isExpanded))
154
+ }
155
+ }, [storageKey, isExpanded])
156
+
157
+ if (items.length === 0) return null
158
+
159
+ const containerStyles = cn(
160
+ 'w-full overflow-hidden flex flex-col',
161
+ 'border border-b-0 border-border border-border-tertiary bg-bg-base',
162
+ !hasStatusCardBelow && 'rounded-t-xl'
163
+ )
164
+
165
+ return (
166
+ <motion.div
167
+ initial={{ opacity: 0, y: 8 }}
168
+ animate={{ opacity: 1, y: 0 }}
169
+ transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
170
+ className={containerStyles}
171
+ >
172
+ <div
173
+ role="button"
174
+ tabIndex={0}
175
+ onClick={() => setIsExpanded(!isExpanded)}
176
+ onKeyDown={(e) => {
177
+ if (e.key === 'Enter' || e.key === ' ') {
178
+ e.preventDefault()
179
+ setIsExpanded(!isExpanded)
180
+ }
181
+ }}
182
+ aria-expanded={isExpanded}
183
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} queue`}
184
+ className="flex items-center justify-between px-3 py-1 h-7 cursor-pointer bg-fill-tertiary hover:bg-fill-secondary transition-colors duration-150 focus:outline-none"
185
+ >
186
+ <div className="flex items-center gap-2 flex-1 min-w-0">
187
+ <span
188
+ className={cn(
189
+ 'inline-flex items-center justify-center',
190
+ !isExpanded && '-rotate-90'
191
+ )}
192
+ >
193
+ {defaultArrowDown}
194
+ </span>
195
+ <span className="text-xs leading-xs font-medium text-text-secondary">
196
+ {labels.title}
197
+ </span>
198
+ </div>
199
+ </div>
200
+
201
+ <AnimatePresence initial={false}>
202
+ {isExpanded && (
203
+ <motion.div
204
+ initial={{ height: 0, opacity: 0 }}
205
+ animate={{ height: 'auto', opacity: 1 }}
206
+ exit={{ height: 0, opacity: 0 }}
207
+ transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
208
+ className="overflow-hidden"
209
+ >
210
+ <Scrollbar
211
+ scrollbarVisibility="auto"
212
+ maxHeight={208}
213
+ className="border-t border-border-tertiary scroll-smooth"
214
+ >
215
+ {items.map((item) => (
216
+ <QueueItemRow
217
+ key={item.id}
218
+ item={item}
219
+ onRemove={onRemoveItem}
220
+ onSendNow={onSendNow}
221
+ labels={labels}
222
+ arrowUpIcon={defaultArrowUp}
223
+ closeIcon={defaultClose}
224
+ />
225
+ ))}
226
+ </Scrollbar>
227
+ </motion.div>
228
+ )}
229
+ </AnimatePresence>
230
+ </motion.div>
231
+ )
232
+ })
233
+
234
+ QueueIndicator.displayName = 'QueueIndicator'
@@ -0,0 +1,336 @@
1
+ import { memo, useMemo, useCallback, Fragment } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { motion } from 'framer-motion'
4
+ import { cn } from '../../lib/utils'
5
+ import { useReasoningStepContext } from './context'
6
+ import type {
7
+ ReasoningStepHeaderProps,
8
+ ReasoningStepDetailsProps,
9
+ ReasoningStepDetailRowProps,
10
+ ReasoningStepDetail,
11
+ } from './types'
12
+
13
+ // ===== 样式常量 =====
14
+
15
+ const iconSizeStyle = { width: 'var(--font-size-xs)', height: 'var(--font-size-xs)' }
16
+
17
+ const FILE_ICON_COLOR_MAP: Record<string, string> = {
18
+ ts: 'text-info', tsx: 'text-info', js: 'text-info', jsx: 'text-info',
19
+ py: 'text-teal',
20
+ md: 'text-text-secondary', mdx: 'text-text-secondary',
21
+ }
22
+
23
+ function getFileIconColorClass(fileType?: string, filename?: string): string {
24
+ const ext = (fileType ?? filename?.split('.').pop() ?? '').toLowerCase()
25
+ return FILE_ICON_COLOR_MAP[ext] ?? 'text-text-tertiary'
26
+ }
27
+
28
+ // ===== 辅助组件 =====
29
+
30
+ const FileIconPlaceholder = memo(function FileIconPlaceholder({ className }: { className?: string }) {
31
+ return (
32
+ <span
33
+ className={cn('inline-flex items-center justify-center text-xs leading-xs font-mono', className)}
34
+ style={iconSizeStyle}
35
+ aria-hidden
36
+ >
37
+
38
+ </span>
39
+ )
40
+ })
41
+
42
+ const InlineRow = memo(function InlineRow({
43
+ prefix,
44
+ primary,
45
+ secondary,
46
+ }: {
47
+ prefix?: ReactNode
48
+ primary: string
49
+ secondary?: string
50
+ }) {
51
+ return (
52
+ <div className="flex flex-row items-center gap-1.5 min-w-0 flex-1">
53
+ {prefix ? <div className="flex items-center justify-center shrink-0">{prefix}</div> : null}
54
+ <div className="flex flex-row items-center gap-1 min-w-0 overflow-hidden flex-1">
55
+ <span className="text-xs leading-xs font-normal text-text-tertiary truncate" title={primary}>
56
+ {primary}
57
+ </span>
58
+ {secondary ? (
59
+ <span className="text-xs leading-xs font-normal text-text-quaternary truncate" title={secondary}>
60
+ {secondary}
61
+ </span>
62
+ ) : null}
63
+ </div>
64
+ </div>
65
+ )
66
+ })
67
+
68
+ // ===== ReasoningStep.Header =====
69
+
70
+ export function ReasoningStepHeader({ className }: ReasoningStepHeaderProps) {
71
+ const ctx = useReasoningStepContext()
72
+
73
+ const dynamicSpread = useMemo(() => {
74
+ if (typeof ctx.text === 'string') return ctx.text.length * ctx.spread
75
+ return 10 * ctx.spread
76
+ }, [ctx.text, ctx.spread])
77
+
78
+ const getIcon = () => (ctx.icon ? ctx.icon : ctx.eyeIcon)
79
+
80
+ const renderPrimaryText = () => {
81
+ if (typeof ctx.text !== 'string') return ctx.text
82
+ if (ctx.status === 'in-progress') {
83
+ return (
84
+ <motion.span
85
+ className="text-xs leading-xs font-normal relative inline-block bg-clip-text text-transparent bg-[length:250%_100%,auto] bg-no-repeat truncate"
86
+ style={{
87
+ backgroundImage: `linear-gradient(90deg, transparent calc(50% - ${dynamicSpread}px), var(--color-text), transparent calc(50% + ${dynamicSpread}px)), linear-gradient(var(--color-text-tertiary), var(--color-text-tertiary))`,
88
+ }}
89
+ initial={{ backgroundPosition: '100% center' }}
90
+ animate={{ backgroundPosition: '0% center' }}
91
+ transition={{ repeat: Infinity, duration: ctx.duration, ease: 'linear' }}
92
+ >
93
+ {ctx.text}
94
+ </motion.span>
95
+ )
96
+ }
97
+ return (
98
+ <span className="text-xs leading-xs font-normal text-text-tertiary truncate">
99
+ {ctx.text}
100
+ </span>
101
+ )
102
+ }
103
+
104
+ const renderDescription = () => {
105
+ if (!ctx.description) return null
106
+ return (
107
+ <span className="text-xs leading-xs font-normal text-text-quaternary truncate">
108
+ {ctx.description}
109
+ </span>
110
+ )
111
+ }
112
+
113
+ return (
114
+ <div
115
+ className={cn('flex flex-row items-center gap-1.5', ctx.hasExpandableContent && 'cursor-pointer', className)}
116
+ onClick={ctx.toggleExpanded}
117
+ >
118
+ {ctx.effectiveShowIcon && (
119
+ <div className="flex items-center justify-center shrink-0" style={iconSizeStyle}>
120
+ {getIcon()}
121
+ </div>
122
+ )}
123
+ <div className="flex flex-row items-center gap-1 flex-1 min-w-0">
124
+ {renderPrimaryText()}
125
+ {ctx.description ? renderDescription() : null}
126
+ {ctx.hasExpandableContent && (
127
+ <div
128
+ className={cn(
129
+ 'flex items-center justify-center shrink-0 text-text-tertiary transition-opacity duration-200 ease-in-out',
130
+ ctx.effectiveExpanded ? 'opacity-100' : ctx.isHovered ? 'opacity-100' : 'opacity-0',
131
+ ctx.effectiveExpanded ? 'rotate-90' : 'rotate-0'
132
+ )}
133
+ style={iconSizeStyle}
134
+ >
135
+ {ctx.arrowRightIcon}
136
+ </div>
137
+ )}
138
+ </div>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ ReasoningStepHeader.displayName = 'ReasoningStepHeader'
144
+
145
+ // ===== ReasoningStep.DetailRow(内部递归组件)=====
146
+
147
+ interface DetailRowInternalProps {
148
+ detail: ReasoningStepDetail
149
+ depth: number
150
+ pathKey: string
151
+ detailIndent: string
152
+ disableExpandAnimation: boolean
153
+ expandedNestedKeys: Set<string>
154
+ onToggleNested: (pathKey: string) => void
155
+ arrowRightIcon: ReactNode
156
+ }
157
+
158
+ const DetailRowInternal = memo(function DetailRowInternal({
159
+ detail,
160
+ depth,
161
+ pathKey,
162
+ detailIndent,
163
+ expandedNestedKeys,
164
+ onToggleNested,
165
+ arrowRightIcon,
166
+ }: DetailRowInternalProps) {
167
+ const hasNested = detail.details != null && detail.details.length > 0
168
+ const isExpandedNested = expandedNestedKeys.has(pathKey)
169
+ const showIconOnRow = !hasNested
170
+ const primary = detail.type === 'action' ? detail.action : detail.filename
171
+ const secondary =
172
+ detail.type === 'action' ? detail.desc : [detail.lines, detail.path].filter(Boolean).join(' ')
173
+ const prefix =
174
+ showIconOnRow && detail.type === 'file' ? (
175
+ <FileIconPlaceholder className={getFileIconColorClass(detail.fileType, detail.filename)} />
176
+ ) : undefined
177
+
178
+ const handleClick = useCallback(() => {
179
+ if (hasNested) onToggleNested(pathKey)
180
+ }, [hasNested, onToggleNested, pathKey])
181
+
182
+ const rowContent = (
183
+ <div
184
+ className={cn(
185
+ 'flex flex-row items-center gap-1.5 self-stretch min-h-5 min-w-0',
186
+ hasNested && 'cursor-pointer'
187
+ )}
188
+ onClick={hasNested ? handleClick : undefined}
189
+ >
190
+ <InlineRow prefix={prefix} primary={primary} secondary={secondary} />
191
+ {hasNested && (
192
+ <div
193
+ className={cn(
194
+ 'flex items-center justify-center shrink-0 text-text-tertiary',
195
+ isExpandedNested ? 'rotate-90' : 'rotate-0'
196
+ )}
197
+ style={iconSizeStyle}
198
+ >
199
+ {arrowRightIcon}
200
+ </div>
201
+ )}
202
+ </div>
203
+ )
204
+
205
+ const rowWithOptionalLine = (
206
+ <div
207
+ className="flex flex-row items-center gap-0 self-stretch"
208
+ style={{ marginLeft: detailIndent }}
209
+ >
210
+ <div className="shrink-0 w-[var(--font-size-xs)] h-5 border-l border-border-tertiary" />
211
+ {rowContent}
212
+ </div>
213
+ )
214
+
215
+ if (!hasNested) return <Fragment key={pathKey}>{rowWithOptionalLine}</Fragment>
216
+
217
+ const isThirdLevelContainer = depth === 1
218
+ const nestedWrapperClass = isThirdLevelContainer
219
+ ? 'rounded-lg border border-border-tertiary bg-bg-base p-2 flex flex-col gap-0'
220
+ : 'flex flex-col'
221
+
222
+ return (
223
+ <div key={pathKey} className="flex flex-col self-stretch">
224
+ {rowWithOptionalLine}
225
+ <div
226
+ className={cn(
227
+ 'grid transition-[grid-template-rows,opacity] duration-150 ease-out',
228
+ isExpandedNested ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
229
+ )}
230
+ >
231
+ <div className="overflow-hidden">
232
+ <div className={cn(nestedWrapperClass)}>
233
+ {detail.details?.map((d, i) => (
234
+ <DetailRowInternal
235
+ key={`${pathKey}-${i}`}
236
+ detail={d}
237
+ depth={depth + 1}
238
+ pathKey={`${pathKey}-${i}`}
239
+ detailIndent={detailIndent}
240
+ disableExpandAnimation={false}
241
+ expandedNestedKeys={expandedNestedKeys}
242
+ onToggleNested={onToggleNested}
243
+ arrowRightIcon={arrowRightIcon}
244
+ />
245
+ ))}
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ )
251
+ })
252
+
253
+ // ===== ReasoningStep.DetailRow(公开 API)=====
254
+
255
+ export function ReasoningStepDetailRow({
256
+ detail,
257
+ depth = 0,
258
+ pathKey = '0',
259
+ }: ReasoningStepDetailRowProps) {
260
+ const ctx = useReasoningStepContext()
261
+ return (
262
+ <DetailRowInternal
263
+ detail={detail}
264
+ depth={depth}
265
+ pathKey={pathKey}
266
+ detailIndent={ctx.detailIndent}
267
+ disableExpandAnimation={ctx.disableExpandAnimation}
268
+ expandedNestedKeys={ctx.expandedNestedKeys}
269
+ onToggleNested={ctx.toggleNested}
270
+ arrowRightIcon={ctx.arrowRightIcon}
271
+ />
272
+ )
273
+ }
274
+
275
+ ReasoningStepDetailRow.displayName = 'ReasoningStepDetailRow'
276
+
277
+ // ===== ReasoningStep.Details =====
278
+
279
+ export function ReasoningStepDetails({ className, children }: ReasoningStepDetailsProps) {
280
+ const ctx = useReasoningStepContext()
281
+ const hasDetails = ctx.details != null && ctx.details.length > 0
282
+
283
+ return (
284
+ <div
285
+ className={cn(
286
+ 'grid transition-[grid-template-rows,opacity] duration-200 ease-out',
287
+ ctx.effectiveExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
288
+ className
289
+ )}
290
+ >
291
+ <div className="overflow-hidden">
292
+ {hasDetails ? (
293
+ <div className="flex flex-col">
294
+ {ctx.details?.map((d, i) => (
295
+ <DetailRowInternal
296
+ key={String(i)}
297
+ detail={d}
298
+ depth={0}
299
+ pathKey={String(i)}
300
+ detailIndent={ctx.detailIndent}
301
+ disableExpandAnimation={ctx.disableExpandAnimation}
302
+ expandedNestedKeys={ctx.expandedNestedKeys}
303
+ onToggleNested={ctx.toggleNested}
304
+ arrowRightIcon={ctx.arrowRightIcon}
305
+ />
306
+ ))}
307
+ </div>
308
+ ) : (
309
+ children ?? ctx.detailsChildren
310
+ )}
311
+ </div>
312
+ </div>
313
+ )
314
+ }
315
+
316
+ ReasoningStepDetails.displayName = 'ReasoningStepDetails'
317
+
318
+ // ===== ReasoningStep.DefaultLayout =====
319
+
320
+ export function ReasoningStepDefaultLayout() {
321
+ const ctx = useReasoningStepContext()
322
+
323
+ return (
324
+ <div
325
+ data-reasoning-step
326
+ className="flex flex-col gap-1 py-0.5"
327
+ onMouseEnter={() => ctx.setIsHovered(true)}
328
+ onMouseLeave={() => ctx.setIsHovered(false)}
329
+ >
330
+ <ReasoningStepHeader />
331
+ {ctx.hasExpandableContent && <ReasoningStepDetails />}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ ReasoningStepDefaultLayout.displayName = 'ReasoningStepDefaultLayout'
@@ -0,0 +1,114 @@
1
+ import { createContext, useContext, useState, useCallback, useMemo } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { EyeLine, ArrowRightLine } from '../../basic/icons-inline'
4
+ import type { ReasoningStepContextValue, ReasoningStepRootProps } from './types'
5
+
6
+ // ===== 样式常量 =====
7
+
8
+ const eyeClass = 'w-full h-full text-text-tertiary transition-transform duration-200 ease-in-out'
9
+ const arrowClass = 'w-full h-full transition-transform duration-200 ease-in-out'
10
+
11
+ // ===== Context =====
12
+
13
+ const ReasoningStepContext = createContext<ReasoningStepContextValue | null>(null)
14
+ ReasoningStepContext.displayName = 'ReasoningStepContext'
15
+
16
+ export function useReasoningStepContext() {
17
+ const ctx = useContext(ReasoningStepContext)
18
+ if (!ctx) throw new Error('ReasoningStep compound components must be used within ReasoningStep.Root')
19
+ return ctx
20
+ }
21
+
22
+ // ===== Provider =====
23
+
24
+ export function ReasoningStepRootProvider({
25
+ text,
26
+ description,
27
+ status = 'completed',
28
+ icon,
29
+ showIcon = true,
30
+ details,
31
+ children,
32
+ detailsChildren,
33
+ duration = 2,
34
+ spread = 2,
35
+ disableExpandAnimation = false,
36
+ defaultExpanded,
37
+ expanded: expandedProp,
38
+ onExpandedChange,
39
+ eyeIcon,
40
+ arrowRightIcon,
41
+ }: ReasoningStepRootProps & { children?: ReactNode; detailsChildren?: ReactNode }) {
42
+ const isControlled = expandedProp !== undefined
43
+ const [isExpandedInternal, setIsExpandedInternal] = useState(defaultExpanded ?? false)
44
+ const isExpanded = isControlled ? expandedProp : isExpandedInternal
45
+ const [isHovered, setIsHovered] = useState(false)
46
+
47
+ const effectiveExpanded = disableExpandAnimation && defaultExpanded !== undefined
48
+ ? defaultExpanded
49
+ : isExpanded
50
+
51
+ const effectiveShowIcon = showIcon && typeof text === 'string'
52
+ const detailIndent = effectiveShowIcon ? 'var(--spacing-1.5)' : 'var(--spacing-2.5)'
53
+
54
+ const hasDetails = details != null && details.length > 0
55
+ const hasExpandableContent = hasDetails || detailsChildren != null
56
+
57
+ const toggleExpanded = useCallback(() => {
58
+ if (hasExpandableContent) {
59
+ if (isControlled) onExpandedChange?.(!expandedProp)
60
+ else setIsExpandedInternal((e) => !e)
61
+ }
62
+ }, [hasExpandableContent, isControlled, expandedProp, onExpandedChange])
63
+
64
+ const [expandedNestedKeys, setExpandedNestedKeys] = useState<Set<string>>(new Set())
65
+ const toggleNested = useCallback((pathKey: string) => {
66
+ setExpandedNestedKeys((prev) => {
67
+ const next = new Set(prev)
68
+ if (next.has(pathKey)) next.delete(pathKey)
69
+ else next.add(pathKey)
70
+ return next
71
+ })
72
+ }, [])
73
+
74
+ const defaultEye = useMemo(
75
+ () => eyeIcon ?? <EyeLine className={eyeClass} />,
76
+ [eyeIcon]
77
+ )
78
+ const defaultArrow = useMemo(
79
+ () => arrowRightIcon ?? <ArrowRightLine className={arrowClass} />,
80
+ [arrowRightIcon]
81
+ )
82
+
83
+ const ctxValue: ReasoningStepContextValue = {
84
+ status,
85
+ isExpanded,
86
+ isHovered,
87
+ setIsHovered,
88
+ toggleExpanded,
89
+ effectiveExpanded,
90
+ effectiveShowIcon,
91
+ hasExpandableContent,
92
+ text,
93
+ description,
94
+ details,
95
+ detailsChildren,
96
+ duration,
97
+ spread,
98
+ disableExpandAnimation,
99
+ detailIndent,
100
+ expandedNestedKeys,
101
+ toggleNested,
102
+ icon,
103
+ eyeIcon: defaultEye,
104
+ arrowRightIcon: defaultArrow,
105
+ }
106
+
107
+ return (
108
+ <ReasoningStepContext.Provider value={ctxValue}>
109
+ {children}
110
+ </ReasoningStepContext.Provider>
111
+ )
112
+ }
113
+
114
+ ReasoningStepRootProvider.displayName = 'ReasoningStepRoot'