From 5c20ae0e45d56fac39c813178bf1eda7acdfd0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 5 May 2026 15:32:48 -0300 Subject: [PATCH 1/3] feat(ai): port shared chat-history persistence + UX fixes from openplc-web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the shared-layer changes from [openplc-web#385](https://github.com/Autonomy-Logic/openplc-web/pull/385). Editor and web share `middleware/shared/ports/`, the AI slice, the shared workspace slice, the toast viewport, and the workspace screen layout — keeping these in sync prevents the two adapters from drifting on what the AI feature exposes to the platform. What's mirrored from web's frontend branch: - middleware/shared/ports/types.ts: - new `AIChatContentBlock` type (text / tool_use / tool_result) - `ChatMessage.content` widens to `string | AIChatContentBlock[]` - new optional `ChatMessage.conversationId` - frontend/store/slices/ai/types.ts + slice.ts: - `conversationId`, `conversations: ConversationSummary[]`, `isLoadingConversation` state - 7 new actions: setConversationId, setConversations, prependConversation, removeConversation, updateConversationTitle, replaceMessages, setLoadingConversation - `clearConversation` now also nulls `conversationId` - `updateMessageContent` widens to accept block-array content - frontend/store/__tests__/ai-slice.test.ts: 17 new tests covering all of the above. 68/68 passing. - frontend/store/slices/shared/slice.ts: `clearStatesOnCloseProject` now calls `aiActions.clearConversation()` so chat doesn't bleed across project switches. - frontend/store/slices/shared/types.ts: SharedRootState gains `AISlice &` so the typed `getState()` inside the shared slice can call `aiActions.*`. The web copy lacks this intersection but its tsc happens to skip the check; adding it everywhere is the correct shape since the AI slice is shared. (Web fix tracked as a follow-up.) - frontend/components/_features/[app]/toast/index.tsx: viewport moves to `top-4 left-1/2 -translate-x-1/2`. Slide-in animation consistently from-top. - frontend/screens/workspace-screen.tsx: chat panel ResizablePanel `defaultSize` 16% → 30%, range 16-25 → 20-50. What's NOT mirrored (web-adapter-specific): - middleware/adapters/web/services/ai/conversations/* (TanStack Query hooks bound to /ai/conversations) — the editor's AI integration is a future workstream; conversation persistence will land in the editor when its AI adapter is wired up. - middleware/adapters/web/components/ai-chat/* (chat panel, switcher, message bubble, agentic loop) — same reason. - router/pages/index-page.tsx (web routing). - The agentic-loop / panel persistence wiring fix (`lastLoadedConversationRef`) — only relevant once an AI chat panel exists in the editor. Validation: - pnpm run lint — 0 errors, 2 pre-existing warnings - pnpm run validate:arch — passes - pnpm exec tsc --noEmit — clean - jest src/frontend/store/__tests__/ai-slice.test.ts — 68/68 pass Tracking: epic DOPE-270 (shared layer changes mirrored). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_features/[app]/toast/index.tsx | 7 +- src/frontend/screens/workspace-screen.tsx | 6 +- src/frontend/store/__tests__/ai-slice.test.ts | 177 +++++++++++++++++- src/frontend/store/slices/ai/slice.ts | 72 +++++++ src/frontend/store/slices/ai/types.ts | 42 ++++- src/frontend/store/slices/shared/slice.ts | 5 + src/frontend/store/slices/shared/types.ts | 4 +- src/middleware/shared/ports/types.ts | 31 ++- 8 files changed, 333 insertions(+), 11 deletions(-) diff --git a/src/frontend/components/_features/[app]/toast/index.tsx b/src/frontend/components/_features/[app]/toast/index.tsx index ff3395197..1646bbad4 100644 --- a/src/frontend/components/_features/[app]/toast/index.tsx +++ b/src/frontend/components/_features/[app]/toast/index.tsx @@ -13,7 +13,10 @@ const ToastViewport = forwardRef< >(({ className, ...props }, ref) => ( For fail toasts to be on top {...props} /> @@ -23,7 +26,7 @@ ToastViewport.displayName = PrimitiveToast.ToastViewport.displayName const toastVariants = cva( // 'group relative pointer-events-auto flex flex-col flex-1 w-full text-neutral-1000 dark:text-white items-start rounded-md shadow-lg px-4 py-3 font-display border border-neutral-200 bg-white dark:bg-neutral-950 dark:border-neutral-850 transition-all data-[swipe=cancel]:translate-y-0 data-[swipe=up]:translate-y-[var(--radix-toast-swipe-end-y)] data-[swipe=move]:translate-y-[var(--radix-toast-swipe-move-y)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=up]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', - 'group relative pointer-events-auto flex flex-col flex-1 w-full text-neutral-1000 dark:text-white items-start rounded-md shadow-lg px-4 py-3 font-display border border-neutral-200 bg-white dark:bg-neutral-950 dark:border-neutral-850 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + 'group relative pointer-events-auto flex flex-col flex-1 w-full text-neutral-1000 dark:text-white items-start rounded-md shadow-lg px-4 py-3 font-display border border-neutral-200 bg-white dark:bg-neutral-950 dark:border-neutral-850 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full', { variants: { variant: { diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 4b88ced50..f2d76cff2 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -661,9 +661,9 @@ const WorkspaceScreen = () => { diff --git a/src/frontend/store/__tests__/ai-slice.test.ts b/src/frontend/store/__tests__/ai-slice.test.ts index 06b152d38..12c360fa8 100644 --- a/src/frontend/store/__tests__/ai-slice.test.ts +++ b/src/frontend/store/__tests__/ai-slice.test.ts @@ -50,6 +50,9 @@ describe('createAISlice', () => { expect(ai.error).toBeNull() expect(ai.preferences).toEqual({ inlineCompletionsEnabled: true }) expect(ai.pendingDiffs).toEqual({}) + expect(ai.conversationId).toBeNull() + expect(ai.conversations).toEqual([]) + expect(ai.isLoadingConversation).toBe(false) }) }) @@ -281,21 +284,24 @@ describe('createAISlice', () => { }) describe('clearConversation', () => { - it('clears all messages and the error', () => { + it('clears messages, error, and the active conversation id', () => { 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.setConversationId('conv-1') store.getState().aiActions.clearConversation() expect(store.getState().ai.messages).toHaveLength(0) expect(store.getState().ai.error).toBeNull() + expect(store.getState().ai.conversationId).toBeNull() }) 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() + expect(store.getState().ai.conversationId).toBeNull() }) }) @@ -404,6 +410,175 @@ describe('createAISlice', () => { expect(store.getState().ai.pendingDiffs).toEqual({}) }) }) + + // --------------------------------------------------------------------------- + // Conversation management (DOPE-2) + // --------------------------------------------------------------------------- + + describe('conversation management', () => { + const summaryA = { + id: 'conv-a', + title: 'Refactor motor control', + lastModel: 'sonnet' as const, + createdAt: '2026-05-04T10:00:00Z', + updatedAt: '2026-05-04T11:00:00Z', + } + const summaryB = { + id: 'conv-b', + title: 'Add timer', + lastModel: 'sonnet' as const, + createdAt: '2026-05-04T09:00:00Z', + updatedAt: '2026-05-04T09:30:00Z', + } + + describe('setConversationId', () => { + it('sets the active conversation id', () => { + store.getState().aiActions.setConversationId('conv-a') + expect(store.getState().ai.conversationId).toBe('conv-a') + }) + + it('clears the active conversation id when set to null', () => { + store.getState().aiActions.setConversationId('conv-a') + store.getState().aiActions.setConversationId(null) + expect(store.getState().ai.conversationId).toBeNull() + }) + }) + + describe('setConversations', () => { + it('replaces the conversations list', () => { + store.getState().aiActions.setConversations([summaryA, summaryB]) + expect(store.getState().ai.conversations).toEqual([summaryA, summaryB]) + }) + + it('replaces with an empty list', () => { + store.getState().aiActions.setConversations([summaryA]) + store.getState().aiActions.setConversations([]) + expect(store.getState().ai.conversations).toEqual([]) + }) + }) + + describe('prependConversation', () => { + it('inserts a new conversation at the top', () => { + store.getState().aiActions.setConversations([summaryB]) + store.getState().aiActions.prependConversation(summaryA) + expect(store.getState().ai.conversations.map((c) => c.id)).toEqual(['conv-a', 'conv-b']) + }) + + it('drops a duplicate id before prepending (no double rows)', () => { + store.getState().aiActions.setConversations([summaryA, summaryB]) + store.getState().aiActions.prependConversation({ ...summaryA, title: 'Renamed' }) + const ids = store.getState().ai.conversations.map((c) => c.id) + expect(ids).toEqual(['conv-a', 'conv-b']) + expect(store.getState().ai.conversations[0].title).toBe('Renamed') + }) + }) + + describe('removeConversation', () => { + it('drops a conversation from the list', () => { + store.getState().aiActions.setConversations([summaryA, summaryB]) + store.getState().aiActions.removeConversation('conv-a') + expect(store.getState().ai.conversations).toEqual([summaryB]) + }) + + it('clears messages + active id when removing the active conversation', () => { + store.getState().aiActions.setConversations([summaryA]) + store.getState().aiActions.setConversationId('conv-a') + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + + store.getState().aiActions.removeConversation('conv-a') + + expect(store.getState().ai.conversations).toEqual([]) + expect(store.getState().ai.conversationId).toBeNull() + expect(store.getState().ai.messages).toHaveLength(0) + }) + + it('keeps messages + active id when removing a different conversation', () => { + store.getState().aiActions.setConversations([summaryA, summaryB]) + store.getState().aiActions.setConversationId('conv-a') + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1' })) + + store.getState().aiActions.removeConversation('conv-b') + + expect(store.getState().ai.conversationId).toBe('conv-a') + expect(store.getState().ai.messages).toHaveLength(1) + }) + + it('is a no-op for unknown ids', () => { + store.getState().aiActions.setConversations([summaryA]) + store.getState().aiActions.removeConversation('does-not-exist') + expect(store.getState().ai.conversations).toEqual([summaryA]) + }) + }) + + describe('updateConversationTitle', () => { + it('updates the title of an existing summary', () => { + store.getState().aiActions.setConversations([summaryA, summaryB]) + store.getState().aiActions.updateConversationTitle('conv-a', 'New title') + expect(store.getState().ai.conversations[0].title).toBe('New title') + expect(store.getState().ai.conversations[1].title).toBe(summaryB.title) + }) + + it('is a no-op for unknown ids', () => { + store.getState().aiActions.setConversations([summaryA]) + store.getState().aiActions.updateConversationTitle('nope', 'x') + expect(store.getState().ai.conversations[0].title).toBe(summaryA.title) + }) + }) + + describe('replaceMessages', () => { + it('replaces the messages list wholesale', () => { + store.getState().aiActions.addMessage(makeMessage({ id: 'old-1' })) + const replacement = [ + makeMessage({ id: 'new-1', role: 'user', content: 'one' }), + makeMessage({ id: 'new-2', role: 'assistant', content: 'two' }), + ] + store.getState().aiActions.replaceMessages(replacement) + + expect(store.getState().ai.messages.map((m) => m.id)).toEqual(['new-1', 'new-2']) + }) + + it('clears the error', () => { + store.getState().aiActions.setAIError('boom') + store.getState().aiActions.replaceMessages([]) + expect(store.getState().ai.error).toBeNull() + }) + + it('caps at MAX_CONVERSATION_MESSAGES, keeping the most recent', () => { + const many: ChatMessage[] = [] + for (let i = 0; i < MAX_CONVERSATION_MESSAGES + 7; 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}`) + }) + }) + + describe('setLoadingConversation', () => { + it('toggles the isLoadingConversation flag', () => { + store.getState().aiActions.setLoadingConversation(true) + expect(store.getState().ai.isLoadingConversation).toBe(true) + store.getState().aiActions.setLoadingConversation(false) + expect(store.getState().ai.isLoadingConversation).toBe(false) + }) + }) + }) + + describe('updateMessageContent with block array', () => { + it('replaces a streamed text content with a block array (used at agentic-loop iteration boundary)', () => { + store.getState().aiActions.addMessage(makeMessage({ id: 'msg-1', role: 'assistant', content: 'streamed text' })) + const blocks = [ + { type: 'text' as const, text: 'streamed text' }, + { type: 'tool_use' as const, id: 'toolu_1', name: 'create_pou', input: { name: 'Foo' } }, + ] + store.getState().aiActions.updateMessageContent('msg-1', blocks) + + expect(store.getState().ai.messages[0].content).toEqual(blocks) + }) + }) }) // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/ai/slice.ts b/src/frontend/store/slices/ai/slice.ts index a21ba843f..8dc0c2485 100644 --- a/src/frontend/store/slices/ai/slice.ts +++ b/src/frontend/store/slices/ai/slice.ts @@ -22,6 +22,9 @@ const DEFAULT_AI_STATE: AISlice['ai'] = { isChatOpen: false, error: null, pendingDiffs: {}, + conversationId: null, + conversations: [], + isLoadingConversation: false, } export function createAISliceFactory(config?: AIFeatureConfig): StateCreator { @@ -142,6 +145,10 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator { ai.messages = [] ai.error = null + // Drop the active conversation pointer so the next /ai/chat call + // is treated as a fresh conversation. The list itself stays — + // the user can still see prior conversations and resume one. + ai.conversationId = null }), ) }, @@ -200,6 +207,71 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator { + setState( + produce(({ ai }: AISlice) => { + ai.conversationId = id + }), + ) + }, + setConversations: (conversations) => { + setState( + produce(({ ai }: AISlice) => { + ai.conversations = conversations + }), + ) + }, + prependConversation: (conversation) => { + setState( + produce(({ ai }: AISlice) => { + // Defensive: drop any existing entry with the same id + // before prepending so we never end up with duplicates. + ai.conversations = [ + conversation, + ...ai.conversations.filter((c) => c.id !== conversation.id), + ] + }), + ) + }, + removeConversation: (id) => { + setState( + produce(({ ai }: AISlice) => { + ai.conversations = ai.conversations.filter((c) => c.id !== id) + if (ai.conversationId === id) { + ai.conversationId = null + ai.messages = [] + } + }), + ) + }, + updateConversationTitle: (id, title) => { + setState( + produce(({ ai }: AISlice) => { + const summary = ai.conversations.find((c) => c.id === id) + if (summary) { + summary.title = title + } + }), + ) + }, + replaceMessages: (messages) => { + setState( + produce(({ ai }: AISlice) => { + ai.messages = + messages.length > MAX_CONVERSATION_MESSAGES + ? messages.slice(-MAX_CONVERSATION_MESSAGES) + : messages + ai.error = null + }), + ) + }, + setLoadingConversation: (loading) => { + setState( + produce(({ ai }: AISlice) => { + ai.isLoadingConversation = loading + }), + ) + }, }, }) } diff --git a/src/frontend/store/slices/ai/types.ts b/src/frontend/store/slices/ai/types.ts index ebd284cfa..16f3a98f1 100644 --- a/src/frontend/store/slices/ai/types.ts +++ b/src/frontend/store/slices/ai/types.ts @@ -1,7 +1,19 @@ -import type { ChatMessage, ChatMessageRole } from '../../../../middleware/shared/ports/types' +import type { AIChatContentBlock, ChatMessage, ChatMessageRole } from '../../../../middleware/shared/ports/types' import type { DiffHunk } from '../../../utils/ai-diff-review' -export type { ChatMessage, ChatMessageRole } +export type { AIChatContentBlock, ChatMessage, ChatMessageRole } + +// --------------------------------------------------------------------------- +// Conversation summary (returned by GET /ai/conversations) +// --------------------------------------------------------------------------- + +export type ConversationSummary = { + id: string + title: string + lastModel: 'haiku' | 'sonnet' | null + createdAt: string + updatedAt: string +} // --------------------------------------------------------------------------- // AI message types @@ -59,6 +71,17 @@ export type AIState = { error: string | null /** Pending diff review entries, keyed by POU name. */ pendingDiffs: Record + /** + * Currently-active backend conversation. `null` when the user is + * starting a fresh chat (the backend creates the conversation on the + * first /ai/chat call and emits `conversation_started`, after which + * this id is set so iteration N+1 of the agentic loop can append). + */ + conversationId: string | null + /** Recent conversations for the active project, populated by the list query. */ + conversations: ConversationSummary[] + /** True while a conversation detail (with messages) is being fetched. */ + isLoadingConversation: boolean } } @@ -78,7 +101,7 @@ export type AIActions = { setActiveEditorPou: (pouName: string | null) => void setAgenticLoopRunning: (running: boolean) => void addMessage: (message: ChatMessage) => void - updateMessageContent: (messageId: string, content: string) => void + updateMessageContent: (messageId: string, content: string | AIChatContentBlock[]) => void rateMessage: (messageId: string, rating: 'up' | 'down' | undefined) => void clearConversation: () => void toggleChat: () => void @@ -88,6 +111,19 @@ export type AIActions = { updatePendingDiff: (pouName: string, update: { newBody: string; hunks: DiffHunk[]; acceptedHunks: string[] }) => void clearPendingDiff: (pouName: string) => void clearAllPendingDiffs: () => void + /** Set the active backend conversation id (or null on "+ New chat"). */ + setConversationId: (id: string | null) => void + /** Replace the conversation list (typically called after a list query resolves). */ + setConversations: (conversations: ConversationSummary[]) => void + /** Insert a new conversation summary at the top of the list (after creation). */ + prependConversation: (conversation: ConversationSummary) => void + /** Drop a conversation from the list by id (after delete). */ + removeConversation: (id: string) => void + /** Update a conversation summary's title (after rename). */ + updateConversationTitle: (id: string, title: string) => void + /** Replace the message list wholesale (used when loading a persisted conversation). */ + replaceMessages: (messages: ChatMessage[]) => void + setLoadingConversation: (loading: boolean) => void } // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index e7b98103a..6207a46d8 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -446,6 +446,11 @@ const createSharedSlice: StateCreator = (s getState().searchActions.clearSearch() getState().modalActions.closeModal() getState().versionControlActions.clearVersionControlState() + // Drop the active conversation pointer + its loaded messages so the + // chat doesn't bleed across project switches. The project-scoped + // conversation list is refetched separately on project_id change + // (see IndexPage's effect). + getState().aiActions.clearConversation() }, handleOpenProjectResponse: (data) => { diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 0dad42439..2eed5f75e 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -5,6 +5,7 @@ import type { PLCVariable, ProjectMeta, } from '../../../../middleware/shared/ports/types' +import type { AISlice } from '../ai' import type { ConsoleSlice } from '../console' import type { DeviceSlice } from '../device' import type { EditorSlice } from '../editor' @@ -24,7 +25,8 @@ import type { WorkspaceSlice } from '../workspace' // Root state type for shared slice (it orchestrates across all slices) // --------------------------------------------------------------------------- -export type SharedRootState = ProjectSlice & +export type SharedRootState = AISlice & + ProjectSlice & FileSlice & EditorSlice & TabsSlice & diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 11d931b64..e9c7745c4 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -724,12 +724,41 @@ export interface AIFeatureConfig { export type ChatMessageRole = 'user' | 'assistant' +/** + * Anthropic-compatible content block. Persisted on the backend as JSONB. + * On the wire from `/ai/chat`, mirrors what we re-send to Anthropic + * across iterations of the agentic loop. Plain user text is normalized + * to `[{ type: 'text', text: '...' }]` so readers don't need to branch + * on string vs array. + */ +export type AIChatContentBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: unknown } + | { + type: 'tool_result' + tool_use_id: string + content: string + is_error?: boolean + } + export type ChatMessage = { id: string role: ChatMessageRole - content: string + /** + * Plain string for legacy text-only turns; block array for restored + * conversations from the backend (so `tool_use` / `tool_result` blocks + * survive a reload). Renderers handle either form. + */ + content: string | AIChatContentBlock[] timestamp: number rating?: 'up' | 'down' + /** + * Set when the message was loaded from the backend as part of a + * persisted conversation; absent for in-progress local-only turns. + * Used by the chat panel to know which conversation the message + * belongs to when multiple are open across tabs. + */ + conversationId?: string } // --------------------------------------------------------------------------- From 57a0ac76287032b45e1fce39ceaca27a4ec23891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 5 May 2026 19:43:33 -0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(ai):=20unblock=20CI=20on=20PR=20#747=20?= =?UTF-8?q?=E2=80=94=20Prettier=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `format / Format Check` step (`prettier --check src/`) flagged `src/frontend/store/slices/ai/slice.ts` after the slice extensions landed. The patch I applied from openplc-web kept that repo's formatting verbatim — but the editor uses a stricter Prettier config that re-collapses some of the multi-line `produce()` callbacks. Ran `pnpm exec prettier --write` on the file. Only that one file needed reformatting; the full `prettier --check 'src/**/*.{ts,tsx}'` now reports "All matched files use Prettier code style!". Other CI checks already pass on this PR (lint, build, arch, tsc). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/frontend/store/slices/ai/slice.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/frontend/store/slices/ai/slice.ts b/src/frontend/store/slices/ai/slice.ts index 8dc0c2485..87cdcc674 100644 --- a/src/frontend/store/slices/ai/slice.ts +++ b/src/frontend/store/slices/ai/slice.ts @@ -226,10 +226,7 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator { // Defensive: drop any existing entry with the same id // before prepending so we never end up with duplicates. - ai.conversations = [ - conversation, - ...ai.conversations.filter((c) => c.id !== conversation.id), - ] + ai.conversations = [conversation, ...ai.conversations.filter((c) => c.id !== conversation.id)] }), ) }, @@ -258,9 +255,7 @@ export function createAISliceFactory(config?: AIFeatureConfig): StateCreator { ai.messages = - messages.length > MAX_CONVERSATION_MESSAGES - ? messages.slice(-MAX_CONVERSATION_MESSAGES) - : messages + messages.length > MAX_CONVERSATION_MESSAGES ? messages.slice(-MAX_CONVERSATION_MESSAGES) : messages ai.error = null }), ) From fea05f6100717f4d8a62b3e38aee823764210a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 5 May 2026 19:50:44 -0300 Subject: [PATCH 3/3] ci: re-trigger sync check after web PR #385 prettier fix