Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,28 @@ ${skillMd}
return index.entries.find((entry) => entry.id === sessionId) ?? null;
}

/**
* Delete a session by its ID.
* Removes the session entry from the index and deletes the associated messages file.
* Returns true if the session was found and deleted, false otherwise.
*/
deleteSession(sessionId: string): boolean {
const index = this.loadSessionsIndex();
const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId);
if (entryIndex === -1) {
return false;
}

// Remove from index
index.entries.splice(entryIndex, 1);
this.saveSessionsIndex(index);

// Remove messages file
this.removeSessionMessages([sessionId]);

return true;
}

listSessionMessages(sessionId: string): SessionMessage[] {
const messagePath = this.getSessionMessagesPath(sessionId);
if (!fs.existsSync(messagePath)) {
Expand Down
129 changes: 129 additions & 0 deletions src/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,135 @@ test("SessionManager adjusts the active Bash timeout control and session metadat
assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString());
});

test("SessionManager.deleteSession removes session entry from the index", () => {
const workspace = createTempDir("deepcode-delete-workspace-");
const home = createTempDir("deepcode-delete-home-");
setHomeDir(home);

const manager = createSessionManager(workspace, "machine-id-delete");
(manager as any).activateSession = async () => {};

// Create two sessions
const session1 = createSessionAndMessages(manager, "session-delete-1", "First session");
const session2 = createSessionAndMessages(manager, "session-delete-2", "Second session");

assert.equal(manager.listSessions().length, 2);

// Delete the first session
const result = manager.deleteSession(session1);
assert.equal(result, true);

const remaining = manager.listSessions();
assert.equal(remaining.length, 1);
assert.equal(remaining[0]?.id, session2);
});

test("SessionManager.deleteSession removes the messages file", () => {
const workspace = createTempDir("deepcode-delete-msg-workspace-");
const home = createTempDir("deepcode-delete-msg-home-");
setHomeDir(home);

const manager = createSessionManager(workspace, "machine-id-delete-msg");
(manager as any).activateSession = async () => {};

const sessionId = createSessionAndMessages(manager, "session-delete-msg", "Test session");
const messagePath = path.join(
home,
".deepcode",
"projects",
workspace.replace(/[\\\\/]/g, "-").replace(/:/g, ""),
`${sessionId}.jsonl`
);

// Verify messages file exists
assert.ok(fs.existsSync(messagePath));

manager.deleteSession(sessionId);

// Verify messages file is removed
assert.equal(fs.existsSync(messagePath), false);
});

test("SessionManager.deleteSession returns false when session does not exist", () => {
const workspace = createTempDir("deepcode-delete-nonexist-workspace-");
const home = createTempDir("deepcode-delete-nonexist-home-");
setHomeDir(home);

const manager = createSessionManager(workspace, "machine-id-delete-nonexist");

const result = manager.deleteSession("nonexistent-session-id");
assert.equal(result, false);
assert.equal(manager.listSessions().length, 0);
});

test("SessionManager.deleteSession does not affect other sessions", () => {
const workspace = createTempDir("deepcode-delete-others-workspace-");
const home = createTempDir("deepcode-delete-others-home-");
setHomeDir(home);

const manager = createSessionManager(workspace, "machine-id-delete-others");
(manager as any).activateSession = async () => {};

const session1 = createSessionAndMessages(manager, "session-keep-1", "Keep session 1");
const session2 = createSessionAndMessages(manager, "session-keep-2", "Keep session 2");

// Delete non-existent session
const result = manager.deleteSession("non-existent");
assert.equal(result, false);
assert.equal(manager.listSessions().length, 2);

// Delete one session
assert.equal(manager.deleteSession(session1), true);
assert.equal(manager.listSessions().length, 1);
assert.equal(manager.listSessions()[0]?.id, session2);

// The remaining session should still have its messages accessible
const messages = manager.listSessionMessages(session2);
assert.ok(messages.length > 0);
});

/**
* Helper: creates a session and writes a few messages to it so we can test
* that deleteSession removes both the index entry and the messages file.
*/
function createSessionAndMessages(manager: SessionManager, sessionId: string, summary: string): string {
const now = new Date().toISOString();
const index = (manager as any).loadSessionsIndex();
index.entries.push({
id: sessionId,
summary,
assistantReply: null,
assistantThinking: null,
assistantRefusal: null,
toolCalls: null,
status: "completed",
failReason: null,
usage: null,
usagePerModel: null,
activeTokens: 0,
createTime: now,
updateTime: now,
processes: null,
});
(manager as any).saveSessionsIndex(index);

// Write a couple of message lines to the messages file
const projectDir = (manager as any).getProjectStorage().projectDir;
const messagePath = path.join(projectDir, `${sessionId}.jsonl`);
const msg = JSON.stringify({
id: "msg-1",
sessionId,
role: "user",
content: summary,
visible: true,
createTime: now,
updateTime: now,
});
fs.writeFileSync(messagePath, `${msg}\n`, "utf8");

return sessionId;
}

