FN-7441: add sidebar rename controls for chat conversations

Add an explicit sidebar rename affordance for direct Chat conversations while preserving existing rename paths.

- Add edit buttons beside delete controls on direct chat session rows and wire them to the existing rename dialog.
- Keep sidebar action spacing and accessibility labels stable across desktop and mobile layouts.
- Cover sidebar, context-menu, and mobile switcher rename behavior with ChatView tests.
- Document the sidebar rename entry point and add the published package changeset.

Files changed:
 .changeset/fn-7441-chat-conversation-rename.md     |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/ChatView.css     |  51 ++++-----
 packages/dashboard/app/components/ChatView.tsx     |  54 ++++++---
 .../__tests__/ChatView.core-interactions.test.tsx  | 122 +++++++++++++++++++++
 .../components/__tests__/ChatView.mobile.test.tsx  |  72 ++++++++++++
 6 files changed, 264 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-7441

Fusion-Task-Lineage: 39a2c393-dd85-4362-9472-58bb137b906c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 13:23:13 -07:00
parent 91fb53f3c7
commit 72adb5231a
6 changed files with 264 additions and 44 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add sidebar rename buttons to direct Chat conversations.
category: feature
dev: Reuses ChatView's existing rename dialog and useChat renameSession path.

View File

@@ -408,7 +408,7 @@ Chat view provides project-scoped conversations with agents.
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail.
- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
- Direct chat sessions can be renamed from the sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->
<!-- FNXC:TaskDetailPlannerChat 2026-07-01-22:02: Done-task planner Chat remains available for retrospective Q&A and can create a task-scoped refinement through the planner tool, while common Chat feed visibility remains opt-in. -->
- Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task.

View File

