Skip to content

Commit fb5dacc

Browse files
paritoshkclaude
andcommitted
feat: Add 3 critical UX improvements (save state, example prompts, auto-expand)
Implements three high-impact UX improvements identified from user journey analysis: ## 1. Save State Indicator (#1 Priority - CRITICAL) **Problem**: Users had no visibility into whether changes were saved **Solution**: Real-time visual feedback in top bar - Created SaveStateManager to track save states (saved/saving/unsaved) - Integrated into EditorEngine lifecycle - CodeManager notifies on write start/complete/error - Visual indicator shows: spinning "Saving...", checkmark "Saved", orange dot "Unsaved" - Tooltip displays time since last save **Files**: - apps/web/client/src/components/store/editor/save-state/index.ts (NEW) - apps/web/client/src/app/project/[id]/_components/top-bar/save-indicator.tsx (NEW) - apps/web/client/src/components/store/editor/engine.ts - apps/web/client/src/components/store/editor/code/index.ts - apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx **Impact**: Builds user trust, eliminates confusion about save status --- ## 2. Example Prompts (#5 - Conversion Boost) **Problem**: Empty chat provided no guidance for new users **Solution**: Mode-specific clickable example prompts - 3 examples per mode (CREATE/EDIT/ASK/FIX) - Auto-send message on click - Examples: "Add hero section", "Change button color", "Explain component", "Fix layout" **Files**: - apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/example-prompts.tsx (NEW) - apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/index.tsx - apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-tab-content/index.tsx **Impact**: Reduces time-to-first-message, lowers cognitive load --- ## 3. Auto-Expand Last Tool Call (#4 - Transparency) **Problem**: Users manually clicked every tool to see AI actions **Solution**: Latest tool call auto-expands - Identifies last tool in message parts - Passes defaultOpen flag to CollapsibleCodeBlock - Previous tools remain collapsed - Works with all code editing tools **Files**: - apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/message-content/index.tsx - apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/message-content/tool-call-display.tsx - apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/code-display/collapsible-code-block.tsx **Impact**: Improves transparency, reduces clicks --- ## Testing All changes type-checked successfully with `bun run typecheck` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent cb97e01 commit fb5dacc

11 files changed

