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
35 changes: 35 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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).
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/sessions/sessionServiceRetryConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 46 additions & 2 deletions packages/ui/src/features/message-editor/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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(
'<skill name="local-test-skill" source="user" path="/Users/example/.claude/skills/local-test-skill" />',
Expand All @@ -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.",
);
});
});
15 changes: 15 additions & 0 deletions packages/ui/src/features/message-editor/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

export interface CodeCommandInsertContext {
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
},
{
Expand Down
32 changes: 22 additions & 10 deletions packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -76,6 +96,7 @@ export function useSessionCallbacks({
}
: null,
taskRun: task.latest_run ?? null,
onNewSession: handleStartFreshSession,
});
if (handled) return;

Expand Down Expand Up @@ -169,6 +190,7 @@ export function useSessionCallbacks({
messagingMode,
setPendingContent,
requestFocus,
handleStartFreshSession,
],
);

Expand Down Expand Up @@ -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;
Expand Down