Compare commits

...
Author SHA1 Message Date
bincxz e77df6f063 fix(ai): keep undo branches in the viewing scope 2026-10-15 17:11:44 +08:00
4 changed files with 53 additions and 2 deletions

No files matched your search

+9 -1
View File
@@ -757,7 +757,10 @@ export function useAIState() {
// 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<{
const undoLastTurnInSession = useCallback(async (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
@@ -768,6 +771,11 @@ export function useAIState() {
if (!result) return null;
const branched = result.session;
// Like Fork, Undo belongs to the panel viewing the source, which may have
// resumed a chat from a closed terminal or inherited it after a merge.
if (viewingScope) {
branched.scope = { ...branched.scope, ...viewingScope };
}
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
@@ -8,6 +8,7 @@ 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 { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
@@ -192,3 +193,39 @@ for (const newerCount of [49, 50]) {
reloaded.container.remove(); reloaded.dom.window.close();
});
}
for (const viewingScope of [
{ type: 'terminal' as const, targetId: 'terminal-b' },
{ type: 'workspace' as const, targetId: 'workspace-b' },
]) {
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
const source = {
...structuredClone(SOURCE_SESSION),
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
};
const { dom, container, root, capture } = await setupAiState([source]);
const sourceBefore = structuredClone(source);
let branchId: string | undefined;
await act(async () => {
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
});
assert.ok(branchId);
const branch = capture.sessions.find(item => item.id === branchId)!;
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
// The real history predicate must still include the selected branch after
// the source terminal no longer belongs to the current workspace.
assert.equal(getSessionScopeMatchRank(
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
}
+6 -1
View File
@@ -852,7 +852,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
const result = await undoLastTurnInSession(sessionId, {
type: scopeType,
targetId: scopeTargetId,
});
if (!result) return;
ensureScopeDraft(currentAgentId);
@@ -899,6 +902,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
scopeType,
scopeTargetId,
]);
const providersRef = useRef(providers);
+1
View File
@@ -40,6 +40,7 @@ export interface AIChatSidePanelProps {
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
) => 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;