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));
}
.chat-view--narrow .chat-sidebar-filter-row {
flex-wrap: wrap;
}
.chat-view--narrow .chat-sidebar-list {
flex: 1;
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:
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-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-sidebar-filter-row { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) 0; min-width: 0; }
.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-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. */
@@ -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 .input { min-width: 0; }
@media (max-width: 768px) {
.chat-sidebar-filter-row { flex-wrap: wrap; }
.chat-session-tags { max-height: calc(var(--space-lg) * 2); }
/*
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"
/>
</div>
<label className="chat-tag-filter" htmlFor="chat-tag-filter">
<Tag size={14} aria-hidden="true" />
<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")}
{/*
FNXC:ChatArchived 2026-08-23-16:27:
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.
*/}
<div className="chat-sidebar-filter-row">
<label className="chat-tag-filter" htmlFor="chat-tag-filter">
<Tag size={14} aria-hidden="true" />
<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>
{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>
{t("chat.archived", "Archived")}
</button>
</div>
</div>
{/* 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">
{sessionsLoading ? (
<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*;/);
});
});