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
32 changes: 32 additions & 0 deletions __tests__/lib/ai-conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
getConversation,
deleteConversation,
addMessage,
renameConversation,
deriveConversationTitle,
} from "~/lib/ai-conversations";

const mockApiFetch = apiFetch as jest.Mock;
Expand Down Expand Up @@ -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");
});
});
4 changes: 4 additions & 0 deletions __tests__/stores/ai-chat-store.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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" });
Expand Down
22 changes: 22 additions & 0 deletions lib/ai-conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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() + "…";
}
5 changes: 4 additions & 1 deletion stores/ai-chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getConversation,
deleteConversation,
addMessage,
deriveConversationTitle,
type ConversationSummary,
} from "~/lib/ai-conversations";

Expand Down Expand Up @@ -183,7 +184,9 @@ export const useAIChatStore = create<AIChatState>((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);
Expand Down
Loading