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
3 changes: 3 additions & 0 deletions src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ describe("CanvasHistoryPopover", () => {
undefined, // forkedFromShareId
2, // ephemeralSeedCount = messages.length
"conv-a", // the persisted row it joins
"Planning session", // persisted title seeded onto the slot
);
expect(mockSetServerConversationId).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -404,6 +405,7 @@ describe("CanvasHistoryPopover", () => {
undefined,
2,
"conv-a",
"Planning session",
);
});

Expand Down Expand Up @@ -496,6 +498,7 @@ describe("CanvasHistoryPopover", () => {
undefined,
2,
"conv-a",
"Planning session",
),
);

Expand Down
45 changes: 45 additions & 0 deletions src/__tests__/unit/canvas/canvasChatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,48 @@ describe("canvasChatStore — pendingDeeplink", () => {
expect(dl?.label).toBe("Second");
});
});

describe("canvasChatStore — conversation title", () => {
beforeEach(freshStore);

it("seeds title as null when startConversation omits the 6th arg", () => {
const id = useCanvasChatStore.getState().startConversation(baseContext);
expect(useCanvasChatStore.getState().conversations[id].title).toBeNull();
});

it("seeds title from startConversation's 6th arg", () => {
const id = useCanvasChatStore.getState().startConversation(
baseContext,
[],
undefined,
0,
"srv-1",
"Auth token refresh",
);
expect(useCanvasChatStore.getState().conversations[id].title).toBe("Auth token refresh");
expect(useCanvasChatStore.getState().conversations[id].serverConversationId).toBe("srv-1");
});

it("setConversationTitle updates the right conversation", () => {
const id1 = useCanvasChatStore.getState().startConversation(baseContext);
const id2 = useCanvasChatStore.getState().startConversation(baseContext);
useCanvasChatStore.getState().setConversationTitle(id1, "Auth token refresh");
expect(useCanvasChatStore.getState().conversations[id1].title).toBe("Auth token refresh");
expect(useCanvasChatStore.getState().conversations[id2].title).toBeNull();
});

it("clearActiveConversation resets title to null", () => {
const id = useCanvasChatStore.getState().startConversation(
baseContext,
[],
undefined,
0,
"srv-1",
"Auth token refresh",
);
useCanvasChatStore.getState().setActiveConversation(id);
useCanvasChatStore.getState().clearActiveConversation();
expect(useCanvasChatStore.getState().conversations[id].title).toBeNull();
expect(useCanvasChatStore.getState().conversations[id].serverConversationId).toBeNull();
});
});
65 changes: 65 additions & 0 deletions src/__tests__/unit/canvas/forkCanvasConversation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCanvasChatStore, type ConversationContext } from "@/app/org/[githubLogin]/_state/canvasChatStore";
import { forkCanvasConversation } from "@/app/org/[githubLogin]/_state/forkCanvasConversation";

const context: ConversationContext = {
orgId: "org-1",
githubLogin: "acme",
workspaceSlug: null,
workspaceSlugs: [],
currentCanvasRef: "",
currentCanvasBreadcrumb: "",
selectedNodeId: null,
selectedNodeIds: [],
};

describe("forkCanvasConversation", () => {
const fetchMock = vi.fn();

beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
useCanvasChatStore.setState({ conversations: {}, activeConversationId: null, ephemeralSeedCounts: {} });
useCanvasChatStore.getState().startConversation(context);
});

it("POSTs the source title and preserves settings.titleSource, then seeds the store", async () => {
fetchMock.mockImplementation((url: string, opts?: RequestInit) => {
if (!opts || opts.method !== "POST") {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
messages: [
{ id: "m1", role: "user", content: "hello" },
{ id: "m2", role: "assistant", content: "hi" },
],
title: "Auth token refresh",
settings: { titleSource: "llm", extraWorkspaceSlugs: ["hive"] },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: "fork-srv-1" }),
});
});

const forkId = await forkCanvasConversation("acme", "srv-1");
expect(forkId).toBe("fork-srv-1");

const postCall = fetchMock.mock.calls.find((c) => c[1]?.method === "POST");
expect(postCall?.[0]).toBe("/api/orgs/acme/chat/conversations");
const body = JSON.parse((postCall![1] as RequestInit).body as string);
expect(body.title).toBe("Auth token refresh");
expect(body.settings).toEqual({ titleSource: "llm", extraWorkspaceSlugs: ["hive"] });

