Skip to content

feat(chat): add conversation history and message actions#20

Open
Dennis-Huangm wants to merge 3 commits intoOpenDCAI:mainfrom
Dennis-Huangm:pr/chat-history
Open

feat(chat): add conversation history and message actions#20
Dennis-Huangm wants to merge 3 commits intoOpenDCAI:mainfrom
Dennis-Huangm:pr/chat-history

Conversation

@Dennis-Huangm
Copy link
Copy Markdown

@Dennis-Huangm Dennis-Huangm commented Mar 7, 2026

Summary

This PR improves the editor chat panel with persistent conversation history and message-level actions.

Changes included

  • save chat conversations locally for each project
  • restore the most recent conversation after page reload
  • support conversation management, including create, rename, clear, and delete
  • add a copy action for assistant responses
  • add a redo action to regenerate a previous assistant response
  • update related UI text and styles

Motivation

At the moment, chat history is lost after refreshing the page, which breaks continuity during editing sessions.

This PR aims to make the chat experience more practical by preserving local conversation history across reloads and adding a couple of small interaction
improvements for response reuse and retry.

Testing

  • npm run build
  • manually tested conversation persistence after reload
  • manually tested create / rename / clear / delete actions
  • manually tested copy and redo actions

Notes

I tried to keep the changes limited to chat-related UX improvements.
Feedback is very welcome if this would be better split into separate PRs.

Screenshot

Chat history UI      Conversation actions UI
Conversation history and message actions in the editor chat panel

Thank you for reviewing this PR.

store chat/agent conversations per project in local storage and
restore the latest thread by mode on load

add conversation controls for new, history, clear, rename, and
delete to make thread management easier

add assistant message actions for copy and regenerate, plus enter-to-send
and in-flight guards to prevent duplicate requests

update i18n strings and styles to support the new history and action UI

(cherry picked from commit e79c664)
Copilot AI review requested due to automatic review settings March 7, 2026 02:48
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds local (per-project) persistence and basic management for chat/agent conversation history in the editor’s chat panel, along with UI affordances for history, copy, and regenerate.

Changes:

  • Persist and restore chat/agent conversations via localStorage, with basic conversation management (create/load/rename/clear/delete).
  • Add UI controls for conversation history, title display/rename, copy assistant message, and “regenerate” latest assistant response.
  • Add corresponding styling and i18n strings; update .gitignore for local tool/config directories.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
