From 7b081d518913a89ac423265091d95526524584a9 Mon Sep 17 00:00:00 2001 From: igennova Date: Fri, 17 Jul 2026 03:03:15 +0530 Subject: [PATCH] feat: add /new slash command to clear chat --- packages/core/src/sessions/sessionService.ts | 35 ++++++++++++++ .../sessionServiceRetryConfig.test.ts | 42 ++++++++++++++++ .../features/message-editor/commands.test.ts | 48 ++++++++++++++++++- .../src/features/message-editor/commands.ts | 15 ++++++ .../suggestions/getSuggestions.test.ts | 4 +- .../sessions/hooks/useSessionCallbacks.ts | 32 +++++++++---- 6 files changed, 162 insertions(+), 14 deletions(-) diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 096826e4fb..603ef0e5fc 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -4258,6 +4258,41 @@ export class SessionService { await this.reconnectInPlace(taskId, repoPath, null); } + /** Tear down the current run and create a new one with a blank transcript. */ + async startFreshSession(taskId: string, repoPath: string): Promise { + this.localRepoPaths.set(taskId, repoPath); + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return; + + const { taskTitle, executionMode, adapter, model, reasoningLevel } = + session; + + await this.teardownSession(session.taskRunId); + + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind === "restoring") { + throw new Error("Authentication is still restoring. Please wait."); + } + if (authStatus.kind !== "ready") { + throw new Error("Unable to reach server. Please check your connection."); + } + + // teardownSession clears localRepoPaths; restore before starting again. + this.localRepoPaths.set(taskId, repoPath); + + await this.createNewLocalSession( + taskId, + taskTitle, + repoPath, + authStatus.auth, + undefined, + executionMode, + adapter, + model, + reasoningLevel, + ); + } + /** * Cancel the current backend agent and reconnect under the same taskRunId. * Does NOT remove the session from the store (avoids connect effect loop). diff --git a/packages/core/src/sessions/sessionServiceRetryConfig.test.ts b/packages/core/src/sessions/sessionServiceRetryConfig.test.ts index 2e482d41d3..4c19ff873c 100644 --- a/packages/core/src/sessions/sessionServiceRetryConfig.test.ts +++ b/packages/core/src/sessions/sessionServiceRetryConfig.test.ts @@ -192,6 +192,48 @@ describe("SessionService.clearSessionError retry config", () => { }); }); +describe("SessionService.startFreshSession", () => { + it("tears down and creates a new task run without replaying the prompt", async () => { + const session = makeSession({ + events: [PROMPT_ECHO_EVENT, AGENT_MESSAGE_EVENT], + model: "claude-fable-5", + adapter: "claude", + executionMode: "acceptEdits", + reasoningLevel: "medium", + }); + const { service, createNewLocalSession, reconnectInPlace } = + createHarness(session); + + await service.startFreshSession("task-1", "/repo"); + + expect(createNewLocalSession).toHaveBeenCalledWith( + "task-1", + "Test task", + "/repo", + { client: {} }, + undefined, + "acceptEdits", + "claude", + "claude-fable-5", + "medium", + ); + expect(reconnectInPlace).not.toHaveBeenCalled(); + }); + + it("leaves resetSession on the reconnect-in-place path", async () => { + const session = makeSession({ + events: [PROMPT_ECHO_EVENT, AGENT_MESSAGE_EVENT], + }); + const { service, createNewLocalSession, reconnectInPlace } = + createHarness(session); + + await service.resetSession("task-1", "/repo"); + + expect(reconnectInPlace).toHaveBeenCalledWith("task-1", "/repo", null); + expect(createNewLocalSession).not.toHaveBeenCalled(); + }); +}); + const CONNECT_PARAMS: ConnectParams = { task: { id: "task-1", diff --git a/packages/ui/src/features/message-editor/commands.test.ts b/packages/ui/src/features/message-editor/commands.test.ts index 420de06c2f..1f7e2f6814 100644 --- a/packages/ui/src/features/message-editor/commands.test.ts +++ b/packages/ui/src/features/message-editor/commands.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { rewriteLocalSkillCommandPrompt } from "./commands"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + CODE_COMMANDS, + rewriteLocalSkillCommandPrompt, + tryExecuteCodeCommand, +} from "./commands"; import type { EditorAvailableCommand } from "./types"; +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: toastError, success: vi.fn() }, +})); + const commands: EditorAvailableCommand[] = [ { name: "local-test-skill", @@ -15,6 +24,10 @@ const commands: EditorAvailableCommand[] = [ ]; describe("message editor commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("rewrites local skill slash commands to skill tags", () => { expect(rewriteLocalSkillCommandPrompt("/local-test-skill", commands)).toBe( '', @@ -37,4 +50,35 @@ describe("message editor commands", () => { rewriteLocalSkillCommandPrompt("/feedback looks good", commands), ).toBe(null); }); + + it("exposes /new as a built-in code command", () => { + expect(CODE_COMMANDS.some((cmd) => cmd.name === "new")).toBe(true); + }); + + it("runs /new via onNewSession for local chats", async () => { + const onNewSession = vi.fn().mockResolvedValue(undefined); + const handled = await tryExecuteCodeCommand("/new", { + taskId: "task-1", + repoPath: "/repo", + session: null, + taskRun: null, + onNewSession, + }); + expect(handled).toBe(true); + expect(onNewSession).toHaveBeenCalledOnce(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("rejects /new when no local session is available", async () => { + const handled = await tryExecuteCodeCommand("/new", { + taskId: "task-1", + repoPath: null, + session: null, + taskRun: null, + }); + expect(handled).toBe(true); + expect(toastError).toHaveBeenCalledWith( + "Clearing chat is only available for local chats.", + ); + }); }); diff --git a/packages/ui/src/features/message-editor/commands.ts b/packages/ui/src/features/message-editor/commands.ts index 8fb6956755..845a9f5397 100644 --- a/packages/ui/src/features/message-editor/commands.ts +++ b/packages/ui/src/features/message-editor/commands.ts @@ -26,6 +26,8 @@ interface CommandContext { events: unknown[]; } | null; taskRun: { id?: string; log_url?: string } | null; + /** Clears the transcript and starts a blank local chat (new task run). */ + onNewSession?: () => Promise; } export interface CodeCommandInsertContext { @@ -104,7 +106,20 @@ const addDirCommand: CodeCommand = { }, }; +const newCommand: CodeCommand = { + name: "new", + description: "Clear the chat and start a fresh conversation", + async execute(_args, ctx) { + if (!ctx.onNewSession || !ctx.repoPath) { + toast.error("Clearing chat is only available for local chats."); + return; + } + await ctx.onNewSession(); + }, +}; + const commands: CodeCommand[] = [ + newCommand, addDirCommand, makeFeedbackCommand("good", "good", "Positive"), makeFeedbackCommand("bad", "bad", "Negative"), diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts index 0d0a7181eb..6659bba5d9 100644 --- a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts @@ -78,7 +78,7 @@ interface Scenario { const SCENARIOS: Scenario[] = [ { name: "built-ins are always present", - expectContains: ["good", "bad", "feedback"], + expectContains: ["new", "good", "bad", "feedback"], }, { name: "agent-supplied skills surface from session events", @@ -135,7 +135,7 @@ const SCENARIOS: Scenario[] = [ { name: "fallback-only", description: "Should not appear" }, ], sessionCommands: [], - expectContains: ["good", "bad", "feedback"], + expectContains: ["new", "good", "bad", "feedback"], expectNotContains: ["fallback-only"], }, { diff --git a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index 188d470ce2..3bc63f4b97 100644 --- a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -61,6 +61,26 @@ export function useSessionCallbacks({ const messagingMode = useMessagingMode(taskId); + const handleNewSession = useCallback(async () => { + if (!repoPath) return; + try { + await sessionService.resetSession(taskId, repoPath); + } catch (error) { + log.error("Failed to reset session", error); + toast.error("Failed to start new session. Please try again."); + } + }, [taskId, repoPath, sessionService]); + + const handleStartFreshSession = useCallback(async () => { + if (!repoPath) return; + try { + await sessionService.startFreshSession(taskId, repoPath); + } catch (error) { + log.error("Failed to start fresh session", error); + toast.error("Failed to clear chat. Please try again."); + } + }, [taskId, repoPath, sessionService]); + const handleSendPrompt = useCallback( async (text: string) => { const currentSession = sessionRef.current; @@ -76,6 +96,7 @@ export function useSessionCallbacks({ } : null, taskRun: task.latest_run ?? null, + onNewSession: handleStartFreshSession, }); if (handled) return; @@ -169,6 +190,7 @@ export function useSessionCallbacks({ messagingMode, setPendingContent, requestFocus, + handleStartFreshSession, ], ); @@ -222,16 +244,6 @@ export function useSessionCallbacks({ } }, [taskId, repoPath, sessionService]); - const handleNewSession = useCallback(async () => { - if (!repoPath) return; - try { - await sessionService.resetSession(taskId, repoPath); - } catch (error) { - log.error("Failed to reset session", error); - toast.error("Failed to start new session. Please try again."); - } - }, [taskId, repoPath, sessionService]); - const handleBashCommand = useCallback( async (command: string) => { if (!repoPath) return;