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:
gsxdsm
2026-07-24 23:31:11 -07:00
parent 4b0edf65d5
commit cb8d116b2d
4 changed files with 133 additions and 4 deletions

View 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.

View File

@@ -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

View File

@@ -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);

View File

@@ -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[]) => {