const state = useCanvasChatStore.getState();
const active = state.conversations[state.activeConversationId!];
expect(active.serverConversationId).toBe("fork-srv-1");
expect(active.forkedFromShareId).toBe("srv-1");
expect(active.title).toBe("Auth token refresh");
expect(state.ephemeralSeedCounts[active.id]).toBe(2);
});
});
27 changes: 21 additions & 6 deletions src/__tests__/unit/canvas/openOrgConversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ const context: ConversationContext = {
selectedNodeIds: [],
};

const serverConversation = (messages: unknown[]) =>
new Response(JSON.stringify({ messages, settings: {} }), { status: 200 });
const serverConversation = (messages: unknown[], extras: Record<string, unknown> = {}) =>
new Response(JSON.stringify({ messages, settings: {}, ...extras }), { status: 200 });

const userMessage = (id: string) => ({
id,
Expand Down Expand Up @@ -66,10 +66,13 @@ describe("openOrgConversation", () => {

it("fetches a conversation the tab does not hold into a new slot", async () => {
fetchMock.mockResolvedValueOnce(
serverConversation([
{ id: "u1", role: "user", content: "hi", timestamp: "2026-09-04T10:00:00Z" },
{ id: "a1", role: "assistant", content: "hello", timestamp: "2026-09-04T10:00:01Z" },
]),
serverConversation(
[
{ id: "u1", role: "user", content: "hi", timestamp: "2026-09-04T10:00:00Z" },
{ id: "a1", role: "assistant", content: "hello", timestamp: "2026-09-04T10:00:01Z" },
],
{ title: "Auth token refresh" },
),
);

const opened = await openOrgConversation("acme", "srv-new", { syncUrl: true });
Expand All @@ -81,10 +84,22 @@ describe("openOrgConversation", () => {
expect(active).not.toBeNull();
expect(state.conversations[active!].serverConversationId).toBe("srv-new");
expect(state.conversations[active!].messages.map((m) => m.id)).toEqual(["u1", "a1"]);
expect(state.conversations[active!].title).toBe("Auth token refresh");
expect(state.ephemeralSeedCounts[active!]).toBe(2);
expect(new URLSearchParams(window.location.search).get("chat")).toBe("srv-new");
});

it("does not overwrite a held slot's title on reopen", async () => {
const store = useCanvasChatStore.getState();
const held = store.startConversation(context, [userMessage("u1")], undefined, 1, "srv-a", "Held title");
store.startConversation(context, [], undefined, 0, "srv-b");

await openOrgConversation("acme", "srv-a");

expect(fetchMock).not.toHaveBeenCalled();
expect(useCanvasChatStore.getState().conversations[held].title).toBe("Held title");
});

it("returns false and leaves the store alone when the fetch fails", async () => {
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 404 }));

Expand Down
110 changes: 108 additions & 2 deletions src/__tests__/unit/canvas/useCanvasChatAutoSave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ type ConvState = {
isLoading: boolean;
isStreaming: boolean;
serverConversationId: string | null;
title: string | null;
context: ConvContext;
}
>;
ephemeralSeedCounts: Record<string, number>;
locallyAuthoredTurnIds: Set<string>;
setServerConversationId: (conversationId: string, serverId: string) => void;
setConversationMessages: (conversationId: string, messages: Msg[]) => void;
setConversationTitle: (conversationId: string, title: string | null) => void;
};

function makeStore(initial?: Partial<ConvState>) {
Expand Down Expand Up @@ -68,6 +70,16 @@ function makeStore(initial?: Partial<ConvState>) {
},
},
})),
setConversationTitle: (conversationId, title) =>
set((s) => ({
conversations: {
...s.conversations,
[conversationId]: {
...s.conversations[conversationId],
title,
},
},
})),
...initial,
}));
}
Expand Down Expand Up @@ -164,22 +176,24 @@ function makeConv(
isLoading: boolean;
isStreaming: boolean;
serverConversationId: string | null;
title: string | null;
}> = {},
) {
return {
messages: [],
isLoading: false,
isStreaming: false,
serverConversationId: null,
title: null as string | null,
context: baseContext,
...overrides,
};
}

function fetchReturning(messages: Msg[]) {
function fetchReturning(messages: Msg[], extras: Record<string, unknown> = {}) {
return vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ messages }),
json: () => Promise.resolve({ messages, ...extras }),
});
}

