Skip to content

Commit 07f1ae1

Browse files
refactor(chat): extract useCompact hook from Chat
- Move handleCompact → useCompact returning an error string instead of calling setCompactError directly - Move getLatestTurn into useCompact (only used there) - Chat.tsx reduced to ~340 lines
1 parent b5a2882 commit 07f1ae1

3 files changed

Lines changed: 187 additions & 143 deletions

File tree

src/components/Chat/Chat.tsx

Lines changed: 18 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@ import {
88
useState,
99
} from 'react';
1010

11-
import { prewarmCodeBlocks } from '@/components/CodeBlock';
1211
import { Messages } from '@/components/Messages';
1312
import { TURN_ABORTED_MESSAGE } from '@/components/Messages/constants';
1413
import { PlanReview } from '@/components/PlanReview';
1514
import { ToolApproval } from '@/components/ToolApproval';
16-
import { DECISION, MODE, PROMPT, ROLE, THEME, UI } from '@/constants';
15+
import { DECISION, MODE, ROLE, THEME, UI } from '@/constants';
1716
import type { Decision, Mode, ThemeDefinition } from '@/types';
18-
import { ollama, screen, tools } from '@/utils';
17+
import { ollama, tools } from '@/utils';
1918

2019
import { ChatInput, type SubmittedInput } from './ChatInput';
2120
import { ChatActionType, InterruptReason } from './constants';
22-
import { useRunTurn } from './hooks';
21+
import { useCompact, useRunTurn } from './hooks';
2322
import { chatReducer, createInitialChatState } from './reducer';
2423

