From a660f21a26c1a756284ea91bcb377691e99193b4 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 4 Sep 2026 17:45:58 +0000 Subject: [PATCH 1/2] Generated with Hive: Propagate and display LLM-generated conversation titles in Ask Jamie client --- .../unit/canvas/canvasChatStore.test.ts | 45 +++++++ .../canvas/forkCanvasConversation.test.ts | 65 +++++++++++ .../unit/canvas/openOrgConversation.test.ts | 27 ++++- .../unit/canvas/useCanvasChatAutoSave.test.ts | 110 +++++++++++++++++- .../unit/components/SidebarChat.test.tsx | 10 +- .../unit/lib/ai/conversationHelpers.test.ts | 16 ++- .../unit/services/control-panel-state.test.ts | 25 ++++ .../_components/OrgCanvasView.tsx | 5 + .../_components/OrgRightPanel.tsx | 12 ++ .../control-panel/useControlPanel.ts | 6 +- .../[githubLogin]/_state/canvasChatStore.ts | 34 +++++- .../_state/forkCanvasConversation.ts | 4 +- .../_state/openOrgConversation.ts | 1 + .../_state/useCanvasChatAutoSave.ts | 17 ++- src/lib/ai/conversationHelpers.ts | 9 ++ src/services/orgs/control-panel-state.ts | 18 +++ 16 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/unit/canvas/forkCanvasConversation.test.ts diff --git a/src/__tests__/unit/canvas/canvasChatStore.test.ts b/src/__tests__/unit/canvas/canvasChatStore.test.ts index f9b9f7f5e7..6be17e355c 100644 --- a/src/__tests__/unit/canvas/canvasChatStore.test.ts +++ b/src/__tests__/unit/canvas/canvasChatStore.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/unit/canvas/forkCanvasConversation.test.ts b/src/__tests__/unit/canvas/forkCanvasConversation.test.ts new file mode 100644 index 0000000000..1b4f033ae8 --- /dev/null +++ b/src/__tests__/unit/canvas/forkCanvasConversation.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/unit/canvas/openOrgConversation.test.ts b/src/__tests__/unit/canvas/openOrgConversation.test.ts index 063b09427e..ccf70ce88a 100644 --- a/src/__tests__/unit/canvas/openOrgConversation.test.ts +++ b/src/__tests__/unit/canvas/openOrgConversation.test.ts @@ -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 = {}) => + new Response(JSON.stringify({ messages, settings: {}, ...extras }), { status: 200 }); const userMessage = (id: string) => ({ id, @@ -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 }); @@ -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 })); diff --git a/src/__tests__/unit/canvas/useCanvasChatAutoSave.test.ts b/src/__tests__/unit/canvas/useCanvasChatAutoSave.test.ts index efbe47847c..5bddb9d5b6 100644 --- a/src/__tests__/unit/canvas/useCanvasChatAutoSave.test.ts +++ b/src/__tests__/unit/canvas/useCanvasChatAutoSave.test.ts @@ -33,6 +33,7 @@ type ConvState = { isLoading: boolean; isStreaming: boolean; serverConversationId: string | null; + title: string | null; context: ConvContext; } >; @@ -40,6 +41,7 @@ type ConvState = { locallyAuthoredTurnIds: Set; setServerConversationId: (conversationId: string, serverId: string) => void; setConversationMessages: (conversationId: string, messages: Msg[]) => void; + setConversationTitle: (conversationId: string, title: string | null) => void; }; function makeStore(initial?: Partial) { @@ -68,6 +70,16 @@ function makeStore(initial?: Partial) { }, }, })), + setConversationTitle: (conversationId, title) => + set((s) => ({ + conversations: { + ...s.conversations, + [conversationId]: { + ...s.conversations[conversationId], + title, + }, + }, + })), ...initial, })); } @@ -164,6 +176,7 @@ function makeConv( isLoading: boolean; isStreaming: boolean; serverConversationId: string | null; + title: string | null; }> = {}, ) { return { @@ -171,15 +184,16 @@ function makeConv( isLoading: false, isStreaming: false, serverConversationId: null, + title: null as string | null, context: baseContext, ...overrides, }; } -function fetchReturning(messages: Msg[]) { +function fetchReturning(messages: Msg[], extras: Record = {}) { return vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ messages }), + json: () => Promise.resolve({ messages, ...extras }), }); } @@ -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"); + }); }); diff --git a/src/__tests__/unit/components/SidebarChat.test.tsx b/src/__tests__/unit/components/SidebarChat.test.tsx index 329c921b96..71e1998757 100644 --- a/src/__tests__/unit/components/SidebarChat.test.tsx +++ b/src/__tests__/unit/components/SidebarChat.test.tsx @@ -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" }, }), }); } @@ -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); diff --git a/src/__tests__/unit/lib/ai/conversationHelpers.test.ts b/src/__tests__/unit/lib/ai/conversationHelpers.test.ts index c01957cc2f..19f69e44e0 100644 --- a/src/__tests__/unit/lib/ai/conversationHelpers.test.ts +++ b/src/__tests__/unit/lib/ai/conversationHelpers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, it, expect } from "vitest"; -import { toModelMessages } from "@/lib/ai/conversationHelpers"; +import { UNTITLED_CONVERSATION, orgChatChromeTitle, toModelMessages } from "@/lib/ai/conversationHelpers"; import type { StoredMessage } from "@/services/canvas-turn-persistence"; describe("toModelMessages", () => { @@ -233,3 +233,17 @@ describe("toModelMessages", () => { expect(toolResultMsg.content[1].output.value.payload.diff.length).toBe(10_000); }); }); + +describe("orgChatChromeTitle", () => { + it("falls back to Ask Jamie when title is null, empty, or the untitled placeholder", () => { + expect(orgChatChromeTitle(null)).toBe("Ask Jamie"); + expect(orgChatChromeTitle(undefined)).toBe("Ask Jamie"); + expect(orgChatChromeTitle("")).toBe("Ask Jamie"); + expect(orgChatChromeTitle(UNTITLED_CONVERSATION)).toBe("Ask Jamie"); + }); + + it("renders the real title once set", () => { + expect(orgChatChromeTitle("Auth token refresh")).toBe("Auth token refresh"); + }); +}); + diff --git a/src/__tests__/unit/services/control-panel-state.test.ts b/src/__tests__/unit/services/control-panel-state.test.ts index e1b51ea64b..cd0bdfa17f 100644 --- a/src/__tests__/unit/services/control-panel-state.test.ts +++ b/src/__tests__/unit/services/control-panel-state.test.ts @@ -20,6 +20,7 @@ import { derivePlanState, matchesControlPanelQuery, overlayActiveChat, + unlistedOnStageChatTitle, previewLine, sortControlPanelItems, } from "@/services/orgs/control-panel-state"; @@ -197,6 +198,7 @@ describe("search, ordering and the chat on stage", () => { lastReply: null, hasMessages: false, isStreaming: false, + title: null, }; const startedAt = "2026-09-04T09:00:00.000Z"; @@ -266,6 +268,29 @@ describe("search, ordering and the chat on stage", () => { expect( overlayActiveChat(server, { ...fresh, serverId: "srv-1", hasMessages: true, isStreaming: true }), ).toMatchObject({ state: "running", sinceYou: "Jamie is replying" }); + // Store title wins so the live list row updates without a refetch. + expect( + overlayActiveChat(server, { + ...fresh, + serverId: "srv-1", + hasMessages: true, + title: "Auth token refresh", + }).title, + ).toBe("Auth token refresh"); + }); + + test("unlisted on-stage row prefers store title over generateTitle", () => { + const messages = [{ role: "user", content: "How does the auth middleware work when tokens expire?" }]; + expect( + unlistedOnStageChatTitle( + { ...fresh, hasMessages: true, title: "Auth token refresh" }, + messages, + ), + ).toBe("Auth token refresh"); + expect(unlistedOnStageChatTitle({ ...fresh, hasMessages: true, title: null }, messages)).toBe( + "How does the auth middleware work when tokens expire?", + ); + expect(unlistedOnStageChatTitle(fresh, [])).toBe("New chat"); }); test("previewLine collapses whitespace and cuts long text with an ellipsis", () => { diff --git a/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx b/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx index 02323d7e15..522cb65e0e 100644 --- a/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx +++ b/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx @@ -247,6 +247,7 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp // JOIN someone else's shared room — rather than starting a fresh one. const sharedChatId = searchParams.get("chat"); const [chatInitialMessages, setChatInitialMessages] = useState(null); + const [chatInitialTitle, setChatInitialTitle] = useState(null); const [chatLoadComplete, setChatLoadComplete] = useState(false); const setUrlSlug = useCallback( @@ -334,6 +335,9 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp })); setChatInitialMessages(seeded); } + if (typeof data?.title === "string") { + setChatInitialTitle(data.title); + } }) .catch(() => {}) .finally(() => { @@ -866,6 +870,7 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp sharedChatId ?? undefined, ephemeralSeedCount, joinServerConversationId, + chatInitialTitle, ); setConversationStarted(true); diff --git a/src/app/org/[githubLogin]/_components/OrgRightPanel.tsx b/src/app/org/[githubLogin]/_components/OrgRightPanel.tsx index 452bd7c42e..696a71a5ac 100644 --- a/src/app/org/[githubLogin]/_components/OrgRightPanel.tsx +++ b/src/app/org/[githubLogin]/_components/OrgRightPanel.tsx @@ -18,6 +18,8 @@ import { formatRelativeTime } from "./CanvasHistoryPopover"; import { PlanStage, taskNodeFor, type ControlPanelStageProps } from "./control-panel/ControlPanelStage"; import type { ControlPanelFocus } from "./control-panel/types"; import { ActionTip } from "./ActionTip"; +import { useCanvasChatStore } from "../_state/canvasChatStore"; +import { orgChatChromeTitle } from "@/lib/ai/conversationHelpers"; type Tab = "chat" | "details" | "connections"; @@ -177,6 +179,11 @@ export function OrgRightPanel({ }, [selectedEdge?.edge.id]); const { count, runs, openRun } = useAutomationInbox(githubLogin, { chatReady }); + const conversationTitle = useCanvasChatStore((s) => { + const id = s.activeConversationId; + return id ? (s.conversations[id]?.title ?? null) : null; + }); + const chatHeaderTitle = orgChatChromeTitle(conversationTitle); // On the control panel what shows follows its focus, not the tabs. The // last plan/task on stage stays mounted (hidden) while the chat is up — @@ -300,6 +307,11 @@ export function OrgRightPanel({ /> )} + {activeTab === "chat" && ( +
+ {chatHeaderTitle} +
+ )}
{/* Kept mounted: its settings popover and activity hook fetch on mount. */}
diff --git a/src/app/org/[githubLogin]/_components/control-panel/useControlPanel.ts b/src/app/org/[githubLogin]/_components/control-panel/useControlPanel.ts index be0c74e20b..0df1d1e93a 100644 --- a/src/app/org/[githubLogin]/_components/control-panel/useControlPanel.ts +++ b/src/app/org/[githubLogin]/_components/control-panel/useControlPanel.ts @@ -3,12 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname, useSearchParams } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; -import { generateTitle } from "@/lib/ai/conversationHelpers"; import { activeChatItem, buildControlPanelGroups, matchesControlPanelQuery, overlayActiveChat, + unlistedOnStageChatTitle, type ActiveChatSnapshot, } from "@/services/orgs/control-panel-state"; import type { ControlPanelItem } from "@/types/control-panel"; @@ -90,6 +90,7 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP lastReply: !conv.isStreaming && last?.role === "assistant" ? last.content : null, hasMessages: conv.messages.length > 0, isStreaming: conv.isStreaming, + title: conv.title, }; }), ); @@ -149,9 +150,8 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP return items.map((item) => (item.key === activeChatKey ? overlayActiveChat(item, activeChat) : item)); } if (!activeChat.hasMessages && !chatOnStage) return items; - // The server titles a chat from its first user message. const conv = useCanvasChatStore.getState().conversations[activeChat.localId]; - const title = activeChat.hasMessages && conv ? generateTitle(conv.messages) : "New chat"; + const title = unlistedOnStageChatTitle(activeChat, conv?.messages); return [activeChatItem(activeChat, newChatStartedAtRef.current, title), ...items]; }, [items, activeChat, activeChatKey, chatOnStage]); diff --git a/src/app/org/[githubLogin]/_state/canvasChatStore.ts b/src/app/org/[githubLogin]/_state/canvasChatStore.ts index c630a70967..51e4609a97 100644 --- a/src/app/org/[githubLogin]/_state/canvasChatStore.ts +++ b/src/app/org/[githubLogin]/_state/canvasChatStore.ts @@ -270,6 +270,12 @@ export interface CanvasConversation { id: string; /** Server-side `SharedConversation.id`, if auto-save has created one. */ serverConversationId: string | null; + /** + * Persisted conversation title (`SharedConversation.title`). `null` + * until an LLM title (or a seeded legacy/share/fork title) lands — + * the chat chrome falls back to "Ask Jamie" in that case. + */ + title: string | null; /** * Provenance: the `?chat=` this conversation originated * from, if any. Informational only — by default we *join* that @@ -475,6 +481,11 @@ interface CanvasChatState { * landing on `?chat=` passes the shared row's id here. Omit * it to fork (start a brand-new row from the seed) — kept reachable * for a future explicit "Fork" action. + * + * `title` (default = null) seeds the chrome/list label so a share-link + * recipient, reopen, or fork sees the persisted title without waiting + * on live-sync. Omit it for a fresh chat ("Ask Jamie" until the LLM + * title arrives). */ startConversation: ( context: ConversationContext, @@ -482,6 +493,7 @@ interface CanvasChatState { forkedFromShareId?: string, ephemeralSeedCount?: number, serverConversationId?: string, + title?: string | null, ) => string; setActiveConversation: (conversationId: string | null) => void; /** Record a turn id this client just sent (see `locallyAuthoredTurnIds`). */ @@ -494,6 +506,8 @@ interface CanvasChatState { resetActiveConversation: () => void; /** Record the server-assigned `SharedConversation` id (auto-save creation). */ setServerConversationId: (conversationId: string, serverId: string) => void; + /** Record the persisted conversation title (LLM live-sync / seed). */ + setConversationTitle: (conversationId: string, title: string | null) => void; // ─── Message actions ───────────────────────────────────────────────── appendUserMessage: (conversationId: string, message: CanvasChatMessage) => void; @@ -595,12 +609,13 @@ export const useCanvasChatStore = create()( artifacts: {}, dismissedArtifactIds: {}, - startConversation: (context, seedMessages, forkedFromShareId, ephemeralSeedCount, serverConversationId) => { + startConversation: (context, seedMessages, forkedFromShareId, ephemeralSeedCount, serverConversationId, title) => { const id = newConversationId(); const conv: CanvasConversation = { id, serverConversationId: serverConversationId ?? null, forkedFromShareId: forkedFromShareId ?? null, + title: title ?? null, messages: seedMessages ?? [], isLoading: false, isStreaming: false, @@ -674,6 +689,7 @@ export const useCanvasChatStore = create()( // on the next user message — not an append to the old // auto-save row that still has the wiped messages. serverConversationId: null, + title: null, }, }, }; @@ -717,6 +733,22 @@ export const useCanvasChatStore = create()( "setServerConversationId", ), + setConversationTitle: (conversationId, title) => + set( + (s) => { + const conv = s.conversations[conversationId]; + if (!conv) return s; + return { + conversations: { + ...s.conversations, + [conversationId]: { ...conv, title }, + }, + }; + }, + false, + "setConversationTitle", + ), + appendUserMessage: (conversationId, message) => set( (s) => { diff --git a/src/app/org/[githubLogin]/_state/forkCanvasConversation.ts b/src/app/org/[githubLogin]/_state/forkCanvasConversation.ts index 9391885efd..9fba8094bb 100644 --- a/src/app/org/[githubLogin]/_state/forkCanvasConversation.ts +++ b/src/app/org/[githubLogin]/_state/forkCanvasConversation.ts @@ -58,8 +58,9 @@ export async function forkCanvasConversation( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: rawMessages, - settings, + settings, // preserves titleSource when the source was LLM-titled source: "org-canvas", + title: sourceConv.title, }), }, ); @@ -120,6 +121,7 @@ export async function forkCanvasConversation( sourceServerId, // forkedFromShareId hydrated.length, // ephemeralSeedCount — skip re-persisting copied history forkId, // serverConversationId — bind directly to the fork row + typeof sourceConv.title === "string" ? sourceConv.title : null, ); // ── 5. Return fork id ──────────────────────────────────────────────── diff --git a/src/app/org/[githubLogin]/_state/openOrgConversation.ts b/src/app/org/[githubLogin]/_state/openOrgConversation.ts index 4597a4b518..fd3d70abd6 100644 --- a/src/app/org/[githubLogin]/_state/openOrgConversation.ts +++ b/src/app/org/[githubLogin]/_state/openOrgConversation.ts @@ -159,6 +159,7 @@ export async function openOrgConversation( undefined, messages.length, // already persisted — never re-save conversationId, + typeof conv.title === "string" ? conv.title : null, ); if (opts.syncUrl && typeof window !== "undefined") { diff --git a/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts b/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts index c4f495410a..9108d318ab 100644 --- a/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts +++ b/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts @@ -209,13 +209,28 @@ export function useCanvasChatAutoSave({ githubLogin }: AutoSaveArgs) { mapped, ); + const store = useCanvasChatStore.getState(); + + // Title can land independently of messages (LLM title after the + // first turn). Apply even when the merge is a no-op so a + // title-only Pusher nudge isn't dropped. Do not adopt a first-turn + // generateTitle() placeholder onto a still-null store title — that + // would replace the "Ask Jamie" chrome fallback before the real + // LLM title arrives. Reopened / shared / forked slots seed title + // at startConversation, so legacy truncations still display. + const fetchedTitle = typeof body.title === "string" ? body.title : null; + const adoptTitle = + body.settings?.titleSource === "llm" || now.title != null; + if (adoptTitle && fetchedTitle !== now.title) { + store.setConversationTitle(conversationId, fetchedTitle); + } + if ( merged.added.length === 0 && !reconciled.changed && !reconciledAr.changed ) return; // in sync - const store = useCanvasChatStore.getState(); store.setConversationMessages(conversationId, reconciledAr.messages); // The user is looking at this chat (only the active conv is diff --git a/src/lib/ai/conversationHelpers.ts b/src/lib/ai/conversationHelpers.ts index 1f33510d28..957faedd64 100644 --- a/src/lib/ai/conversationHelpers.ts +++ b/src/lib/ai/conversationHelpers.ts @@ -98,6 +98,15 @@ export function toModelMessages(messages: StoredMessage[]): ModelMessage[] { /** Placeholder title for a conversation with no usable first user message. */ export const UNTITLED_CONVERSATION = "Untitled Conversation"; +/** + * Org-canvas chat chrome label. Real titles display as-is; null / empty / + * {@link UNTITLED_CONVERSATION} fall back to "Ask Jamie" while a title is + * still generating (or on a brand-new chat). + */ +export function orgChatChromeTitle(title: string | null | undefined): string { + return title && title !== UNTITLED_CONVERSATION ? title : "Ask Jamie"; +} + /** * Upper bound for stored titles. This is a storage guard, not a display * concern — UIs truncate titles visually (CSS `truncate`) so the title is diff --git a/src/services/orgs/control-panel-state.ts b/src/services/orgs/control-panel-state.ts index d31b1e4325..c3da71cedc 100644 --- a/src/services/orgs/control-panel-state.ts +++ b/src/services/orgs/control-panel-state.ts @@ -10,6 +10,7 @@ * "created or assigned". Running comes from the same predicate the * canvas projector uses (`deriveFeatureRunState`). */ +import { generateTitle } from "@/lib/ai/conversationHelpers"; import { deriveFeatureRunState, formatRunningLabel } from "@/lib/canvas/feature-live-state"; import { FEATURE_STATUS_LABELS } from "@/types/roadmap"; import type { ControlPanelItem, ControlPanelItemState } from "@/types/control-panel"; @@ -100,6 +101,8 @@ export interface ActiveChatSnapshot { lastReply: string | null; hasMessages: boolean; isStreaming: boolean; + /** Store title when set (LLM / seeded); null while still generating. */ + title: string | null; } function sinceYouOf(chat: ActiveChatSnapshot): string { @@ -108,6 +111,20 @@ function sinceYouOf(chat: ActiveChatSnapshot): string { return chat.hasMessages ? "No reply yet" : "Empty chat"; } +/** + * Label for an unlisted on-stage chat row. Prefer the store title (LLM / + * seeded) so the live list does not stay stuck on a truncated first + * message until the next list refetch. + */ +export function unlistedOnStageChatTitle( + chat: ActiveChatSnapshot, + messages: unknown[] | undefined, +): string { + if (chat.title) return chat.title; + if (chat.hasMessages && messages) return generateTitle(messages); + return "New chat"; +} + /** * The list row for the chat on stage before the server lists it. A fresh * chat has no `SharedConversation` row until its first turn is persisted, @@ -145,6 +162,7 @@ export function overlayActiveChat(item: ControlPanelItem, chat: ActiveChatSnapsh lastActivityAt: storeAhead ? chat.lastMessageAt! : item.lastActivityAt, state: chat.isStreaming ? "running" : item.state, sinceYou: chat.isStreaming || storeAhead ? sinceYouOf(chat) : item.sinceYou, + title: chat.title || item.title, }; } From cbbb783f16f58c9991ce692cbeff5ae848009b29 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 4 Sep 2026 18:12:11 +0000 Subject: [PATCH 2/2] Generated with Hive: Resolve merge conflicts for LLM conversation title propagation and client header display --- src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx | 3 +++ src/__tests__/unit/services/control-panel-state.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx b/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx index d29b638c16..588434d5ec 100644 --- a/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx +++ b/src/__tests__/unit/canvas/CanvasHistoryPopover.test.tsx @@ -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(); }); @@ -404,6 +405,7 @@ describe("CanvasHistoryPopover", () => { undefined, 2, "conv-a", + "Planning session", ); }); @@ -496,6 +498,7 @@ describe("CanvasHistoryPopover", () => { undefined, 2, "conv-a", + "Planning session", ), ); diff --git a/src/__tests__/unit/services/control-panel-state.test.ts b/src/__tests__/unit/services/control-panel-state.test.ts index f4969f19b3..b0c3ea7cbb 100644 --- a/src/__tests__/unit/services/control-panel-state.test.ts +++ b/src/__tests__/unit/services/control-panel-state.test.ts @@ -400,6 +400,7 @@ describe("archive move and on-stage gating", () => { lastReply: "Done.", hasMessages: true, isStreaming: false, + title: null, }; test("moveChatToArchive takes nested plans with the chat and inserts them at the top of Archive", () => { @@ -444,6 +445,7 @@ describe("archive move and on-stage gating", () => { lastReply: null, hasMessages: false, isStreaming: false, + title: null, }; const resolved = resolveControlPanelLists([other], [], fresh, { chatOnStage: true,