feat: add Ghostwriter "Start over" reset with confirmation dialog (#289)

This commit is contained in:
0x1355 2026-03-19 09:44:40 +01:00 committed by GitHub
parent 0b55cb260a
commit 4894711396
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 148 additions and 1 deletions

View File

@ -671,6 +671,18 @@ export async function cancelJobChatRun(
);
}
export async function resetJobGhostwriterConversation(
jobId: string,
): Promise<{ deletedMessages: number; deletedRuns: number }> {
return fetchApi<{ deletedMessages: number; deletedRuns: number }>(
`/jobs/${jobId}/chat/reset`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function cancelJobGhostwriterRun(
jobId: string,
runId: string,

View File

@ -1,5 +1,5 @@
import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key";
import { RefreshCcw, Send, Square } from "lucide-react";
import { Eraser, RefreshCcw, Send, Square } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
@ -9,18 +9,22 @@ type ComposerProps = {
disabled?: boolean;
isStreaming: boolean;
canRegenerate: boolean;
canReset: boolean;
onRegenerate: () => Promise<void>;
onStop: () => Promise<void>;
onSend: (content: string) => Promise<void>;
onReset: () => void;
};
export const Composer: React.FC<ComposerProps> = ({
disabled,
isStreaming,
canRegenerate,
canReset,
onRegenerate,
onStop,
onSend,
onReset,
}) => {
const [value, setValue] = useState("");
@ -51,6 +55,18 @@ export const Composer: React.FC<ComposerProps> = ({
{getMetaShortcutLabel("Enter")} to send
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="outline"
onClick={onReset}
disabled={disabled || !canReset}
aria-label="Start over"
title="Start over"
className="text-destructive hover:text-destructive"
>
<Eraser className="h-3.5 w-3.5" />
</Button>
{isStreaming ? (
<Button
size="icon"

View File

@ -3,6 +3,16 @@ import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Composer } from "./Composer";
import { MessageList } from "./MessageList";
@ -19,6 +29,8 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const messageListRef = useRef<HTMLDivElement | null>(null);
const streamAbortRef = useRef<AbortController | null>(null);
@ -235,6 +247,22 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
}
}, [isStreaming, job.id, loadMessages, messages, onStreamEvent]);
const canReset = useMemo(() => {
return !isStreaming && messages.length > 0;
}, [isStreaming, messages]);
const resetConversation = useCallback(async () => {
try {
await api.resetJobGhostwriterConversation(job.id);
setMessages([]);
toast.success("Conversation cleared");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to reset conversation";
toast.error(message);
}
}, [job.id]);
return (
<div className="flex h-full min-h-0 flex-1 flex-col">
<div
@ -266,11 +294,34 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
disabled={isLoading || isStreaming}
isStreaming={isStreaming}
canRegenerate={canRegenerate}
canReset={canReset}
onRegenerate={regenerate}
onStop={stopStreaming}
onSend={sendMessage}
onReset={() => setIsResetDialogOpen(true)}
/>
</div>
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Start over?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently erase the entire conversation. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => void resetConversation()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Erase conversation
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@ -259,6 +259,21 @@ ghostwriterRouter.post(
}),
);
ghostwriterRouter.post(
"/reset",
asyncRoute(async (req, res) => {
const jobId = getJobId(req);
await runWithRequestContext({ jobId }, async () => {
const result = await ghostwriterService.resetConversationForJob({
jobId,
});
ok(res, result);
});
}),
);
ghostwriterRouter.get(
"/threads",
asyncRoute(async (req, res) => {

View File

@ -342,6 +342,26 @@ export async function completeRun(
return getRunById(runId);
}
export async function deleteAllMessagesForThread(
threadId: string,
): Promise<number> {
const result = await db
.delete(jobChatMessages)
.where(eq(jobChatMessages.threadId, threadId));
return result.changes;
}
export async function deleteAllRunsForThread(
threadId: string,
): Promise<number> {
const result = await db
.delete(jobChatRuns)
.where(eq(jobChatRuns.threadId, threadId));
return result.changes;
}
export async function completeRunIfRunning(
runId: string,
input: {

View File

@ -581,6 +581,39 @@ export async function cancelRun(input: {
};
}
export async function resetConversationForJob(input: {
jobId: string;
}): Promise<{ deletedMessages: number; deletedRuns: number }> {
const thread = await ensureJobThread(input.jobId);
const activeRun = await jobChatRepo.getActiveRunForThread(thread.id);
if (activeRun) {
const controller = abortControllers.get(activeRun.id);
if (controller) {
controller.abort();
}
await jobChatRepo.completeRunIfRunning(activeRun.id, {
status: "cancelled",
errorCode: "REQUEST_TIMEOUT",
errorMessage: "Conversation reset by user",
});
}
const deletedMessages = await jobChatRepo.deleteAllMessagesForThread(
thread.id,
);
const deletedRuns = await jobChatRepo.deleteAllRunsForThread(thread.id);
logger.info("Ghostwriter conversation reset", {
jobId: input.jobId,
threadId: thread.id,
deletedMessages,
deletedRuns,
});
return { deletedMessages, deletedRuns };
}
export async function cancelRunForJob(input: {
jobId: string;
runId: string;