2524
interface Props {
@@ -34,32 +33,6 @@ interface Props {
3433
theme?: ThemeDefinition;
3534
}
3635

37-
/**
38-
* Gets the latest user interaction plus the assistant response.
39-
*/
40-
function getLatestTurn(messages: ollama.Message[]): ollama.Message[] {
41-
const latestAssistantIndex = messages.findLastIndex(
42-
({ role }) => role === ROLE.ASSISTANT,
43-
);
44-
45-
if (latestAssistantIndex >= 0) {
46-
const latestUserIndex = messages
47-
.slice(0, latestAssistantIndex)
48-
.findLastIndex(({ role }) => role === ROLE.USER);
49-
50-
return [
51-
// v8 ignore start
52-
...(latestUserIndex >= 0 ? [messages[latestUserIndex]] : []),
53-
// v8 ignore stop
54-
messages[latestAssistantIndex],
55-
];
56-
}
57-
58-
const latestUser = messages.findLast(({ role }) => role === ROLE.USER);
59-
// v8 ignore next
60-
return latestUser ? [latestUser] : [];
61-
}
62-
6336
export function Chat({
6437
initialMessages,
6538
model,
@@ -95,6 +68,16 @@ export function Chat({
9568
const abortControllerRef = useRef<AbortController | null>(null);
9669
const persistedSnapshotRef = useRef('');
9770
const [compactError, setCompactError] = useState<string | null>(null);
71+
const compact = useCompact({
72+
abortControllerRef,
73+
dispatch,
74+
model,
75+
onMessagesReplace,
76+
persistedSnapshotRef,
77+
sessionId,
78+
state: { isLoading, messages, pendingPlan, pendingToolCall },
79+
theme,
80+
});
9881

9982
useEffect(() => {
10083
dispatch({
@@ -131,117 +114,6 @@ export function Chat({
131114
});
132115
}, []);
133116

134-
const handleCompact = useCallback(async () => {
135-
// v8 ignore start
136-
if (isLoading || pendingPlan || pendingToolCall) {
137-
setCompactError('Cannot compact while another action is pending.');
138-
return;
139-
}
140-
// v8 ignore stop
141-
142-
if (!messages.length) {
143-
setCompactError('Nothing to compact yet.');
144-
return;
145-
}
146-
147-
const modelName = model;
148-
// v8 ignore next
149-
if (!modelName) {
150-
setCompactError('Model is required.');
151-
return;
152-
}
153-
154-
const controller = new AbortController();
155-
abortControllerRef.current = controller;
156-
let summary = '';
157-
158-
setCompactError(null);
159-
dispatch({
160-
type: ChatActionType.SetLoading,
161-
isLoading: true,
162-
});
163-
dispatch({
164-
type: ChatActionType.SetStreamingMessage,
165-
message: { role: ROLE.ASSISTANT, content: '' },
166-
});
167-
168-
try {
169-
const compactionMessages: ollama.Message[] = [
170-
...messages,
171-
{
172-
role: ROLE.USER,
173-
content: PROMPT.COMPACT_MESSAGES_INSTRUCTION,
174-
},
175-
];
176-
177-
for await (const chunk of ollama.streamChat(
178-
compactionMessages,
179-
modelName,
180-
[],
181-
controller.signal,
182-
)) {
183-
// v8 ignore next 3
184-
if (chunk.type === 'content') {
185-
summary = ollama.sanitizeAssistantContent(summary + chunk.content);
186-
}
187-
}
188-
189-
summary = summary.trim();
190-
if (!summary) {
191-
throw new Error('Compaction summary was empty');
192-
}
193-
194-
await prewarmCodeBlocks(summary, theme);
195-
196-
const compactedMessages: ollama.Message[] = [
197-
{
198-
role: ROLE.SYSTEM,
199-
content: `Compacted conversation context:\n\n${summary}`,
200-
},
201-
...getLatestTurn(messages),
202-
];
203-
204-
onMessagesReplace?.(compactedMessages);
205-
persistedSnapshotRef.current = JSON.stringify(compactedMessages);
206-
dispatch({
207-
type: ChatActionType.CommitMessages,
208-
messages: compactedMessages,
209-
});
210-
screen.clear(sessionId);
211-
} catch (error) {
212-
// v8 ignore start
213-
if (!controller.signal.aborted) {
214-
setCompactError(
215-
`Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
216-
);
217-
}
218-
} finally {
219-
if (abortControllerRef.current === controller) {
220-
abortControllerRef.current = null;
221-
}
222-
// v8 ignore stop
223-
224-
dispatch({
225-
type: ChatActionType.SetLoading,
226-
isLoading: false,
227-
});
228-
229-
dispatch({
230-
type: ChatActionType.SetStreamingMessage,
231-
message: null,
232-
});
233-
}
234-
}, [
235-
isLoading,
236-
messages,
237-
model,
238-
onMessagesReplace,
239-
pendingPlan,
240-
pendingToolCall,
241-
sessionId,
242-
theme,
243-
]);
244-
245117
const handlePlanReview = useCallback(
246118
async (mode: Mode) => {
247119
// v8 ignore next
@@ -375,7 +247,10 @@ export function Chat({
375247
}
376248

377249
if (userContent === '/compact' && !images?.length) {
378-
await handleCompact();
250+
const error = await compact();
251+
if (error) {
252+
setCompactError(error);
253+
}
379254
return;
380255
}
381256

@@ -403,7 +278,7 @@ export function Chat({
403278
await runTurn(updatedMessages);
404279
}
405280
},
406-
[handleCompact, messages, mode, onCommand, runTurn, runTurnReadOnly],
281+
[compact, messages, mode, onCommand, runTurn, runTurnReadOnly],
407282
);
408283

409284
return (

src/components/Chat/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export { useCompact } from './useCompact';
12
export { useRunTurn } from './useRunTurn';
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useCallback } from 'react';
2+
3+
import { prewarmCodeBlocks } from '@/components/CodeBlock';
4+
import { PROMPT, ROLE } from '@/constants';
5+
import type { ThemeDefinition } from '@/types';
6+
import { ollama, screen } from '@/utils';
7+
8+
import { ChatActionType } from '../constants';
9+
import type { ChatAction, ChatState } from '../types';
10+
11+
function getLatestTurn(messages: ollama.Message[]): ollama.Message[] {
12+
const latestAssistantIndex = messages.findLastIndex(
13+
({ role }) => role === ROLE.ASSISTANT,
14+
);
15+
16+
if (latestAssistantIndex >= 0) {
17+
const latestUserIndex = messages
18+
.slice(0, latestAssistantIndex)
19+
.findLastIndex(({ role }) => role === ROLE.USER);
20+
21+
return [
22+
// v8 ignore start
23+
...(latestUserIndex >= 0 ? [messages[latestUserIndex]] : []),
24+
// v8 ignore stop
25+
messages[latestAssistantIndex],
26+
];
27+
}
28+
29+
const latestUser = messages.findLast(({ role }) => role === ROLE.USER);
30+
// v8 ignore next
31+
return latestUser ? [latestUser] : [];
32+
}
33+
34+
interface UseCompactOptions {
35+
abortControllerRef: React.RefObject<AbortController | null>;
36+
dispatch: React.Dispatch<ChatAction>;
37+
model: string | undefined;
38+
onMessagesReplace: ((messages: ollama.Message[]) => void) | undefined;
39+
persistedSnapshotRef: React.RefObject<string>;
40+
sessionId: string;
41+
state: Pick<
42+
ChatState,
43+
'isLoading' | 'messages' | 'pendingPlan' | 'pendingToolCall'
44+
>;
45+
theme: ThemeDefinition;
46+
}
47+
48+
/**
49+
* Hook to handle the `/compact` command.
50+
* It summarizes the conversation history into a single context message to reduce token usage.
51+
*/
52+
export function useCompact({
53+
abortControllerRef,
54+
dispatch,
55+
model,
56+
onMessagesReplace,
57+
persistedSnapshotRef,
58+
sessionId,
59+
state,
60+
theme,
61+
}: UseCompactOptions) {
62+
return useCallback(async () => {
63+
const { isLoading, messages, pendingPlan, pendingToolCall } = state;
64+
65+
// v8 ignore start
66+
if (isLoading || pendingPlan || pendingToolCall) {
67+
return 'Cannot compact while another action is pending.';
68+
}
69+
// v8 ignore stop
70+
71+
if (!messages.length) {
72+
return 'Nothing to compact yet.';
73+
}
74+
75+
const modelName = model;
76+
// v8 ignore next
77+
if (!modelName) {
78+
return 'Model is required.';
79+
}
80+
81+
const controller = new AbortController();
82+
abortControllerRef.current = controller;
83+
let summary = '';
84+
85+
dispatch({
86+
type: ChatActionType.SetLoading,
87+
isLoading: true,
88+
});
89+
dispatch({
90+
type: ChatActionType.SetStreamingMessage,
91+
message: { role: ROLE.ASSISTANT, content: '' },
92+
});
93+
94+
try {
95+
const compactionMessages: ollama.Message[] = [
96+
...messages,
97+
{
98+
role: ROLE.USER,
99+
content: PROMPT.COMPACT_MESSAGES_INSTRUCTION,
100+
},
101+
];
102+
103+
for await (const chunk of ollama.streamChat(
104+
compactionMessages,
105+
modelName,
106+
[],
107+
controller.signal,
108+
)) {
109+
// v8 ignore next 3
110+
if (chunk.type === 'content') {
111+
summary = ollama.sanitizeAssistantContent(summary + chunk.content);
112+
}
113+
}
114+
115+
summary = summary.trim();
116+
if (!summary) {
117+
throw new Error('Compaction summary was empty');
118+
}
119+
120+
await prewarmCodeBlocks(summary, theme);
121+
122+
const compactedMessages: ollama.Message[] = [
123+
{
124+
role: ROLE.SYSTEM,
125+
content: `Compacted conversation context:\n\n${summary}`,
126+
},
127+
...getLatestTurn(messages),
128+
];
129+
130+
onMessagesReplace?.(compactedMessages);
131+
persistedSnapshotRef.current = JSON.stringify(compactedMessages);
132+
dispatch({
133+
type: ChatActionType.CommitMessages,
134+
messages: compactedMessages,
135+
});
136+
screen.clear(sessionId);
137+
} catch (error) {
138+
// v8 ignore start
139+
if (!controller.signal.aborted) {
140+
return `Compaction failed: ${error instanceof Error ? error.message : String(error)}`;
141+
}
142+
} finally {
143+
if (abortControllerRef.current === controller) {
144+
abortControllerRef.current = null;
145+
}
146+
// v8 ignore stop
147+
148+
dispatch({
149+
type: ChatActionType.SetLoading,
150+
isLoading: false,
151+
});
152+
153+
dispatch({
154+
type: ChatActionType.SetStreamingMessage,
155+
message: null,
156+
});
157+
}
158+
}, [
159+
abortControllerRef,
160+
dispatch,
161+
model,
162+
onMessagesReplace,
163+
persistedSnapshotRef,
164+
sessionId,
165+
state,
166+
theme,
167+
]);
168+
}

0 commit comments

Comments
 (0)