Compare commits

...
Author SHA1 Message Date
bincxz d504909d4c refactor(ai-chat): limit undo to conversation and draft restoration 2026-10-15 12:36:47 +08:00
bincxz f39c664ef5 Merge remote-tracking branch 'origin/main' into codex/review-pr-3602 2026-10-15 12:27:43 +08:00
netcatty-bot 3e39b3a170 fix: address Codex review on PR #3602 2026-10-09 02:01:18 +00:00
netcatty-bot 358beb5869 fix: address Codex review on PR #3602 2026-10-09 01:27:56 +00:00
netcatty-bot 648a8c8c13 fix: address Codex review on PR #3602 2026-10-09 00:07:10 +00:00
netcatty-bot 5ae7db8e8a fix: address Codex review on PR #3602 2026-10-08 23:38:41 +00:00
netcatty-bot af9157a80a fix: address Codex review on PR #3602 2026-10-08 23:08:34 +00:00
netcatty-bot 9e8773d973 fix: address Codex review on PR #3602 2026-10-08 22:30:32 +00:00
netcatty-bot 5b28b600d4 fix: address Codex review on PR #3602 2026-10-08 21:50:04 +00:00
netcatty-bot 936cfec3b9 fix: address Codex review on PR #3602 2026-10-08 21:12:07 +00:00
netcatty-bot 34e8c7c7a7 fix: address Codex review on PR #3602 2026-10-08 20:43:51 +00:00
netcatty-bot f7b76395ce fix: address Codex review on PR #3602 2026-10-08 19:57:04 +00:00
netcatty-bot e8d1a8d3c7 fix: address Codex review on PR #3602 2026-10-08 19:25:15 +00:00
netcatty-bot 3d87dc26d2 fix: address Codex review on PR #3602 2026-10-08 18:46:40 +00:00
netcatty-bot 6a955c4205 fix: address Codex review on PR #3602 2026-10-08 18:16:00 +00:00
netcatty-bot efd8ac3db6 fix: address Codex review on PR #3602 2026-10-08 17:50:48 +00:00
netcatty-bot cca53cda19 fix: address Codex review on PR #3602 2026-10-08 17:17:40 +00:00
netcatty-bot 2a629b61e2 fix: address Codex review on PR #3602 2026-10-08 16:48:47 +00:00
netcatty-bot 50c6761239 fix: address Codex review on PR #3602 2026-10-08 16:20:26 +00:00
netcatty-bot 06dadb325a fix: address Codex review on PR #3602 2026-10-08 15:22:14 +00:00
netcatty-bot 084751a1a3 fix: address Codex review on PR #3602 2026-10-08 14:40:37 +00:00
netcatty-bot 6f6ef6c82a fix: address Codex review on PR #3602 2026-10-08 14:09:27 +00:00
netcatty-bot 13705f8b0d fix: address Codex review on PR #3602 2026-10-08 13:23:39 +00:00
netcatty-bot 886da82842 fix: address Codex review on PR #3602 2026-10-08 12:50:50 +00:00
netcatty-bot 3eda2aa257 fix: address Codex review on PR #3602 2026-10-08 12:21:11 +00:00
netcatty-bot 9185d15848 fix: address Codex review on PR #3602 2026-10-08 11:42:40 +00:00
netcatty-bot 96eb532720 fix: address Codex review on PR #3602 2026-10-08 11:07:13 +00:00
netcatty-bot 120f41d377 fix: address Codex review on PR #3602 2026-10-08 10:26:23 +00:00
netcatty-bot cbfdafaa66 fix: address Codex review on PR #3602 2026-10-08 09:57:38 +00:00
netcatty-bot a4525c1b92 fix: address Codex review on PR #3602 2026-10-08 09:16:58 +00:00
netcatty-bot 7bdaad37ff fix: address Codex review on PR #3602 2026-10-08 08:48:40 +00:00
netcatty-bot 71cb099639 fix: address Codex review on PR #3602 2026-10-08 07:58:28 +00:00
netcatty-bot cd060c0028 fix: address Codex review on PR #3602 2026-10-08 07:08:18 +00:00
netcatty-bot 364d23a3f2 fix(#3599): automated AI fix 2026-10-08 06:21:21 +00:00
16 changed files with 767 additions and 6 deletions

No files matched your search

+2
View File
@@ -522,6 +522,8 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
+2
View File
@@ -513,6 +513,8 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
+2
View File
@@ -469,6 +469,8 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
+2
View File
@@ -522,6 +522,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
+2
View File
@@ -512,6 +512,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
+33
View File
@@ -86,6 +86,11 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -697,6 +702,32 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, source, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1275,6 +1306,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1335,6 +1367,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify(capture.sessions.find(item => item.id === source.id)), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
test('undo keeps an old source beside its copy within ordinary history retention', async () => {
const sources = Array.from({ length: 50 }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`,
}));
const { dom, container, root, capture } = await setupAiState(sources);
await act(async () => { await capture.ai!.undoLastTurnInSession('history-49'); });
assert.equal(capture.sessions[1].id, 'history-49');
await new Promise(resolve => setTimeout(resolve, 350));
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.ok(saved.some((item: AISession) => item.id === 'history-49'));
assert.deepEqual(saved.find((item: AISession) => item.id === 'history-49').messages, sources[49].messages);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
+23 -1
View File
@@ -1,5 +1,5 @@
import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus } from 'lucide-react';
import { History, Plus, Undo2 } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
@@ -55,6 +55,9 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
@@ -132,6 +135,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
handleSteer,
handleStop,
@@ -232,6 +237,23 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
{handleUndoLastTurn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
aria-label={t('ai.chat.undoLastTurn')}
disabled={sending || isStreaming || !canUndoLastTurn}
onClick={handleUndoLastTurn}
>
<Undo2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
</Tooltip>
)}
<ConversationExport
session={activeSession}
onExport={handleExport}
@@ -120,7 +120,9 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
test('send preflight locks header agent and new-chat controls', () => {
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
assert.match(panel, /sending=\{isSending\}/);
// `sending` also covers an in-flight undo-last-turn so the header controls
// (and a quick send) stay locked while the branch materializes.
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
assert.match(content, /disabled=\{sending\}/);
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
+93 -4
View File
@@ -65,6 +65,7 @@ import {
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -269,6 +270,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
@@ -319,6 +321,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
@@ -821,6 +828,78 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
// Undo last turn: offered only at a safe boundary (never inside a compacted
// prefix, never between an assistant tool call and its result).
const canUndoLastTurn = useMemo(() => Boolean(
activeSession
&& !isStreaming
&& resolveUndoLastTurnBoundary(
activeSession.messages,
activeSession.contextCompaction?.compactedMessageCount ?? 0,
) != null,
), [activeSession, isStreaming]);
const undoInFlightRef = useRef(false);
const handleUndoLastTurn = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
if (!sessionId || !undoLastTurnInSession) return;
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
// click must not start a parallel undo of the same session — that would
// publish duplicate branches and restore the same prompt twice.
if (undoInFlightRef.current) return;
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
if (!result) return;
ensureScopeDraft(currentAgentId);
// Text typed since the last flush lives only in the live composer buffer
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
// merge from the live buffer so it is not lost, then drop the pending
// buffer so the later flushDraftText() cannot overwrite the merged text.
const liveDraft = currentDraftRef.current;
updateScopeDraft(currentAgentId, (draft) => {
const liveText = liveDraft?.text ?? draft.text;
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
return {
...draft,
// Keep anything the user is still typing; the undone prompt goes first.
text: liveText.trim()
? `${result.restored.text}\n\n${liveText}`
: result.restored.text,
attachments: [
...result.restored.attachments,
...(liveDraft?.attachments ?? draft.attachments),
],
// The undone turn was selected with these pills; keep any pills the
// user picked since the send on top of them.
selectedUserSkillSlugs: [
...result.restored.selectedUserSkillSlugs,
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
],
};
});
discardPendingComposerText();
showScopeSessionView(result.sessionId);
// Tool side effects live outside the conversation and cannot be rolled back.
toast.info(t('ai.chat.undoLastTurnNotice'));
} finally {
undoInFlightRef.current = false;
setIsUndoingLastTurn(false);
}
}, [
discardPendingComposerText,
ensureScopeDraft,
isStreaming,
showScopeSessionView,
t,
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
]);
const providersRef = useRef(providers);
providersRef.current = providers;
@@ -1177,8 +1256,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending],
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
@@ -1314,6 +1393,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// While undo-last-turn is creating the branch, the target is still the source; a
// turn submitted now would run in the soon-hidden source session, so
// block it until the branch settles.
if (isUndoingLastTurn) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
@@ -1438,6 +1521,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
@@ -1452,6 +1536,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
@@ -1465,6 +1550,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
// Kept on the user message so undo last turn can restore the pills.
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
@@ -1546,7 +1633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1834,7 +1921,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
>
<AIChatPanelContent
parked={!isVisible}
sending={isSending}
sending={isSending || isUndoingLastTurn}
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -1864,6 +1951,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
+4
View File
@@ -37,6 +37,10 @@ export interface AIChatSidePanelProps {
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
@@ -584,6 +584,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts';
import {
buildUndoLastTurnBranch,
hasUnresolvedToolCalls,
resolveUndoLastTurnBoundary,
} from './aiUndoLastTurn.ts';
const user = (content: string): ChatMessage => ({
id: `u_${content}`,
role: 'user',
content,
timestamp: 1,
});
const assistant = (content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
id: `a_${content}`,
role: 'assistant',
content,
timestamp: 2,
...extra,
});
const session = (messages: ChatMessage[], extra: Partial<AISession> = {}): AISession => ({
id: 'chat-1',
title: 'Chat',
agentId: 'catty',
scope: { type: 'terminal', targetId: 't1' },
messages,
createdAt: 1,
updatedAt: 2,
...extra,
});
test('resolveUndoLastTurnBoundary finds the last user message index', () => {
const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')];
assert.equal(resolveUndoLastTurnBoundary(messages), 2);
});
test('resolveUndoLastTurnBoundary returns null without a user message', () => {
assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null);
assert.equal(resolveUndoLastTurnBoundary([]), null);
});
test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => {
const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')];
assert.equal(resolveUndoLastTurnBoundary(messages, 3), null);
assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2);
});
test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => {
// The call sits in the kept prefix while its result would be cut away.
const split = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
}),
user('again?'),
assistant('', {
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
];
assert.equal(resolveUndoLastTurnBoundary(split), null);
assert.ok(hasUnresolvedToolCalls(split.slice(0, 2)));
// Result already inside the kept prefix — the boundary is safe.
const resolved = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
user('again?'),
assistant('done'),
];
assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false);
assert.equal(resolveUndoLastTurnBoundary(resolved), 1);
});
test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => {
const source = session([
user('first'),
assistant('answer'),
user('bad prompt'),
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.equal(result.session.id, 'chat-2');
assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']);
assert.equal(result.restored.text, 'bad prompt');
// Original session untouched (non-destructive).
assert.equal(source.messages.length, 4);
});
test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => {
const source = session(
[user('compacted'), assistant('x'), user('last'), assistant('y')],
{
externalSessionId: 'prov-1',
contextCompaction: { summary: 'summary', compactedMessageCount: 2 },
},
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 });
assert.ok(result);
assert.equal(result.session.externalSessionId, undefined);
assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 });
});
test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => {
const source = session([
user('with file'),
assistant('ok'),
{
id: 'u_last',
role: 'user',
content: 'look at this',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 },
],
},
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 });
assert.ok(result);
assert.deepEqual(result.restored.attachments, [
{
id: 'undo_99_0',
filename: 'notes.txt',
mediaType: 'text/plain',
base64Data: 'QUJD',
dataUrl: 'data:text/plain;base64,QUJD',
lineCount: 3,
},
]);
});
test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => {
const source = session([
user('first'),
assistant('answer'),
{ ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] },
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
// Original session untouched (non-destructive).
assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
});
test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => {
const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, []);
});
test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => {
assert.equal(
buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }),
null,
);
});
test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => {
// Undoing the first turn leaves the branch with no user message at all, so
// the inherited title was derived from the removed prompt and would stay
// stale forever (auto-titling only runs for untitled/New Chat sessions).
const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' });
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 0);
assert.equal(result?.restored.text, 'first prompt');
assert.equal(result?.session.title, 'New Chat');
});
test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => {
// A note-only send persists empty user text while the mentioned note rides
// on the attachment (its title seeded the conversation title), so a
// blank-content retained user turn must still count as "a user message is
// kept" and must not reset the title to the untitled placeholder.
const noteOnlyUser: ChatMessage = {
id: 'u_note-only',
role: 'user',
content: '',
timestamp: 3,
attachments: [{
base64Data: '',
mediaType: 'text/markdown',
filename: 'plan.md',
vaultNoteId: 'note-1',
vaultNoteTitle: 'Deploy plan',
previewText: '# Deploy plan',
}],
};
const source = session(
[noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'Deploy plan' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.title, 'Deploy plan');
});
test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => {
const source = session(
[user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'renamed by user' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 2);
assert.equal(result?.restored.text, 'second prompt');
// The retained prefix still contains a user message, so the source title
// keeps describing the branch's conversation.
assert.equal(result?.session.title, 'renamed by user');
});
test('undo allows a failed last reply while retaining portable replay metadata', () => {
const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const source = session([
user('first'),
assistant('first answer', { providerContinuation: continuation }),
user('retry this request'),
assistant('request failed', { executionStatus: 'failed' }),
], { externalSessionId: 'original-provider-thread' });
const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 });
assert.ok(result);
assert.deepEqual(result.session.messages[1].providerContinuation, continuation);
assert.equal(result.session.externalSessionId, undefined);
assert.equal(result.restored.text, 'retry this request');
assert.equal(source.messages.length, 4);
});
test('safe boundary does not pair a reused call ID with an earlier result', () => {
const prefix = [
assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }),
assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
];
assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null);
assert.equal(resolveUndoLastTurnBoundary([
...prefix,
assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }),
user('latest'),
]), 4);
});
+154
View File
@@ -0,0 +1,154 @@
import type {
AISession,
ChatMessage,
ChatMessageAttachment,
UploadedFile,
} from '../infrastructure/ai/types';
/**
* Non-destructive "undo last turn" for AI chat sessions.
*
* Undo branches the conversation at the boundary *before* the latest user
* message instead of truncating the original session. The original session
* stays in history untouched; the branch (without the last turn) becomes the
* conversation the user continues, and the undone user message is restored
* into the composer for editing and resending.
*/
export interface UndoLastTurnRestoredDraft {
text: string;
attachments: UploadedFile[];
selectedUserSkillSlugs: string[];
}
export interface UndoLastTurnResult {
/** Branched copy of the source session without the last user turn. */
session: AISession;
/** Undone user message content for the composer. */
restored: UndoLastTurnRestoredDraft;
}
/**
* True when any assistant tool call in `messages` has no matching tool result
* inside the same list. Cutting between a tool call and its result would
* desync agent tool bookkeeping, so such boundaries are rejected.
*/
export function hasUnresolvedToolCalls(messages: readonly ChatMessage[]): boolean {
// Providers may reuse tool-call IDs across a session. Pair each result with
// the nearest preceding unresolved call carrying the same ID (same strategy
// as `repairToolMessageIntegrity`), otherwise a result for an earlier
// occurrence would satisfy a later call that reused the ID.
const pendingCalls = new Map<string, number>();
for (const message of messages) {
for (const call of message.toolCalls ?? []) {
pendingCalls.set(call.id, (pendingCalls.get(call.id) ?? 0) + 1);
}
for (const result of message.toolResults ?? []) {
const pending = pendingCalls.get(result.toolCallId) ?? 0;
if (pending > 0) pendingCalls.set(result.toolCallId, pending - 1);
}
}
for (const pending of pendingCalls.values()) {
if (pending > 0) return true;
}
return false;
}
/**
* Index of the cut point for undoing the last turn (the last user message).
* Returns null when undo is not offered:
* - there is no user message at all,
* - the boundary would fall inside the compacted prefix (its summary would no
* longer match the kept messages),
* - cutting there would split an assistant tool call from its result.
*/
export function resolveUndoLastTurnBoundary(
messages: readonly ChatMessage[],
compactedMessageCount = 0,
): number | null {
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === 'user') {
lastUserIndex = i;
break;
}
}
if (lastUserIndex < 0) return null;
if (lastUserIndex < compactedMessageCount) return null;
const keptPrefix = messages.slice(0, lastUserIndex);
if (hasUnresolvedToolCalls(keptPrefix)) return null;
return lastUserIndex;
}
/** Map a persisted message attachment back to a composer upload (by value). */
export function buildRestoredAttachments(
message: ChatMessage,
now: number,
): UploadedFile[] {
const attachments: ChatMessageAttachment[] = message.attachments ?? message.images ?? [];
return attachments.map((attachment, index) => ({
id: `undo_${now}_${index}`,
filename: attachment.filename ?? '',
mediaType: attachment.mediaType,
base64Data: attachment.base64Data,
dataUrl: attachment.base64Data
? `data:${attachment.mediaType};base64,${attachment.base64Data}`
: '',
...(attachment.filePath !== undefined ? { filePath: attachment.filePath } : {}),
...(attachment.terminalSelection ? { terminalSelection: true } : {}),
...(attachment.vaultNoteId !== undefined ? { vaultNoteId: attachment.vaultNoteId } : {}),
...(attachment.vaultNoteTitle !== undefined ? { vaultNoteTitle: attachment.vaultNoteTitle } : {}),
...(attachment.previewText !== undefined ? { previewText: attachment.previewText } : {}),
...(attachment.lineCount !== undefined ? { lineCount: attachment.lineCount } : {}),
}));
}
/**
* Build the branched "undo last turn" copy of a session.
*
* The branch keeps the compacted prefix intact (its summary still describes
* the kept messages) and drops the external agent session id: external agents
* resume provider-side sessions, so the branch must start a fresh one.
*/
export function buildUndoLastTurnBranch(
source: AISession,
options: { newId: string; now: number },
): UndoLastTurnResult | null {
const compactedMessageCount = source.contextCompaction?.compactedMessageCount ?? 0;
const boundary = resolveUndoLastTurnBoundary(source.messages, compactedMessageCount);
if (boundary == null) return null;
const removedUserMessage = source.messages[boundary];
const retainedPrefix = source.messages.slice(0, boundary);
// When undo removes the first turn, the branch has no user message left and
// still carries the source title, which was auto-titled from the removed
// prompt. Reset it to the untitled placeholder so `autoTitleSession` can
// retitle the branch from the prompt the user sends next; otherwise the
// branch is permanently labeled with text that is no longer its prompt.
// A retained user turn can legitimately carry empty text: a note-only send
// leaves `content` blank while the mentioned note rides on the attachment
// (and its title) — that turn still anchored the conversation, so it must
// keep the note-derived title and suppress the reset.
const hasRetainedUserMessage = retainedPrefix.some(
message => message.role === 'user',
);
const branchedSession: AISession = {
...source,
id: options.newId,
messages: retainedPrefix,
...(hasRetainedUserMessage ? {} : { title: 'New Chat' }),
externalSessionId: undefined,
createdAt: options.now,
updatedAt: options.now,
};
return {
session: branchedSession,
restored: {
text: removedUserMessage.content,
attachments: buildRestoredAttachments(removedUserMessage, options.now),
// The undone turn was selected with these skill pills: they shaped the
// model context, so resending the turn must not silently omit them.
selectedUserSkillSlugs: [...(removedUserMessage.selectedUserSkillSlugs ?? [])],
},
};
}
+2
View File
@@ -162,6 +162,8 @@ export interface ChatMessage {
providerContinuation?: ProviderContinuation;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
/** User-skill pills selected for this turn (user messages); restored on undo. */
selectedUserSkillSlugs?: string[];
agentActivities?: AgentActivity[];
usage?: AgentUsage;
timestamp: number;