feat(ui,ai): enhance chat panel and design generation feedback

- Introduce a "Thinking..." indicator in the AI chat panel to improve user experience during processing.
- Update chat message styles for better spacing and alignment.
- Implement batch undo functionality for number input adjustments to streamline user interactions.
- Refactor canvas event handling to ensure accurate history tracking during drag operations.
- Add keyboard shortcuts for agent settings and improve canvas selection handling during history state restoration.
This commit is contained in:
Kayshen-X 2026-02-20 13:33:54 +08:00
parent 4a51f4742d
commit afdec6c2d6
8 changed files with 96 additions and 29 deletions

View file

@ -87,7 +87,8 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
- `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design
- `code-panel.tsx` — Code generation output (React/Tailwind and HTML/CSS)
- **`src/components/shared/`** — Reusable UI (8 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Toggle, Tooltip
- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip
- **`src/services/ai/`** — AI chat service, design prompts, design-to-node generation, AI types
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators
- **`src/hooks/`** — `use-keyboard-shortcuts` (global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order)

View file

@ -76,6 +76,12 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
- Dual provider: Anthropic API or local Claude Code (OAuth)
- Multi-provider settings: Claude Code, Codex CLI
### Editor UI
- Dark / light theme toggle (persisted to localStorage)
- Fullscreen mode
- Draggable, snap-to-corner AI chat panel
### Keyboard Shortcuts
| Shortcut | Action |
@ -86,6 +92,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
| L | Line |
| T | Text |
| F | Frame |
| P | Path (pen tool) |
| H | Hand (pan) |
| Cmd+A | Select all |
| Cmd+Z | Undo |
@ -97,6 +104,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
| Cmd+Shift+E | Export |
| Cmd+Shift+C | Code panel |
| Cmd+J | AI chat |
| Cmd+, | Agent settings |
| Delete/Backspace | Delete selected |
| Arrow keys | Nudge (1px, +Shift = 10px) |
| [ / ] | Reorder layers |
@ -165,8 +173,9 @@ src/
components/
editor/ # Editor layout, toolbar, tool buttons, status bar
panels/ # Layer panel, property panel (15 files), AI chat, code panel
shared/ # ColorPicker, NumberInput, ExportDialog, IconPickerDialog, etc.
ui/ # shadcn/ui primitives (Button, Select, Slider, etc.)
shared/ # ColorPicker, NumberInput, ExportDialog, AgentSettingsDialog, etc.
icons/ # Provider logos (Claude, OpenAI)
ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.)
hooks/ # Keyboard shortcuts
lib/ # Utility functions (cn class merging)
services/

View file

@ -3,7 +3,7 @@ import * as fabric from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, generateId } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import type { PenNode } from '@/types/pen'
import type { PenDocument, PenNode } from '@/types/pen'
import type { ToolType } from '@/types/canvas'
import {
DEFAULT_FILL,
@ -409,16 +409,24 @@ export function useCanvasEvents() {
upperEl.addEventListener('pointerup', onPointerUp)
upperEl.addEventListener('dblclick', onDoubleClick)
// --- History batching for drag/resize/rotate ---
// --- Drag session setup (layout reorder + parent-child propagation) ---
// We capture the document snapshot here (before any modification) so that
// `object:modified` can use it as the undo base state. History batching
// lives in `object:modified` — NOT here — so that click-to-select without
// modification never creates a no-op undo entry.
let preModificationDoc: PenDocument | null = null
canvas.on('mouse:down', (opt) => {
clipPathsCleared = false
preModificationDoc = null
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
const target = opt.target as FabricObjectWithPenId | null
if (!target?.penNodeId) return
useHistoryStore
.getState()
.startBatch(useDocumentStore.getState().document)
// Snapshot the document BEFORE any drag/resize/rotate begins.
// structuredClone ensures we have a deep copy unaffected by later mutations.
preModificationDoc = structuredClone(useDocumentStore.getState().document)
// Try to start layout reorder drag first
beginLayoutDrag(target.penNodeId)
@ -433,7 +441,6 @@ export function useCanvasEvents() {
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
// object:modified, which would clear the session prematurely.
endParentDrag()
useHistoryStore.getState().endBatch()
})
// --- Object modifications (drag, resize, rotate) via Fabric events ---
@ -528,17 +535,6 @@ export function useCanvasEvents() {
setFabricSyncLock(false)
}
// History batching: group all intermediate drag/resize/rotate updates
// into a single undo entry instead of one per mouse-move event.
canvas.on('mouse:down', () => {
useHistoryStore
.getState()
.startBatch(useDocumentStore.getState().document)
})
canvas.on('mouse:up', () => {
useHistoryStore.getState().endBatch()
})
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
let clipPathsCleared = false
@ -591,11 +587,28 @@ export function useCanvasEvents() {
}
})
// Final sync: reset scale to 1 and bake into width/height
// Final sync: reset scale to 1 and bake into width/height.
// History batching lives here (not in mouse:down/mouse:up) so that
// click-to-select without modification never creates a no-op undo
// entry. We use the pre-modification snapshot captured in mouse:down
// as the batch base to guarantee a correct undo point.
canvas.on('object:modified', (opt) => {
clearGuides()
const target = opt.target
// Use the snapshot from mouse:down if available; otherwise fall back
// to the current document (e.g. programmatic modifications).
const baseDoc = preModificationDoc ?? useDocumentStore.getState().document
preModificationDoc = null
// Open a history batch for this modification when no outer batch
// (e.g. AI generation) is active.
const needsBatch = useHistoryStore.getState().batchDepth === 0
if (needsBatch) {
useHistoryStore.getState().startBatch(baseDoc)
}
try {
// Single object -- bake scale and sync
const asPen = target as FabricObjectWithPenId
if (asPen.penNodeId) {
@ -686,6 +699,12 @@ export function useCanvasEvents() {
// committed (e.g. cursor left the container on the final move frame).
cancelDragInto()
} finally {
if (needsBatch) {
useHistoryStore.getState().endBatch()
}
}
// Force re-sync so clip paths (which use absolute coordinates) are
// recomputed from the new node positions. Without this, children of
// a dragged frame stay clipped to the old parent frame bounds.

View file

@ -206,8 +206,16 @@ function useChatHandlers() {
} else {
// --- CHAT MODE ---
chatHistory.push({ role: 'user', content: fullUserMessage })
let chatThinking = false
for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) {
if (chunk.type === 'text') {
if (chunk.type === 'thinking') {
// Show a brief thinking indicator so the UI isn't stuck on empty
if (!chatThinking && !accumulated) {
chatThinking = true
updateLastMessage('*Thinking...*')
}
} else if (chunk.type === 'text') {
chatThinking = false
accumulated += chunk.content
updateLastMessage(accumulated)
} else if (chunk.type === 'error') {

View file

@ -65,7 +65,7 @@ function ActionSteps({ steps }: { steps: string[] }) {
if (steps.length === 0) return null
return (
<div className="flex flex-col gap-2 my-1 w-full">
<div className="flex flex-col gap-1 w-full">
{steps.map((step, i) => {
const title = parseStepTitle(step)
const content = parseStepContent(step)
@ -110,7 +110,7 @@ function ActionStepItem({
// Pencil-like style: Rounded pill/card with subtle border
return (
<div className="group mb-1.5 last:mb-0">
<div className="group">
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
@ -460,7 +460,7 @@ function DesignJsonBlock({
}
return (
<div className="group my-1.5 first:mt-0 last:mb-0 w-full">
<div className="group mt-0.5 w-full">
<button
type="button"
onClick={() => setExpanded(!expanded)}
@ -563,7 +563,7 @@ export default function ChatMessage({
}
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start mt-2')}>
{isUser ? (
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-br-sm">
{content}
@ -581,7 +581,7 @@ export default function ChatMessage({
</span>
</div>
) : (
<div className="whitespace-pre-wrap mb-2">
<div className="whitespace-pre-wrap">
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
</div>
)}

View file

@ -1,5 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useHistoryStore } from '@/stores/history-store'
import { useDocumentStore } from '@/stores/document-store'
interface NumberInputProps {
value: number
@ -72,6 +74,9 @@ export default function NumberInput({
dragStartY.current = e.clientY
dragStartValue.current = value
// Batch all scrub-drag onChange calls into a single undo entry
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
const handleMouseMove = (ev: MouseEvent) => {
const delta = dragStartY.current - ev.clientY
const newValue = clamp(dragStartValue.current + delta * step)
@ -80,6 +85,7 @@ export default function NumberInput({
const handleMouseUp = () => {
setIsDragging(false)
useHistoryStore.getState().endBatch()
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}

View file

@ -61,6 +61,13 @@ export function useKeyboardShortcuts() {
if (prev) {
useDocumentStore.getState().applyHistoryState(prev)
}
// Deselect so Fabric re-renders objects at their restored dimensions
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
return
}
@ -72,6 +79,12 @@ export function useKeyboardShortcuts() {
if (next) {
useDocumentStore.getState().applyHistoryState(next)
}
useCanvasStore.getState().clearSelection()
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
return
}

View file

@ -171,11 +171,20 @@ export async function generateDesign(
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
}
let isThinking = false
try {
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
{ role: 'user', content: userMessage },
], undefined, DESIGN_STREAM_TIMEOUTS)) {
if (chunk.type === 'text') {
if (chunk.type === 'thinking') {
// Show a "Thinking" step so the UI isn't stuck on the empty indicator
if (!isThinking && !fullResponse) {
isThinking = true
callbacks?.onTextUpdate?.('<step title="Thinking">Analyzing your design request...</step>')
}
} else if (chunk.type === 'text') {
isThinking = false
fullResponse += chunk.content
if (callbacks?.onTextUpdate) {
callbacks.onTextUpdate(fullResponse)
@ -265,7 +274,9 @@ export async function generateDesignModification(
for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [
{ role: 'user', content: userMessage },
], undefined, DESIGN_STREAM_TIMEOUTS)) {
if (chunk.type === 'text') {
if (chunk.type === 'thinking') {
// Ignore thinking chunks for modification — caller already shows progress
} else if (chunk.type === 'text') {
fullResponse += chunk.content
} else if (chunk.type === 'error') {
streamError = chunk.content