Skip to content

Commit 18dacc5

Browse files
committed
improvement: chat, workspace header
1 parent 4a135aa commit 18dacc5

File tree

21 files changed

+759
-166
lines changed

21 files changed

+759
-166
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
'use client'
2+
3+
import { Children, type ComponentPropsWithoutRef, isValidElement } from 'react'
4+
import ReactMarkdown from 'react-markdown'
5+
import remarkGfm from 'remark-gfm'
6+
import 'prismjs/components/prism-typescript'
7+
import 'prismjs/components/prism-bash'
8+
import 'prismjs/components/prism-css'
9+
import 'prismjs/components/prism-markup'
10+
import '@/components/emcn/components/code/code.css'
11+
import { Checkbox, highlight, languages } from '@/components/emcn'
12+
import { cn } from '@/lib/core/utils/cn'
13+
import { useThrottledValue } from '@/hooks/use-throttled-value'
14+
15+
const REMARK_PLUGINS = [remarkGfm]
16+
17+
const LANG_ALIASES: Record<string, string> = {
18+
js: 'javascript',
19+
ts: 'typescript',
20+
tsx: 'typescript',
21+
jsx: 'javascript',
22+
sh: 'bash',
23+
shell: 'bash',
24+
html: 'markup',
25+
xml: 'markup',
26+
yml: 'yaml',
27+
py: 'python',
28+
}
29+
30+
function extractTextContent(node: React.ReactNode): string {
31+
if (typeof node === 'string') return node
32+
if (typeof node === 'number') return String(node)
33+
if (!node) return ''
34+
if (Array.isArray(node)) return node.map(extractTextContent).join('')
35+
if (isValidElement(node))
36+
return extractTextContent((node.props as { children?: React.ReactNode }).children)
37+
return ''
38+
}
39+
40+
const PROSE_CLASSES = cn(
41+
'prose prose-base dark:prose-invert max-w-none',
42+
'font-[family-name:var(--font-inter)] antialiased break-words font-[420] tracking-[0]',
43+
'prose-headings:font-[600] prose-headings:tracking-[0] prose-headings:text-[var(--text-primary)]',
44+
'prose-headings:mb-3 prose-headings:mt-6 first:prose-headings:mt-0',
45+
'prose-p:text-[16px] prose-p:leading-7 prose-p:text-[var(--text-primary)]',
46+
'first:prose-p:mt-0 last:prose-p:mb-0',
47+
'prose-li:text-[16px] prose-li:leading-7 prose-li:text-[var(--text-primary)]',
48+
'prose-li:my-1',
49+
'prose-ul:my-4 prose-ol:my-4',
50+
'prose-strong:font-[600] prose-strong:text-[var(--text-primary)]',
51+
'prose-a:text-[var(--text-primary)] prose-a:underline prose-a:decoration-dashed prose-a:underline-offset-2',
52+
'prose-code:rounded prose-code:bg-[var(--surface-5)] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[13px] prose-code:font-mono prose-code:font-[400] prose-code:text-[var(--text-primary)]',
53+
'prose-code:before:content-none prose-code:after:content-none',
54+
'prose-hr:border-[var(--divider)] prose-hr:my-6',
55+
'prose-table:my-0'
56+
)
57+
58+
type TdProps = ComponentPropsWithoutRef<'td'>
59+
type ThProps = ComponentPropsWithoutRef<'th'>
60+
61+
const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['components'] = {
62+
table({ children }) {
63+
return (
64+
<div className='not-prose my-4 w-full overflow-x-auto'>
65+
<table className='min-w-full border-collapse'>{children}</table>
66+
</div>
67+
)
68+
},
69+
thead({ children }) {
70+
return <thead>{children}</thead>
71+
},
72+
th({ children, style }: ThProps) {
73+
return (
74+
<th
75+
style={style}
76+
className='whitespace-nowrap border-[var(--divider)] border-b px-3 py-2 text-left font-[600] text-[14px] text-[var(--text-primary)] leading-6'
77+
>
78+
{children}
79+
</th>
80+
)
81+
},
82+
td({ children, style }: TdProps) {
83+
return (
84+
<td
85+
style={style}
86+
className='whitespace-nowrap border-[var(--divider)] border-b px-3 py-2 text-[14px] text-[var(--text-primary)] leading-6'
87+
>
88+
{children}
89+
</td>
90+
)
91+
},
92+
pre({ children }) {
93+
let codeString = ''
94+
let language = ''
95+
96+
for (const child of Children.toArray(children)) {
97+
if (isValidElement(child) && child.type === 'code') {
98+
const props = child.props as { className?: string; children?: React.ReactNode }
99+
codeString = extractTextContent(props.children)
100+
if (props.className?.startsWith('language-')) {
101+
language = props.className.slice(9)
102+
}
103+
break
104+
}
105+
}
106+
107+
if (!codeString) {
108+
return (
109+
<pre className='not-prose my-6 overflow-x-auto rounded-lg bg-[var(--surface-5)] p-4 font-[420] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:bg-[#1F1F1F]'>
110+
{children}
111+
</pre>
112+
)
113+
}
114+
115+
const resolved = LANG_ALIASES[language] || language || 'javascript'
116+
const grammar = languages[resolved] || languages.javascript
117+
const html = highlight(codeString.trimEnd(), grammar, resolved)
118+
119+
return (
120+
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
121+
{language && (
122+
<div className='border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 text-[var(--text-tertiary)] text-xs dark:bg-[#2a2a2a]'>
123+
{language}
124+
</div>
125+
)}
126+
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[#1F1F1F]'>
127+
<pre
128+
className='m-0 overflow-x-auto whitespace-pre p-4 font-[420] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
129+
dangerouslySetInnerHTML={{ __html: html }}
130+
/>
131+
</div>
132+
</div>
133+
)
134+
},
135+
a({ children, href }) {
136+
return (
137+
<a
138+
href={href}
139+
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-2'
140+
target='_blank'
141+
rel='noopener noreferrer'
142+
>
143+
{children}
144+
</a>
145+
)
146+
},
147+
ul({ children, className }) {
148+
if (className?.includes('contains-task-list')) {
149+
return <ul className='my-4 list-none space-y-2 pl-0'>{children}</ul>
150+
}
151+
return <ul className='my-4 list-disc pl-5 marker:text-[var(--text-primary)]'>{children}</ul>
152+
},
153+
ol({ children }) {
154+
return <ol className='my-4 list-decimal pl-5 marker:text-[var(--text-primary)]'>{children}</ol>
155+
},
156+
li({ children, className }) {
157+
if (className?.includes('task-list-item')) {
158+
return (
159+
<li className='flex list-none items-start gap-2 text-[16px] text-[var(--text-primary)] leading-7'>
160+
{children}
161+
</li>
162+
)
163+
}
164+
return (
165+
<li className='my-1 text-[16px] text-[var(--text-primary)] leading-7 marker:text-[var(--text-primary)]'>
166+
{children}
167+
</li>
168+
)
169+
},
170+
input({ type, checked }) {
171+
if (type === 'checkbox') {
172+
return <Checkbox checked={checked || false} disabled size='sm' className='mt-1.5 shrink-0' />
173+
}
174+
return <input type={type} checked={checked} readOnly />
175+
},
176+
}
177+
178+
interface ChatContentProps {
179+
content: string
180+
}
181+
182+
export function ChatContent({ content }: ChatContentProps) {
183+
const throttled = useThrottledValue(content)
184+
return (
185+
<div className={PROSE_CLASSES}>
186+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
187+
{throttled}
188+
</ReactMarkdown>
189+
</div>
190+
)
191+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChatContent } from './chat-content'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { ChatContent } from './chat-content'
2+
export { Options } from './options'
3+
export { Subagent } from './subagent'
4+
export { ToolCall } from './tool-calls'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Options } from './options'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { OptionItem } from '../../../../types'
2+
3+
interface OptionsProps {
4+
items: OptionItem[]
5+
onSelect?: (id: string) => void
6+
}
7+
8+
export function Options({ items, onSelect }: OptionsProps) {
9+
if (items.length === 0) return null
10+
11+
return (
12+
<div className='flex flex-wrap gap-2'>
13+
{items.map((item) => (
14+
<button
15+
key={item.id}
16+
type='button'
17+
onClick={() => onSelect?.(item.id)}
18+
className='rounded-full border border-[var(--divider)] bg-[var(--bg)] px-3.5 py-1.5 font-[420] font-[family-name:var(--font-inter)] text-[14px] text-[var(--text-primary)] leading-5 transition-colors hover:bg-[var(--surface-5)]'
19+
>
20+
{item.label}
21+
</button>
22+
))}
23+
</div>
24+
)
25+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Subagent } from './subagent'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { cn } from '@/lib/core/utils/cn'
2+
import type { ToolCallStatus } from '../../../../types'
3+
import { getToolIcon } from '../../utils'
4+
5+
const STATUS_STYLES: Record<ToolCallStatus, string> = {
6+
executing: 'bg-[var(--text-tertiary)] animate-pulse',
7+
success: 'bg-[var(--text-tertiary)]',
8+
error: 'bg-red-500',
9+
}
10+
11+
interface SubagentProps {
12+
id: string
13+
name: string
14+
label: string
15+
status: ToolCallStatus
16+
}
17+
18+
export function Subagent({ name, label, status }: SubagentProps) {
19+
const Icon = getToolIcon(name)
20+
21+
return (
22+
<div className='flex items-center gap-[6px]'>
23+
<div className={cn('h-[5px] w-[5px] shrink-0 rounded-full', STATUS_STYLES[status])} />
24+
{Icon && <Icon className='h-3.5 w-3.5 shrink-0 text-[var(--text-tertiary)]' />}
25+
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>{label}</span>
26+
</div>
27+
)
28+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ToolCall } from './tool-calls'
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { cn } from '@/lib/core/utils/cn'
2+
import type { MothershipToolName, ToolCallStatus, ToolPhase } from '../../../../types'
3+
import { TOOL_UI_METADATA } from '../../../../types'
4+
import { getToolIcon } from '../../utils'
5+
6+
const STATUS_STYLES: Record<ToolCallStatus, string> = {
7+
executing: 'bg-[var(--text-tertiary)] animate-pulse',
8+
success: 'bg-[var(--text-tertiary)]',
9+
error: 'bg-red-500',
10+
}
11+
12+
const PHASE_COLORS: Record<ToolPhase, string> = {
13+
workspace: 'text-blue-500',
14+
search: 'text-emerald-500',
15+
management: 'text-amber-500',
16+
execution: 'text-purple-500',
17+
resource: 'text-cyan-500',
18+
subagent: 'text-orange-500',
19+
}
20+
21+
interface ToolCallProps {
22+
id: string
23+
toolName: string
24+
displayTitle?: string
25+
status: ToolCallStatus
26+
phaseLabel?: string
27+
}
28+
29+
export function ToolCall({ toolName, displayTitle, status, phaseLabel }: ToolCallProps) {
30+
const metadata = TOOL_UI_METADATA[toolName as MothershipToolName]
31+
const resolvedTitle = displayTitle || metadata?.title || toolName
32+
const resolvedPhase = phaseLabel || metadata?.phaseLabel
33+
const resolvedPhaseType = metadata?.phase
34+
const Icon = getToolIcon(toolName)
35+
36+
return (
37+
<div className='flex items-center gap-[6px]'>
38+
<div className={cn('h-[5px] w-[5px] shrink-0 rounded-full', STATUS_STYLES[status])} />
39+
{Icon && <Icon className='h-3.5 w-3.5 shrink-0 text-[var(--text-tertiary)]' />}
40+
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>{resolvedTitle}</span>
41+
{resolvedPhase && (
42+
<span
43+
className={cn(
44+
'rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-[500] text-[10px]',
45+
resolvedPhaseType ? PHASE_COLORS[resolvedPhaseType] : 'text-[var(--text-tertiary)]'
46+
)}
47+
>
48+
{resolvedPhase}
49+
</span>
50+
)}
51+
</div>
52+
)
53+
}

0 commit comments

Comments
 (0)