Lines changed: 321 additions & 9 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use client';
2+
3+
import { useEditorEngine } from '@/components/store/editor';
4+
import { ChatType } from '@onlook/models';
5+
import { Button } from '@onlook/ui/button';
6+
import { Icons } from '@onlook/ui/icons';
7+
import { observer } from 'mobx-react-lite';
8+
9+
interface ExamplePrompt {
10+
icon: keyof typeof Icons;
11+
text: string;
12+
prompt: string;
13+
}
14+
15+
const EXAMPLE_PROMPTS: Record<ChatType, ExamplePrompt[]> = {
16+
[ChatType.CREATE]: [
17+
{
18+
icon: 'MagicWand',
19+
text: 'Add a hero section',
20+
prompt: 'Create a modern hero section with a heading, subheading, and CTA button',
21+
},
22+
{
23+
icon: 'Component',
24+
text: 'Build a contact form',
25+
prompt: 'Add a contact form with name, email, message fields and a submit button',
26+
},
27+
{
28+
icon: 'Frame',
29+
text: 'Create a navbar',
30+
prompt: 'Build a responsive navigation bar with logo and menu items',
31+
},
32+
],
33+
[ChatType.EDIT]: [
34+
{
35+
icon: 'Pencil',
36+
text: 'Change the button color',
37+
prompt: 'Make the selected button blue with white text',
38+
},
39+
{
40+
icon: 'Size',
41+
text: 'Adjust spacing',
42+
prompt: 'Increase the padding around the selected element',
43+
},
44+
{
45+
icon: 'Text',
46+
text: 'Update text content',
47+
prompt: 'Change the heading text to say "Welcome to Our Platform"',
48+
},
49+
],
50+
[ChatType.ASK]: [
51+
{
52+
icon: 'QuestionMarkCircled',
53+
text: 'Explain this component',
54+
prompt: 'What does this component do and how does it work?',
55+
},
56+
{
57+
icon: 'Code',
58+
text: 'Show me the structure',
59+
prompt: 'Explain the component hierarchy and data flow',
60+
},
61+
{
62+
icon: 'InfoCircled',
63+
text: 'Suggest improvements',
64+
prompt: 'What improvements can I make to this component?',
65+
},
66+
],
67+
[ChatType.FIX]: [
68+
{
69+
icon: 'ExclamationTriangle',
70+
text: 'Fix layout issues',
71+
prompt: 'Fix any layout or alignment problems in the selected element',
72+
},
73+
{
74+
icon: 'CrossCircled',
75+
text: 'Debug errors',
76+
prompt: 'Find and fix any errors or warnings in the code',
77+
},
78+
{
79+
icon: 'Reset',
80+
text: 'Optimize performance',
81+
prompt: 'Improve the performance and remove any unnecessary code',
82+
},
83+
],
84+
};
85+
86+
interface ExamplePromptsProps {
87+
onSelectPrompt: (prompt: string, type: ChatType) => void;
88+
}
89+
90+
export const ExamplePrompts = observer(({ onSelectPrompt }: ExamplePromptsProps) => {
91+
const editorEngine = useEditorEngine();
92+
const chatMode = editorEngine.state.chatMode;
93+
const prompts = EXAMPLE_PROMPTS[chatMode] || EXAMPLE_PROMPTS[ChatType.EDIT];
94+
95+
const getModeTitle = () => {
96+
switch (chatMode) {
97+
case ChatType.CREATE:
98+
return 'Create something new';
99+
case ChatType.EDIT:
100+
return 'Edit your design';
101+
case ChatType.ASK:
102+
return 'Ask about your code';
103+
case ChatType.FIX:
104+
return 'Fix issues';
105+
default:
106+
return 'Try an example';
107+
}
108+
};
109+
110+
return (
111+
<div className="flex-1 flex flex-col items-center justify-center text-foreground-tertiary/80 h-full px-6">
112+
<Icons.EmptyState className="size-24 mb-4" />
113+
<h3 className="text-lg font-medium text-foreground-secondary mb-2">
114+
{getModeTitle()}
115+
</h3>
116+
<p className="text-sm text-foreground-tertiary/80 mb-6 text-center max-w-[300px]">
117+
Start a conversation or try one of these examples
118+
</p>
119+
<div className="flex flex-col gap-2 w-full max-w-[320px]">
120+
{prompts.map((example, index) => {
121+
const IconComponent = Icons[example.icon];
122+
return (
123+
<Button
124+
key={index}
125+
variant="outline"
126+
className="justify-start gap-3 h-auto py-3 px-4 text-left hover:bg-background-secondary/50 hover:border-foreground-secondary/30 transition-all"
127+
onClick={() => onSelectPrompt(example.prompt, chatMode)}
128+
>
129+
<IconComponent className="h-4 w-4 flex-shrink-0 text-foreground-secondary" />
130+
<span className="text-sm text-foreground-secondary">{example.text}</span>
131+
</Button>
132+
);
133+
})}
134+
</div>
135+
</div>
136+
);
137+
});

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/index.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,21 @@ import { useTranslations } from 'next-intl';
1616
import { useCallback } from 'react';
1717
import { AssistantMessage } from './assistant-message';
1818
import { ErrorMessage } from './error-message';
19+
import { ExamplePrompts } from './example-prompts';
1920
import { UserMessage } from './user-message';
2021

2122
interface ChatMessagesProps {
2223
messages: ChatMessage[];
2324
onEditMessage: EditMessage;
25+
onSendMessage: (content: string, type: import('@onlook/models').ChatType) => void;
2426
isStreaming: boolean;
2527
error?: Error;
2628
}
2729

2830
export const ChatMessages = observer(({
2931
messages,
3032
onEditMessage,
33+
onSendMessage,
3134
isStreaming,
3235
error,
3336
}: ChatMessagesProps) => {
@@ -64,12 +67,7 @@ export const ChatMessages = observer(({
6467
if (!messages || messages.length === 0) {
6568
return (
6669
!editorEngine.elements.selected.length && (
67-
<div className="flex-1 flex flex-col items-center justify-center text-foreground-tertiary/80 h-full">
68-
<Icons.EmptyState className="size-32" />
69-
<p className="text-center text-regularPlus text-balance max-w-[300px]">
70-
{t(transKeys.editor.panels.edit.tabs.chat.emptyState)}
71-
</p>
72-
</div>
70+
<ExamplePrompts onSelectPrompt={onSendMessage} />
7371
)
7472
);
7573
}

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/message-content/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const MessageContentComponent = ({
1717
isStream: boolean;
1818
}) => {
1919
let lastIncompleteToolIndex = -1;
20+
let lastToolIndex = -1;
21+
2022
if (isStream) {
2123
for (let i = parts.length - 1; i >= 0; i--) {
2224
const part = parts[i];
@@ -30,6 +32,15 @@ const MessageContentComponent = ({
3032
}
3133
}
3234

35+
// Find the last tool call (for auto-expand)
36+
for (let i = parts.length - 1; i >= 0; i--) {
37+
const part = parts[i];
38+
if (part?.type.startsWith('tool-')) {
39+
lastToolIndex = i;
40+
break;
41+
}
42+
}
43+
3344
const renderedParts = parts.map((part, idx) => {
3445
if (part?.type === 'text') {
3546
return (
@@ -41,13 +52,15 @@ const MessageContentComponent = ({
4152
} else if (part?.type.startsWith('tool-')) {
4253
const toolPart = part as ToolUIPart;// Only show loading animation for the last incomplete tool call
4354
const isLoadingThisTool = isStream && idx === lastIncompleteToolIndex;
55+
const isLatestTool = idx === lastToolIndex;
4456
return (
4557
<ToolCallDisplay
4658
messageId={messageId}
4759
toolPart={toolPart}
4860
key={toolPart.toolCallId}
4961
isStream={isLoadingThisTool}
5062
applied={applied}
63+
isLatest={isLatestTool}
5164
/>
5265
);
5366
} else if (part?.type === 'reasoning') {

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/message-content/tool-call-display.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ const ToolCallDisplayComponent = ({
1313
messageId,
1414
toolPart,
1515
isStream,
16-
applied
16+
applied,
17+
isLatest = false,
1718
}: {
1819
messageId: string,
1920
toolPart: ToolUIPart,
2021
isStream: boolean,
21-
applied: boolean
22+
applied: boolean,
23+
isLatest?: boolean,
2224
}) => {
2325
const toolName = toolPart.type.split('-')[1];
2426

@@ -91,6 +93,7 @@ const ToolCallDisplayComponent = ({
9193
applied={applied}
9294
isStream={isStream}
9395
branchId={branchId}
96+
defaultOpen={isLatest}
9497
/>
9598
);
9699
}
@@ -116,6 +119,7 @@ const ToolCallDisplayComponent = ({
116119
applied={applied}
117120
isStream={isStream}
118121
branchId={branchId}
122+
defaultOpen={isLatest}
119123
/>
120124
);
121125
}
@@ -141,6 +145,7 @@ const ToolCallDisplayComponent = ({
141145
applied={applied}
142146
isStream={isStream}
143147
branchId={branchId}
148+
defaultOpen={isLatest}
144149
/>
145150
);
146151
}
@@ -166,6 +171,7 @@ const ToolCallDisplayComponent = ({
166171
applied={applied}
167172
isStream={isStream}
168173
branchId={branchId}
174+
defaultOpen={isLatest}
169175
/>
170176
);
171177
}

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-tab-content/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const ChatTabContent = ({
2828
isStreaming={isStreaming}
2929
error={error}
3030
onEditMessage={editMessage}
31+
onSendMessage={sendMessage}
3132
/>
3233
<ErrorSection isStreaming={isStreaming} onSendMessage={sendMessage} />
3334
<ChatInput

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/code-display/collapsible-code-block.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@ interface CollapsibleCodeBlockProps {
1515
applied: boolean;
1616
isStream?: boolean;
1717
branchId?: string;
18+
defaultOpen?: boolean;
1819
}
1920

2021
const CollapsibleCodeBlockComponent = ({
2122
path,
2223
content,
2324
isStream,
2425
branchId,
26+
defaultOpen = false,
2527
}: CollapsibleCodeBlockProps) => {
2628
const editorEngine = useEditorEngine();
27-
const [isOpen, setIsOpen] = useState(false);
29+
const [isOpen, setIsOpen] = useState(defaultOpen);
2830
const [copied, setCopied] = useState(false);
2931

3032
const copyToClipboard = () => {

apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { BranchDisplay } from './branch';
1919
import { ModeToggle } from './mode-toggle';
2020
import { ProjectBreadcrumb } from './project-breadcrumb';
2121
import { PublishButton } from './publish';
22+
import { SaveIndicator } from './save-indicator';
2223

2324
export const TopBar = observer(() => {
2425
const stateManager = useStateManager();
@@ -50,6 +51,7 @@ export const TopBar = observer(() => {
5051
</div>
5152
<ModeToggle />
5253
<div className="flex flex-grow basis-0 justify-end items-center gap-1.5 mr-2">
54+
<SaveIndicator />
5355
<div className="flex items-center group">
5456
<div className={`transition-all duration-200 ${isMembersPopoverOpen ? 'mr-2' : '-mr-2 group-hover:mr-2'}`}>
5557
<Members onPopoverOpenChange={setIsMembersPopoverOpen} />
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use client';
2+
3+
import { useEditorEngine } from '@/components/store/editor';
4+
import type { SaveState } from '@/components/store/editor/save-state';
5+
import { Icons } from '@onlook/ui/icons';
6+
import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip';
7+
import { observer } from 'mobx-react-lite';
8+
9+
export const SaveIndicator = observer(() => {
10+
const editorEngine = useEditorEngine();
11+
const saveState: SaveState = editorEngine.saveState.saveState;
12+
13+
const getIndicatorContent = () => {
14+
switch (saveState) {
15+
case 'saving':
16+
return (
17+
<div className="flex items-center gap-1.5 text-xs text-foreground-secondary">
18+
<Icons.Reload className="h-3 w-3 animate-spin" />
19+
<span>Saving...</span>
20+
</div>
21+
);
22+
case 'saved':
23+
return (
24+
<div className="flex items-center gap-1.5 text-xs text-foreground-tertiary">
25+
<Icons.Check className="h-3 w-3" />
26+
<span>Saved</span>
27+
</div>
28+
);
29+
case 'unsaved':
30+
return (
31+
<div className="flex items-center gap-1.5 text-xs text-orange-500">
32+
<Icons.Circle className="h-3 w-3 fill-current" />
33+
<span>Unsaved changes</span>
34+
</div>
35+
);
36+
}
37+
};
38+
39+
const getTooltipContent = () => {
40+
switch (saveState) {
41+
case 'saving':
42+
return 'Saving your changes...';
43+
case 'saved':
44+
return `Last saved ${editorEngine.saveState.formattedTimeSinceLastSave}`;
45+
case 'unsaved':
46+
return 'You have unsaved changes';
47+
}
48+
};
49+
50+
return (
51+
<Tooltip>
52+
<TooltipTrigger asChild>
53+
<div className="flex items-center px-2 py-1 rounded-md hover:bg-background-secondary/50 transition-colors cursor-default">
54+
{getIndicatorContent()}
55+
</div>
56+
</TooltipTrigger>
57+
<TooltipContent side="bottom" className="mt-1" hideArrow>
58+
<p>{getTooltipContent()}</p>
59+
</TooltipContent>
60+
</Tooltip>
61+
);
62+
});

apps/web/client/src/components/store/editor/code/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class CodeManager {
2424
}
2525

2626
async write(action: Action) {
27+
this.editorEngine.saveState.startSaving();
2728
try {
2829
// TODO: This is a hack to write code, we should refactor this
2930
if (action.type === 'write-code' && action.diffs[0]) {
@@ -36,12 +37,14 @@ export class CodeManager {
3637
const requests = await this.collectRequests(action);
3738
await this.writeRequest(requests);
3839
}
40+
this.editorEngine.saveState.debouncedCompleteSave();
3941
} catch (error) {
4042
console.error('Error writing requests:', error);
4143
toast.error('Error writing requests', {
4244
description: error instanceof Error ? error.message : 'Unknown error',
4345
});
4446
this.editorEngine.branches.activeError.addCodeApplicationError(error instanceof Error ? error.message : 'Unknown error', action);
47+
this.editorEngine.saveState.markUnsaved();
4548
}
4649
}
4750

0 commit comments

Comments
 (0)