mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 10:56:33 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d504909d4c | ||
|
|
f39c664ef5 | ||
|
|
3e39b3a170 | ||
|
|
358beb5869 | ||
|
|
648a8c8c13 | ||
|
|
5ae7db8e8a | ||
|
|
af9157a80a | ||
|
|
9e8773d973 | ||
|
|
5b28b600d4 | ||
|
|
936cfec3b9 | ||
|
|
34e8c7c7a7 | ||
|
|
f7b76395ce | ||
|
|
e8d1a8d3c7 | ||
|
|
3d87dc26d2 | ||
|
|
6a955c4205 | ||
|
|
efd8ac3db6 | ||
|
|
cca53cda19 | ||
|
|
2a629b61e2 | ||
|
|
50c6761239 | ||
|
|
06dadb325a | ||
|
|
084751a1a3 | ||
|
|
6f6ef6c82a | ||
|
|
13705f8b0d | ||
|
|
886da82842 | ||
|
|
3eda2aa257 | ||
|
|
9185d15848 | ||
|
|
96eb532720 | ||
|
|
120f41d377 | ||
|
|
cbfdafaa66 | ||
|
|
a4525c1b92 | ||
|
|
7bdaad37ff | ||
|
|
71cb099639 | ||
|
|
cd060c0028 | ||
|
|
364d23a3f2 |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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': '跳转到消息',
|
||||
|
||||
@@ -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': '跳轉到訊息',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 ?? [])],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user