diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/.openspec.yaml b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/.openspec.yaml similarity index 100% rename from openspec/changes/sidebar-tag-collapse-and-delete/.openspec.yaml rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/.openspec.yaml diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/design.md b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/design.md similarity index 100% rename from openspec/changes/sidebar-tag-collapse-and-delete/design.md rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/design.md diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/mockups/index.html b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/mockups/index.html similarity index 100% rename from openspec/changes/sidebar-tag-collapse-and-delete/mockups/index.html rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/mockups/index.html diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/mockups/ux-review.md b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/mockups/ux-review.md similarity index 100% rename from openspec/changes/sidebar-tag-collapse-and-delete/mockups/ux-review.md rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/mockups/ux-review.md diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/proposal.md b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/proposal.md similarity index 100% rename from openspec/changes/sidebar-tag-collapse-and-delete/proposal.md rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/proposal.md diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/specs/session-tags/spec.md b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/specs/session-tags/spec.md similarity index 100% rename from openspec/changes/sidebar-tag-collapse-and-delete/specs/session-tags/spec.md rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/specs/session-tags/spec.md diff --git a/openspec/changes/sidebar-tag-collapse-and-delete/tasks.md b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/tasks.md similarity index 52% rename from openspec/changes/sidebar-tag-collapse-and-delete/tasks.md rename to openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/tasks.md index e60fd1d3b..47af1adc6 100644 --- a/openspec/changes/sidebar-tag-collapse-and-delete/tasks.md +++ b/openspec/changes/archive/2026-07-22-sidebar-tag-collapse-and-delete/tasks.md @@ -1,66 +1,66 @@ ## 1. Protocol (shared) -- [ ] 1.1 Add `remove_tag_globally` to `BrowserToServerMessage` in `packages/shared/src/browser-protocol.ts` with `{ type: "remove_tag_globally"; tag: string }` -- [ ] 1.2 Rebuild shared types so client + server pick up the new message (server restart, no client build yet) +- [x] 1.1 Add `remove_tag_globally` to `BrowserToServerMessage` in `packages/shared/src/browser-protocol.ts` with `{ type: "remove_tag_globally"; tag: string }` +- [x] 1.2 Rebuild shared types so client + server pick up the new message (server restart, no client build yet) — shared exports resolve `./src/*.ts` directly (no build step) ## 2. Server handler (TDD) -- [ ] 2.1 Author the L1 server-handler tests TEST-FIRST (see §10: E3, E4, E5, E6, X3) in `session-meta-handler.test.ts` and confirm they FAIL (red) before implementing 2.2 -- [ ] 2.2 Implement `handleRemoveTagGlobally` in `packages/server/src/browser-handlers/session-meta-handler.ts`: normalize the inbound tag first (`normalizeTags([tag])[0]`; if `undefined`/empty, early-return no-op), iterate `sessionManager.listAll()` for sessions whose `tags` include it, strip it, reuse the existing `normalizeTags` → `sessionManager.update` → `broadcast(session_updated)` path (no `mergeSessionMeta`) -- [ ] 2.3 Wire the handler into the browser-gateway message switch (`packages/server/src/pairing/browser-gateway.ts`) — REQUIRED: an unwired type falls through `default:` → `handlePiGatewayForward` and is misrouted to a bridge -- [ ] 2.4 Run the server test file green (`npm test -- session-meta-handler`) +- [x] 2.1 Author the L1 server-handler tests TEST-FIRST (see §10: E3, E4, E5, E6, X3) in `session-meta-handler.test.ts` and confirm they FAIL (red) before implementing 2.2 +- [x] 2.2 Implement `handleRemoveTagGlobally` in `packages/server/src/browser-handlers/session-meta-handler.ts`: normalize the inbound tag first (`normalizeTags([tag])[0]`; if `undefined`/empty, early-return no-op), iterate `sessionManager.listAll()` for sessions whose `tags` include it, strip it, reuse the existing `normalizeTags` → `sessionManager.update` → `broadcast(session_updated)` path (no `mergeSessionMeta`) +- [x] 2.3 Wire the handler into the browser-gateway message switch (`packages/server/src/pairing/browser-gateway.ts`) — REQUIRED: an unwired type falls through `default:` → `handlePiGatewayForward` and is misrouted to a bridge +- [x] 2.4 Run the server test file green (`npm test -- session-meta-handler`) ## 3. Client sender -- [ ] 3.1 Add `removeTagGlobally(tag: string)` to `packages/client/src/hooks/useSessionActions.ts` sending `{ type: "remove_tag_globally", tag }` +- [x] 3.1 Add `removeTagGlobally(tag: string)` to `packages/client/src/hooks/useSessionActions.ts` sending `{ type: "remove_tag_globally", tag }` ## 4. Chip primitive — destructive remove on filter chips -- [ ] 4.1 Extend `TagChip` `filter` variant in `packages/client/src/components/tags/TagChip.tsx`: wrap the existing bare toggle ` - {!sessions.some(passesTagAxes) && ( - - {t("sessionList.zeroMatch", undefined, "0 match")} - + {t("sessionList.tagAreaActiveCount", { count: activeFilterCount }, `${activeFilterCount} active`)} + + )} + + {/* Clear affordance reachable while collapsed (D8) — only when a + filter is active AND the area is folded. */} + {!tagAreaOpen && activeFilterCount > 0 && ( + + )} + {tagAreaOpen && ( + <> + setPendingDeleteTag(tag) : undefined} + /> + + {anyTagFilterActive && ( +
+ + {!sessions.some(passesTagAxes) && ( + + {t("sessionList.zeroMatch", undefined, "0 match")} + + )} +
)} - + + )} + {pendingDeleteTag != null && onRemoveTagGlobally && ( + { + onRemoveTagGlobally(pendingDeleteTag); + // Drop the just-deleted tag from the active filter selection so a + // now-nonexistent tag can't leave the list filtered to 0 with no + // chip left to deselect (CodeRabbit #5). + setSelectedTags((prev) => { + if (!prev.has(pendingDeleteTag)) return prev; + const next = new Set(prev); + next.delete(pendingDeleteTag); + return next; + }); + }} + onClose={() => setPendingDeleteTag(null)} + /> )} )} diff --git a/packages/client/src/components/session/SessionList.tsx.AGENTS.md b/packages/client/src/components/session/SessionList.tsx.AGENTS.md index 407835e7d..0e8537db3 100644 --- a/packages/client/src/components/session/SessionList.tsx.AGENTS.md +++ b/packages/client/src/components/session/SessionList.tsx.AGENTS.md @@ -1,3 +1,3 @@ # SessionList.tsx — index -Main sidebar session list. DnD-ordered (`@dnd-kit`) pinned/unpinned + workspace tiers, folder grouping,… `renderGroup` folder-home open affordance (`folder-open-home-`) renders when `isPinned \|\| inWorkspace` (unpinned workspace folder has `folder.pinned===false`). See change: enable-workspace-folder-home-page. Whole folder header name-row (`folder-home-row-`) navigates to `buildFolderHomeUrl(cwd)` on click (all folders); collapse moved solely to chevron `folder-toggle-btn` in drag gutter. See change: directory-card-clickable-select. → see `SessionList.tsx.AGENTS.md` +Main sidebar session list. DnD-ordered (`@dnd-kit`) pinned/unpinned + workspace tiers, folder grouping,… `renderGroup` folder-home open affordance (`folder-open-home-`) renders when `isPinned \|\| inWorkspace` (unpinned workspace folder has `folder.pinned===false`). See change: enable-workspace-folder-home-page. Whole folder header name-row (`folder-home-row-`) navigates to `buildFolderHomeUrl(cwd)` on click (all folders); collapse moved solely to chevron `folder-toggle-btn` in drag gutter. See change: directory-card-clickable-select. Sidebar tag/phase filter area sits under ONE master collapse (`tag-area-toggle`, `aria-expanded`, default-collapsed via `getTagAreaOpen`/`setTagAreaOpen`); collapsed header shows `N tags · M phases` (`tag-area-count`) + an active-selection badge (`tag-area-active-indicator`) with a fold-free clear (`clear-tag-filters-collapsed`) when filters active; expanded renders `TagFilterGroup` user (cap=10, per-chip ✕ → `TagDeleteConfirmDialog` → `onRemoveTagGlobally`) + read-only phase group. See change: sidebar-tag-collapse-and-delete. → see `SessionList.tsx.AGENTS.md` diff --git a/packages/client/src/components/tags/AGENTS.md b/packages/client/src/components/tags/AGENTS.md index 2fbefa1c0..d6e7f7470 100644 --- a/packages/client/src/components/tags/AGENTS.md +++ b/packages/client/src/components/tags/AGENTS.md @@ -4,8 +4,9 @@ Session tag chip primitives + selectors. Shared across session card, detail head | File | Purpose | |------|---------| -| `TagChip.tsx` | Shared chip primitive. Exports `TagChip`, `TagChipVariant`, `TagChipTone`. Variants: `user` (colorized via `tagColor`, optional remove ✕ button), `exec` (dashed/muted read-only phase chip + 🔒), `filter` (selectable, `tone` picks user-colorized vs dashed, `aria-pressed`, `sel` ring). Interactive controls are real ` ); + // Destructive global-delete ✕ (user-tone filter chips only). Wrap the + // toggle + ✕ in a single inline-flex unit so the ✕ never wraps to its own + // line, and re-home the `selected` ring onto the wrapper. The ✕ is a true + // sibling + + ); + } + return toggleBtn; } if (variant === "exec") { diff --git a/packages/client/src/components/tags/TagDeleteConfirmDialog.tsx b/packages/client/src/components/tags/TagDeleteConfirmDialog.tsx new file mode 100644 index 000000000..2f79cbeae --- /dev/null +++ b/packages/client/src/components/tags/TagDeleteConfirmDialog.tsx @@ -0,0 +1,39 @@ +/** + * Confirm dialog for a destructive global tag delete. Names the tag, states + * the carrying-session blast radius (global across folders/projects), and warns + * the action is not undoable and that the tag reappears if a session re-adds it + * (derived-union). On confirm, dispatches `remove_tag_globally`. + * See change: sidebar-tag-collapse-and-delete. + */ +import { Dialog } from "@blackbelt-technology/pi-dashboard-client-utils/Dialog"; + +interface Props { + tag: string; + /** How many sessions currently carry the tag (client-derived count). */ + count: number; + onConfirm: () => void; + onClose: () => void; +} + +export function TagDeleteConfirmDialog({ tag, count, onConfirm, onClose }: Props) { + return ( + +

+ Remove #{tag} from{" "} + + {count} session{count === 1 ? "" : "s"} + {" "} + across all folders? +

+

+ This is not undoable. The tag will reappear if any session re-adds it. +

+ + + { onConfirm(); onClose(); }} testId="tag-delete-confirm-btn"> + Remove tag + + +
+ ); +} diff --git a/packages/client/src/components/tags/TagFilterGroup.tsx b/packages/client/src/components/tags/TagFilterGroup.tsx index 9c06ddcdc..849b151a4 100644 --- a/packages/client/src/components/tags/TagFilterGroup.tsx +++ b/packages/client/src/components/tags/TagFilterGroup.tsx @@ -3,8 +3,15 @@ * "Your tags" group (`tone="user"`, colorized) and the "Phase (read-only)" * group (`tone="exec"`, dashed). Selection state is owned by the parent * (`SessionList`) — this component is presentational. - * See change: add-session-tags. + * + * Rendered as a plain sub-label group under the sidebar's single master + * `Tags` collapse (owned by `SessionList`); it carries no chevron of its own. + * The user-tone group caps chip overflow at `cap` (default uncapped) with a + * `+N more` / `show less` inline expander, and wires an optional per-chip + * destructive remove (✕) via `onRemove`. + * See change: add-session-tags · sidebar-tag-collapse-and-delete. */ +import { useState } from "react"; import { TagChip } from "./TagChip.js"; interface TagFilterGroupProps { @@ -16,17 +23,28 @@ interface TagFilterGroupProps { onToggle: (tag: string) => void; /** Colorized user tags vs dashed phase chips. */ tone: "user" | "exec"; + /** + * Overflow cap: show at most this many chips, then a `+N more` inline + * expander. Omit (or 0) for no cap. See change: sidebar-tag-collapse-and-delete. + */ + cap?: number; + /** Per-chip destructive global-delete handler (user tone only). */ + onRemove?: (tag: string) => void; } -export function TagFilterGroup({ label, tags, selected, onToggle, tone }: TagFilterGroupProps) { +export function TagFilterGroup({ label, tags, selected, onToggle, tone, cap, onRemove }: TagFilterGroupProps) { + const [expanded, setExpanded] = useState(false); if (tags.length === 0) return null; + const capped = cap && cap > 0 && tags.length > cap && !expanded; + const shown = capped ? tags.slice(0, cap) : tags; + const hidden = capped ? tags.length - cap! : 0; return (
{label}
- {tags.map((tag) => ( + {shown.map((tag) => ( onToggle(tag)} + onRemove={onRemove ? () => onRemove(tag) : undefined} /> ))} + {cap && cap > 0 && tags.length > cap ? ( + + ) : null}
); diff --git a/packages/client/src/components/tags/__tests__/tags-components.test.tsx b/packages/client/src/components/tags/__tests__/tags-components.test.tsx index 8ce3078e1..e7afbd168 100644 --- a/packages/client/src/components/tags/__tests__/tags-components.test.tsx +++ b/packages/client/src/components/tags/__tests__/tags-components.test.tsx @@ -8,6 +8,7 @@ import { cleanup, fireEvent, render, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TagChip } from "../TagChip.js"; import { TagEditor } from "../TagEditor.js"; +import { TagFilterGroup } from "../TagFilterGroup.js"; import { TagStrip } from "../TagStrip.js"; afterEach(() => cleanup()); @@ -89,6 +90,91 @@ describe("TagChip keyboard operability", () => { }); }); +// Sidebar filter overflow + destructive remove control. +// See change: sidebar-tag-collapse-and-delete. +describe("TagFilterGroup overflow cap", () => { + const tags10 = Array.from({ length: 10 }, (_, i) => `tag${i}`); + const tags13 = Array.from({ length: 13 }, (_, i) => `tag${i}`); + + // E1: exactly at the cap → all 10 chips, NO +N more control. + it("E1 — at the cap shows all chips and no overflow control", () => { + const { getByLabelText, queryByTestId } = render( + {}} tone="user" cap={10} />, + ); + for (const tag of tags10) expect(getByLabelText(`Filter by tag ${tag}`)).toBeTruthy(); + expect(queryByTestId("tag-overflow-toggle")).toBeNull(); + }); + + // E2: above the cap → 10 + `+3 more`; expand → all 13 + `show less`; collapse → back. + it("E2 — above the cap shows +N more, expands to all, and collapses back", () => { + const { getByTestId, queryByLabelText } = render( + {}} tone="user" cap={10} />, + ); + const toggle = getByTestId("tag-overflow-toggle"); + expect(toggle.textContent).toBe("+3 more"); + // First render: 10 shown, 3 hidden. + expect(queryByLabelText("Filter by tag tag9")).toBeTruthy(); + expect(queryByLabelText("Filter by tag tag10")).toBeNull(); + // Expand → all 13 + show less. + fireEvent.click(toggle); + expect(getByTestId("tag-overflow-toggle").textContent).toBe("show less"); + expect(queryByLabelText("Filter by tag tag12")).toBeTruthy(); + // Collapse back → 10 + +3 more. + fireEvent.click(getByTestId("tag-overflow-toggle")); + expect(getByTestId("tag-overflow-toggle").textContent).toBe("+3 more"); + expect(queryByLabelText("Filter by tag tag10")).toBeNull(); + }); +}); + +describe("TagChip filter remove control", () => { + // F2: the ✕ is a separate control — activating it fires onRemove, NOT onToggle. + it("F2 — remove is independent of the filter toggle", () => { + const onToggle = vi.fn(); + const onRemove = vi.fn(); + const { getByLabelText } = render( + , + ); + const removeBtn = getByLabelText("Remove tag explore from all sessions"); + fireEvent.click(removeBtn); + expect(onRemove).toHaveBeenCalledTimes(1); + expect(onToggle).not.toHaveBeenCalled(); + // The toggle is still reachable and independent. + fireEvent.click(getByLabelText("Filter by tag explore")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + // X2: the ✕ is independently keyboard-operable with an action+tag name. + it("X2 — remove control is keyboard-operable with an accessible name", () => { + const onRemove = vi.fn(); + const { getByLabelText } = render( + {}} onRemove={onRemove} />, + ); + const removeBtn = getByLabelText("Remove tag explore from all sessions"); + removeBtn.focus(); + expect(document.activeElement).toBe(removeBtn); + fireEvent.click(removeBtn); // Enter/Space on a focused