From 70e4a43181860f1ed4881d7bb63d26b37966a2b8 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:59:40 -0300 Subject: [PATCH 1/4] refactor(ai): sync shared surfaces for PR #353 AI chat enhancements Sync byte-identical surfaces from openplc-web refactor/ai-chat-pr353: - Store: project-scoped flat messages[] replacing per-POU conversations, add isAgenticLoopRunning, increase MAX_MESSAGES to 50 - Monaco: replace DiffEditor with ai-pou-updated event handler - Architecture: add adapter-components layer with frontend-level permissions - CSS: add AI diff review decoration styles - Tests: update AI slice tests for new schema All AI feature logic remains web-only (in adapters). These shared surface changes keep store types, components, and validation in sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__architecture__/validate.ts | 20 +- src/backend/shared/styles/globals.css | 18 ++ .../[workspace]/editor/monaco/index.tsx | 159 +++++---------- src/frontend/store/__tests__/ai-slice.test.ts | 185 ++++++++---------- src/frontend/store/slices/ai/index.ts | 2 +- src/frontend/store/slices/ai/slice.ts | 50 +++-- src/frontend/store/slices/ai/types.ts | 23 +-- 7 files changed, 196 insertions(+), 261 deletions(-) diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index ae9362d29..964fcef1a 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -23,6 +23,7 @@ type LayerName = | 'ports' | 'provider' | 'adapters' + | 'adapter-components' | 'backend-shared' | 'backend-web' | 'store' @@ -64,9 +65,25 @@ const LAYER_RULES: Record = { allowedDeps: ['ports', 'utils'], }, adapters: { - name: 'Adapters (middleware/adapters/)', + name: 'Adapter Services (middleware/adapters/**/services/, middleware/adapters/*.ts)', allowedDeps: ['ports', 'provider', 'utils', 'backend-shared', 'backend-web', 'store', 'assets'], }, + 'adapter-components': { + name: 'Adapter Components (middleware/adapters/**/components/)', + allowedDeps: [ + 'ports', + 'provider', + 'store', + 'hooks', + 'services', + 'components', + 'data', + 'utils', + 'assets', + 'adapters', + 'adapter-components', + ], + }, 'backend-shared': { name: 'Backend Shared (backend/shared/)', allowedDeps: ['ports', 'utils', 'types'], @@ -131,6 +148,7 @@ function getLayer(filePath: string): LayerName | null { // Middleware layers if (rel.startsWith('middleware/shared/ports/')) return 'ports' if (rel.startsWith('middleware/shared/providers/')) return 'provider' + if (rel.match(/^middleware\/adapters\/[^/]+\/components\//)) return 'adapter-components' if (rel.startsWith('middleware/adapters/')) return 'adapters' // Backend layers diff --git a/src/backend/shared/styles/globals.css b/src/backend/shared/styles/globals.css index 287908744..aa0aecdc9 100644 --- a/src/backend/shared/styles/globals.css +++ b/src/backend/shared/styles/globals.css @@ -234,3 +234,21 @@ [role='listbox'] { z-index: 1000 !important; } + +/* AI Diff Review decorations */ +.ai-diff-added { + background: rgba(34, 197, 94, 0.12) !important; +} +.dark .ai-diff-added { + background: rgba(34, 197, 94, 0.1) !important; +} +.ai-diff-added-gutter { + background: rgba(34, 197, 94, 0.3) !important; + border-left: 3px solid #22c55e !important; +} +.ai-diff-removed-zone { + background: rgba(239, 68, 68, 0.06); +} +.dark .ai-diff-removed-zone { + background: rgba(239, 68, 68, 0.08); +} diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index bb2a1be29..b1b044130 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -1,6 +1,6 @@ import './configs' -import { DiffEditor, Editor as PrimitiveEditor } from '@monaco-editor/react' +import { Editor as PrimitiveEditor } from '@monaco-editor/react' import * as monaco from 'monaco-editor' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -176,25 +176,11 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType(null) - // AI diff review state — when active, swaps the editor to a DiffEditor - const [diffReview, setDiffReview] = useState<{ - active: boolean - proposedBody: string - variableSummary: string - }>({ active: false, proposedBody: '', variableSummary: '' }) - const [templatesInjected, setTemplatesInjected] = useState>(new Set()) const pou = pous.find((p) => p.name === name) const pouVariables = pou?.interface?.variables ?? [] - // Restore custom theme after DiffEditor unmounts - useEffect(() => { - if (diffReview.active) return - const m = monacoRef.current - if (m) requestAnimationFrame(() => applyThemeNow(m, shouldUseDarkMode)) - }, [diffReview.active, shouldUseDarkMode]) - // Sync local text when POU identity changes useEffect(() => { const currentPou = openPLCStoreBase.getState().project.data.pous.find((p) => p.name === name) @@ -957,40 +943,46 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { - const { - pouName: targetPou, - proposedBody, - variableSummary, - } = (e as CustomEvent<{ pouName: string; proposedBody: string; variableSummary: string }>).detail + // Listen for AI tool updates — sync editor model with new body + const handlePouUpdated = (e: Event) => { + const { pouName: targetPou, body } = (e as CustomEvent<{ pouName: string; body: string; oldBody?: string }>) + .detail if (targetPou !== name) return - setDiffReview({ active: true, proposedBody, variableSummary }) - } - window.addEventListener('ai-review-code', handleCodeReview) - // Listen for AI chat "apply code" — applies after the user accepted the diff - const handleCodeApplied = (e: Event) => { - const { pouName: targetPou, body } = (e as CustomEvent<{ pouName: string; body: string }>).detail - if (targetPou !== name) return const model = editorInstance.getModel() - if (model) { + if (model && model.getValue() !== body) { isSyncingModelRef.current = true const fullRange = model.getFullModelRange() - editorInstance.executeEdits('ai-code-apply', [{ range: fullRange, text: body }]) + editorInstance.executeEdits('ai-tool-update', [{ range: fullRange, text: body }]) isSyncingModelRef.current = false - editorInstance.focus() } + setLocalText(body) + } + window.addEventListener('ai-pou-updated', handlePouUpdated) + + // Listen for global accept/reject from chat panel + const handleAcceptAllHunks = (e: Event) => { + const { pouName: targetPou } = (e as CustomEvent<{ pouName: string }>).detail + if (targetPou !== name) return + // No-op — changes are already in the editor model + } + window.addEventListener('ai-accept-all-hunks', handleAcceptAllHunks) + + const handleRejectAllHunks = (e: Event) => { + const { pouName: targetPou } = (e as CustomEvent<{ pouName: string }>).detail + if (targetPou !== name) return + // Body is restored by the chat panel's undo handler via store setState } - window.addEventListener('ai-code-applied', handleCodeApplied) + window.addEventListener('ai-reject-all-hunks', handleRejectAllHunks) editorInstance.onDidDispose(() => { window.removeEventListener('keyup', handleKeyUp) if (handleInsertAtCursor) { window.removeEventListener('ai-insert-at-cursor', handleInsertAtCursor) } - window.removeEventListener('ai-review-code', handleCodeReview) - window.removeEventListener('ai-code-applied', handleCodeApplied) + window.removeEventListener('ai-pou-updated', handlePouUpdated) + window.removeEventListener('ai-accept-all-hunks', handleAcceptAllHunks) + window.removeEventListener('ai-reject-all-hunks', handleRejectAllHunks) }) editorInstance.focus() @@ -1307,23 +1299,6 @@ void loop() }, []) // ----------------------------------------------------------------------- - // AI diff review handlers - // ----------------------------------------------------------------------- - - const handleDiffAccept = useCallback(() => { - if (!diffReview.active) return - setDiffReview({ active: false, proposedBody: '', variableSummary: '' }) - window.dispatchEvent( - new CustomEvent('ai-review-accepted', { detail: { pouName: name, body: diffReview.proposedBody } }), - ) - }, [diffReview, name]) - - const handleDiffReject = useCallback(() => { - setDiffReview({ active: false, proposedBody: '', variableSummary: '' }) - }, []) - - const diffThemeName = shouldUseDarkMode ? 'openplc-dark' : 'openplc-light' - // ----------------------------------------------------------------------- // Render // ----------------------------------------------------------------------- @@ -1331,69 +1306,23 @@ void loop() return ( <>
- {diffReview.active ? ( - <> - {/* Diff review label bar */} -
- {diffReview.variableSummary && ( - - {diffReview.variableSummary} - - )} - AI suggestion -
- - -
-
- ensureOpenplcThemes(m)} - options={{ - readOnly: true, - minimap: { enabled: false }, - fontSize: 13, - scrollBeyondLastLine: false, - domReadOnly: true, - renderSideBySide: true, - originalEditable: false, - }} - /> - - ) : ( - <> - {capabilities.hasAIAssistant && } - - - )} + {capabilities.hasAIAssistant && } +
diff --git a/src/frontend/store/__tests__/ai-slice.test.ts b/src/frontend/store/__tests__/ai-slice.test.ts index cd9988fff..9678cead5 100644 --- a/src/frontend/store/__tests__/ai-slice.test.ts +++ b/src/frontend/store/__tests__/ai-slice.test.ts @@ -44,8 +44,9 @@ describe('createAISlice', () => { expect(ai.creditsTotal).toBe(500) expect(ai.tier).toBe('free') expect(ai.currentPeriodEnd).toBeNull() - expect(ai.conversations).toEqual([]) - expect(ai.activeConversationPou).toBeNull() + expect(ai.messages).toEqual([]) + expect(ai.activeEditorPou).toBeNull() + expect(ai.isAgenticLoopRunning).toBe(false) expect(ai.isChatOpen).toBe(false) expect(ai.error).toBeNull() }) @@ -154,69 +155,68 @@ describe('createAISlice', () => { }) }) - describe('setActiveConversationPou', () => { - it('sets the active conversation POU name', () => { - store.getState().aiActions.setActiveConversationPou('MainProgram') - expect(store.getState().ai.activeConversationPou).toBe('MainProgram') + describe('setActiveEditorPou', () => { + it('sets the active editor POU name', () => { + store.getState().aiActions.setActiveEditorPou('MainProgram') + expect(store.getState().ai.activeEditorPou).toBe('MainProgram') }) - it('clears the active conversation POU', () => { - store.getState().aiActions.setActiveConversationPou('MainProgram') - store.getState().aiActions.setActiveConversationPou(null) - expect(store.getState().ai.activeConversationPou).toBeNull() + it('clears the active editor POU', () => { + store.getState().aiActions.setActiveEditorPou('MainProgram') + store.getState().aiActions.setActiveEditorPou(null) + expect(store.getState().ai.activeEditorPou).toBeNull() + }) + }) + + describe('setAgenticLoopRunning', () => { + it('sets the agentic loop to running', () => { + store.getState().aiActions.setAgenticLoopRunning(true) + expect(store.getState().ai.isAgenticLoopRunning).toBe(true) + }) + + it('sets the agentic loop to not running', () => { + store.getState().aiActions.setAgenticLoopRunning(true) + store.getState().aiActions.setAgenticLoopRunning(false) + expect(store.getState().ai.isAgenticLoopRunning).toBe(false) }) }) // --------------------------------------------------------------------------- - // Conversation management + // Message management (project-scoped) // --------------------------------------------------------------------------- describe('addMessage', () => { - it('creates a new conversation when none exists for the POU', () => { + it('adds a message to the flat messages array', () => { const msg = makeMessage({ id: 'msg-1', role: 'user', content: 'Help me' }) - store.getState().aiActions.addMessage('MyPou', msg) + store.getState().aiActions.addMessage(msg) - const conversations = store.getState().ai.conversations - expect(conversations).toHaveLength(1) - expect(conversations[0].pouName).toBe('MyPou') - expect(conversations[0].messages).toHaveLength(1) - expect(conversations[0].messages[0]).toEqual(msg) + const messages = store.getState().ai.messages + expect(messages).toHaveLength(1) + expect(messages[0]).toEqual(msg) }) - it('appends to an existing conversation', () => { + it('appends multiple messages in order', () => { const msg1 = makeMessage({ id: 'msg-1' }) const msg2 = makeMessage({ id: 'msg-2', role: 'assistant', content: 'Sure' }) - store.getState().aiActions.addMessage('MyPou', msg1) - store.getState().aiActions.addMessage('MyPou', msg2) + store.getState().aiActions.addMessage(msg1) + store.getState().aiActions.addMessage(msg2) - const conversations = store.getState().ai.conversations - expect(conversations).toHaveLength(1) - expect(conversations[0].messages).toHaveLength(2) - expect(conversations[0].messages[1].id).toBe('msg-2') - }) - - it('keeps separate conversations per POU', () => { - store.getState().aiActions.addMessage('PouA', makeMessage({ id: 'a-1' })) - store.getState().aiActions.addMessage('PouB', makeMessage({ id: 'b-1' })) - - const conversations = store.getState().ai.conversations - expect(conversations).toHaveLength(2) - expect(conversations[0].pouName).toBe('PouA') - expect(conversations[1].pouName).toBe('PouB') + const messages = store.getState().ai.messages + expect(messages).toHaveLength(2) + expect(messages[0].id).toBe('msg-1') + expect(messages[1].id).toBe('msg-2') }) it('enforces MAX_CONVERSATION_MESSAGES by keeping the most recent messages', () => { - expect(MAX_CONVERSATION_MESSAGES).toBe(20) + expect(MAX_CONVERSATION_MESSAGES).toBe(50) - // Add MAX + 5 messages for (let i = 0; i < MAX_CONVERSATION_MESSAGES + 5; i++) { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: `msg-${i}`, content: `Message ${i}` })) + store.getState().aiActions.addMessage(makeMessage({ id: `msg-${i}`, content: `Message ${i}` })) } - const messages = store.getState().ai.conversations[0].messages + const messages = store.getState().ai.messages expect(messages).toHaveLength(MAX_CONVERSATION_MESSAGES) - // The first 5 messages should have been trimmed expect(messages[0].id).toBe('msg-5') expect(messages[MAX_CONVERSATION_MESSAGES - 1].id).toBe(`msg-${MAX_CONVERSATION_MESSAGES + 4}`) }) @@ -224,101 +224,77 @@ describe('createAISlice', () => { describe('updateMessageContent', () => { it('updates the content of an existing message', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1', content: 'initial' })) - store.getState().aiActions.updateMessageContent('MyPou', 'msg-1', 'updated content') - - expect(store.getState().ai.conversations[0].messages[0].content).toBe('updated content') - }) + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1', content: 'initial' })) + store.getState().aiActions.updateMessageContent('msg-1', 'updated content') - it('does nothing when the conversation does not exist', () => { - store.getState().aiActions.updateMessageContent('NonExistent', 'msg-1', 'content') - expect(store.getState().ai.conversations).toHaveLength(0) + expect(store.getState().ai.messages[0].content).toBe('updated content') }) it('does nothing when the message id does not match', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1', content: 'original' })) - store.getState().aiActions.updateMessageContent('MyPou', 'msg-999', 'changed') + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1', content: 'original' })) + store.getState().aiActions.updateMessageContent('msg-999', 'changed') - expect(store.getState().ai.conversations[0].messages[0].content).toBe('original') + expect(store.getState().ai.messages[0].content).toBe('original') + }) + + it('does nothing when there are no messages', () => { + store.getState().aiActions.updateMessageContent('msg-1', 'content') + expect(store.getState().ai.messages).toHaveLength(0) }) }) describe('rateMessage', () => { it('sets a thumbs-up rating', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1' })) - store.getState().aiActions.rateMessage('MyPou', 'msg-1', 'up') + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + store.getState().aiActions.rateMessage('msg-1', 'up') - expect(store.getState().ai.conversations[0].messages[0].rating).toBe('up') + expect(store.getState().ai.messages[0].rating).toBe('up') }) it('sets a thumbs-down rating', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1' })) - store.getState().aiActions.rateMessage('MyPou', 'msg-1', 'down') + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + store.getState().aiActions.rateMessage('msg-1', 'down') - expect(store.getState().ai.conversations[0].messages[0].rating).toBe('down') + expect(store.getState().ai.messages[0].rating).toBe('down') }) it('clears the rating when set to undefined', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1' })) - store.getState().aiActions.rateMessage('MyPou', 'msg-1', 'up') - store.getState().aiActions.rateMessage('MyPou', 'msg-1', undefined) - - expect(store.getState().ai.conversations[0].messages[0].rating).toBeUndefined() - }) + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + store.getState().aiActions.rateMessage('msg-1', 'up') + store.getState().aiActions.rateMessage('msg-1', undefined) - it('does nothing when the conversation does not exist', () => { - store.getState().aiActions.rateMessage('NonExistent', 'msg-1', 'up') - expect(store.getState().ai.conversations).toHaveLength(0) + expect(store.getState().ai.messages[0].rating).toBeUndefined() }) it('does nothing when the message id does not match', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1' })) - store.getState().aiActions.rateMessage('MyPou', 'msg-999', 'up') + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + store.getState().aiActions.rateMessage('msg-999', 'up') - expect(store.getState().ai.conversations[0].messages[0].rating).toBeUndefined() + expect(store.getState().ai.messages[0].rating).toBeUndefined() }) - }) - - describe('clearConversation', () => { - it('removes the conversation for the given POU', () => { - store.getState().aiActions.addMessage('PouA', makeMessage({ id: 'a-1' })) - store.getState().aiActions.addMessage('PouB', makeMessage({ id: 'b-1' })) - store.getState().aiActions.clearConversation('PouA') - - const conversations = store.getState().ai.conversations - expect(conversations).toHaveLength(1) - expect(conversations[0].pouName).toBe('PouB') + it('does nothing when there are no messages', () => { + store.getState().aiActions.rateMessage('msg-1', 'up') + expect(store.getState().ai.messages).toHaveLength(0) }) + }) - it('clears activeConversationPou and error if it matches the cleared POU', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1' })) - store.getState().aiActions.setActiveConversationPou('MyPou') + describe('clearConversation', () => { + it('clears all messages and the error', () => { + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-2' })) store.getState().aiActions.setAIError('some error') - store.getState().aiActions.clearConversation('MyPou') + store.getState().aiActions.clearConversation() - expect(store.getState().ai.activeConversationPou).toBeNull() + expect(store.getState().ai.messages).toHaveLength(0) expect(store.getState().ai.error).toBeNull() }) - it('does not clear activeConversationPou when it does not match', () => { - store.getState().aiActions.addMessage('PouA', makeMessage({ id: 'a-1' })) - store.getState().aiActions.addMessage('PouB', makeMessage({ id: 'b-1' })) - store.getState().aiActions.setActiveConversationPou('PouB') - store.getState().aiActions.setAIError('preserved error') - - store.getState().aiActions.clearConversation('PouA') - - expect(store.getState().ai.activeConversationPou).toBe('PouB') - expect(store.getState().ai.error).toBe('preserved error') - }) - - it('is a no-op when the POU has no conversation', () => { - store.getState().aiActions.addMessage('MyPou', makeMessage({ id: 'msg-1' })) - store.getState().aiActions.clearConversation('NonExistent') - - expect(store.getState().ai.conversations).toHaveLength(1) + it('is a no-op on empty state (no error)', () => { + store.getState().aiActions.clearConversation() + expect(store.getState().ai.messages).toHaveLength(0) + expect(store.getState().ai.error).toBeNull() }) }) @@ -380,8 +356,9 @@ describe('createAISliceFactory', () => { expect(ai.creditsTotal).toBe(500) expect(ai.tier).toBe('free') expect(ai.currentPeriodEnd).toBeNull() - expect(ai.conversations).toEqual([]) - expect(ai.activeConversationPou).toBeNull() + expect(ai.messages).toEqual([]) + expect(ai.activeEditorPou).toBeNull() + expect(ai.isAgenticLoopRunning).toBe(false) expect(ai.isChatOpen).toBe(false) expect(ai.error).toBeNull() }) diff --git a/src/frontend/store/slices/ai/index.ts b/src/frontend/store/slices/ai/index.ts index 0059bdef7..6e6257698 100644 --- a/src/frontend/store/slices/ai/index.ts +++ b/src/frontend/store/slices/ai/index.ts @@ -1,3 +1,3 @@ export { createAISlice, createAISliceFactory } from './slice' -export type { AIActions, AIModel, AISlice, AIState, ChatConversation, ChatMessage, ChatMessageRole } from './types' +export type { AIActions, AIModel, AISlice, AIState, ChatMessage, ChatMessageRole } from './types' export { MAX_CONVERSATION_MESSAGES } from './types' diff --git a/src/frontend/store/slices/ai/slice.ts b/src/frontend/store/slices/ai/slice.ts index 3a9f79331..1c7e2f828 100644 --- a/src/frontend/store/slices/ai/slice.ts +++ b/src/frontend/store/slices/ai/slice.ts @@ -14,8 +14,9 @@ const DEFAULT_AI_STATE: AISlice['ai'] = { creditsTotal: 500, tier: 'free', currentPeriodEnd: null, - conversations: [], - activeConversationPou: null, + messages: [], + activeEditorPou: null, + isAgenticLoopRunning: false, isChatOpen: false, error: null, } @@ -83,60 +84,55 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator { + setActiveEditorPou: (pouName) => { setState( produce(({ ai }: AISlice) => { - ai.activeConversationPou = pouName + ai.activeEditorPou = pouName }), ) }, - addMessage: (pouName, message) => { + setAgenticLoopRunning: (running) => { setState( produce(({ ai }: AISlice) => { - let conversation = ai.conversations.find((c) => c.pouName === pouName) - if (!conversation) { - conversation = { pouName, messages: [] } - ai.conversations.push(conversation) - } - conversation.messages.push(message) - if (conversation.messages.length > MAX_CONVERSATION_MESSAGES) { - conversation.messages = conversation.messages.slice(-MAX_CONVERSATION_MESSAGES) + ai.isAgenticLoopRunning = running + }), + ) + }, + addMessage: (message) => { + setState( + produce(({ ai }: AISlice) => { + ai.messages.push(message) + if (ai.messages.length > MAX_CONVERSATION_MESSAGES) { + ai.messages = ai.messages.slice(-MAX_CONVERSATION_MESSAGES) } }), ) }, - updateMessageContent: (pouName, messageId, content) => { + updateMessageContent: (messageId, content) => { setState( produce(({ ai }: AISlice) => { - const conversation = ai.conversations.find((c) => c.pouName === pouName) - if (!conversation) return - const msg = conversation.messages.find((m) => m.id === messageId) + const msg = ai.messages.find((m) => m.id === messageId) if (msg) { msg.content = content } }), ) }, - rateMessage: (pouName, messageId, rating) => { + rateMessage: (messageId, rating) => { setState( produce(({ ai }: AISlice) => { - const conversation = ai.conversations.find((c) => c.pouName === pouName) - if (!conversation) return - const msg = conversation.messages.find((m) => m.id === messageId) + const msg = ai.messages.find((m) => m.id === messageId) if (msg) { msg.rating = rating } }), ) }, - clearConversation: (pouName) => { + clearConversation: () => { setState( produce(({ ai }: AISlice) => { - ai.conversations = ai.conversations.filter((c) => c.pouName !== pouName) - if (ai.activeConversationPou === pouName) { - ai.activeConversationPou = null - ai.error = null - } + ai.messages = [] + ai.error = null }), ) }, diff --git a/src/frontend/store/slices/ai/types.ts b/src/frontend/store/slices/ai/types.ts index cb07edc71..e330db688 100644 --- a/src/frontend/store/slices/ai/types.ts +++ b/src/frontend/store/slices/ai/types.ts @@ -9,11 +9,6 @@ export type { ChatMessage, ChatMessageRole } export type AIModel = 'haiku' | 'sonnet' export type AIAction = 'complete' | 'chat' -export type ChatConversation = { - pouName: string - messages: ChatMessage[] -} - // --------------------------------------------------------------------------- // AI state // --------------------------------------------------------------------------- @@ -28,8 +23,9 @@ export type AIState = { creditsTotal: number tier: 'free' | 'pro' currentPeriodEnd: string | null - conversations: ChatConversation[] - activeConversationPou: string | null + messages: ChatMessage[] + activeEditorPou: string | null + isAgenticLoopRunning: boolean isChatOpen: boolean error: string | null } @@ -48,11 +44,12 @@ export type AIActions = { setTier: (tier: 'free' | 'pro') => void setCurrentPeriodEnd: (date: string | null) => void setAIError: (error: string | null) => void - setActiveConversationPou: (pouName: string | null) => void - addMessage: (pouName: string, message: ChatMessage) => void - updateMessageContent: (pouName: string, messageId: string, content: string) => void - rateMessage: (pouName: string, messageId: string, rating: 'up' | 'down' | undefined) => void - clearConversation: (pouName: string) => void + setActiveEditorPou: (pouName: string | null) => void + setAgenticLoopRunning: (running: boolean) => void + addMessage: (message: ChatMessage) => void + updateMessageContent: (messageId: string, content: string) => void + rateMessage: (messageId: string, rating: 'up' | 'down' | undefined) => void + clearConversation: () => void toggleChat: () => void setChatOpen: (open: boolean) => void } @@ -69,4 +66,4 @@ export type AISlice = AIState & { // Constants // --------------------------------------------------------------------------- -export const MAX_CONVERSATION_MESSAGES = 20 +export const MAX_CONVERSATION_MESSAGES = 50 From 672e71bd43d90c998ef529270f64fe68a95f3489 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:45:41 -0300 Subject: [PATCH 2/4] chore(deps): bump dompurify from 3.2.4 to 3.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security update — fixes mXSS, prototype pollution, and config bypass vulnerabilities. Addresses openplc-web Dependabot PR #359. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5838f9729..0d7970f2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "axios": "^1.13.6", "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", - "dompurify": "^3.2.4", + "dompurify": "^3.4.0", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", "electron-store": "^8.1.0", @@ -14282,9 +14282,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", - "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/package.json b/package.json index 682f985d1..a67ce1b54 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "axios": "^1.13.6", "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", - "dompurify": "^3.2.4", + "dompurify": "^3.4.0", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", "electron-store": "^8.1.0", From c906ab06dde107f3931e67eb851ce9c203178cbe Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:12:35 -0300 Subject: [PATCH 3/4] fix(ai): remove AI badge, restore per-hunk inline diff review - Remove AIStatusIndicator badge from Monaco editor (removed in PR #353 per Thiago's request) - Restore per-hunk inline diff review with Keep/Undo buttons per change - Add ai-diff-review utility (computeHunks, applyAcceptedHunks) - Add Monaco ai-diff-review renderer (green/red decorations + floating buttons) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/monaco/ai-diff-review.ts | 159 +++++++++++++++++ .../editor/monaco/ai-status-indicator.tsx | 51 ------ .../[workspace]/editor/monaco/index.tsx | 127 +++++++++++++- src/frontend/utils/ai-diff-review.ts | 165 ++++++++++++++++++ 4 files changed, 444 insertions(+), 58 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/editor/monaco/ai-diff-review.ts delete mode 100644 src/frontend/components/_features/[workspace]/editor/monaco/ai-status-indicator.tsx create mode 100644 src/frontend/utils/ai-diff-review.ts diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/ai-diff-review.ts b/src/frontend/components/_features/[workspace]/editor/monaco/ai-diff-review.ts new file mode 100644 index 000000000..7663103c6 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/monaco/ai-diff-review.ts @@ -0,0 +1,159 @@ +import * as monaco from 'monaco-editor' + +import type { DiffHunk } from '../../../../../utils/ai-diff-review' + +/** Render all diff review UI for the given hunks. Returns a cleanup function. */ +export function renderDiffReview( + editor: monaco.editor.IStandaloneCodeEditor, + hunks: DiffHunk[], + onKeep: (hunkId: string) => void, + onUndo: (hunkId: string) => void, +): () => void { + const viewZoneIds: string[] = [] + + // Clean up any stale buttons from previous renders + const editorDom = editor.getDomNode() + if (editorDom) { + editorDom.querySelectorAll('.ai-hunk-buttons').forEach((el) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(el as any)._scrollDisposable?.dispose() + el.remove() + }) + } + + // 1. Line decorations (green backgrounds for added/modified lines) + const decoOptions: monaco.editor.IModelDeltaDecoration[] = [] + for (const hunk of hunks) { + if (hunk.type === 'added' || hunk.type === 'modified') { + for (let line = hunk.startLine; line <= hunk.endLine; line++) { + decoOptions.push({ + range: new monaco.Range(line, 1, line, 1), + options: { + isWholeLine: true, + className: 'ai-diff-added', + glyphMarginClassName: 'ai-diff-added-gutter', + }, + }) + } + } + } + const decorations = editor.createDecorationsCollection(decoOptions) + + // 2. View zones for deleted/modified lines (red ghost text) + editor.changeViewZones((accessor) => { + for (const hunk of hunks) { + if (hunk.oldLines.length === 0) continue + if (hunk.type !== 'removed' && hunk.type !== 'modified') continue + + const domNode = document.createElement('div') + domNode.className = 'ai-diff-removed-zone' + domNode.style.fontFamily = 'var(--vscode-editor-font-family, monospace)' + domNode.style.fontSize = '13px' + domNode.style.lineHeight = '19px' + domNode.style.paddingLeft = '60px' + domNode.style.opacity = '0.45' + + for (const line of hunk.oldLines) { + const lineDiv = document.createElement('div') + lineDiv.textContent = line || ' ' + lineDiv.style.textDecoration = 'line-through' + lineDiv.style.color = 'rgba(239, 68, 68, 0.7)' + domNode.appendChild(lineDiv) + } + + const zoneId = accessor.addZone({ + afterLineNumber: hunk.startLine - 1, + heightInLines: hunk.oldLines.length, + domNode, + }) + viewZoneIds.push(zoneId) + } + }) + + // 3. Action buttons per hunk ("Keep" / "Undo") + const buttonContainers: HTMLDivElement[] = [] + + if (editorDom) { + for (const hunk of hunks) { + const container = document.createElement('div') + container.className = 'ai-hunk-buttons' + container.style.cssText = ` + position: absolute; right: 24px; z-index: 20; pointer-events: auto; + display: inline-flex; flex-direction: row; gap: 4px; + ` + + const keepBtn = document.createElement('button') + keepBtn.textContent = 'Keep' + keepBtn.style.cssText = ` + cursor: pointer; border: none; background: rgba(34,197,94,0.2); + color: #4ade80; border-radius: 3px; padding: 1px 8px; + font-size: 10px; font-weight: 500; font-family: inherit; + transition: background 0.15s; line-height: 16px; + ` + keepBtn.onmouseenter = () => { + keepBtn.style.background = 'rgba(34,197,94,0.35)' + } + keepBtn.onmouseleave = () => { + keepBtn.style.background = 'rgba(34,197,94,0.2)' + } + keepBtn.addEventListener('click', (e) => { + e.stopPropagation() + onKeep(hunk.id) + }) + + const undoBtn = document.createElement('button') + undoBtn.textContent = 'Undo' + undoBtn.style.cssText = ` + cursor: pointer; border: none; background: rgba(239,68,68,0.2); + color: #f87171; border-radius: 3px; padding: 1px 8px; + font-size: 10px; font-weight: 500; font-family: inherit; + transition: background 0.15s; line-height: 16px; + ` + undoBtn.onmouseenter = () => { + undoBtn.style.background = 'rgba(239,68,68,0.35)' + } + undoBtn.onmouseleave = () => { + undoBtn.style.background = 'rgba(239,68,68,0.2)' + } + undoBtn.addEventListener('click', (e) => { + e.stopPropagation() + onUndo(hunk.id) + }) + + container.appendChild(keepBtn) + container.appendChild(undoBtn) + + // Position based on the line's top coordinate + const topPx = Math.max(0, editor.getTopForLineNumber(hunk.startLine) - editor.getScrollTop()) + container.style.top = `${topPx}px` + + editorDom.appendChild(container) + buttonContainers.push(container) + + // Update position on scroll + const scrollDisposable = editor.onDidScrollChange(() => { + const newTop = Math.max(0, editor.getTopForLineNumber(hunk.startLine) - editor.getScrollTop()) + container.style.top = `${newTop}px` + }) + + // Store disposable for cleanup + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(container as any)._scrollDisposable = scrollDisposable + } + } + + // Cleanup function — removes everything + return () => { + decorations.clear() + editor.changeViewZones((accessor) => { + for (const id of viewZoneIds) { + accessor.removeZone(id) + } + }) + for (const container of buttonContainers) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(container as any)._scrollDisposable?.dispose() + container.remove() + } + } +} diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/ai-status-indicator.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/ai-status-indicator.tsx deleted file mode 100644 index fe029653c..000000000 --- a/src/frontend/components/_features/[workspace]/editor/monaco/ai-status-indicator.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useOpenPLCStore } from '../../../../../store' - -type AIStatus = 'ready' | 'loading' | 'error' | 'disabled' - -function useAIStatus(): AIStatus { - const { isEnabled, isLoading, error, hasConsented } = useOpenPLCStore().ai - if (!isEnabled || !hasConsented) return 'disabled' - if (error) return 'error' - if (isLoading) return 'loading' - return 'ready' -} - -const statusConfig: Record = { - ready: { - label: 'AI', - dotClass: 'bg-green-500', - title: 'AI completions active', - }, - loading: { - label: 'AI', - dotClass: 'bg-yellow-400 animate-pulse', - title: 'AI completion in progress...', - }, - error: { - label: 'AI', - dotClass: 'bg-red-500', - title: 'AI error — check console for details', - }, - disabled: { - label: 'AI', - dotClass: 'bg-neutral-400', - title: 'AI completions disabled', - }, -} - -const AIStatusIndicator = () => { - const status = useAIStatus() - const { label, dotClass, title } = statusConfig[status] - - return ( -
- - {label} -
- ) -} - -export { AIStatusIndicator } diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index b1b044130..355866225 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { baseTypeSchema } from '../../../../../../middleware/shared/ports/plc-schemas' import type { PLCPou } from '../../../../../../middleware/shared/ports/types' import { useAI, useCapabilities, useProject } from '../../../../../../middleware/shared/providers' +import { applyAcceptedHunks, computeHunks, type DiffHunk } from '../../../../../utils/ai-diff-review' import { useDebugBoolValuesMap, useDebugNonBoolValuesMap } from '../../../../../hooks/use-debug-value' import { executeSaveActiveFile, executeSaveProject } from '../../../../../services/save-actions' import { openPLCStoreBase, useOpenPLCStore } from '../../../../../store' @@ -14,7 +15,7 @@ import { getExtensionFromLanguage, getFolderFromPouType } from '../../../../../u import { parseHybridPouFromString, parseTextualPouFromString } from '../../../../../utils/PLC/pou-text-parser' import { Modal, ModalContent, ModalTitle } from '../../../../_molecules/modal' import { toast } from '../../../[app]/toast/use-toast' -import { AIStatusIndicator } from './ai-status-indicator' +import { renderDiffReview } from './ai-diff-review' import { arduinoApiCompletion, cppSignatureHelp, @@ -176,6 +177,15 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType(null) + // AI diff review state — per-hunk inline review with keep/undo buttons + const [diffReview, setDiffReview] = useState<{ + active: boolean + oldBody: string + newBody: string + hunks: DiffHunk[] + acceptedHunks: Set + } | null>(null) + const [templatesInjected, setTemplatesInjected] = useState>(new Set()) const pou = pous.find((p) => p.name === name) @@ -196,6 +206,85 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { + const editor = editorRef.current + if (!editor) return + if (!diffReview?.active || diffReview.hunks.length === 0) return () => {} + + // Get only pending (unresolved) hunks + const pendingHunks = diffReview.hunks.filter((h) => diffReview.acceptedHunks.has(h.id)) + if (pendingHunks.length === 0) { + // All hunks resolved — exit diff review + setDiffReview(null) + return () => {} + } + + const handleKeepHunk = (hunkId: string) => { + // "Keep" = accept this hunk (new code stays), remove from pending + setDiffReview((prev) => { + if (!prev) return prev + const newAccepted = new Set(prev.acceptedHunks) + newAccepted.delete(hunkId) // Remove from pending set = resolved as kept + const remaining = prev.hunks.filter((h) => newAccepted.has(h.id)) + if (remaining.length === 0) return null // All resolved + return { ...prev, acceptedHunks: newAccepted } + }) + } + + const handleUndoHunk = (hunkId: string) => { + // "Undo" = reject this hunk, revert those lines to old version + setDiffReview((prev) => { + if (!prev) return prev + const newAccepted = new Set(prev.acceptedHunks) + newAccepted.delete(hunkId) + + // Rebuild body: accepted hunks keep new code, this rejected hunk keeps old code + const keptIds = new Set() + for (const h of prev.hunks) { + if (h.id === hunkId) continue // This one is undone + if (!newAccepted.has(h.id)) { + // Already resolved as kept + keptIds.add(h.id) + } else { + // Still pending — treat as kept for now (new code) + keptIds.add(h.id) + } + } + + const newBody = applyAcceptedHunks(prev.oldBody, prev.newBody, prev.hunks, keptIds) + + // Update editor model with rebuilt body + const model = editor.getModel() + if (model) { + isSyncingModelRef.current = true + const fullRange = model.getFullModelRange() + editor.executeEdits('ai-diff-undo-hunk', [{ range: fullRange, text: newBody }]) + isSyncingModelRef.current = false + } + setLocalText(newBody) + + // Update store + const state = openPLCStoreBase.getState() + state.projectActions.updatePou({ name, content: { language, value: newBody } }) + + // Recompute hunks for remaining pending changes + const remainingHunks = prev.hunks.filter((h) => newAccepted.has(h.id)) + if (remainingHunks.length === 0) return null + + // Recompute line positions for remaining hunks + const freshHunks = computeHunks(prev.oldBody, newBody) + const freshAccepted = new Set(freshHunks.map((h) => h.id)) + + if (freshHunks.length === 0) return null + return { ...prev, newBody, hunks: freshHunks, acceptedHunks: freshAccepted } + }) + } + + const cleanup = renderDiffReview(editor, pendingHunks, handleKeepHunk, handleUndoHunk) + return cleanup + }, [diffReview, name, language]) + useEffect(() => { if (editorRef.current && searchQuery) { moveToMatch(editorRef.current, searchQuery, sensitiveCase, regularExpression) @@ -943,12 +1032,29 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { - const { pouName: targetPou, body } = (e as CustomEvent<{ pouName: string; body: string; oldBody?: string }>) - .detail + const { + pouName: targetPou, + body, + oldBody, + } = (e as CustomEvent<{ pouName: string; body: string; oldBody?: string }>).detail if (targetPou !== name) return + // If no old body provided (backward compat), apply directly + if (oldBody === undefined) { + const model = editorInstance.getModel() + if (model && model.getValue() !== body) { + isSyncingModelRef.current = true + const fullRange = model.getFullModelRange() + editorInstance.executeEdits('ai-tool-update', [{ range: fullRange, text: body }]) + isSyncingModelRef.current = false + } + setLocalText(body) + return + } + + // Update the editor model with the new body first (so decorations render on actual lines) const model = editorInstance.getModel() if (model && model.getValue() !== body) { isSyncingModelRef.current = true @@ -957,6 +1063,14 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType h.id)) + setDiffReview({ active: true, oldBody, newBody: body, hunks, acceptedHunks: acceptedIds }) } window.addEventListener('ai-pou-updated', handlePouUpdated) @@ -964,14 +1078,14 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { const { pouName: targetPou } = (e as CustomEvent<{ pouName: string }>).detail if (targetPou !== name) return - // No-op — changes are already in the editor model + setDiffReview(null) } window.addEventListener('ai-accept-all-hunks', handleAcceptAllHunks) const handleRejectAllHunks = (e: Event) => { const { pouName: targetPou } = (e as CustomEvent<{ pouName: string }>).detail if (targetPou !== name) return - // Body is restored by the chat panel's undo handler via store setState + setDiffReview(null) } window.addEventListener('ai-reject-all-hunks', handleRejectAllHunks) @@ -1306,7 +1420,6 @@ void loop() return ( <>
- {capabilities.hasAIAssistant && } 0 ? changes[i - 1] : null + if (prev && prev.removed) { + // This is a modification — the previous removal + this addition form a pair + // The removal was already processed, update the last hunk to be 'modified' + const lastHunk = hunks[hunks.length - 1] + if (lastHunk && lastHunk.type === 'removed') { + lastHunk.type = 'modified' + lastHunk.newLines = lines + lastHunk.startLine = newLineNum + lastHunk.endLine = newLineNum + lines.length - 1 + newLineNum += lines.length + continue + } + } + + // Pure addition + hunks.push({ + id: uuidv4(), + type: 'added', + startLine: newLineNum, + endLine: newLineNum + lines.length - 1, + newLines: lines, + oldLines: [], + }) + newLineNum += lines.length + } else if (change.removed) { + // Check if the next change is an addition (modification pattern) + // If so, we'll handle it when we process the addition + const next = i + 1 < changes.length ? changes[i + 1] : null + if (next && next.added) { + // Will be completed when we process the next addition + hunks.push({ + id: uuidv4(), + type: 'removed', + startLine: newLineNum, + endLine: newLineNum - 1, // Will be updated when addition is processed + newLines: [], + oldLines: lines, + }) + } else { + // Pure removal + hunks.push({ + id: uuidv4(), + type: 'removed', + startLine: newLineNum, + endLine: newLineNum - 1, // No new lines occupy space + newLines: [], + oldLines: lines, + }) + } + } + } + + return hunks +} + +/** + * Apply only accepted hunks to produce the final body. + * Accepted hunks use their new content; rejected hunks keep old content. + */ +export function applyAcceptedHunks( + oldBody: string, + newBody: string, + hunks: DiffHunk[], + acceptedIds: Set, +): string { + if (acceptedIds.size === hunks.length) { + // All accepted — use new body directly + return newBody + } + if (acceptedIds.size === 0) { + // None accepted — keep old body + return oldBody + } + + // Rebuild from diff changes + const changes = diffLines(oldBody, newBody) + const result: string[] = [] + let hunkIndex = 0 + + for (let i = 0; i < changes.length; i++) { + const change = changes[i] + + if (!change.added && !change.removed) { + // Unchanged — always include + result.push(change.value) + continue + } + + if (change.removed) { + const next = i + 1 < changes.length ? changes[i + 1] : null + if (next && next.added) { + // Modification pair (removed + added) + const hunk = hunks[hunkIndex++] + if (hunk && acceptedIds.has(hunk.id)) { + result.push(next.value) // Use new + } else { + result.push(change.value) // Keep old + } + i++ // Skip the addition + continue + } + + // Pure removal + const hunk = hunks[hunkIndex++] + if (!hunk || !acceptedIds.has(hunk.id)) { + result.push(change.value) // Keep old (reject removal) + } + // If accepted, omit the removed lines + continue + } + + if (change.added) { + // Pure addition (not preceded by removal) + const hunk = hunks[hunkIndex++] + if (hunk && acceptedIds.has(hunk.id)) { + result.push(change.value) // Accept addition + } + // If rejected, omit the added lines + } + } + + return result.join('') +} From 1efb52c60a93c0f8006fb2db71d5566c16db5677 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:39:44 -0300 Subject: [PATCH 4/4] fix(ai): sort imports, add diff and @types/diff dependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 26 ++++++++++++++++--- package.json | 2 ++ .../[workspace]/editor/monaco/index.tsx | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d7970f2e..257bf515d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "axios": "^1.13.6", "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", + "diff": "^9.0.0", "dompurify": "^3.4.0", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", @@ -84,6 +85,7 @@ "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^14.0.0", + "@types/diff": "^7.0.2", "@types/eslint": "^9.6.1", "@types/jest": "^30.0.0", "@types/lodash": "^4.14.200", @@ -9830,6 +9832,13 @@ "@types/ms": "*" } }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -14087,10 +14096,9 @@ "license": "Apache-2.0" }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -28194,6 +28202,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", diff --git a/package.json b/package.json index a67ce1b54..3a8436ee7 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "axios": "^1.13.6", "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", + "diff": "^9.0.0", "dompurify": "^3.4.0", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", @@ -106,6 +107,7 @@ "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^14.0.0", + "@types/diff": "^7.0.2", "@types/eslint": "^9.6.1", "@types/jest": "^30.0.0", "@types/lodash": "^4.14.200", diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index 355866225..74f4f2b60 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -7,10 +7,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { baseTypeSchema } from '../../../../../../middleware/shared/ports/plc-schemas' import type { PLCPou } from '../../../../../../middleware/shared/ports/types' import { useAI, useCapabilities, useProject } from '../../../../../../middleware/shared/providers' -import { applyAcceptedHunks, computeHunks, type DiffHunk } from '../../../../../utils/ai-diff-review' import { useDebugBoolValuesMap, useDebugNonBoolValuesMap } from '../../../../../hooks/use-debug-value' import { executeSaveActiveFile, executeSaveProject } from '../../../../../services/save-actions' import { openPLCStoreBase, useOpenPLCStore } from '../../../../../store' +import { applyAcceptedHunks, computeHunks, type DiffHunk } from '../../../../../utils/ai-diff-review' import { getExtensionFromLanguage, getFolderFromPouType } from '../../../../../utils/PLC/pou-file-extensions' import { parseHybridPouFromString, parseTextualPouFromString } from '../../../../../utils/PLC/pou-text-parser' import { Modal, ModalContent, ModalTitle } from '../../../../_molecules/modal'