FN-9196: Move archived chat toggle into filter row

Restyle the chat archive control as a compact, responsive filter-line toggle.

- Place the Archived toggle beside the tag filter with stable pressed-state semantics.
- Add token-based responsive styling for desktop, narrow, and mobile sidebars.
- Cover toggle behavior, selected-tag coexistence, and filter-row layout.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/chat-archived-filter-row.md             |  7 ++
 packages/dashboard/app/components/ChatView.css     | 17 +++-
 packages/dashboard/app/components/ChatView.tsx     | 44 +++++++---
 .../ChatView.archived-toggle-row.test.tsx          | 99 ++++++++++++++++++++++
 4 files changed, 151 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-9196

Fusion-Task-Lineage: 2bd3d000-84be-4798-9b73-de1feac40536

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 10:03:46 -07:00
parent 52d124bde2
commit 323d55a43b
4 changed files with 151 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Chat sidebar shows a compact Archived toggle on the tag filter line.
category: fix
dev: ChatView sidebar filter row (.chat-sidebar-filter-row); .chat-archived-toggle restyled, testid unchanged.

View File

@@ -706,6 +706,10 @@ A narrow floating host keeps the one-pane list/detail flow. FN-9193 adds a docke
min-height: calc(var(--space-2xl) + var(--space-xs)); min-height: calc(var(--space-2xl) + var(--space-xs));
} }
.chat-view--narrow .chat-sidebar-filter-row {
flex-wrap: wrap;
}
.chat-view--narrow .chat-sidebar-list { .chat-view--narrow .chat-sidebar-list {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -2498,9 +2502,17 @@ Shared ChatView hosts expose Direct-only tag filters and compact chips without i
FNXC:ChatTags 2026-08-23-15:51: FNXC:ChatTags 2026-08-23-15:51:
The tag filter select must use the sibling sidebar search input's comfortable token-based inner padding so "All tags" is not flush against its border across desktop, narrow, and mobile hosts. The tag filter select must use the sibling sidebar search input's comfortable token-based inner padding so "All tags" is not flush against its border across desktop, narrow, and mobile hosts.
FNXC:ChatArchived 2026-08-23-16:27:
The compact Archived toggle shares the narrowed tag-filter line, with tokenized row padding separating the search input and session list. Narrow and mobile sidebars may wrap the controls rather than clipping or horizontally overflowing them.
*/ */
.chat-tag-filter { display: flex; align-items: center; gap: var(--space-xs); color: var(--text-muted); } .chat-sidebar-filter-row { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) 0; min-width: 0; }
.chat-tag-filter select { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg); color: var(--text); padding: var(--space-sm) var(--space-md); } .chat-tag-filter { display: flex; flex: 1 1 auto; align-items: center; gap: var(--space-xs); min-width: 0; overflow: hidden; color: var(--text-muted); }
.chat-tag-filter > svg,
.chat-tag-filter > .btn-icon { flex: 0 0 auto; }
.chat-tag-filter select { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg); color: var(--text); padding: var(--space-sm) var(--space-md); }
.chat-archived-toggle { flex: 0 0 auto; padding-inline: var(--space-sm); white-space: nowrap; }
.chat-archived-toggle--active { background: color-mix(in srgb, var(--accent) 16%, var(--surface-2)); color: var(--accent-text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.chat-session-tags { display: flex; flex-wrap: wrap; gap: var(--space-xs); overflow: hidden; } .chat-session-tags { display: flex; flex-wrap: wrap; gap: var(--space-xs); overflow: hidden; }
.chat-session-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 var(--space-xs); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text-muted); font-size: var(--font-size-xs); } .chat-session-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 var(--space-xs); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text-muted); font-size: var(--font-size-xs); }
/* FNXC:ChatTags 2026-08-05-12:15: direct-conversation tag controls keep assignment, rename, and deletion in the existing context menu so shared Chat hosts do not grow another sidebar action row. */ /* FNXC:ChatTags 2026-08-05-12:15: direct-conversation tag controls keep assignment, rename, and deletion in the existing context menu so shared Chat hosts do not grow another sidebar action row. */
@@ -2511,6 +2523,7 @@ The tag filter select must use the sibling sidebar search input's comfortable to
.chat-tag-create-row { display: flex; gap: var(--space-xs); } .chat-tag-create-row { display: flex; gap: var(--space-xs); }
.chat-tag-create-row .input { min-width: 0; } .chat-tag-create-row .input { min-width: 0; }
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-sidebar-filter-row { flex-wrap: wrap; }
.chat-session-tags { max-height: calc(var(--space-lg) * 2); } .chat-session-tags { max-height: calc(var(--space-lg) * 2); }
/* /*
FNXC:ChatTags 2026-07-24-23:05: FNXC:ChatTags 2026-07-24-23:05:

View File

@@ -3508,23 +3508,39 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
data-testid="chat-search-input" data-testid="chat-search-input"
/> />
</div> </div>
<label className="chat-tag-filter" htmlFor="chat-tag-filter"> {/*
<Tag size={14} aria-hidden="true" /> FNXC:ChatArchived 2026-08-23-16:27:
<select The archived affordance is a compact Archived toggle sharing the tag-filter line, so the sidebar does not spend a full row on it. aria-pressed and active styling convey state instead of changing the visible label.
id="chat-tag-filter" */}
value={selectedTagId ?? ""} <div className="chat-sidebar-filter-row">
onChange={(event) => setSelectedTagId(event.target.value || null)} <label className="chat-tag-filter" htmlFor="chat-tag-filter">
data-testid="chat-tag-filter" <Tag size={14} aria-hidden="true" />
aria-label={t("chat.filterByTag", "Filter conversations by tag")} <select
id="chat-tag-filter"
value={selectedTagId ?? ""}
onChange={(event) => setSelectedTagId(event.target.value || null)}
data-testid="chat-tag-filter"
aria-label={t("chat.filterByTag", "Filter conversations by tag")}
>
<option value="">{t("chat.allTags", "All tags")}</option>
{tags.map((tag) => <option key={tag.id} value={tag.id}>{tag.name}</option>)}
</select>
{selectedTagId ? <button type="button" className="btn-icon" aria-label={t("chat.clearTagFilter", "Clear tag filter")} onClick={() => setSelectedTagId(null)}><X size={14} /></button> : null}
</label>
<button
type="button"
className={`btn btn-sm chat-archived-toggle${showArchivedSessions ? " chat-archived-toggle--active" : ""}`}
data-testid="chat-archived-toggle"
aria-pressed={showArchivedSessions}
title={t("chat.showArchivedConversations", "Show archived conversations")}
aria-label={t("chat.showArchivedConversations", "Show archived conversations")}
onClick={() => { const next = !showArchivedSessions; setShowArchivedSessions(next); if (next) void refreshArchivedSessions(); }}
> >
<option value="">{t("chat.allTags", "All tags")}</option> {t("chat.archived", "Archived")}
{tags.map((tag) => <option key={tag.id} value={tag.id}>{tag.name}</option>)} </button>
</select> </div>
{selectedTagId ? <button type="button" className="btn-icon" aria-label={t("chat.clearTagFilter", "Clear tag filter")} onClick={() => setSelectedTagId(null)}><X size={14} /></button> : null}
</label>
</div> </div>
{/* Session list section */} {/* Session list section */}
<div className="chat-archived-toggle"><button type="button" className="btn btn-sm btn-secondary" data-testid="chat-archived-toggle" onClick={() => { const next = !showArchivedSessions; setShowArchivedSessions(next); if (next) void refreshArchivedSessions(); }}>{showArchivedSessions ? "Active conversations" : "Archived conversations"}</button></div>
<div className="chat-session-list chat-sidebar-list"> <div className="chat-session-list chat-sidebar-list">
{sessionsLoading ? ( {sessionsLoading ? (
<div className="chat-empty-state chat-empty-state--padded">{t("chat.loadingConversations", "Loading...")}</div> <div className="chat-empty-state chat-empty-state--padded">{t("chat.loadingConversations", "Loading...")}</div>

View File

@@ -0,0 +1,99 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import { installChatViewEnv, renderWithAct, setupMockChat } from "./ChatView.test-harness";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({
...(await importOriginal<typeof import("../../hooks/useNavigationHistory")>()),
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
}));
vi.mock("../../api", () => ({
fetchSettings: vi.fn().mockResolvedValue({}),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
installChatViewEnv();
const chatViewCss = readFileSync(resolve(__dirname, "../ChatView.css"), "utf8");
const archivedSession = {
id: "session-archived",
agentId: "agent-002",
status: "archived" as const,
title: "Archived conversation",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z",
};
function getFilterRow() {
const toggle = screen.getByTestId("chat-archived-toggle");
const row = toggle.closest(".chat-sidebar-filter-row");
expect(row).toContainElement(screen.getByTestId("chat-tag-filter"));
expect(row?.parentElement).toHaveClass("chat-sidebar-search-container");
return row!;
}
describe("ChatView archived toggle filter row", () => {
it("keeps the Archived label and archive behavior in the shared filter row", async () => {
const refreshArchivedSessions = vi.fn().mockResolvedValue(undefined);
setupMockChat({ archivedSessions: [archivedSession], refreshArchivedSessions });
const user = userEvent.setup();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const toggle = screen.getByTestId("chat-archived-toggle");
expect(toggle).toHaveTextContent(/^Archived$/);
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(screen.queryByText("Archived conversations")).not.toBeInTheDocument();
expect(screen.queryByText("Active conversations")).not.toBeInTheDocument();
getFilterRow();
expect(document.querySelectorAll("[data-testid=\"chat-archived-toggle\"]")).toHaveLength(1);
expect(document.querySelector(".chat-sidebar-search-container")?.nextElementSibling).toHaveClass("chat-session-list");
await user.click(toggle);
expect(toggle).toHaveTextContent(/^Archived$/);
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(refreshArchivedSessions).toHaveBeenCalledOnce();
expect(screen.getByTestId("chat-archived-session-session-archived")).toBeInTheDocument();
await user.click(toggle);
expect(toggle).toHaveTextContent(/^Archived$/);
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(refreshArchivedSessions).toHaveBeenCalledOnce();
});
it("keeps populated selected-tag controls beside Archived", async () => {
const tag = {
id: "tag-long",
projectId: "proj-123",
name: "A very long tag name that must not displace the archive toggle",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z",
};
setupMockChat({ tags: [tag], selectedTagId: tag.id, setSelectedTagId: vi.fn() });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const row = getFilterRow();
expect(within(row).getByLabelText("Clear tag filter")).toBeInTheDocument();
expect(within(row).getByTestId("chat-archived-toggle")).toHaveTextContent(/^Archived$/);
});
it("declares a token-padded flex filter row without a full-row tag filter", () => {
const rowRule = chatViewCss.match(/(?:^|\n)\.chat-sidebar-filter-row\s*\{([^{}]*)\}/)?.[1];
const tagFilterRule = chatViewCss.match(/(?:^|\n)\.chat-tag-filter\s*\{([^{}]*)\}/)?.[1];
expect(rowRule).toMatch(/display:\s*flex/);
expect(rowRule).toMatch(/padding:\s*var\(--space-/);
expect(tagFilterRule).toContain("flex: 1 1 auto");
expect(tagFilterRule).not.toMatch(/flex:\s*1\s*;/);
});
});