diff --git a/__tests__/lib/ai-conversations.test.ts b/__tests__/lib/ai-conversations.test.ts index bed7bf0..9260ec1 100644 --- a/__tests__/lib/ai-conversations.test.ts +++ b/__tests__/lib/ai-conversations.test.ts @@ -8,6 +8,8 @@ import { getConversation, deleteConversation, addMessage, + renameConversation, + deriveConversationTitle, } from "~/lib/ai-conversations"; const mockApiFetch = apiFetch as jest.Mock; @@ -111,4 +113,34 @@ describe("conversation CRUD", () => { expect.objectContaining({ method: "DELETE" }), ); }); + + it("renames a conversation via PATCH", async () => { + mockApiFetch.mockResolvedValue(res({ status: 200 })); + await renameConversation("c1", "Renamed"); + expect(mockApiFetch).toHaveBeenCalledWith( + "/api/v1/ai/conversations/c1", + expect.objectContaining({ method: "PATCH", body: JSON.stringify({ title: "Renamed" }) }), + ); + }); +}); + +describe("deriveConversationTitle", () => { + it("uses a short first message verbatim, collapsing whitespace", () => { + expect(deriveConversationTitle(" How many orders? ")).toBe("How many orders?"); + }); + + it("truncates long messages at a word boundary with an ellipsis", () => { + const msg = "Show me everything that happened across all of the projects this past quarter please"; + const title = deriveConversationTitle(msg); + expect(title.length).toBeLessThanOrEqual(49); + expect(title.endsWith("…")).toBe(true); + const stem = title.slice(0, -1); // drop the ellipsis + expect(msg.startsWith(stem)).toBe(true); // a real prefix of the message + // the very next char in the message is whitespace → we cut on a word boundary + expect(/\s/.test(msg[stem.length])).toBe(true); + }); + + it("falls back for empty input", () => { + expect(deriveConversationTitle(" ")).toBe("New conversation"); + }); }); diff --git a/__tests__/stores/ai-chat-store.test.ts b/__tests__/stores/ai-chat-store.test.ts index 7a50a9b..5b33fdc 100644 --- a/__tests__/stores/ai-chat-store.test.ts +++ b/__tests__/stores/ai-chat-store.test.ts @@ -1,5 +1,7 @@ jest.mock("~/lib/ai-chat", () => ({ streamAiChat: jest.fn() })); jest.mock("~/lib/ai-conversations", () => ({ + // Keep the pure helpers (deriveConversationTitle) real; stub the network ones. + ...jest.requireActual("~/lib/ai-conversations"), conversationsAvailable: jest.fn(), listConversations: jest.fn(), createConversation: jest.fn(), @@ -146,6 +148,8 @@ describe("ai-chat-store", () => { await get().send("a question"); expect(conv.createConversation).toHaveBeenCalledTimes(1); + // titled from the first message + expect(conv.createConversation).toHaveBeenCalledWith("a question"); expect(get().conversationId).toBe("c1"); // user + assistant persisted to the server expect(conv.addMessage).toHaveBeenCalledWith("c1", { role: "user", content: "a question" }); diff --git a/lib/ai-conversations.ts b/lib/ai-conversations.ts index cd0e136..4c50833 100644 --- a/lib/ai-conversations.ts +++ b/lib/ai-conversations.ts @@ -134,3 +134,25 @@ export async function addMessage(id: string, message: ConversationMessage): Prom body: JSON.stringify(message), }); } + +export async function renameConversation(id: string, title: string): Promise { + await apiFetch(`${BASE}/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }).catch(() => {}); +} + +/** + * A human title for a conversation derived from its first user message — the + * same first-message fallback the web (`objectui`) uses for display. The + * server's LLM auto-titling only fires on its own chat-persist path (which the + * mobile client bypasses), so we set this at creation instead. + */ +export function deriveConversationTitle(firstMessage: string): string { + const oneLine = firstMessage.replace(/\s+/g, " ").trim(); + if (oneLine === "") return "New conversation"; + if (oneLine.length <= 48) return oneLine; + // Trim to a word boundary under the cap. + return oneLine.slice(0, 48).replace(/\s+\S*$/, "").trimEnd() + "…"; +} diff --git a/stores/ai-chat-store.ts b/stores/ai-chat-store.ts index c05ac36..b7b5752 100644 --- a/stores/ai-chat-store.ts +++ b/stores/ai-chat-store.ts @@ -8,6 +8,7 @@ import { getConversation, deleteConversation, addMessage, + deriveConversationTitle, type ConversationSummary, } from "~/lib/ai-conversations"; @@ -183,7 +184,9 @@ export const useAIChatStore = create((set, get) => ({ let conversationId = get().conversationId; if (serverBacked && !conversationId) { try { - const conv = await createConversation(); + // Title the conversation from its first message (the server's LLM + // auto-titling only runs on its own persist path, which we bypass). + const conv = await createConversation(deriveConversationTitle(trimmed)); conversationId = conv.id; set({ conversationId }); rememberActiveId(conversationId);