# Chat UI Overhaul — Bug Fix, Feature Optimization, and UI Clone
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Overhaul the desktop chat UI to match the original Claude Code's clean, compact design — fix CSS bugs in code blocks, add tool-call grouping/collapsing, and streamline all message components.
**Architecture:** Three-phase approach: (1) Fix CSS bugs in CodeViewer/DiffViewer/MarkdownRenderer for compact code blocks, (2) Implement ToolCallGroup component to collapse consecutive tool calls into summary lines like the original ("Read 7 files, ran a command"), (3) Streamline AssistantMessage, ThinkingBlock, and PermissionDialog to reduce visual noise and vertical space.
**Tech Stack:** React 18, Tailwind CSS 4, TypeScript, highlight.js, marked, diff
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `desktop/src/components/chat/CodeViewer.tsx` | Fix line height, padding, line number styling |
| Modify | `desktop/src/components/chat/DiffViewer.tsx` | Fix line height, add gutter indicators |
| Modify | `desktop/src/components/markdown/MarkdownRenderer.tsx` | Sync code block styles with CodeViewer fixes |
| Create | `desktop/src/components/chat/ToolCallGroup.tsx` | New component: collapsible group of tool calls |
| Modify | `desktop/src/components/chat/MessageList.tsx` | Group consecutive tool calls, use ToolCallGroup |
| Modify | `desktop/src/components/chat/ToolCallBlock.tsx` | Add compact mode, simplify badge display |
| Modify | `desktop/src/components/chat/AssistantMessage.tsx` | Remove Copy button reserved space, use absolute positioning |
| Modify | `desktop/src/components/chat/ThinkingBlock.tsx` | Reduce nesting, improve preview |
| Modify | `desktop/src/components/chat/StreamingIndicator.tsx` | Remove elapsed seconds display |
| Modify | `desktop/src/theme/globals.css` | Add code-block CSS variables |
---
### Task 1: Fix CodeViewer Line Height and Padding
**Files:**
- Modify: `desktop/src/components/chat/CodeViewer.tsx:57-78`
- [ ] **Step 1: Fix line height from 1.45 to 1.3**
In `CodeViewer.tsx`, line 57, change the container class:
```tsx
```
- [ ] **Step 2: Reduce line padding from py-1 to py-px**
In `CodeViewer.tsx`, line 66, change the line number span padding:
```tsx
```
In `CodeViewer.tsx`, line 73, change the code content span padding:
```tsx
```
- [ ] **Step 3: Soften border colors and remove per-line borders**
In `CodeViewer.tsx`, line 61-63, simplify the row div:
```tsx
```
Remove the `border-b border-[#d8dee4]` from each line — the per-line border creates visual noise and adds height.
- [ ] **Step 4: Soften the outer container and header**
In `CodeViewer.tsx`, line 44, change `rounded-2xl` to `rounded-lg` and soften border:
```tsx
```
In `CodeViewer.tsx`, line 45, reduce header padding from `py-2` to `py-1.5`:
```tsx
```
- [ ] **Step 5: Fix the expand button colors (currently broken — white text on light bg)**
In `CodeViewer.tsx`, line 84, fix the expand toggle button:
```tsx
setExpanded((value) => !value)}
className="w-full border-t border-[#d0d7de] bg-[#f6f8fa] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#57606a] transition-colors hover:bg-[#eaeef2] hover:text-[#24292f]"
>
```
- [ ] **Step 6: Verify changes visually**
Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm run dev`
Open the app and send a message that produces code blocks. Verify:
- Lines are visually tighter (~16px per line instead of ~21px)
- Line numbers are subdued (light gray, no strong background contrast)
- No per-line horizontal borders
- Expand/collapse button is readable
- [ ] **Step 7: Commit**
```bash
git add desktop/src/components/chat/CodeViewer.tsx
git commit -m "fix: compact code block rendering — tighter line height, softer borders"
```
---
### Task 2: Fix DiffViewer Line Height and Add Gutter
**Files:**
- Modify: `desktop/src/components/chat/DiffViewer.tsx:49-79`
- [ ] **Step 1: Fix line height and padding in DiffViewer rows**
In `DiffViewer.tsx`, line 67, change the row container:
```tsx
className={`grid grid-cols-[2.5rem,2.5rem,1.25rem,minmax(0,1fr)] gap-0 font-[var(--font-mono)] text-[12px] leading-[1.3] ${rowClass}`}
```
Key changes: `leading-[1.45]` → `leading-[1.3]`, column widths from `3rem` to `2.5rem`.
- [ ] **Step 2: Fix line number and content padding**
In `DiffViewer.tsx`, lines 69-76, change all `py-1` to `py-px` and soften border colors:
```tsx
{line.oldLineNo ?? ''}
{line.newLineNo ?? ''}
{prefix}
{line.content}
```
- [ ] **Step 3: Add gutter color indicator for added/removed lines**
Add a left border indicator to each row. In `DiffViewer.tsx`, update the `rowClass` computation (around line 50-55):
```tsx
const rowClass =
line.type === 'added'
? 'bg-[#dafbe1]/60 border-l-2 border-l-[#1a7f37]'
: line.type === 'removed'
? 'bg-[#ffebe9]/60 border-l-2 border-l-[#cf222e]'
: 'bg-white border-l-2 border-l-transparent'
```
- [ ] **Step 4: Soften the outer container**
In `DiffViewer.tsx`, line 28, change `rounded-2xl` to `rounded-lg`:
```tsx
```
Also reduce header padding in line 29:
```tsx
```
- [ ] **Step 5: Commit**
```bash
git add desktop/src/components/chat/DiffViewer.tsx
git commit -m "fix: compact diff viewer — tighter rows, gutter indicators, softer borders"
```
---
### Task 3: Sync MarkdownRenderer Code Blocks
**Files:**
- Modify: `desktop/src/components/markdown/MarkdownRenderer.tsx:11-41`
- [ ] **Step 1: Update the code block renderer to match CodeViewer fixes**
In `MarkdownRenderer.tsx`, replace the entire `renderer.code` function (lines 11-41):
```tsx
renderer.code = function ({ text, lang }: Tokens.Code) {
const languageLabel = escapeHtml(lang || 'code')
const lines = text.split('\n')
const highlightedLines = highlightCodeLines(text, lang)
const body = highlightedLines
.map((line, index) => `
${index + 1}
${line || ' '}
`)
.join('')
return `
${languageLabel}
${lines.length} ${lines.length === 1 ? 'line' : 'lines'}
Copy
`
}
```
Key changes synced with CodeViewer:
- `leading-[1.45]` → `leading-[1.3]`
- `py-1` → `py-px`
- `border-b border-[#d8dee4]` removed from rows
- `bg-[#f6f8fa]` → `bg-[#fafbfc]` for line numbers
- `text-[#57606a]` → `text-[#8b949e]` for line numbers
- `rounded-2xl` → `rounded-lg`
- Added `hover:bg-[#f6f8fa]/50` to rows
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/markdown/MarkdownRenderer.tsx
git commit -m "fix: sync markdown code blocks with CodeViewer compact styles"
```
---
### Task 4: Create ToolCallGroup Component
**Files:**
- Create: `desktop/src/components/chat/ToolCallGroup.tsx`
- [ ] **Step 1: Create the ToolCallGroup component**
Create `desktop/src/components/chat/ToolCallGroup.tsx`:
```tsx
import { useState } from 'react'
import { ToolCallBlock } from './ToolCallBlock'
import type { UIMessage } from '../../types/chat'
type ToolCall = Extract
type ToolResult = Extract
type Props = {
toolCalls: ToolCall[]
resultMap: Map
/** When true, the last tool is still executing — show expanded */
isStreaming?: boolean
}
const TOOL_VERBS: Record string> = {
Read: (n) => `Read ${n} file${n > 1 ? 's' : ''}`,
Write: (n) => `created ${n > 1 ? `${n} files` : 'a file'}`,
Edit: (n) => `edited ${n > 1 ? `${n} files` : 'a file'}`,
Bash: (n) => `ran ${n > 1 ? `${n} commands` : 'a command'}`,
Glob: (n) => `found files`,
Grep: (n) => `searched ${n > 1 ? `${n} patterns` : 'code'}`,
Agent: (n) => `dispatched ${n > 1 ? `${n} agents` : 'an agent'}`,
WebSearch: (n) => `searched the web`,
WebFetch: (n) => `fetched ${n > 1 ? `${n} pages` : 'a page'}`,
}
function generateSummary(toolCalls: ToolCall[]): string {
const counts = new Map()
for (const tc of toolCalls) {
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
}
const parts: string[] = []
for (const [name, count] of counts) {
const verbFn = TOOL_VERBS[name]
parts.push(verbFn ? verbFn(count) : `${name} (${count})`)
}
return parts.join(', ')
}
function hasErrors(toolCalls: ToolCall[], resultMap: Map): boolean {
return toolCalls.some((tc) => {
const result = resultMap.get(tc.toolUseId)
return result?.isError
})
}
export function ToolCallGroup({ toolCalls, resultMap, isStreaming }: Props) {
// Single tool call — render directly without group wrapper
if (toolCalls.length === 1) {
const tc = toolCalls[0]
const result = resultMap.get(tc.toolUseId)
return (
)
}
const [expanded, setExpanded] = useState(false)
const summary = generateSummary(toolCalls)
const errorPresent = hasErrors(toolCalls, resultMap)
const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId))
return (
setExpanded((v) => !v)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left transition-colors ${
errorPresent
? 'border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/30 hover:bg-[var(--color-error-container)]/50'
: 'border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-container-high)]'
}`}
>
{expanded ? 'expand_less' : 'expand_more'}
{summary}
{!isStreaming && allComplete && !errorPresent && (
check_circle
)}
{errorPresent && (
ERROR
)}
{isStreaming && (
)}
{expanded && (
{toolCalls.map((tc) => {
const result = resultMap.get(tc.toolUseId)
return (
)
})}
)}
)
}
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/chat/ToolCallGroup.tsx
git commit -m "feat: add ToolCallGroup component for collapsible tool call summaries"
```
---
### Task 5: Add Compact Mode to ToolCallBlock
**Files:**
- Modify: `desktop/src/components/chat/ToolCallBlock.tsx:7-114`
- [ ] **Step 1: Add `compact` prop and adjust rendering**
In `ToolCallBlock.tsx`, add `compact` to the Props type (line 7-11):
```tsx
type Props = {
toolName: string
input: unknown
result?: { content: unknown; isError: boolean } | null
compact?: boolean
}
```
Update the component signature (line 34):
```tsx
export function ToolCallBlock({ toolName, input, result, compact = false }: Props) {
```
- [ ] **Step 2: Adjust the outer container for compact mode**
In `ToolCallBlock.tsx`, update the outer div (line 53):
```tsx
```
Key changes:
- `rounded-xl` → `rounded-lg`
- `mb-2 ml-10` only when not compact (ToolCallGroup handles margins)
- Border opacity reduced
- [ ] **Step 3: Simplify the summary row — reduce from 6 info layers to 3**
In `ToolCallBlock.tsx`, replace lines 62-105 (the button contents) with a cleaner layout:
```tsx
{
if (expandable) {
setExpanded((value) => !value)
}
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
{icon}
{toolName}
{filePath ? (
{filePath.split('/').pop()}
) : summary ? (
{summary}
) : (
)}
{result && outputSummary && (
{outputSummary}
)}
{result?.isError && (
ERROR
)}
{expandable && (
{expanded ? 'expand_less' : 'expand_more'}
)}
```
Key changes:
- Removed the standalone SUCCESS/MODIFIED/READ/EXECUTED badges for non-error states
- Simplified to single-line: `icon + toolName + filePath/summary + outputSummary + expand`
- Only ERROR badge shown for errors
- Reduced padding from `py-2.5` to `py-2`
- Removed the nested divs and multi-line layout
- [ ] **Step 4: Remove unused badge constant**
In `ToolCallBlock.tsx`, remove the `STATUS_BADGE` constant (lines 27-32) since we no longer show per-tool status badges:
```tsx
// DELETE these lines:
// const STATUS_BADGE: Record
= {
// Edit: { ... },
// Write: { ... },
// Read: { ... },
// Bash: { ... },
// }
```
Also remove the `badge` variable computation (lines 42-46).
- [ ] **Step 5: Commit**
```bash
git add desktop/src/components/chat/ToolCallBlock.tsx
git commit -m "feat: add compact mode to ToolCallBlock, simplify to single-line layout"
```
---
### Task 6: Update MessageList to Group Tool Calls
**Files:**
- Modify: `desktop/src/components/chat/MessageList.tsx`
- [ ] **Step 1: Add grouping logic and import ToolCallGroup**
Replace the entire `MessageList.tsx` with:
```tsx
import { useRef, useEffect } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { UserMessage } from './UserMessage'
import { AssistantMessage } from './AssistantMessage'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
import { ToolCallGroup } from './ToolCallGroup'
import { ToolResultBlock } from './ToolResultBlock'
import { PermissionDialog } from './PermissionDialog'
import { AskUserQuestion } from './AskUserQuestion'
import { StreamingIndicator } from './StreamingIndicator'
import type { UIMessage } from '../../types/chat'
type ToolCall = Extract
type ToolResult = Extract
/**
* Group consecutive tool_use messages together.
* A group breaks when a non-tool message (assistant_text, thinking, etc.) appears.
* Returns an array of render items — either a group or a single message.
*/
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
| { kind: 'message'; message: UIMessage }
function buildRenderItems(messages: UIMessage[], toolUseIds: Set): RenderItem[] {
const items: RenderItem[] = []
let pendingToolCalls: ToolCall[] = []
const flushGroup = () => {
if (pendingToolCalls.length > 0) {
items.push({
kind: 'tool_group',
toolCalls: [...pendingToolCalls],
id: `group-${pendingToolCalls[0].id}`,
})
pendingToolCalls = []
}
}
for (const msg of messages) {
// Skip tool_results that will be rendered inside their ToolCallBlock/Group
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
continue
}
if (msg.type === 'tool_use') {
// Skip AskUserQuestion — it renders separately
if (msg.toolName === 'AskUserQuestion') {
flushGroup()
items.push({ kind: 'message', message: msg })
} else {
pendingToolCalls.push(msg)
}
} else {
flushGroup()
items.push({ kind: 'message', message: msg })
}
}
flushGroup()
return items
}
export function MessageList() {
const { messages, chatState, streamingText, activeThinkingId } = useChatStore()
const bottomRef = useRef(null)
useEffect(() => {
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, streamingText])
// Build lookup maps
const toolUseIds = new Set()
const toolResultMap = new Map()
for (const msg of messages) {
if (msg.type === 'tool_use') {
toolUseIds.add(msg.toolUseId)
}
if (msg.type === 'tool_result' && msg.toolUseId) {
toolResultMap.set(msg.toolUseId, msg)
}
}
const renderItems = buildRenderItems(messages, toolUseIds)
return (
{renderItems.map((item) => {
if (item.kind === 'tool_group') {
return (
!toolResultMap.has(tc.toolUseId))
}
/>
)
}
const msg = item.message
return (
)
})}
{streamingText && chatState === 'streaming' && (
)}
{chatState !== 'idle' && chatState !== 'streaming' && chatState !== 'permission_pending' && (
)}
)
}
function MessageBlock({
message,
activeThinkingId,
toolResult,
}: {
message: UIMessage
activeThinkingId: string | null
toolResult?: { content: unknown; isError: boolean } | null
}) {
switch (message.type) {
case 'user_text':
return
case 'assistant_text':
return
case 'thinking':
return
case 'tool_use':
if (message.toolName === 'AskUserQuestion') {
return (
)
}
return (
)
case 'tool_result':
return (
)
case 'permission_request':
return (
)
case 'error':
return (
Error: {message.message}
)
case 'system':
return (
{message.content}
)
}
}
```
Key changes:
- Added `buildRenderItems()` to group consecutive `tool_use` messages
- `ToolCallGroup` renders groups of 2+ tool calls as a collapsible summary
- Single tool calls still render as individual `ToolCallBlock`
- `tool_result` messages are passed via `toolResultMap` to groups
- Simplified error/system message margins to `mb-3`
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/chat/MessageList.tsx
git commit -m "feat: group consecutive tool calls into collapsible summaries"
```
---
### Task 7: Streamline AssistantMessage
**Files:**
- Modify: `desktop/src/components/chat/AssistantMessage.tsx`
- [ ] **Step 1: Remove Copy button reserved space — use absolute positioning**
Replace the entire `AssistantMessage.tsx`:
```tsx
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { useState } from 'react'
type Props = {
content: string
isStreaming?: boolean
}
export function AssistantMessage({ content, isStreaming }: Props) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
setCopied(false)
}
}
return (
{/* Copy button — absolute positioned, no reserved space */}
{!isStreaming && content.trim() && (
{copied ? 'Copied' : 'Copy'}
)}
{isStreaming && (
)}
)
}
```
Key changes:
- Removed the avatar (saves 28px + 12px gap = 40px horizontal space)
- `mb-5` → `mb-3` (consistent with other blocks)
- Copy button is now `absolute -right-1 -top-1` — zero reserved space
- Removed the flex layout with avatar, now uses `ml-10` to match tool call alignment
- Simplified structure from 3 nested divs to 2
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/chat/AssistantMessage.tsx
git commit -m "fix: remove assistant avatar and copy-button reserved space"
```
---
### Task 8: Streamline ThinkingBlock
**Files:**
- Modify: `desktop/src/components/chat/ThinkingBlock.tsx`
- [ ] **Step 1: Reduce nesting and improve preview**
Replace the entire `ThinkingBlock.tsx`:
```tsx
import { useState, useEffect, useRef } from 'react'
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
const [expanded, setExpanded] = useState(false)
const contentRef = useRef(null)
useEffect(() => {
if (expanded && isActive && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, expanded, isActive])
// Preview: take first meaningful line, not first 140 chars
const lines = content.split('\n').filter((l) => l.trim())
const firstLine = lines[0]?.replace(/\s+/g, ' ').trim() || ''
const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
return (
setExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
>
{expanded ? '\u25BE' : '\u25B8'}
Thinking
{isActive && }
{!expanded && preview && (
{preview}
{isActive && }
)}
{expanded && (
{content}
{isActive && expanded && }
)}
)
}
const thinkingStyles = `
@keyframes thinking-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes thinking-dots {
0%, 20% { content: ''; }
40% { content: '.'; }
60% { content: '..'; }
80%, 100% { content: '...'; }
}
.thinking-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--color-text-tertiary);
vertical-align: middle;
margin-left: 1px;
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-inline-cursor {
display: inline-block;
width: 1px;
height: 0.95em;
margin-left: 3px;
vertical-align: text-bottom;
background: var(--color-text-tertiary);
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-dots::after {
content: '';
animation: thinking-dots 1.4s steps(1, end) infinite;
}
`
```
Key changes:
- Removed the `rounded-full` expand icon — just use a simple triangle character
- Removed 1 layer of nesting in the expanded state (was: outer div → inner div → content; now: outer div → content div)
- `rounded-2xl` → `rounded-lg`
- `mb-2` → `mb-1` (more compact)
- `max-h-[220px]` → `max-h-[300px]` (give more room when expanded)
- Preview now takes the first line instead of first 140 chars (more meaningful)
- `leading-[1.45]` → `leading-[1.35]`
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/chat/ThinkingBlock.tsx
git commit -m "fix: streamline ThinkingBlock — less nesting, better preview"
```
---
### Task 9: Simplify StreamingIndicator
**Files:**
- Modify: `desktop/src/components/chat/StreamingIndicator.tsx`
- [ ] **Step 1: Remove elapsed seconds, keep only essential info**
Replace `StreamingIndicator.tsx`:
```tsx
import { useChatStore } from '../../stores/chatStore'
export function StreamingIndicator() {
const { chatState } = useChatStore()
const verb = chatState === 'thinking'
? 'Thinking'
: chatState === 'tool_executing'
? 'Running'
: 'Working'
return (
✦
{verb}...
)
}
```
Key changes:
- Removed `elapsedSeconds` display (users don't need per-second counts)
- Removed `tokenUsage` display (noise)
- `mb-4` → `mb-2`, `py-1.5` → `py-1`
- `text-sm` → `text-xs` (more compact)
- Simplified verb labels
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/chat/StreamingIndicator.tsx
git commit -m "fix: simplify StreamingIndicator — remove elapsed time and token count"
```
---
### Task 10: Final Visual Verification
- [ ] **Step 1: Start the dev server**
Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm run dev`
- [ ] **Step 2: Run the TypeScript compiler to check for type errors**
Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit`
Expected: No type errors.
- [ ] **Step 3: Run existing tests**
Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm test -- --run`
Expected: All tests pass.
- [ ] **Step 4: Verify the chat UI visually**
Check these items in the running app:
1. **Code blocks:** Lines are tight (~16px/line), no per-line borders, line numbers are subdued
2. **Diff blocks:** Compact rows with green/red gutter indicators
3. **Tool calls:** 2+ consecutive tool calls show as "Read 3 files, ran a command" summary line
4. **Single tool call:** Still shows as individual ToolCallBlock
5. **Thinking:** Single-line with first-line preview, click expands to scrollable content
6. **Assistant messages:** No avatar, copy button appears on hover without reserving space
7. **Streaming indicator:** Compact, no elapsed time counter
- [ ] **Step 5: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix: address visual polish issues found during verification"
```