Skip to content

Commit 1589172

Browse files
authored
feat: introduce useRewindLogic hook for conversation history navigation (#15716)
1 parent 8a2e0fa commit 1589172

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { act } from 'react';
9+
import { renderHook } from '../../test-utils/render.js';
10+
import { useRewind } from './useRewind.js';
11+
import * as rewindFileOps from '../utils/rewindFileOps.js';
12+
import type { FileChangeStats } from '../utils/rewindFileOps.js';
13+
import type {
14+
ConversationRecord,
15+
MessageRecord,
16+
} from '@google/gemini-cli-core';
17+
18+
// Mock the dependency
19+
vi.mock('../utils/rewindFileOps.js', () => ({
20+
calculateTurnStats: vi.fn(),
21+
calculateRewindImpact: vi.fn(),
22+
}));
23+
24+
describe('useRewindLogic', () => {
25+
const mockUserMessage: MessageRecord = {
26+
id: 'msg-1',
27+
type: 'user',
28+
content: 'Hello',
29+
timestamp: new Date(1000).toISOString(),
30+
};
31+
32+
const mockModelMessage: MessageRecord = {
33+
id: 'msg-2',
34+
type: 'gemini',
35+
content: 'Hi there',
36+
timestamp: new Date(1001).toISOString(),
37+
};
38+
39+
const mockConversation: ConversationRecord = {
40+
sessionId: 'conv-1',
41+
projectHash: 'hash-1',
42+
startTime: new Date(1000).toISOString(),
43+
lastUpdated: new Date(1001).toISOString(),
44+
messages: [mockUserMessage, mockModelMessage],
45+
};
46+
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
});
50+
51+
it('should initialize with no selection', () => {
52+
const { result } = renderHook(() => useRewind(mockConversation));
53+
54+
expect(result.current.selectedMessageId).toBeNull();
55+
expect(result.current.confirmationStats).toBeNull();
56+
});
57+
58+
it('should update state when a message is selected', () => {
59+
const mockStats: FileChangeStats = {
60+
fileCount: 1,
61+
addedLines: 5,
62+
removedLines: 0,
63+
};
64+
vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats);
65+
66+
const { result } = renderHook(() => useRewind(mockConversation));
67+
68+
act(() => {
69+
result.current.selectMessage('msg-1');
70+
});
71+
72+
expect(result.current.selectedMessageId).toBe('msg-1');
73+
expect(result.current.confirmationStats).toEqual(mockStats);
74+
expect(rewindFileOps.calculateRewindImpact).toHaveBeenCalledWith(
75+
mockConversation,
76+
mockUserMessage,
77+
);
78+
});
79+
80+
it('should not update state if selected message is not found', () => {
81+
const { result } = renderHook(() => useRewind(mockConversation));
82+
83+
act(() => {
84+
result.current.selectMessage('non-existent-id');
85+
});
86+
87+
expect(result.current.selectedMessageId).toBeNull();
88+
expect(result.current.confirmationStats).toBeNull();
89+
});
90+
91+
it('should clear selection correctly', () => {
92+
const mockStats: FileChangeStats = {
93+
fileCount: 1,
94+
addedLines: 5,
95+
removedLines: 0,
96+
};
97+
vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats);
98+
99+
const { result } = renderHook(() => useRewind(mockConversation));
100+
101+
// Select first
102+
act(() => {
103+
result.current.selectMessage('msg-1');
104+
});
105+
expect(result.current.selectedMessageId).toBe('msg-1');
106+
107+
// Then clear
108+
act(() => {
109+
result.current.clearSelection();
110+
});
111+
112+
expect(result.current.selectedMessageId).toBeNull();
113+
expect(result.current.confirmationStats).toBeNull();
114+
});
115+
116+
it('should proxy getStats call to utility function', () => {
117+
const mockStats: FileChangeStats = {
118+
fileCount: 2,
119+
addedLines: 10,
120+
removedLines: 2,
121+
};
122+
vi.mocked(rewindFileOps.calculateTurnStats).mockReturnValue(mockStats);
123+
124+
const { result } = renderHook(() => useRewind(mockConversation));
125+
126+
const stats = result.current.getStats(mockUserMessage);
127+
128+
expect(stats).toEqual(mockStats);
129+
expect(rewindFileOps.calculateTurnStats).toHaveBeenCalledWith(
130+
mockConversation,
131+
mockUserMessage,
132+
);
133+
});
134+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useState, useCallback } from 'react';
8+
import type {
9+
ConversationRecord,
10+
MessageRecord,
11+
} from '@google/gemini-cli-core';
12+
import {
13+
calculateTurnStats,
14+
calculateRewindImpact,
15+
type FileChangeStats,
16+
} from '../utils/rewindFileOps.js';
17+
18+
export function useRewind(conversation: ConversationRecord) {
19+
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(
20+
null,
21+
);
22+
const [confirmationStats, setConfirmationStats] =
23+
useState<FileChangeStats | null>(null);
24+
25+
const getStats = useCallback(
26+
(userMessage: MessageRecord) =>
27+
calculateTurnStats(conversation, userMessage),
28+
[conversation],
29+
);
30+
31+
const selectMessage = useCallback(
32+
(messageId: string) => {
33+
const msg = conversation.messages.find((m) => m.id === messageId);
34+
if (msg) {
35+
setSelectedMessageId(messageId);
36+
setConfirmationStats(calculateRewindImpact(conversation, msg));
37+
}
38+
},
39+
[conversation],
40+
);
41+
42+
const clearSelection = useCallback(() => {
43+
setSelectedMessageId(null);
44+
setConfirmationStats(null);
45+
}, []);
46+
47+
return {
48+
selectedMessageId,
49+
getStats,
50+
confirmationStats,
51+
selectMessage,
52+
clearSelection,
53+
};
54+
}

0 commit comments

Comments
 (0)