Skip to content

Commit 8f9dcc1

Browse files
committed
Add history chat deletion
1 parent ca4a032 commit 8f9dcc1

4 files changed

Lines changed: 255 additions & 58 deletions

File tree

cli/src/components/chat-history-screen.tsx

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { SelectableList } from './selectable-list'
77
import { useSearchableList } from '../hooks/use-searchable-list'
88
import { useTerminalLayout } from '../hooks/use-terminal-layout'
99
import { useTheme } from '../hooks/use-theme'
10-
import { getAllChats, formatRelativeTime } from '../utils/chat-history'
10+
import {
11+
deleteChatSession,
12+
formatRelativeTime,
13+
getAllChats,
14+
} from '../utils/chat-history'
1115

1216
import type { SelectableListItem } from './selectable-list'
1317

@@ -21,6 +25,7 @@ const LAYOUT = {
2125
MAX_RENDERED_CHATS: 100, // Only render this many in the list
2226
TIME_COL_WIDTH: 12, // e.g., "2 hours ago"
2327
MSGS_COL_WIDTH: 8, // e.g., "99 msgs"
28+
DELETE_COL_WIDTH: 8, // e.g., " Delete "
2429
GAP_WIDTH: 3, // gap between columns
2530
} as const
2631

@@ -42,34 +47,37 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
4247
const contentWidth = terminalWidth - LAYOUT.CONTENT_PADDING
4348

4449
// Two-phase loading: load initial chats immediately, then more in background
45-
const initialChats = useMemo(() => getAllChats(LAYOUT.INITIAL_CHATS), [])
46-
const [backgroundChats, setBackgroundChats] = useState<typeof initialChats>(
47-
[],
48-
)
50+
const [chats, setChats] = useState(() => getAllChats(LAYOUT.INITIAL_CHATS))
51+
const [statusMessage, setStatusMessage] = useState<string | null>(null)
4952

5053
// Load more chats in the background after initial render
5154
useEffect(() => {
5255
// Use setTimeout to defer the expensive loading to after first paint
5356
const timer = setTimeout(() => {
54-
const moreChats = getAllChats(
55-
LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS,
56-
)
57-
// Only keep the chats beyond the initial set
58-
setBackgroundChats(moreChats.slice(LAYOUT.INITIAL_CHATS))
57+
setChats(getAllChats(LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS))
5958
}, 0)
6059
return () => clearTimeout(timer)
6160
}, [])
6261

63-
// Combine initial and background chats
64-
const chats = useMemo(
65-
() => [...initialChats, ...backgroundChats],
66-
[initialChats, backgroundChats],
67-
)
62+
const handleDeleteChat = useCallback((chatId: string) => {
63+
const deleted = deleteChatSession(chatId)
64+
if (deleted) {
65+
setChats((prev) => prev.filter((chat) => chat.chatId !== chatId))
66+
setStatusMessage('Chat deleted')
67+
return
68+
}
69+
70+
setStatusMessage('Could not delete chat')
71+
}, [])
6872

6973
// Calculate available width for the prompt text (last column, variable width)
70-
// Format: "[time] [msgs] [prompt...]"
74+
// Format: "[time] [msgs] [prompt...] [Delete]"
7175
const reservedWidth =
72-
LAYOUT.TIME_COL_WIDTH + LAYOUT.MSGS_COL_WIDTH + LAYOUT.GAP_WIDTH * 2 + 2 // +2 for padding
76+
LAYOUT.TIME_COL_WIDTH +
77+
LAYOUT.MSGS_COL_WIDTH +
78+
LAYOUT.DELETE_COL_WIDTH +
79+
LAYOUT.GAP_WIDTH * 2 +
80+
2 // +2 for padding
7381
const maxPromptWidth = Math.max(20, contentWidth - reservedWidth)
7482

7583
// Truncate text to fit single line
@@ -146,6 +154,13 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
146154
[onSelectChat],
147155
)
148156

