Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/backend/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@
}
}

/* Tailwind's animate-spin utility emits `animation: spin 1s linear infinite`
but our tailwind.config.ts overrides `theme.keyframes` (instead of
extending it), wiping out the default `spin` keyframe. Redefine it here
so animate-spin works app-wide. */
@keyframes spin {
to {
transform: rotate(360deg);
}
}

.ai-chat-typing-dot {
display: inline-block;
width: 5px;
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/components/_molecules/menu-bar/menus/help.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const HelpMenu = () => {

const handleOpenCommunitySupport = () => {
try {
window.open('https://openplc.discussion.community/', '_blank')
window.open('https://edge.autonomylogic.com/forum', '_blank')
} catch (error) {
console.error('Error opening link:', error)
}
Expand Down
25 changes: 12 additions & 13 deletions src/frontend/store/__tests__/ai-slice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { createStore, StoreApi } from 'zustand/vanilla'

import { createAISlice, createAISliceFactory } from '../slices/ai/slice'
import type { AISlice, ChatMessage } from '../slices/ai/types'
import { MAX_CONVERSATION_MESSAGES } from '../slices/ai/types'

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -282,17 +281,16 @@ describe('createAISlice', () => {
expect(messages[1].id).toBe('msg-2')
})

it('enforces MAX_CONVERSATION_MESSAGES by keeping the most recent messages', () => {
expect(MAX_CONVERSATION_MESSAGES).toBe(50)

for (let i = 0; i < MAX_CONVERSATION_MESSAGES + 5; i++) {
it('retains every appended message without truncation', () => {
const total = 75
for (let i = 0; i < total; i++) {
store.getState().aiActions.addMessage(makeMessage({ id: `msg-${i}`, content: `Message ${i}` }))
}

const messages = store.getState().ai.messages
expect(messages).toHaveLength(MAX_CONVERSATION_MESSAGES)
expect(messages[0].id).toBe('msg-5')
expect(messages[MAX_CONVERSATION_MESSAGES - 1].id).toBe(`msg-${MAX_CONVERSATION_MESSAGES + 4}`)
expect(messages).toHaveLength(total)
expect(messages[0].id).toBe('msg-0')
expect(messages[total - 1].id).toBe(`msg-${total - 1}`)
})
})

Expand Down Expand Up @@ -613,17 +611,18 @@ describe('createAISlice', () => {
expect(store.getState().ai.error).toBeNull()
})

it('caps at MAX_CONVERSATION_MESSAGES, keeping the most recent', () => {
it('replaces with the full payload without truncation', () => {
const total = 75
const many: ChatMessage[] = []
for (let i = 0; i < MAX_CONVERSATION_MESSAGES + 7; i++) {
for (let i = 0; i < total; i++) {
many.push(makeMessage({ id: `m-${i}`, content: `Message ${i}` }))
}
store.getState().aiActions.replaceMessages(many)

const messages = store.getState().ai.messages
expect(messages).toHaveLength(MAX_CONVERSATION_MESSAGES)
expect(messages[0].id).toBe('m-7')
expect(messages[MAX_CONVERSATION_MESSAGES - 1].id).toBe(`m-${MAX_CONVERSATION_MESSAGES + 6}`)
expect(messages).toHaveLength(total)
expect(messages[0].id).toBe('m-0')
expect(messages[total - 1].id).toBe(`m-${total - 1}`)
})
})

Expand Down
1 change: 0 additions & 1 deletion src/frontend/store/slices/ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export { createAISlice, createAISliceFactory } from './slice'
export type { AIActions, AIPreferences, AISlice, AIState, ChatMessage, ChatMessageRole, DiffReviewEntry } from './types'
export { MAX_CONVERSATION_MESSAGES } from './types'
7 changes: 1 addition & 6 deletions src/frontend/store/slices/ai/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { StateCreator } from 'zustand'

import type { AIFeatureConfig } from '../../../../middleware/shared/ports/types'
import type { AISlice } from './types'
import { MAX_CONVERSATION_MESSAGES } from './types'

const DEFAULT_AI_STATE: AISlice['ai'] = {
isEnabled: false,
Expand Down Expand Up @@ -152,9 +151,6 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator<AIS
setState(
produce(({ ai }: AISlice) => {
ai.messages.push(message)
if (ai.messages.length > MAX_CONVERSATION_MESSAGES) {
ai.messages = ai.messages.slice(-MAX_CONVERSATION_MESSAGES)
}
}),
)
},
Expand Down Expand Up @@ -292,8 +288,7 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator<AIS
replaceMessages: (messages) => {
setState(
produce(({ ai }: AISlice) => {
ai.messages =
messages.length > MAX_CONVERSATION_MESSAGES ? messages.slice(-MAX_CONVERSATION_MESSAGES) : messages
ai.messages = messages
ai.error = null
}),
Comment on lines 288 to 293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect replaceMessages call sites with context
rg -n --type=ts --type=tsx -C4 '\breplaceMessages\s*\(' src

# Inspect nearby in-place array mutations that may affect message arrays
rg -n --type=ts --type=tsx -C2 '\.(push|pop|shift|unshift|splice|sort|reverse)\(' src/frontend | rg -n 'message|messages' -i

Repository: Autonomy-Logic/openplc-editor

Length of output: 101


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact implementation around the reviewed lines
echo "==== src/frontend/store/slices/ai/slice.ts (around replaceMessages) ===="
nl -ba src/frontend/store/slices/ai/slice.ts | sed -n '250,340p'

# Find all replaceMessages call sites (ts/tsx only)
echo "==== replaceMessages call sites ===="
rg -n --glob '*.ts' --glob '*.tsx' -C4 '\breplaceMessages\s*\(' src/frontend

# Heuristic: find in-place mutations of things named message/messages nearby in frontend
echo "==== Heuristic: message/messages in-place mutations in src/frontend ===="
rg -n --glob '*.ts' --glob '*.tsx' -C2 '\.(push|pop|shift|unshift|splice|sort|reverse)\(' src/frontend \
  | rg -n 'message|messages' -i

Repository: Autonomy-Logic/openplc-editor

Length of output: 207


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== src/frontend/store/slices/ai/slice.ts (around replaceMessages) ===="
cat -n src/frontend/store/slices/ai/slice.ts | sed -n '250,340p'

echo "==== replaceMessages call sites ===="
rg -n --glob '*.ts' --glob '*.tsx' -C4 '\breplaceMessages\s*\(' src/frontend

echo "==== Heuristic: message/messages in-place mutations in src/frontend ===="
rg -n --glob '*.ts' --glob '*.tsx' -C2 '\.(push|pop|shift|unshift|splice|sort|reverse)\(' src/frontend \
  | rg -n 'message|messages' -i

Repository: Autonomy-Logic/openplc-editor

Length of output: 5433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== replaceMessages usages in whole src (ts/tsx/js/jsx) ===="
rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' -C2 '\breplaceMessages\b' src

echo "==== aiActions.replaceMessages call sites in whole src ===="
rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' -C4 'aiActions\.replaceMessages\s*\(' src

echo "==== Heuristic: look for caller mutating a 'messages' variable that is passed to replaceMessages ===="
# After a replaceMessages call, see if the file later mutates arrays named messages/messages*
rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' -C6 'replaceMessages\s*\(' src \
  | rg -n 'push|pop|splice|sort|reverse|unshift|shift' -i

echo "==== Direct search for messages variable mutations in files that mention replaceMessages ===="
files=$(rg -l --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' '\breplaceMessages\b' src || true)
if [ -n "$files" ]; then
  echo "$files" | head -n 50
  rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' -C2 '\.(push|pop|shift|unshift|splice|sort|reverse)\(' $files | rg -n 'message|messages' -i
else
  echo "No files found containing replaceMessages"
fi

Repository: Autonomy-Logic/openplc-editor

Length of output: 5942


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== ChatMessage type (ai slice types.ts) ===="
rg -n --glob '*.ts' 'export type ChatMessage|export interface ChatMessage|type ChatMessage|interface ChatMessage' src/frontend/store/slices/ai/types.ts
echo "---- context ----"
sed -n '1,260p' src/frontend/store/slices/ai/types.ts

echo "==== ai/messages mutation patterns in slice.ts (around line ~120-200) ===="
sed -n '100,220p' src/frontend/store/slices/ai/slice.ts

Repository: Autonomy-Logic/openplc-editor

Length of output: 120


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== types.ts size ===="
wc -l src/frontend/store/slices/ai/types.ts

echo "==== Search for ChatMessage in types.ts ===="
rg -n 'ChatMessage' src/frontend/store/slices/ai/types.ts

echo "==== types.ts (relevant sections) ===="
cat -n src/frontend/store/slices/ai/types.ts | sed -n '1,260p'

echo "==== slice.ts size ===="
wc -l src/frontend/store/slices/ai/slice.ts

echo "==== slice.ts around messages mutation ===="
cat -n src/frontend/store/slices/ai/slice.ts | sed -n '110,210p'

Repository: Autonomy-Logic/openplc-editor

Length of output: 13469


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== replaceMessages occurrences in entire repo ===="
rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' '\breplaceMessages\b' .

echo "==== external direct mutations of ai.messages (set/assign) ===="
rg -n --glob '*.ts' --glob '*.tsx' '\bai\.messages\s*=' src/frontend

echo "==== external in-place array mutations on ai.messages (heuristic) ===="
rg -n --glob '*.ts' --glob '*.tsx' '\bai\.messages\.(push|pop|splice|sort|reverse|shift|unshift)\(' src/frontend

echo "==== mutations of message arrays named messages/message outside slice.ts (heuristic) ===="
rg -n --glob '*.ts' --glob '*.tsx' '\.(push|pop|splice|sort|reverse|shift|unshift)\(' src/frontend \
  | rg -n 'message|messages' -i

echo "==== inspect any bracket access 'replaceMessages' usages (heuristic) ===="
rg -n --glob '*.ts' --glob '*.tsx' '\[['"'"]replaceMessages['"'"]\]' src/frontend

Repository: Autonomy-Logic/openplc-editor

Length of output: 2031


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== replaceMessages( patterns in src/frontend ===="
rg -n --glob '*.ts' --glob '*.tsx' '\breplaceMessages\s*\(' src/frontend

echo "==== .replaceMessages( patterns in src/frontend ===="
rg -n --glob '*.ts' --glob '*.tsx' '\.replaceMessages\s*\(' src/frontend

echo "==== destructured replaceMessages usage patterns ===="
rg -n --glob '*.ts' --glob '*.tsx' '(replaceMessages\s*=\s*|{[^}]*replaceMessages[^}]*})' src/frontend

Repository: Autonomy-Logic/openplc-editor

Length of output: 1054


Avoid storing caller-owned messages array by reference in replaceMessages

src/frontend/store/slices/ai/slice.ts sets ai.messages = messages inside Immer, which aliases the caller’s array; if the caller mutates that array later, ai.messages can drift outside the store update flow. Only unit tests currently call this action, but cloning on write keeps store state ownership intact.

Proposed fix
       replaceMessages: (messages) => {
         setState(
           produce(({ ai }: AISlice) => {
-            ai.messages = messages
+            ai.messages = [...messages]
             ai.error = null
           }),
         )
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
replaceMessages: (messages) => {
setState(
produce(({ ai }: AISlice) => {
ai.messages =
messages.length > MAX_CONVERSATION_MESSAGES ? messages.slice(-MAX_CONVERSATION_MESSAGES) : messages
ai.messages = messages
ai.error = null
}),
replaceMessages: (messages) => {
setState(
produce(({ ai }: AISlice) => {
ai.messages = [...messages]
ai.error = null
}),
)
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/store/slices/ai/slice.ts` around lines 288 - 293,
replaceMessages currently assigns the caller-owned messages array by reference
(ai.messages = messages), which can cause external mutations to affect store
state; inside the replaceMessages reducer (the setState/produce callback for
AISlice) replace that assignment with a cloned array (e.g., a shallow copy like
[...messages] or messages.slice()) so the store owns its copy of messages and
ai.error is still cleared.

)
Expand Down
6 changes: 0 additions & 6 deletions src/frontend/store/slices/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,3 @@ export type AIActions = {
export type AISlice = AIState & {
aiActions: AIActions
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

export const MAX_CONVERSATION_MESSAGES = 50
1 change: 0 additions & 1 deletion src/frontend/store/slices/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export type { AISlice } from './ai'
export { createAISlice, createAISliceFactory } from './ai'
export { MAX_CONVERSATION_MESSAGES } from './ai'
export type { ConsoleSlice } from './console'
export { createConsoleSlice } from './console'
export type { DeviceSlice } from './device'
Expand Down
Loading