Expand Down Expand Up @@ -496,4 +510,96 @@ describe("useCanvasChatAutoSave (live-sync)", () => {
);
expect(ids).toContain("planner-recon");
});

it("applies an LLM title on a title-only nudge with no message delta", async () => {
_store.setState({
activeConversationId: "conv-1",
conversations: {
"conv-1": makeConv({
messages: [makeMsg("user", "m1"), makeMsg("assistant", "a1")],
serverConversationId: "server-1",
title: null,
}),
},
});

global.fetch = fetchReturning(
[makeMsg("user", "m1"), makeMsg("assistant", "a1")],
{ title: "Auth token refresh", settings: { titleSource: "llm" } },
);

renderHook(() => useCanvasChatAutoSave({ githubLogin: "my-org" }));
act(() => {
_store.setState((s) => ({ ...s }));
});
await act(async () => {
fakePusher.fire("canvas-conversation-updated");
await Promise.resolve();
await Promise.resolve();
});

const conv = _store.getState().conversations["conv-1"];
expect(conv.title).toBe("Auth token refresh");
expect(conv.messages.map((m) => m.id)).toEqual(["m1", "a1"]);
});

it("does not clobber a null store title with a non-LLM placeholder", async () => {
_store.setState({
activeConversationId: "conv-1",
conversations: {
"conv-1": makeConv({
messages: [makeMsg("user", "m1")],
serverConversationId: "server-1",
title: null,
}),
},
});

global.fetch = fetchReturning([makeMsg("user", "m1")], {
title: "How does the auth middleware work",
settings: {},
});

renderHook(() => useCanvasChatAutoSave({ githubLogin: "my-org" }));
act(() => {
_store.setState((s) => ({ ...s }));
});
await act(async () => {
fakePusher.fire("canvas-conversation-updated");
await Promise.resolve();
await Promise.resolve();
});

expect(_store.getState().conversations["conv-1"].title).toBeNull();
});

it("adopts a fetched title when the store already has one (legacy / reopened)", async () => {
_store.setState({
activeConversationId: "conv-1",
conversations: {
"conv-1": makeConv({
messages: [makeMsg("user", "m1")],
serverConversationId: "server-1",
title: "Old truncation",
}),
},
});

global.fetch = fetchReturning([makeMsg("user", "m1")], {
title: "Legacy truncation",
settings: {},
});

renderHook(() => useCanvasChatAutoSave({ githubLogin: "my-org" }));
act(() => {
_store.setState((s) => ({ ...s }));
});
await act(async () => {
fakePusher.fire("canvas-conversation-updated");
await Promise.resolve();
await Promise.resolve();
});

expect(_store.getState().conversations["conv-1"].title).toBe("Legacy truncation");
});
});
10 changes: 8 additions & 2 deletions src/__tests__/unit/components/SidebarChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,8 @@ describe("SidebarChat — Fork chat button", () => {
{ id: "m1", role: "user", content: "hello" },
{ id: "m2", role: "assistant", content: "hi" },
],
settings: {},
title: "Auth token refresh",
settings: { titleSource: "llm" },
}),
});
}
Expand Down Expand Up @@ -957,14 +958,19 @@ describe("SidebarChat — Fork chat button", () => {
const postCall = fetchMock.mock.calls.find((c) => c[1]?.method === "POST");
expect(postCall).toBeTruthy();
expect(postCall![0]).toBe("/api/orgs/test-org/chat/conversations");
const postBody = JSON.parse((postCall![1] as RequestInit).body as string);
expect(postBody.title).toBe("Auth token refresh");
expect(postBody.settings).toEqual({ titleSource: "llm" });

// 3. store.startConversation was called with forkedFromShareId = "srv-1",
// ephemeralSeedCount = 2 (two messages), serverConversationId = "fork-srv-1"
expect(startConversationMock).toHaveBeenCalledTimes(1);
const [, hydrated, forkedFromShareId, ephemeralSeedCount, serverConvId] = startConversationMock.mock.calls[0];
const [, hydrated, forkedFromShareId, ephemeralSeedCount, serverConvId, title] =
startConversationMock.mock.calls[0];
expect(forkedFromShareId).toBe("srv-1");
expect(ephemeralSeedCount).toBe(2);
expect(serverConvId).toBe("fork-srv-1");
expect(title).toBe("Auth token refresh");
expect(Array.isArray(hydrated)).toBe(true);
expect(hydrated).toHaveLength(2);

Expand Down
Loading
Loading