Skip to content

Commit 339525f

Browse files
committed
Add contextual chat editing and json-render preview support
1 parent fb2d2a3 commit 339525f

31 files changed

Lines changed: 5450 additions & 1660 deletions

apps/desktop/src/renderer/src/App.tsx

Lines changed: 716 additions & 13 deletions
Large diffs are not rendered by default.

apps/desktop/src/renderer/src/ChatPanel.tsx

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Features:
55
* - Message thread with user/assistant/system bubbles
66
* - Live streaming text with typing indicator
7-
* - Real-time visual preview via @next-dev/json-render
7+
* - Real-time visual preview via json-render's React runtime
88
* - JSON operations inspector with syntax highlighting
99
* - Accept / Reject buttons for AI-generated changes
1010
* - Provider settings (Mock, OpenAI/Local LLM, MCP)
@@ -15,8 +15,14 @@
1515
import { useEffect, useRef, useState } from 'react';
1616
import { useChatStore, type ChatMessage, type ChatMode } from '@/chat-store';
1717
import { useSettingsStore } from '@/settings-store';
18+
import { useEditorStore } from '@/store';
1819
import type { AIOperation, ProviderType } from '@/ai-providers';
19-
import { renderOperations } from '@next-dev/json-render';
20+
import {
21+
createSelectedElementReference,
22+
type SelectedElementReference,
23+
} from '@/chat-context';
24+
import type { CatalogBlockProviderSelection } from '@next-dev/catalog';
25+
import { JsonRenderOperationPreview } from '@next-dev/json-render';
2026
import {
2127
Send,
2228
Square,
@@ -74,9 +80,26 @@ const PROVIDER_LABELS: Record<ProviderType, string> = {
7480
mcp: 'MCP Server',
7581
};
7682

83+
function SelectedNodeChip({
84+
reference,
85+
muted = false,
86+
}: {
87+
reference: SelectedElementReference;
88+
muted?: boolean;
89+
}) {
90+
return (
91+
<div className="chat-context-chip" data-muted={muted}>
92+
<span className="chat-context-chip-label">{reference.label}</span>
93+
<span className="chat-context-chip-id">#{reference.shortId}</span>
94+
</div>
95+
);
96+
}
97+
7798
// ─── Visual Preview (via json-render) ───────────────────────────────────────
7899

79100
function VisualPreview({ operations }: { operations: AIOperation[] }) {
101+
const spec = useEditorStore((state) => state.spec);
102+
80103
if (operations.length === 0) return null;
81104

82105
return (
@@ -86,7 +109,12 @@ function VisualPreview({ operations }: { operations: AIOperation[] }) {
86109
<span>Live Preview</span>
87110
</div>
88111
<div className="op-preview-canvas">
89-
{renderOperations(operations, { scale: 0.55, interactive: false })}
112+
<JsonRenderOperationPreview
113+
baseSpec={spec}
114+
operations={operations}
115+
scale={0.55}
116+
interactive={true}
117+
/>
90118
</div>
91119
</div>
92120
);
@@ -206,6 +234,11 @@ function MessageBubble({ message }: { message: ChatMessage }) {
206234
<Icon size={14} />
207235
</div>
208236
<div className="chat-message-body">
237+
{message.selectionRef && (
238+
<div className="chat-message-reference">
239+
<SelectedNodeChip reference={message.selectionRef} muted={true} />
240+
</div>
241+
)}
209242
<div className="chat-message-content">
210243
{message.content}
211244
{message.streaming && <span className="typing-cursor" />}
@@ -301,7 +334,11 @@ function SettingsPanel() {
301334

302335
// ─── Chat Panel ─────────────────────────────────────────────────────────────
303336

304-
export function ChatPanel() {
337+
export function ChatPanel({
338+
providerSelection,
339+
}: {
340+
providerSelection: CatalogBlockProviderSelection;
341+
}) {
305342
const messages = useChatStore((s) => s.messages);
306343
const isStreaming = useChatStore((s) => s.isStreaming);
307344
const mode = useChatStore((s) => s.mode);
@@ -313,6 +350,8 @@ export function ChatPanel() {
313350
const setMode = useChatStore((s) => s.setMode);
314351
const setInputValue = useChatStore((s) => s.setInputValue);
315352
const openSettings = useSettingsStore((s) => s.openSettings);
353+
const spec = useEditorStore((s) => s.spec);
354+
const selectedIds = useEditorStore((s) => s.selectedIds);
316355

317356
const scrollRef = useRef<HTMLDivElement>(null);
318357
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -337,9 +376,21 @@ export function ChatPanel() {
337376
inputRef.current?.focus();
338377
}, []);
339378

379+
const selectedReference =
380+
mode === 'edit' && selectedIds.length === 1
381+
? createSelectedElementReference(spec, selectedIds[0])
382+
: null;
383+
384+
const sendPrompt = (prompt: string) =>
385+
sendMessage(prompt, {
386+
mode,
387+
selectedElement: selectedReference,
388+
providerSelection,
389+
});
390+
340391
const handleSubmit = () => {
341392
if (!inputValue.trim() || isStreaming) return;
342-
sendMessage(inputValue);
393+
sendPrompt(inputValue);
343394
};
344395

345396
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -426,7 +477,7 @@ export function ChatPanel() {
426477
key={prompt}
427478
type="button"
428479
className="chat-suggestion-btn"
429-
onClick={() => sendMessage(prompt)}
480+
onClick={() => sendPrompt(prompt)}
430481
>
431482
<Sparkles size={12} />
432483
{prompt}
@@ -440,16 +491,27 @@ export function ChatPanel() {
440491
<div className="chat-input-area">
441492
<div className="chat-input-wrapper">
442493
<ModeIcon size={14} className="chat-input-mode-icon" style={{ color: modeConfig.color }} />
443-
<textarea
444-
ref={inputRef}
445-
className="chat-input"
446-
rows={1}
447-
value={inputValue}
448-
onChange={(e) => setInputValue(e.target.value)}
449-
onKeyDown={handleKeyDown}
450-
placeholder={modeConfig.placeholder}
451-
disabled={isStreaming}
452-
/>
494+
<div className="chat-input-column">
495+
{mode === 'edit' && selectedReference && (
496+
<div className="chat-context-row">
497+
<span className="chat-context-label">Target</span>
498+
<SelectedNodeChip reference={selectedReference} />
499+
</div>
500+
)}
501+
{mode === 'edit' && !selectedReference && (
502+
<div className="chat-context-empty">Select one node to target edit-mode changes.</div>
503+
)}
504+
<textarea
505+
ref={inputRef}
506+
className="chat-input"
507+
rows={1}
508+
value={inputValue}
509+
onChange={(e) => setInputValue(e.target.value)}
510+
onKeyDown={handleKeyDown}
511+
placeholder={modeConfig.placeholder}
512+
disabled={isStreaming}
513+
/>
514+
</div>
453515
{isStreaming ? (
454516
<button type="button" className="chat-send-btn chat-cancel-btn" onClick={cancelStream}>
455517
<Square size={14} />

apps/desktop/src/renderer/src/ai-providers.ts

Lines changed: 136 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313

1414
import type { DesignSpec, Element } from '@next-dev/editor-core';
1515
import { catalog, catalogToPrompt, type ComponentType } from '@next-dev/catalog';
16+
import {
17+
collectSubtreeElementIds,
18+
parsePromptWithContext,
19+
resolveSelectedElementTarget,
20+
resolveValidationHintTarget,
21+
} from '@/chat-context';
1622

1723
// ─── Types ──────────────────────────────────────────────────────────────────
1824

@@ -109,7 +115,9 @@ Operation types: add (parentId, elementType, props), remove (elementId), updateP
109115
Example - "Add a button that says Submit":
110116
{"description":"Added Submit button","operations":[{"type":"add","parentId":"${spec.root}","elementType":"Button","props":{"children":"Submit","variant":"default"}}]}
111117
112-
Rules: Use ONLY listed components. Return ONLY JSON. No markdown fences.`;
118+
Rules: Use ONLY listed components. Return ONLY JSON. No markdown fences.
119+
If the user prompt contains a DesignForge chat context block with selected_element_id, treat relative edit requests as targeting that element first.
120+
If provider.validation is present in the context block, keep validation-related changes aligned with that provider.`;
113121
}
114122

115123
// ─── Mock Provider ──────────────────────────────────────────────────────────
@@ -140,17 +148,24 @@ export class MockProvider implements AIProvider {
140148
}
141149

142150
private parse(prompt: string, spec: DesignSpec): AIResponse {
143-
const p = prompt.trim().toLowerCase();
151+
const promptContext = parsePromptWithContext(prompt);
152+
const request = promptContext.request || prompt.trim();
153+
const p = request.toLowerCase();
144154
const rootId = spec.root;
145155

146156
// Remove commands
147157
if (p.startsWith('remove') || p.startsWith('delete')) {
148-
return this.handleRemove(p, spec);
158+
return this.handleRemove(request, spec, promptContext.selectedElementId);
149159
}
150160

151161
// Update commands
152162
if (p.startsWith('change') || p.startsWith('update') || p.startsWith('set') || p.startsWith('make')) {
153-
return this.handleUpdate(p, spec);
163+
return this.handleUpdate(
164+
request,
165+
spec,
166+
promptContext.selectedElementId,
167+
promptContext.providerSelection.validation,
168+
);
154169
}
155170

156171
// Login form
@@ -177,7 +192,7 @@ export class MockProvider implements AIProvider {
177192
if (matchedType) {
178193
const entry = catalog[matchedType];
179194
const defaultProps = extractDefaults(entry.schema);
180-
const quoted = extractQuotedText(prompt);
195+
const quoted = extractQuotedText(request);
181196
if (quoted && 'children' in defaultProps) defaultProps.children = quoted;
182197

183198
// Variant extraction
@@ -196,7 +211,7 @@ export class MockProvider implements AIProvider {
196211

197212
// Text fallback
198213
if (p.includes('text') || p.includes('heading') || p.includes('paragraph')) {
199-
const text = extractQuotedText(prompt) ?? 'Sample text';
214+
const text = extractQuotedText(request) ?? 'Sample text';
200215
let variant = 'default';
201216
if (p.includes('heading') || p.includes('h1')) variant = 'h1';
202217
else if (p.includes('h2')) variant = 'h2';
@@ -215,13 +230,23 @@ export class MockProvider implements AIProvider {
215230
};
216231
}
217232

218-
private handleRemove(prompt: string, spec: DesignSpec): AIResponse {
233+
private handleRemove(prompt: string, spec: DesignSpec, selectedElementId: string | null): AIResponse {
234+
const normalizedPrompt = prompt.toLowerCase();
219235
const componentTypes = Object.keys(catalog) as ComponentType[];
220-
const matched = componentTypes.find((t) => prompt.includes(t.toLowerCase()));
236+
const matched = componentTypes.find((t) => normalizedPrompt.includes(t.toLowerCase()));
237+
if (!matched && selectedElementId) {
238+
const target = spec.elements[selectedElementId];
239+
if (!target) return { operations: [], description: 'Could not determine which element to remove.' };
240+
return {
241+
operations: [{ type: 'remove', elementId: selectedElementId }],
242+
description: `Removed ${describeElement(spec, selectedElementId)}.`,
243+
};
244+
}
221245
if (!matched) return { operations: [], description: 'Could not determine which element to remove.' };
222246

223-
const all = prompt.includes('all');
224-
const matches = findByType(spec, matched);
247+
const all = normalizedPrompt.includes('all');
248+
const scopedMatches = selectedElementId ? findByTypeInScope(spec, matched, selectedElementId) : [];
249+
const matches = scopedMatches.length > 0 ? scopedMatches : findByType(spec, matched);
225250
if (matches.length === 0) return { operations: [], description: `No ${matched} elements found.` };
226251

227252
const targets = all ? matches : [matches[0]];
@@ -231,19 +256,54 @@ export class MockProvider implements AIProvider {
231256
};
232257
}
233258

234-
private handleUpdate(prompt: string, spec: DesignSpec): AIResponse {
259+
private handleUpdate(
260+
prompt: string,
261+
spec: DesignSpec,
262+
selectedElementId: string | null,
263+
validationProvider?: string,
264+
): AIResponse {
265+
const normalizedPrompt = prompt.toLowerCase();
235266
const componentTypes = Object.keys(catalog) as ComponentType[];
236-
const matched = componentTypes.find((t) => prompt.includes(t.toLowerCase()));
237-
const targets = matched ? findByType(spec, matched) : Object.entries(spec.elements).filter(([id]) => id !== spec.root);
267+
const matched = componentTypes.find((t) => normalizedPrompt.includes(t.toLowerCase()));
268+
const targets = resolveUpdateTargets(spec, matched, selectedElementId);
238269
if (targets.length === 0) return { operations: [], description: 'No matching elements found.' };
239270

240271
const [targetId] = targets[0];
241272
const updatedProps: Record<string, unknown> = {};
242273
const quoted = extractQuotedText(prompt);
274+
const requiredIntent = /\brequired\b/.test(normalizedPrompt) || /\brequire\b/.test(normalizedPrompt);
275+
if (requiredIntent) {
276+
const fieldTargetId = resolveSelectedElementTarget(spec, selectedElementId ?? targetId) ?? targetId;
277+
const operations: AIOperation[] = [
278+
{
279+
type: 'updateProps',
280+
elementId: fieldTargetId,
281+
updatedProps: { required: true },
282+
},
283+
];
284+
285+
const hintTargetId = resolveValidationHintTarget(spec, selectedElementId ?? targetId);
286+
if (hintTargetId) {
287+
operations.push({
288+
type: 'updateProps',
289+
elementId: hintTargetId,
290+
updatedProps: {
291+
children: buildRequiredHint(validationProvider),
292+
},
293+
});
294+
}
295+
296+
return {
297+
operations,
298+
description: buildRequiredDescription(spec, fieldTargetId, validationProvider),
299+
};
300+
}
301+
243302
if (quoted) updatedProps.children = quoted;
244-
if (prompt.includes('destructive')) updatedProps.variant = 'destructive';
245-
if (prompt.includes('outline')) updatedProps.variant = 'outline';
246-
if (prompt.includes('disabled')) updatedProps.disabled = true;
303+
if (normalizedPrompt.includes('destructive')) updatedProps.variant = 'destructive';
304+
if (normalizedPrompt.includes('outline')) updatedProps.variant = 'outline';
305+
if (normalizedPrompt.includes('disabled')) updatedProps.disabled = true;
306+
if (normalizedPrompt.includes('enabled')) updatedProps.disabled = false;
247307

248308
if (Object.keys(updatedProps).length === 0) {
249309
return { operations: [], description: 'Could not determine what to change.' };
@@ -741,6 +801,66 @@ function delay(ms: number): Promise<void> {
741801
return new Promise((resolve) => setTimeout(resolve, ms));
742802
}
743803

804+
function resolveUpdateTargets(
805+
spec: DesignSpec,
806+
matchedType: ComponentType | undefined,
807+
selectedElementId: string | null,
808+
): [string, Element][] {
809+
if (selectedElementId) {
810+
if (matchedType) {
811+
const scopedMatches = findByTypeInScope(spec, matchedType, selectedElementId);
812+
if (scopedMatches.length > 0) return scopedMatches;
813+
}
814+
815+
const selectedTargetId = resolveSelectedElementTarget(spec, selectedElementId) ?? selectedElementId;
816+
const selectedTarget = spec.elements[selectedTargetId];
817+
if (selectedTarget) return [[selectedTargetId, selectedTarget]];
818+
}
819+
820+
return matchedType
821+
? findByType(spec, matchedType)
822+
: Object.entries(spec.elements).filter(([id]) => id !== spec.root);
823+
}
824+
825+
function findByTypeInScope(spec: DesignSpec, type: string, rootId: string): [string, Element][] {
826+
const scopeIds = new Set(collectSubtreeElementIds(spec, rootId));
827+
return findByType(spec, type).filter(([id]) => scopeIds.has(id));
828+
}
829+
830+
function describeElement(spec: DesignSpec, elementId: string): string {
831+
const element = spec.elements[elementId];
832+
if (!element) return 'element';
833+
return element.__editor?.name ?? element.type;
834+
}
835+
836+
function buildRequiredHint(validationProvider?: string): string {
837+
if (validationProvider === 'react-hook-form') {
838+
return 'Required. Enforce this with React Hook Form rules and surface field errors before submit.';
839+
}
840+
841+
if (validationProvider === 'tanstack-form') {
842+
return 'Required. Enforce this with TanStack Form validators before submit.';
843+
}
844+
845+
return 'Required. Validate before submit.';
846+
}
847+
848+
function buildRequiredDescription(
849+
spec: DesignSpec,
850+
elementId: string,
851+
validationProvider?: string,
852+
): string {
853+
const providerLabel =
854+
validationProvider === 'react-hook-form'
855+
? 'React Hook Form'
856+
: validationProvider === 'tanstack-form'
857+
? 'TanStack Form'
858+
: null;
859+
return providerLabel
860+
? `Marked ${describeElement(spec, elementId)} as required using ${providerLabel} guidance.`
861+
: `Marked ${describeElement(spec, elementId)} as required.`;
862+
}
863+
744864
function extractQuotedText(input: string): string | null {
745865
const match = input.match(/["']([^"']+)["']/);
746866
return match ? match[1] : null;

0 commit comments

Comments
 (0)