# 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
${body}
` } ``` 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 (
{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 ``` 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() && ( )}
{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 (
{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" ```