Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,20 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
}
}, [attachments, editor]);

// Notify parent when emptiness changes due to attachment add/remove.
// Only reacts to attachment changes; text changes are handled by onUpdate.
// We read editor text directly because isEmptyState may include stale
// attachment info (isContentEmpty counts attachments in its input).
useEffect(() => {
if (!editor) return;
const hasText = !!editor.getText().trim();
const newIsEmpty = !hasText && attachments.length === 0;
if (newIsEmpty !== prevIsEmptyRef.current) {
prevIsEmptyRef.current = newIsEmpty;
callbackRefs.current.onEmptyChange?.(newIsEmpty);
}
}, [attachments, editor]);

// Restore attachments from draft on mount
useEffect(() => {
setAttachments(draft.restoredAttachments);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockEnrichDescription = vi.hoisted(() =>
vi.fn().mockImplementation((desc: string) => Promise.resolve(desc)),
);
const mockGenerateTitle = vi.hoisted(() => vi.fn());
const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn());
const mockGetCachedTask = vi.hoisted(() => vi.fn());
const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockSetQueriesData = vi.hoisted(() => vi.fn());
const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn());
const mockPrompts = vi.hoisted(() => ({ value: [] as string[] }));
const mockSessionStoreSetters = vi.hoisted(() => ({
updateSession: vi.fn(),
}));

vi.mock("@utils/generateTitle", () => ({
enrichDescriptionWithFileContent: mockEnrichDescription,
generateTitleAndSummary: mockGenerateTitle,
}));

vi.mock("@features/auth/hooks/authClient", () => ({
getAuthenticatedClient: mockGetAuthenticatedClient,
}));

vi.mock("@utils/queryClient", () => ({
getCachedTask: mockGetCachedTask,
queryClient: { setQueriesData: mockSetQueriesData },
}));

vi.mock("@utils/session", () => ({
extractUserPromptsFromEvents: () => mockPrompts.value,
}));

vi.mock("@features/sessions/service/service", () => ({
getSessionService: () => ({
updateSessionTaskTitle: mockUpdateSessionTaskTitle,
}),
}));

vi.mock("@utils/logger", () => ({
logger: {
scope: () => ({
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));

vi.mock("@features/sessions/stores/sessionStore", () => {
const fn: any = (selector: any) =>
selector({
taskIdIndex: { "task-1": "run-1" },
sessions: { "run-1": { events: mockPrompts.value } },
});
fn.getState = () => ({
taskIdIndex: { "task-1": "run-1" },
sessions: { "run-1": { events: mockPrompts.value } },
});
return {
useSessionStore: fn,
sessionStoreSetters: mockSessionStoreSetters,
};
});

import { useChatTitleGenerator } from "./useChatTitleGenerator";

const TASK_ID = "task-1";

describe("useChatTitleGenerator", () => {
beforeEach(() => {
vi.clearAllMocks();
mockPrompts.value = [];
mockEnrichDescription.mockImplementation((desc: string) =>
Promise.resolve(desc),
);
mockGetAuthenticatedClient.mockResolvedValue({
updateTask: mockUpdateTask,
});
mockGetCachedTask.mockReturnValue(undefined);
});

it("does not generate when promptCount is 0", () => {
renderHook(() => useChatTitleGenerator(TASK_ID));
expect(mockGenerateTitle).not.toHaveBeenCalled();
});

it("generates title on first prompt", async () => {
mockGenerateTitle.mockResolvedValue({
title: "Fix login bug",
summary: "User is fixing a login issue",
});
mockPrompts.value = ["Fix the login bug"];

renderHook(() => useChatTitleGenerator(TASK_ID));

await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, {
title: "Fix login bug",
});
});
});

it("allows first generation even when title_manually_set", async () => {
mockGetCachedTask.mockReturnValue({
id: TASK_ID,
title_manually_set: true,
});
mockGenerateTitle.mockResolvedValue({
title: "Auto title",
summary: "",
});
mockPrompts.value = ["some prompt"];

renderHook(() => useChatTitleGenerator(TASK_ID));

await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, {
title: "Auto title",
});
});
});

it("calls enrichDescriptionWithFileContent before generating", async () => {
mockEnrichDescription.mockResolvedValue("enriched content");
mockGenerateTitle.mockResolvedValue({
title: "Enriched title",
summary: "",
});
mockPrompts.value = ['<file path="/tmp/code.ts" />'];

renderHook(() => useChatTitleGenerator(TASK_ID));

await waitFor(() => {
expect(mockEnrichDescription).toHaveBeenCalledWith(
'1. <file path="/tmp/code.ts" />',
);
expect(mockGenerateTitle).toHaveBeenCalledWith("enriched content");
});
});

it("updates conversation summary when returned", async () => {
mockGenerateTitle.mockResolvedValue({
title: "Some title",
summary: "User wants to fix auth",
});
mockPrompts.value = ["fix auth"];

renderHook(() => useChatTitleGenerator(TASK_ID));

await waitFor(() => {
expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith(
"run-1",
{ conversationSummary: "User wants to fix auth" },
);
});
});

it("does not update when generateTitleAndSummary returns null", async () => {
mockGenerateTitle.mockResolvedValue(null);
mockPrompts.value = ["some prompt"];

renderHook(() => useChatTitleGenerator(TASK_ID));

await waitFor(() => {
expect(mockGenerateTitle).toHaveBeenCalled();
});
expect(mockUpdateTask).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
useSessionStore,
} from "@features/sessions/stores/sessionStore";
import type { Task } from "@shared/types";
import { generateTitleAndSummary } from "@utils/generateTitle";
import {
enrichDescriptionWithFileContent,
generateTitleAndSummary,
} from "@utils/generateTitle";
import { logger } from "@utils/logger";
import { getCachedTask, queryClient } from "@utils/queryClient";
import { extractUserPromptsFromEvents } from "@utils/session";
Expand All @@ -32,8 +35,7 @@ export function useChatTitleGenerator(taskId: string): void {
if (isGenerating.current) return;

if (lastGeneratedAtCount.current === null) {
lastGeneratedAtCount.current = promptCount;
return;
lastGeneratedAtCount.current = 0;
}

const shouldGenerate =
Expand Down Expand Up @@ -61,15 +63,22 @@ export function useChatTitleGenerator(taskId: string): void {
const promptsForTitle =
promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL);

const content = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n");
const rawContent = promptsForTitle
.map((p, i) => `${i + 1}. ${p}`)
.join("\n");

const run = async () => {
try {
const content = await enrichDescriptionWithFileContent(rawContent);
const result = await generateTitleAndSummary(content);
if (result) {
const { title, summary } = result;
if (title) {
if (getCachedTask(taskId)?.title_manually_set) {
const isFirstGeneration = lastGeneratedAtCount.current === 0;
if (
!isFirstGeneration &&
getCachedTask(taskId)?.title_manually_set
) {
log.debug("Skipping auto-title, user renamed task", { taskId });
return;
}
Expand Down
Loading
Loading