157+
const handleChatDelete = useCallback(
158+
(item: SelectableListItem) => {
159+
handleDeleteChat(item.id)
160+
},
161+
[handleDeleteChat],
162+
)
163+
149164
// Handle keyboard input
150165
const handleKeyIntercept = useCallback(
151166
(key: { name?: string; shift?: boolean; ctrl?: boolean }) => {
@@ -275,9 +290,11 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
275290
items={filteredItems.slice(0, LAYOUT.MAX_RENDERED_CHATS)}
276291
focusedIndex={focusedIndex}
277292
onSelect={handleChatSelect}
293+
actionLabel="Delete"
294+
onAction={handleChatDelete}
278295
onFocusChange={handleFocusChange}
279296
emptyMessage={
280-
initialChats.length === 0
297+
chats.length === 0
281298
? 'No chat history yet'
282299
: searchQuery
283300
? 'No matching chats'
@@ -314,8 +331,14 @@ export const ChatHistoryScreen: React.FC<ChatHistoryScreenProps> = ({
314331
{/* Help text */}
315332
<box style={{ flexGrow: 1, flexShrink: 1 }}>
316333
<text style={{ fg: theme.muted }}>
317-
↑↓ navigate · Enter select · Esc cancel
334+
↑↓ navigate · Enter select · Click Delete to remove · Esc cancel
318335
</text>
336+
{statusMessage && (
337+
<text style={{ fg: theme.muted }}>
338+
{' · '}
339+
{statusMessage}
340+
</text>
341+
)}
319342
</box>
320343

321344
{/* Buttons - hidden on narrow screens */}

cli/src/components/selectable-list.tsx

Lines changed: 79 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export interface SelectableListProps {
4040
/** Optional max height - if not provided, list fills available space */
4141
maxHeight?: number
4242
onSelect: (item: SelectableListItem, index: number) => void
43+
actionLabel?: string
44+
onAction?: (item: SelectableListItem, index: number) => void
4345
onFocusChange?: (index: number) => void
4446
emptyMessage?: string
4547
}
@@ -53,7 +55,16 @@ export const SelectableList = forwardRef<
5355
SelectableListProps
5456
>(
5557
(
56-
{ items, focusedIndex, maxHeight, onSelect, onFocusChange, emptyMessage = 'No items' },
58+
{
59+
items,
60+
focusedIndex,
61+
maxHeight,
62+
onSelect,
63+
actionLabel,
64+
onAction,
65+
onFocusChange,
66+
emptyMessage = 'No items',
67+
},
5768
ref,
5869
) => {
5970
const theme = useTheme()
@@ -141,13 +152,21 @@ export const SelectableList = forwardRef<
141152
const isHighlighted = isFocused || isHovered
142153

143154
// Use subtle highlight that works in both light and dark themes
144-
const backgroundColor = isHighlighted ? theme.surfaceHover : 'transparent'
155+
const backgroundColor = isHighlighted
156+
? theme.surfaceHover
157+
: 'transparent'
145158
const textColor = isHighlighted ? theme.foreground : theme.muted
146159

147160
return (
148-
<Button
161+
<box
149162
key={item.id}
150-
onClick={() => onSelect(item, idx)}
163+
style={{
164+
flexDirection: 'row',
165+
width: '100%',
166+
backgroundColor,
167+
height: 1,
168+
overflow: 'hidden',
169+
}}
151170
onMouseOver={() => {
152171
setHoveredIndex(idx)
153172
onFocusChange?.(idx)
@@ -157,37 +176,68 @@ export const SelectableList = forwardRef<
157176
setHoveredIndex(null)
158177
}
159178
}}
160-
style={{
161-
flexDirection: 'row',
162-
gap: 3,
163-
backgroundColor,
164-
paddingLeft: 1,
165-
paddingRight: 1,
166-
paddingTop: 0,
167-
paddingBottom: 0,
168-
height: 1,
169-
overflow: 'hidden',
170-
}}
171179
>
172-
{item.icon && (
173-
<text style={{ fg: isHighlighted ? theme.foreground : theme.muted }}>
174-
{item.icon}
175-
</text>
176-
)}
177-
<text
180+
<Button
181+
onClick={() => onSelect(item, idx)}
178182
style={{
179-
fg: item.accent && !isHighlighted ? theme.primary : textColor,
180-
attributes: item.accent || isHighlighted ? TextAttributes.BOLD : undefined,
183+
flexDirection: 'row',
184+
gap: 3,
185+
width: '100%',
186+
flexGrow: 1,
187+
flexShrink: 1,
188+
paddingLeft: 1,
189+
paddingRight: 1,
190+
paddingTop: 0,
191+
paddingBottom: 0,
192+
height: 1,
193+
overflow: 'hidden',
181194
}}
182195
>
183-
{item.label}
184-
</text>
185-
{item.secondary && !item.hideSecondary && (
186-
<text style={{ fg: theme.muted }}>
187-
{item.secondary}
196+
{item.icon && (
197+
<text
198+
style={{
199+
fg: isHighlighted ? theme.foreground : theme.muted,
200+
}}
201+
>
202+
{item.icon}
203+
</text>
204+
)}
205+
<text
206+
style={{
207+
fg:
208+
item.accent && !isHighlighted ? theme.primary : textColor,
209+
attributes:
210+
item.accent || isHighlighted
211+
? TextAttributes.BOLD
212+
: undefined,
213+
}}
214+
>
215+
{item.label}
188216
</text>
217+
{item.secondary && !item.hideSecondary && (
218+
<text style={{ fg: theme.muted }}>{item.secondary}</text>
219+
)}
220+
</Button>
221+
{actionLabel && onAction && (
222+
<Button
223+
onClick={() => onAction(item, idx)}
224+
style={{
225+
paddingLeft: 1,
226+
paddingRight: 1,
227+
paddingTop: 0,
228+
paddingBottom: 0,
229+
height: 1,
230+
flexShrink: 0,
231+
}}
232+
>
233+
<text
234+
style={{ fg: isHighlighted ? theme.error : theme.muted }}
235+
>
236+
{actionLabel}
237+
</text>
238+
</Button>
189239
)}
190-
</Button>
240+
</box>
191241
)
192242
})}
193243
</scrollbox>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
2+
import * as fs from 'fs'
3+
import * as os from 'os'
4+
import * as path from 'path'
5+
6+
let tempDataDir = ''
7+
8+
mock.module('../../project-files', () => ({
9+
getProjectDataDir: () => tempDataDir,
10+
}))
11+
12+
mock.module('../logger', () => ({
13+
logger: {
14+
debug: () => {},
15+
info: () => {},
16+
warn: () => {},
17+
error: () => {},
18+
fatal: () => {},
19+
},
20+
}))
21+
22+
import { deleteChatSession, getAllChats } from '../chat-history'
23+
24+
function writeChat(chatId: string, prompt: string) {
25+
const chatDir = path.join(tempDataDir, 'chats', chatId)
26+
fs.mkdirSync(chatDir, { recursive: true })
27+
fs.writeFileSync(
28+
path.join(chatDir, 'chat-messages.json'),
29+
JSON.stringify([
30+
{
31+
id: `${chatId}-message`,
32+
variant: 'user',
33+
content: prompt,
34+
timestamp: new Date().toISOString(),
35+
blocks: [],
36+
},
37+
]),
38+
)
39+
}
40+
41+
describe('chat-history', () => {
42+
beforeEach(() => {
43+
tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codebuff-history-'))
44+
})
45+
46+
afterEach(() => {
47+
fs.rmSync(tempDataDir, { recursive: true, force: true })
48+
})
49+
50+
test('deleteChatSession removes a saved chat directory', () => {
51+
writeChat('chat-a', 'hello from chat a')
52+
writeChat('chat-b', 'hello from chat b')
53+
54+
expect(deleteChatSession('chat-a')).toBe(true)
55+
56+
expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-a'))).toBe(false)
57+
expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-b'))).toBe(true)
58+
expect(getAllChats().map((chat) => chat.chatId)).toEqual(['chat-b'])
59+
})
60+
61+
test('deleteChatSession rejects invalid chat ids', () => {
62+
const outsideDir = path.join(tempDataDir, 'outside')
63+
fs.mkdirSync(outsideDir, { recursive: true })
64+
65+
expect(deleteChatSession('../outside')).toBe(false)
66+
expect(deleteChatSession('..')).toBe(false)
67+
68+
expect(fs.existsSync(outsideDir)).toBe(true)
69+
})
70+
71+
test('deleteChatSession returns false when the chat does not exist', () => {
72+
expect(deleteChatSession('missing-chat')).toBe(false)
73+
})
74+
})

0 commit comments

Comments
 (0)