function hasGit(): boolean {
try {
execFileSync("git", ["--version"], { stdio: "ignore" });
Expand Down
8 changes: 8 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,14 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
sessions={sessions}
onSelect={(id) => void handleSelectSession(id)}
onCancel={() => setView("chat")}
onDelete={(id) => {
// If the deleted session is the active one, clear it
if (sessionManager.getActiveSessionId() === id) {
sessionManager.setActiveSessionId(null);
}
sessionManager.deleteSession(id);
refreshSessionsList();
}}
/>
) : view === "undo" ? (
<UndoSelector
Expand Down
71 changes: 59 additions & 12 deletions src/ui/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type Props = {
sessions: SessionEntry[];
onSelect: (sessionId: string) => void;
onCancel: () => void;
onDelete?: (sessionId: string) => void;
};

/**
Expand Down Expand Up @@ -36,9 +37,10 @@ export function filterSessions(sessions: SessionEntry[], query: string): Session
});
}

export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement {
export function SessionList({ sessions, onSelect, onCancel, onDelete }: Props): React.ReactElement {
const [index, setIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState<string | null>(null);
const { columns, rows } = useWindowSize();

// Filter sessions by search query
Expand Down Expand Up @@ -77,7 +79,23 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
setIndex(0);
}, []);

const selectedSession = filteredSessions[safeIndex];

useInput((input, key) => {
// If in delete confirmation mode, handle confirm/cancel
if (confirmDeleteSessionId) {
if (key.return) {
onDelete?.(confirmDeleteSessionId);
setConfirmDeleteSessionId(null);
return;
}
if (key.escape) {
setConfirmDeleteSessionId(null);
return;
}
return;
}

// ESC: clear search first, then cancel
if (key.escape) {
if (searchQuery) {
Expand All @@ -95,13 +113,25 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
return;
}

// Backspace / Delete: remove last search character
if (key.backspace || key.delete) {
// Backspace: remove last search character
if (key.backspace) {
if (searchQuery) {
handleBackspace();
return;
}
}

// Delete key: remove search character, or start delete confirmation
if (key.delete) {
if (searchQuery) {
handleBackspace();
return;
}
// If no search query, navigation keys below handle the rest
// No search query: start delete confirmation if session is selected
if (selectedSession && onDelete) {
setConfirmDeleteSessionId(selectedSession.id);
return;
}
}

// Printable character: append to search query
Expand Down Expand Up @@ -211,20 +241,23 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
) : (
visibleSessions.map((session, i) => {
const actualIndex = scrollOffset + i;
const isSelected = actualIndex === safeIndex;
const isConfirming = confirmDeleteSessionId === session.id;
return (
<Box key={session.id} height={2} marginBottom={1}>
<Box>
<Text color="#229ac3">{actualIndex === safeIndex ? "> " : " "}</Text>
<Text color="#229ac3">{isSelected ? "> " : " "}</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
<Box width={"100%"}>
<Text
{...(actualIndex === safeIndex ? { bold: true } : {})}
color={actualIndex === safeIndex ? "#229ac3" : undefined}
>
<Text {...(isSelected ? { bold: true } : {})} color={isSelected ? "#229ac3" : undefined}>
{formatSessionTitle(session.summary || "Untitled")}
</Text>
<Text dimColor> ({formatSessionStatus(session.status)})</Text>
{isConfirming ? (
<Text color="yellow"> [Delete? Enter=yes, Esc=no]</Text>
) : (
<Text dimColor> ({formatSessionStatus(session.status)})</Text>
)}
</Box>
<Box width="100%">
<Text dimColor>{formatTimestamp(session.updateTime)} </Text>
Expand All @@ -245,14 +278,28 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac
</Box>
{/* Footer */}
<Box flexDirection="column">
{hasActiveSearch ? (
{confirmDeleteSessionId ? (
<Box>
<Text color="yellow">Delete this session? </Text>
<Text bold color="green">
Enter
</Text>
<Text dimColor> to confirm · </Text>
<Text bold color="red">
Esc
</Text>
<Text dimColor> to cancel</Text>
</Box>
) : hasActiveSearch ? (
<Box>
<Text dimColor>Esc clear search · </Text>
<Text dimColor>↑/↓ navigate · Enter select · Esc again to cancel</Text>
</Box>
) : (
<Box>
<Text dimColor>Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel</Text>
<Text dimColor>
Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel · Del delete
</Text>
</Box>
)}
</Box>
Expand Down