Skip to content

Commit f27ed3e

Browse files
committed
fix(agent): add conversation history context and sticky diff review bar
- Create context-aware message engine that includes prior conversation bubbles (user/assistant/tool calls) as history when sending messages to the LLM, fixing the missing multi-turn context issue. - Fix diff review summary bar not sticking to the editor bottom by adding flex-column layout to the overlay container so sticky;bottom:0 activates correctly. Made-with: Cursor
1 parent f3514d9 commit f27ed3e

3 files changed

Lines changed: 245 additions & 2 deletions

File tree

src/components/editor/rich/RichEditorWithAgent.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
2-
import { createElement } from 'react'
2+
import { createElement, useMemo } from 'react'
33
import { createRoot } from 'react-dom/client'
44
import { $getRoot, $getState, $parseSerializedNode } from 'lexical'
55
import {
@@ -54,6 +54,7 @@ import { API_URL } from '~/constants/env'
5454
import { useUIStore } from '~/stores/ui'
5555

5656
import { AgentChatPanel } from './agent-chat/AgentChatPanel'
57+
import { createContextAwareEngine } from './agent-chat/composables/context-aware-engine'
5758
import { useAgentSetup } from './agent-chat/composables/use-agent-loop'
5859
import { useReapply } from './agent-chat/composables/use-agent-reapply'
5960
import { provideAgentStore } from './agent-chat/composables/use-agent-store'
@@ -163,7 +164,12 @@ function AgentLoopCaptureInner({
163164
provider: LLMProvider
164165
store: AgentStore
165166
}) {
166-
const loop = useAgentLoop({ provider, store })
167+
const messageEngine = useMemo(() => createContextAwareEngine(store), [store])
168+
const loop = useAgentLoop({
169+
provider,
170+
store,
171+
messageEngine: messageEngine as any,
172+
})
167173
onAgentLoopReady(loop)
168174

169175
const [editor] = useLexicalComposerContext()
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type {
2+
AgentStore,
3+
ChatBubble,
4+
ChatMessage,
5+
MessageEngineContext,
6+
PageContentContext,
7+
PreparedMessages,
8+
ToolCall,
9+
} from '@haklex/rich-agent-core'
10+
import type { SerializedEditorState } from 'lexical'
11+
12+
import {
13+
BaseLastUserContentProvider,
14+
BaseSystemRoleProvider,
15+
BaseSystemRootProvider,
16+
buildDocumentContext,
17+
MessagesEngine,
18+
} from '@haklex/rich-agent-core'
19+
import { defaultAgentSystemMessage } from '@haklex/rich-ext-ai-agent'
20+
21+
const DOCUMENT_TOOL_SYSTEM_ROLE = `Use the document editing tools according to the following contract.
22+
23+
## Document XML Contract
24+
25+
- Document XML references use the serialized \`<doc>...</doc>\` structure.
26+
- Tool \`xml\` arguments must contain block fragments only, not a full \`<document>\` wrapper.
27+
- Use node IDs from injected XML context when a tool requires a target block.
28+
29+
## Tool Contract
30+
31+
### \`insert_node\`
32+
33+
- Insert one or more block nodes.
34+
- \`position.type\` must be \`before\`, \`after\`, or \`root\`.
35+
- \`position.blockId\` is required for \`before\` and \`after\`.
36+
- \`xml\` must be valid block XML fragments.
37+
38+
### \`replace_node\`
39+
40+
- Replace the block identified by \`blockId\`.
41+
- The first block in \`xml\` replaces the target block.
42+
- Additional blocks, if any, are inserted after the replaced block.
43+
- Do not invent a new block ID inside replacement XML.
44+
45+
### \`delete_node\`
46+
47+
- Delete the block identified by \`blockId\`.
48+
- Use only when the user requests removal or the edit clearly requires deleting superseded content.
49+
50+
### \`search_document\`
51+
52+
- Use to locate candidate blocks by text or block type.
53+
- Prefer search when the target block is unknown or a prior edit attempt failed.
54+
55+
## Failure Recovery
56+
57+
- \`block_not_found\`: search again and retry with the correct target.
58+
- \`block_modified\`: assume the reference is stale; re-locate the target or narrow the edit.
59+
- \`xml_parse_error\`, \`invalid_xml\`, \`empty_xml\`: rewrite the XML as valid block fragments and retry.`
60+
61+
class SystemRoleInjector extends BaseSystemRootProvider {
62+
protected buildMessages() {
63+
return [defaultAgentSystemMessage]
64+
}
65+
}
66+
67+
class ToolSystemRoleInjector extends BaseSystemRoleProvider {
68+
protected buildContent() {
69+
return DOCUMENT_TOOL_SYSTEM_ROLE
70+
}
71+
}
72+
73+
class PageEditorContextInjector extends BaseLastUserContentProvider {
74+
protected buildContent(context: MessageEngineContext) {
75+
const pageContext = resolvePageContentContext(context)
76+
if (!pageContext) return null
77+
78+
const formatted = formatPageContentContext(pageContext)
79+
if (!formatted) return null
80+
81+
return { content: formatted, contextType: 'current_page_context' }
82+
}
83+
}
84+
85+
function resolvePageContentContext(
86+
context: MessageEngineContext,
87+
): PageContentContext | undefined {
88+
if (context.pageContentContext) return context.pageContentContext
89+
90+
const initialPageEditor = context.initialContext?.pageEditor
91+
if (!initialPageEditor) return undefined
92+
93+
return {
94+
markdown: initialPageEditor.markdown,
95+
metadata: initialPageEditor.metadata,
96+
xml: context.stepContext?.stepPageEditor?.xml || initialPageEditor.xml,
97+
}
98+
}
99+
100+
function formatPageContentContext(context: PageContentContext): string {
101+
const sections: string[] = []
102+
103+
if (context.markdown) {
104+
const charCount = context.metadata.charCount ?? context.markdown.length
105+
const lineCount =
106+
context.metadata.lineCount ?? context.markdown.split('\n').length
107+
sections.push(
108+
`<markdown chars="${charCount}" lines="${lineCount}">\n${context.markdown}\n</markdown>`,
109+
)
110+
}
111+
112+
if (context.xml) {
113+
sections.push(
114+
`<doc_xml_structure>\n<instruction>IMPORTANT: Use node IDs from this XML structure when performing modify or remove operations.</instruction>\n${context.xml}\n</doc_xml_structure>`,
115+
)
116+
}
117+
118+
return `<current_page title="${context.metadata.title}">\n${sections.join('\n')}\n</current_page>`
119+
}
120+
121+
/**
122+
* Converts store ChatBubble[] to LLM ChatMessage[] for conversation history.
123+
* Skips UI-only bubbles (thinking, error, diff_summary, diff_review).
124+
*/
125+
export function bubblesToChatMessages(bubbles: ChatBubble[]): ChatMessage[] {
126+
const messages: ChatMessage[] = []
127+
128+
for (const bubble of bubbles) {
129+
if (bubble.type === 'user') {
130+
messages.push({ role: 'user', content: bubble.content })
131+
} else if (
132+
bubble.type === 'assistant' &&
133+
bubble.content &&
134+
!bubble.streaming
135+
) {
136+
messages.push({ role: 'assistant', content: bubble.content })
137+
} else if (bubble.type === 'tool_call_group') {
138+
const toolCalls: ToolCall[] = []
139+
for (const item of bubble.items) {
140+
toolCalls.push({
141+
id: item.id,
142+
name: item.toolName,
143+
arguments: JSON.stringify(item.params),
144+
})
145+
}
146+
147+
if (toolCalls.length > 0) {
148+
messages.push({ role: 'assistant_tool_call', toolCalls })
149+
for (const item of bubble.items) {
150+
if (item.status === 'completed' || item.status === 'error') {
151+
messages.push({
152+
role: 'tool_result',
153+
toolCallId: item.id,
154+
content: item.result || item.error || '',
155+
isError: item.status === 'error',
156+
})
157+
}
158+
}
159+
}
160+
}
161+
}
162+
163+
return messages
164+
}
165+
166+
/**
167+
* Creates a message engine that includes conversation history from the
168+
* AgentStore when building messages for the LLM. This is a duck-typed
169+
* replacement for AgentMessagesEngine with multi-turn support.
170+
*
171+
* The engine replicates the same processor pipeline as the library's
172+
* AgentMessagesEngine (system role, tool docs, page context injection)
173+
* but prepends conversation history from bubbles before the current
174+
* user message.
175+
*/
176+
export function createContextAwareEngine(store: AgentStore) {
177+
const engine = new MessagesEngine([
178+
new SystemRoleInjector(),
179+
new ToolSystemRoleInjector(),
180+
new PageEditorContextInjector(),
181+
])
182+
183+
return {
184+
processWithEditor(params: {
185+
editorState: SerializedEditorState
186+
userInput: string
187+
title?: string
188+
}): PreparedMessages {
189+
const bubbles = store.getState().bubbles
190+
191+
// AgentChatPanel adds the user bubble to the store synchronously
192+
// before emitting 'send', so the last bubble may be the current
193+
// user message. Exclude it to avoid duplication (the engine creates
194+
// its own user message below).
195+
const lastBubble = bubbles[bubbles.length - 1]
196+
const historyBubbles =
197+
lastBubble?.type === 'user' && lastBubble.content === params.userInput
198+
? bubbles.slice(0, -1)
199+
: bubbles
200+
201+
const history = bubblesToChatMessages(historyBubbles)
202+
203+
const userMessage: Extract<ChatMessage, { role: 'user' }> = {
204+
role: 'user',
205+
content: params.userInput,
206+
cacheBreakpoint: true,
207+
}
208+
209+
return engine.process({
210+
messages: [...history, userMessage],
211+
pageContentContext: {
212+
metadata: { title: params.title ?? 'Current Document' },
213+
xml: buildDocumentContext(params.editorState, {
214+
mode: 'full',
215+
compact: true,
216+
}),
217+
},
218+
})
219+
},
220+
}
221+
}

src/components/editor/write-editor/index.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,19 @@
171171
.write-editor-wrapper.rich-editor-mode.toolbar-stuck [role='toolbar'] {
172172
box-shadow: var(--rc-shadow-top-bar);
173173
}
174+
175+
/* Diff review summary bar: stick to the bottom of the editor scroll viewport.
176+
The overlay (_12dh7n0) is position:absolute;inset:0 covering the full content.
177+
The bar (_12dh7n8) is position:sticky;bottom:0 (from the library).
178+
Problem: sticky only activates when the element's natural position is past the
179+
viewport edge. With no in-flow siblings, the bar sits at the overlay top.
180+
Fix: flex-column + margin-top:auto pushes the bar to the content bottom,
181+
so sticky;bottom:0 correctly pins it to the scroll viewport bottom. */
182+
.write-editor-wrapper.rich-editor-mode ._12dh7n0 {
183+
display: flex;
184+
flex-direction: column;
185+
}
186+
187+
.write-editor-wrapper.rich-editor-mode ._12dh7n8 {
188+
margin-top: auto;
189+
}

0 commit comments

Comments
 (0)