FN-8568: apply new tags to open chats
Assign context-menu-created tags to their open conversations immediately. - Return the created chat tag from the chat hook. - Preserve existing session tags while assigning the new tag through either creation control. - Cover Enter, Add, existing-tag, and blank-name paths. - Add a patch changeset for the chat-tag behavior fix. Files changed: .changeset/fn-8568-chat-tag-creation.md | 7 ++ packages/dashboard/app/components/ChatView.tsx | 36 ++++++++- .../__tests__/ChatView.core-interactions.test.tsx | 90 ++++++++++++++++++++++ packages/dashboard/app/hooks/useChat.ts | 4 +- 4 files changed, 133 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8568 Fusion-Task-Lineage: 38ee0e02-adbb-4b21-9058-486310da2190 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8568-chat-tag-creation.md
Normal file
7
.changeset/fn-8568-chat-tag-creation.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Apply a newly created chat tag to the open conversation immediately.
|
||||
category: fix
|
||||
dev: Chat context-menu tag creation now returns and assigns the new tag ID.
|
||||
@@ -3167,6 +3167,38 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
? filteredSessions.find((session) => session.id === contextMenu.sessionId) ?? (activeSession?.id === contextMenu.sessionId ? activeSession : undefined)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* FNXC:ChatTags 2026-07-24-23:19:
|
||||
* A tag created from a session context menu must be assigned to that open session immediately,
|
||||
* preserving its existing tags so the user never has to select the newly created tag twice.
|
||||
*/
|
||||
const handleCreateTagForSession = useCallback(async () => {
|
||||
const name = newTagName.trim();
|
||||
if (!name) return;
|
||||
|
||||
let tag;
|
||||
try {
|
||||
tag = await createTag(name);
|
||||
} catch {
|
||||
addToast(t("chat.failedToCreateTag", "Failed to create tag"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (contextMenu?.sessionId) {
|
||||
const tagIds = (contextMenuSession?.tags ?? []).map((candidate) => candidate.id);
|
||||
if (!tagIds.includes(tag.id)) {
|
||||
try {
|
||||
await setSessionTags(contextMenu.sessionId, [...tagIds, tag.id]);
|
||||
} catch {
|
||||
addToast(t("chat.failedToUpdateTags", "Failed to update tags"), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setNewTagName("");
|
||||
}, [addToast, contextMenu, contextMenuSession, createTag, newTagName, setSessionTags, t]);
|
||||
|
||||
const mobileDirectSessionSwitcher = showMobileSessionSwitcher ? (
|
||||
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}>
|
||||
<button
|
||||
@@ -3684,8 +3716,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
</div>;
|
||||
})}
|
||||
<div className="chat-tag-create-row">
|
||||
<input className="input" value={newTagName} placeholder={t("chat.newTag", "New tag")} aria-label={t("chat.newTag", "New tag")} onChange={(event) => setNewTagName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void createTag(newTagName).then(() => setNewTagName("")).catch(() => addToast(t("chat.failedToCreateTag", "Failed to create tag"), "error")); } }} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => void createTag(newTagName).then(() => setNewTagName("")).catch(() => addToast(t("chat.failedToCreateTag", "Failed to create tag"), "error"))}>{t("chat.addTag", "Add")}</button>
|
||||
<input className="input" value={newTagName} placeholder={t("chat.newTag", "New tag")} aria-label={t("chat.newTag", "New tag")} onChange={(event) => setNewTagName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void handleCreateTagForSession(); } }} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleCreateTagForSession()}>{t("chat.addTag", "Add")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1180,6 +1180,96 @@ describe("ChatView core interactions", () => {
|
||||
expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function setupStatefulTagCreationMock(initialTags: Array<{ id: string; projectId: string; name: string; createdAt: string; updatedAt: string }> = []) {
|
||||
const createdTag = { id: "tag-new", projectId: "proj-123", name: "New tag", createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" };
|
||||
const createTag = vi.fn();
|
||||
const setSessionTags = vi.fn();
|
||||
|
||||
mockUseChat.mockImplementation(() => {
|
||||
const [tags, setTags] = useState(initialTags);
|
||||
const [sessions, setSessions] = useState<ChatSessionInfo[]>([{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", tags: initialTags, createdAt: createdTag.createdAt, updatedAt: createdTag.updatedAt }]);
|
||||
return {
|
||||
...defaultChatState,
|
||||
tags,
|
||||
sessions,
|
||||
filteredSessions: sessions,
|
||||
createTag: async (name) => {
|
||||
createTag(name);
|
||||
setTags((previous) => [...previous, createdTag]);
|
||||
return createdTag;
|
||||
},
|
||||
setSessionTags: async (sessionId, tagIds) => {
|
||||
setSessionTags(sessionId, tagIds);
|
||||
setSessions((previous) => previous.map((session) => session.id === sessionId ? { ...session, tags: [...tags, createdTag].filter((tag) => tagIds.includes(tag.id)) } : session));
|
||||
},
|
||||
} as UseChatReturn;
|
||||
});
|
||||
|
||||
return { createTag, createdTag, setSessionTags };
|
||||
}
|
||||
|
||||
it("assigns a newly created tag from the context-menu Enter path", async () => {
|
||||
const { createTag, createdTag, setSessionTags } = setupStatefulTagCreationMock();
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
await userEvent.pointer({ target: screen.getByTestId("chat-session-session-001"), keys: "[MouseRight]" });
|
||||
|
||||
const input = screen.getByLabelText("New tag");
|
||||
await userEvent.type(input, " New tag ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(createTag).toHaveBeenCalledWith("New tag"));
|
||||
await waitFor(() => expect(setSessionTags).toHaveBeenCalledWith("session-001", [createdTag.id]));
|
||||
expect(screen.getByTestId(`chat-context-tag-${createdTag.id}`)).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByTestId("chat-session-tags-session-001")).toHaveTextContent(createdTag.name);
|
||||
});
|
||||
|
||||
it("assigns a newly created tag from the context-menu Add button", async () => {
|
||||
const { createdTag, setSessionTags } = setupStatefulTagCreationMock();
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
await userEvent.pointer({ target: screen.getByTestId("chat-session-session-001"), keys: "[MouseRight]" });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("New tag"), "New tag");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await waitFor(() => expect(setSessionTags).toHaveBeenCalledWith("session-001", [createdTag.id]));
|
||||
expect(screen.getByTestId(`chat-context-tag-${createdTag.id}`)).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("preserves existing tags when creating a context-menu tag", async () => {
|
||||
const existingTag = { id: "tag-existing", projectId: "proj-123", name: "Existing", createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" };
|
||||
const { createdTag, setSessionTags } = setupStatefulTagCreationMock([existingTag]);
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
await userEvent.pointer({ target: screen.getByTestId("chat-session-session-001"), keys: "[MouseRight]" });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("New tag"), "New tag");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await waitFor(() => expect(setSessionTags).toHaveBeenCalledWith("session-001", [existingTag.id, createdTag.id]));
|
||||
expect(screen.getByTestId("chat-session-tags-session-001")).toHaveTextContent("Existing");
|
||||
expect(screen.getByTestId("chat-session-tags-session-001")).toHaveTextContent(createdTag.name);
|
||||
});
|
||||
|
||||
it("does not create or assign a blank context-menu tag", async () => {
|
||||
const createTag = vi.fn();
|
||||
const setSessionTags = vi.fn();
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" }],
|
||||
createTag,
|
||||
setSessionTags,
|
||||
});
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
await userEvent.pointer({ target: screen.getByTestId("chat-session-session-001"), keys: "[MouseRight]" });
|
||||
|
||||
const input = screen.getByLabelText("New tag");
|
||||
await userEvent.type(input, " ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
expect(createTag).not.toHaveBeenCalled();
|
||||
expect(setSessionTags).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders tag rename and deletion controls from the conversation menu", async () => {
|
||||
const renameTag = vi.fn().mockResolvedValue(undefined);
|
||||
const deleteTag = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -151,7 +151,7 @@ export interface UseChatReturn {
|
||||
*/
|
||||
setSessionThinkingLevel: (id: string, level: string) => Promise<void>;
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
createTag: (name: string) => Promise<void>;
|
||||
createTag: (name: string) => Promise<ChatTag>;
|
||||
renameTag: (id: string, name: string) => Promise<void>;
|
||||
deleteTag: (id: string) => Promise<void>;
|
||||
setSessionTags: (sessionId: string, tagIds: string[]) => Promise<void>;
|
||||
@@ -1705,7 +1705,7 @@ export function useChat(
|
||||
}, [trimmedSearchQuery, projectId]);
|
||||
|
||||
/* FNXC:ChatTags 2026-08-05-10:55: optimistic assignment keeps shared Chat hosts in sync while a failed API mutation rolls back exactly the prior session snapshot. */
|
||||
const createTag = useCallback(async (name: string) => { const response = await apiCreateChatTag(name, projectId); setTags((previous) => [...previous, response.tag].sort((a, b) => a.name.localeCompare(b.name))); }, [projectId]);
|
||||
const createTag = useCallback(async (name: string): Promise<ChatTag> => { const response = await apiCreateChatTag(name, projectId); setTags((previous) => [...previous, response.tag].sort((a, b) => a.name.localeCompare(b.name))); return response.tag; }, [projectId]);
|
||||
const renameTag = useCallback(async (id: string, name: string) => { const response = await apiRenameChatTag(id, name, projectId); setTags((previous) => previous.map((tag) => tag.id === id ? response.tag : tag).sort((a, b) => a.name.localeCompare(b.name))); setSessions((previous) => previous.map((session) => ({ ...session, tags: (session.tags ?? []).map((tag) => tag.id === id ? response.tag : tag) }))); }, [projectId]);
|
||||
const deleteTag = useCallback(async (id: string) => { await apiDeleteChatTag(id, projectId); setTags((previous) => previous.filter((tag) => tag.id !== id)); setSessions((previous) => previous.map((session) => ({ ...session, tags: (session.tags ?? []).filter((tag) => tag.id !== id) }))); setSelectedTagId((selected) => selected === id ? null : selected); }, [projectId]);
|
||||
const setSessionTags = useCallback(async (sessionId: string, tagIds: string[]) => {
|
||||
|
||||
Reference in New Issue
Block a user