@@ -344,7 +344,7 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: calc(var(--space-md) * 3);
padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2));
}
.chat-session-preview {
@@ -353,7 +353,7 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: calc(var(--space-md) * 3);
padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2));
}
.chat-session-meta {
@@ -378,39 +378,37 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i
flex-shrink: 0;
}
/* === Chat Session Delete Button === */
.chat-session-delete-btn {
/*
FNXC:ChatSidebar 2026-07-02-00:00:
Direct conversation rows expose edit and delete as a compact action pair. Keep the pair outside the row text flow so title/preview truncation stays stable across desktop, mobile, and compact right-dock layouts.
*/
.chat-session-actions {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.chat-session-action-btn {
width: calc(var(--space-md) * 2);
min-width: calc(var(--space-md) * 2);
height: calc(var(--space-md) * 2);
padding: 0;
background: transparent;
border: none;
min-height: calc(var(--space-md) * 2);
flex: 0 0 auto;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
}
.chat-session-item:hover .chat-session-delete-btn {
opacity: 1;
.chat-session-action-btn:hover,
.chat-session-action-btn:focus-visible {
color: var(--text);
}
.chat-session-delete-btn:hover {
.chat-session-delete-btn:hover,
.chat-session-delete-btn:focus-visible {
color: var(--color-error);
opacity: 1;
}
.chat-session-delete-btn:focus {
opacity: 1;
outline: none;
box-shadow: var(--focus-ring-strong);
}
/* Context menu for session items */
@@ -2200,15 +2198,10 @@ Queued-message banners stack above the composer input with a capped scroll area,
min-height: calc(var(--space-lg) * 2.25);
}
/* FN-4352: secondary control inside tappable session row; keep compact size (inflated by FN-3628). */
.chat-session-delete-btn {
opacity: 1;
}
/* FN-4352/FN-4385: keep row text clearance aligned to compact secondary delete control (matches base compact clearance). */
/* FN-4352/FN-4385/FN-7441: direct session row action pair stays visible and text keeps clearance for both edit and delete touch targets. */
.chat-session-title,
.chat-session-preview {
padding-right: calc(var(--space-md) * 3);
padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2));
}
.chat-message--assistant .chat-message-render-toggle {

View File

@@ -2716,6 +2716,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
defaultModel,
);
const sessionModelTag = formatModelTag(sessionResolvedModel?.provider, sessionResolvedModel?.modelId) ?? "Fusion";
const sessionTitle = session.title || t("chat.untitledSession", "Untitled");
return (
<div
@@ -2728,19 +2729,38 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
}}
data-testid={`chat-session-${session.id}`}
>
<button
className="chat-session-delete-btn"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(session.id);
}}
data-testid="chat-session-delete-btn"
aria-label={t("chat.deleteConversation", "Delete conversation")}
>
<Trash2 size={14} />
</button>
{/*
FNXC:ChatSidebar 2026-07-02-00:00:
Direct conversation rows need an always-discoverable rename affordance before delete while preserving row selection. Keep edit/delete as sibling buttons that stop propagation and share the existing rename/delete flows.
*/}
<div className="chat-session-actions">
<button
type="button"
className="btn-icon chat-session-action-btn chat-session-rename-btn"
onClick={(e) => {
e.stopPropagation();
openRenameDialog(session.id);
}}
data-testid="chat-session-rename-btn"
aria-label={t("chat.renameConversationAria", "Rename conversation {{title}}", { title: sessionTitle })}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon chat-session-action-btn chat-session-delete-btn"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(session.id);
}}
data-testid="chat-session-delete-btn"
aria-label={t("chat.deleteConversation", "Delete conversation")}
>
<Trash2 size={14} />
</button>
</div>
<div className="chat-session-title">
{session.title || t("chat.untitledSession", "Untitled")}
{sessionTitle}
{showUnreadDot ? (
<span
className="chat-unread-dot"
@@ -2935,8 +2955,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{/* Rename Dialog */}
{renameDialog && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setRenameDialog(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.renameConversationTitle", "Rename Conversation")}</h3>
<div
className="chat-new-dialog chat-view-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="chat-rename-dialog-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="chat-rename-dialog-title">{t("chat.renameConversationTitle", "Rename Conversation")}</h3>
<p className="chat-view-delete-dialog-copy">
{t("chat.renameConversationBody", "Choose a new name for this conversation. Leave it blank to show Untitled.")}
</p>

View File

@@ -1288,4 +1288,126 @@ describe("ChatView core interactions", () => {
const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
});
describe("conversation rename affordances", () => {
it("renames the clicked sidebar session without selecting it", async () => {
const selectSession = vi.fn();
const renameSession = vi.fn().mockResolvedValue(undefined);
const sessions: ChatSessionInfo[] = [
{
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Alpha Chat",
lastMessagePreview: "Alpha preview",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
{
id: "session-002",
agentId: "agent-002",
status: "active",
title: "Beta Chat",
lastMessagePreview: "Beta preview",
createdAt: "2026-04-07T00:00:00.000Z",
updatedAt: "2026-04-07T00:00:00.000Z",
},
];
setupMockChat({
sessions,
filteredSessions: sessions,
activeSession: sessions[0],
selectSession,
renameSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const betaRow = screen.getByTestId("chat-session-session-002");
await userEvent.click(within(betaRow).getByTestId("chat-session-rename-btn"));
expect(selectSession).not.toHaveBeenCalled();
const dialog = screen.getByRole("dialog", { name: /rename conversation/i });
const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement;
expect(input).toHaveValue("Beta Chat");
await userEvent.clear(input);
await userEvent.type(input, "Renamed Beta");
await userEvent.click(within(dialog).getByTestId("chat-rename-save"));
await waitFor(() => {
expect(renameSession).toHaveBeenCalledWith("session-002", "Renamed Beta");
});
expect(selectSession).not.toHaveBeenCalled();
await userEvent.click(betaRow);
expect(selectSession).toHaveBeenCalledWith("session-002");
});
it("uses Untitled in the sidebar rename button name for empty session titles", async () => {
const renameSession = vi.fn().mockResolvedValue(undefined);
const untitledSession: ChatSessionInfo = {
id: "session-empty-title",
agentId: "agent-001",
status: "active",
title: undefined,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
setupMockChat({
sessions: [untitledSession],
filteredSessions: [untitledSession],
activeSession: null,
renameSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const row = screen.getByTestId("chat-session-session-empty-title");
const renameButton = within(row).getByRole("button", { name: /rename conversation untitled/i });
expect(renameButton).toHaveAttribute("data-testid", "chat-session-rename-btn");
await userEvent.click(renameButton);
const dialog = screen.getByRole("dialog", { name: /rename conversation/i });
const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement;
expect(input).toHaveValue("");
expect(input).toHaveAttribute("placeholder", "Untitled");
});
it("keeps the existing context-menu rename path wired to renameSession", async () => {
const renameSession = vi.fn().mockResolvedValue(undefined);
const session: ChatSessionInfo = {
id: "session-context",
agentId: "agent-001",
status: "active",
title: "Context Rename",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
setupMockChat({
sessions: [session],
filteredSessions: [session],
activeSession: session,
renameSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
fireEvent.contextMenu(screen.getByTestId("chat-session-session-context"), { clientX: 12, clientY: 24 });
await userEvent.click(screen.getByTestId("chat-context-rename"));
const dialog = screen.getByRole("dialog", { name: /rename conversation/i });
const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement;
expect(input).toHaveValue("Context Rename");
await userEvent.clear(input);
await userEvent.type(input, "Context Renamed");
await userEvent.click(within(dialog).getByTestId("chat-rename-save"));
await waitFor(() => {
expect(renameSession).toHaveBeenCalledWith("session-context", "Context Renamed");
});
});
});
});

View File

@@ -268,6 +268,78 @@ describe("ChatView mobile behavior", () => {
}
});
it("mobile mode: direct sidebar rows expose rename and delete buttons", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
try {
const sessions = [
{ id: "session-001", agentId: "agent-001", status: "active" as const, title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
];
setupMockChat({
sessions,
filteredSessions: sessions,
activeSession: null,
selectSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const row = screen.getByTestId("chat-session-session-001");
const renameButton = within(row).getByTestId("chat-session-rename-btn");
const deleteButton = within(row).getByTestId("chat-session-delete-btn");
expect(renameButton).toHaveAccessibleName(/rename conversation mobile chat/i);
expect(deleteButton).toHaveAccessibleName(/delete conversation/i);
expect(renameButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(renameButton.closest(".chat-session-actions")).toBe(deleteButton.closest(".chat-session-actions"));
await userEvent.click(renameButton);
expect(selectSession).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: /rename conversation/i })).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: quick session switcher rename affordance remains wired", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
const renameSession = vi.fn().mockResolvedValue(undefined);
try {
const sessions = [
{ id: "session-001", agentId: "agent-001", status: "active" as const, title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-002", status: "active" as const, title: "Switcher Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
];
setupMockChat({
sessions,
filteredSessions: sessions,
activeSession: sessions[0],
selectSession,
renameSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-002"));
expect(selectSession).not.toHaveBeenCalled();
expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: /rename conversation/i });
const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement;
expect(input).toHaveValue("Switcher Chat");
await userEvent.clear(input);
await userEvent.type(input, "Switcher Renamed");
await userEvent.click(within(dialog).getByTestId("chat-rename-save"));
await waitFor(() => {
expect(renameSession).toHaveBeenCalledWith("session-002", "Switcher Renamed");
});
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: thread header title opens quick session switcher and closes after selection", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();