apps/frontend/src/i18n/locales/zh-CN.json Adds new UI strings for conversation/history actions in Chinese locale.
apps/frontend/src/i18n/locales/en-US.json Adds new UI strings for conversation/history actions in English locale.
apps/frontend/src/app/EditorPage.tsx Implements conversation persistence lifecycle, UI actions (history, rename, clear/delete), and message-level copy/regenerate.
apps/frontend/src/app/App.css Adds styles for conversation history UI and message action buttons.
.gitignore Ignores local Playwright MCP and Claude local settings files.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1466 to +1473
const loaded = loadConversations(projectId);
setConversations(loaded);
const latest = loaded.find((c) => c.mode === assistantMode);
if (latest) {
setActiveConversationId(latest.id);
activeConvIdRef.current = latest.id;
if (latest.mode === 'chat') setChatMessages(latest.messages);
else setAgentMessages(latest.messages);
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The reload restore logic only looks for the first conversation matching the current assistantMode (which defaults to 'agent'). This does not restore the most recently updated conversation overall (and may restore an older agent chat even if the last used conversation was in chat mode), which conflicts with the PR description. Consider selecting the latest conversation by updatedAt regardless of mode and then setting assistantMode/messages based on that conversation (or persist last active conversation id + mode separately).

Copilot uses AI. Check for mistakes.
Comment on lines +1491 to +1494
const now = new Date().toISOString();
const baseConversations = conversationsRef.current;
const existing = originConversationId
? baseConversations.find((c) => c.id === originConversationId && c.mode === mode)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

persistCurrentConversation uses conversationsRef.current as the source of truth, but conversationsRef.current is only populated via a useEffect after setConversations(loaded). If the user sends a message before that effect runs (first render after navigation/reload), baseConversations will be [] and persistConversations() will overwrite localStorage, potentially dropping previously saved history. Consider initializing conversationsRef.current synchronously when loading (and/or initializing state from loadConversations(projectId)), or disabling send until conversations are loaded.

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +178
function loadConversations(pid: string): Conversation[] {
if (typeof window === 'undefined' || !pid) return [];
try {
const raw = window.localStorage.getItem(HISTORY_KEY_PREFIX + pid);
if (!raw) return [];
return JSON.parse(raw) as Conversation[];
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

loadConversations() blindly casts JSON to Conversation[] without validating shape. If localStorage is corrupted or schema changes (e.g., missing messages/updatedAt), this can later break rendering/sorting (e.g., conv.messages undefined, invalid dates causing NaN). Consider validating that the parsed value is an array and that each item has expected fields (and filtering/clearing invalid entries) before returning.

Suggested change
function loadConversations(pid: string): Conversation[] {
if (typeof window === 'undefined' || !pid) return [];
try {
const raw = window.localStorage.getItem(HISTORY_KEY_PREFIX + pid);
if (!raw) return [];
return JSON.parse(raw) as Conversation[];
function isValidConversation(value: unknown): value is Conversation {
if (!value || typeof value !== 'object') return false;
const v = value as Partial<Conversation> & { [key: string]: unknown };
if (typeof v.id !== 'string') return false;
if (typeof v.title !== 'string') return false;
if (v.mode !== 'chat' && v.mode !== 'agent') return false;
if (!Array.isArray(v.messages)) return false;
if (typeof v.createdAt !== 'string' || typeof v.updatedAt !== 'string') return false;
const createdTime = new Date(v.createdAt).getTime();
const updatedTime = new Date(v.updatedAt).getTime();
if (Number.isNaN(createdTime) || Number.isNaN(updatedTime)) return false;
return true;
}
function loadConversations(pid: string): Conversation[] {
if (typeof window === 'undefined' || !pid) return [];
try {
const key = HISTORY_KEY_PREFIX + pid;
const raw = window.localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
const validConvs = parsed.filter(isValidConversation);
// If some entries were invalid, persist the cleaned list back to storage.
if (validConvs.length !== parsed.length) {
try {
window.localStorage.setItem(key, JSON.stringify(validConvs));
} catch {
// ignore secondary persistence errors
}
}
return validConvs;

Copilot uses AI. Check for mistakes.
Comment on lines +1631 to +1635
// Set prompt and trigger send on next tick
setPrompt(userPrompt);
setTimeout(() => {
sendPromptRef.current?.();
}, 0);
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

handleRetryMessage sets state (setPrompt(userPrompt)) and then triggers sendPromptRef.current via setTimeout(…, 0). In React 18 concurrent rendering, there's a risk the re-render hasn’t committed before the timeout fires, so sendPrompt() may still read the previous prompt value. Consider changing sendPrompt to accept an explicit prompt argument for retries, or maintain a promptRef that is updated immediately and read inside sendPrompt.

Suggested change
// Set prompt and trigger send on next tick
setPrompt(userPrompt);
setTimeout(() => {
sendPromptRef.current?.();
}, 0);
// Set prompt and trigger send with explicit prompt
setPrompt(userPrompt);
sendPromptRef.current?.(userPrompt);

Copilot uses AI. Check for mistakes.
Comment on lines +1638 to +1647
const handleClearConversation = useCallback(() => {
if (!window.confirm(t('确定清空当前对话?'))) return;
if (assistantMode === 'chat') setChatMessages([]);
else setAgentMessages([]);
if (activeConvIdRef.current) {
const next = conversationsRef.current.filter((c) => c.id !== activeConvIdRef.current);
persistConversations(projectId, next);
setConversations(next);
conversationsRef.current = next;
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The "clear" action currently removes the conversation from history (filters it out of conversationsRef.current) which is effectively a delete, while the UI/confirmation copy implies clearing the current conversation’s messages. Consider either (a) clearing messages but keeping the conversation entry (and updating updatedAt), or (b) renaming the action/copy to indicate deletion to avoid surprising data loss.

Copilot uses AI. Check for mistakes.
Comment on lines +4721 to 4722
<button onClick={sendPrompt} className="btn full" disabled={sendInFlightRef.current}>
{assistantMode === 'chat' ? t('发送') : t('生成建议')}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

sendInFlightRef.current is used to drive the button disabled prop, but refs don’t trigger re-renders. This can leave the UI enabled/disabled out of sync in cases where sendInFlightRef.current changes without another state update in the same tick. Consider using a useState flag for in-flight state (and keep the ref only if you need it for guards), so the disabled state reliably updates.

Copilot uses AI. Check for mistakes.
.chat-msg:hover .msg-actions {
opacity: 1;
}

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

On touch devices, .msg-actions is only shown on .chat-msg:hover, which may make the copy/redo actions effectively unreachable. Consider adding a @media (pointer: coarse) rule similar to .history-item-delete/.history-item-rename to keep .msg-actions visible (or provide an always-visible affordance) on coarse pointers.

Suggested change
/* Ensure message actions are visible on touch / coarse pointer devices */
@media (pointer: coarse) {
.msg-actions {
opacity: 1;
}
}

Copilot uses AI. Check for mistakes.
@Dennis-Huangm Dennis-Huangm changed the title feat(ui): add persistent chat conversation history feat(chat): add conversation history and message actions Mar 7, 2026
@Dennis-Huangm Dennis-Huangm marked this pull request as draft March 7, 2026 03:31
@Dennis-Huangm
Copy link
Copy Markdown
Author

I pushed a follow-up fix for the retry flow.

Previously, retry depended on asynchronous state updates before resending, which could cause the request to use stale prompt/history values. The resend path
now uses an explicit retry payload instead.

I also updated the PR title and description to better match the current scope of the changes.

@Dennis-Huangm
Copy link
Copy Markdown
Author

I pushed another follow-up fix for cross-project async state races.

This update makes the conversation persistence flow project-scoped, so late responses from a previous project no longer write into the current project's chat state after
switching projects. I also applied the same guard to the compile-diagnosis flow and kept the recent title / IME fixes in place.

@Dennis-Huangm Dennis-Huangm marked this pull request as ready for review March 8, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants