Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Frontend UI Audit — Navigation Sidebar State Correctness

**Files:** `NavigationSidebar.tsx` and the cloud-scoped session menu helpers
**Date:** 2026-07-30
**Auditor:** ORGII coding session

## D1 — Raw HTML vs Design System

| Line | Element | Verdict | Reason | Suggested change |
|---|---|---|---|---|
| `NavigationSidebar.tsx:442-483` | Existing section header interaction | keep with reason | The change preserves the sidebar's existing keyboard-capable section header and shared icon button; it only corrects which persisted collapse state drives the presentation. | — |

## D2 — Arbitrary Tailwind Value vs Token

| Line | Value | Verdict | Reason | Suggested change |
|---|---|---|---|---|
| — | Existing sidebar classes | keep with reason | No Tailwind or color value changes are introduced. | — |

## D3 — Hardcoded Sizes / Colors

| Line | Value | Verdict | Reason | Suggested change |
|---|---|---|---|---|
| — | Existing section geometry | keep with reason | The change does not add or alter hardcoded dimensions or colors. | — |

## D4 — Accessibility

| Line | Element | Verdict | Reason | Suggested change |
|---|---|---|---|---|
| `NavigationSidebar.tsx:442-483` | Collapsible section header | keep with reason | Mouse, Enter, and Space activation remain aligned, and `aria-expanded` now consistently reflects the authoritative persisted state even while search filters visible rows. | — |

## D5 — Visual Patterns Observed

- Cloud-scoped local sessions retain the canonical sidebar row renderer and are sorted before pagination, so activity ordering cannot diverge between pages.
- Section collapse remains presentation state owned by the sidebar; search changes row visibility without silently rewriting or bypassing that preference.
- No parallel menu, button, empty state, or visual abstraction is introduced.

## Summary

- 0 fixes recommended
- 4 keep-with-reason findings
- 0 abstract candidates
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config";
import type { Session } from "@src/store/session";

import {
CLOUD_MY_SESSIONS_LOAD_MORE_ID,
Expand All @@ -10,6 +11,14 @@ import {
} from "./cloudScopedMenuItems";

describe("buildCloudScopedMenuItems", () => {
const session = (sessionId: string, updatedAt: string): Session => ({
session_id: sessionId,
status: "completed",
created_at: updatedAt,
updated_at: updatedAt,
});
const sessionMap = (...sessions: Session[]) =>
new Map(sessions.map((entry) => [entry.session_id, entry]));
const localSections: NavigationMenuItem[] = [
{ id: "separator-today", key: "separator-today", label: "Today" },
{ id: "session-today", key: "session-today", label: "Today session" },
Expand All @@ -30,6 +39,7 @@ describe("buildCloudScopedMenuItems", () => {
buildCloudScopedMenuItems({
cloudMenuItems: [],
sessionMenuItems: localSections,
sessionById: sessionMap(),
mySessionsLabel: "My sessions",
})
).toEqual(localSections);
Expand All @@ -48,6 +58,10 @@ describe("buildCloudScopedMenuItems", () => {
const result = buildCloudScopedMenuItems({
cloudMenuItems: teamItems,
sessionMenuItems: localSections,
sessionById: sessionMap(
session("session-today", "2026-07-27T12:00:00Z"),
session("session-yesterday", "2026-07-26T12:00:00Z")
),
mySessionsLabel: "My sessions",
});

Expand All @@ -63,6 +77,40 @@ describe("buildCloudScopedMenuItems", () => {
]);
});

it("restores one newest-activity queue after hidden subgroup ordering", () => {
const result = buildCloudScopedMenuItems({
cloudMenuItems: [
{
id: "separator-cloud-team-sessions",
key: "separator-cloud-team-sessions",
label: "Team sessions",
},
],
// Simulates pinned/workspace grouping putting an older row first.
sessionMenuItems: [
{ id: "older-pinned", key: "older-pinned", label: "Older pinned" },
{ id: "separator-workspace", key: "separator-workspace", label: "A" },
{ id: "newest", key: "newest", label: "Newest" },
{ id: "middle", key: "middle", label: "Middle" },
],
sessionById: sessionMap(
session("older-pinned", "2026-07-25T12:00:00Z"),
session("newest", "2026-07-27T12:00:00Z"),
session("middle", "2026-07-26T12:00:00Z")
),
mySessionsLabel: "My sessions",
});

const mySectionIndex = result.findIndex(
(item) => item.id === `separator-${CLOUD_MY_SESSIONS_SECTION_ID}`
);
expect(result.slice(mySectionIndex + 1).map((item) => item.id)).toEqual([
"newest",
"middle",
"older-pinned",
]);
});

it("renders the My sessions section even when it has no local rows", () => {
expect(
buildCloudScopedMenuItems({
Expand All @@ -74,6 +122,7 @@ describe("buildCloudScopedMenuItems", () => {
},
],
sessionMenuItems: [],
sessionById: sessionMap(),
mySessionsLabel: "My sessions",
})
).toEqual([
Expand Down Expand Up @@ -124,6 +173,14 @@ describe("buildCloudScopedMenuItems", () => {
},
],
sessionMenuItems: sessionItems,
sessionById: sessionMap(
...Array.from({ length: 24 }, (_, index) =>
session(
`session-${index}`,
new Date(Date.UTC(2026, 6, 27, 0, 0, 24 - index)).toISOString()
)
)
),
mySessionsLabel: "My sessions",
loadMoreLabel: "Load more",
});
Expand Down Expand Up @@ -160,6 +217,14 @@ describe("buildCloudScopedMenuItems", () => {
},
],
sessionMenuItems: sessionItems,
sessionById: sessionMap(
...Array.from({ length: 21 }, (_, index) =>
session(
`session-${index}`,
new Date(Date.UTC(2026, 6, 27, 0, 0, 21 - index)).toISOString()
)
)
),
mySessionsLabel: "My sessions",
mySessionsVisibleCount: 20,
});
Expand Down Expand Up @@ -191,6 +256,7 @@ describe("buildCloudScopedMenuItems", () => {
{ id: "separator-today", key: "separator-today", label: "Today" },
{ id: "session-today", key: "session-today", label: "Today one" },
],
sessionById: sessionMap(),
mySessionsLabel: "My sessions",
pinnedLabel: "Pinned",
});
Expand Down Expand Up @@ -220,6 +286,7 @@ describe("buildCloudScopedMenuItems", () => {
{ id: "separator-today", key: "separator-today", label: "Today" },
{ id: "session-today", key: "session-today", label: "Today one" },
],
sessionById: sessionMap(),
mySessionsLabel: "My sessions",
});

Expand Down Expand Up @@ -248,6 +315,7 @@ describe("buildCloudScopedMenuItems", () => {
label: "Show more",
},
],
sessionById: sessionMap(),
mySessionsLabel: "My sessions",
});

Expand Down Expand Up @@ -282,6 +350,7 @@ describe("buildCloudScopedMenuItems", () => {
{ id: "separator-today", key: "separator-today", label: "Today" },
{ id: "session-today", key: "session-today", label: "Today one" },
],
sessionById: sessionMap(),
mySessionsLabel: "My sessions",
pinnedLabel: "Pinned",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { MoreHorizontal } from "lucide-react";
import type { ReactNode } from "react";

import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config";
import type { Session } from "@src/store/session";

import { separator } from "../useSessionMenuItems/menuItemBuilders";
import { sortSessionsByActivity } from "../workstationSidebarData";

export const CLOUD_MY_SESSIONS_SECTION_ID = "cloud-my-sessions";
export const CLOUD_PINNED_SECTION_ID = "cloud-pinned";
Expand All @@ -15,6 +17,7 @@ export const CLOUD_MY_SESSIONS_LOAD_MORE_ID = "cloud-my-sessions-next-page";
interface BuildCloudScopedMenuItemsParams {
cloudMenuItems: readonly NavigationMenuItem[];
sessionMenuItems: readonly NavigationMenuItem[];
sessionById: ReadonlyMap<string, Session>;
mySessionsLabel: string;
pinnedLabel?: string;
mySessionsVisibleCount?: number;
Expand Down Expand Up @@ -69,18 +72,45 @@ export function buildCloudSectionLoadMoreItem({
};
}

export function orderSessionMenuRowsByActivity(
rows: readonly NavigationMenuItem[],
sessionById: ReadonlyMap<string, Session>
): NavigationMenuItem[] {
const rowBySessionId = new Map<string, NavigationMenuItem>();
const unmatchedRows: NavigationMenuItem[] = [];

for (const row of rows) {
if (sessionById.has(row.id)) rowBySessionId.set(row.id, row);
else unmatchedRows.push(row);
}

const matchedSessions = Array.from(rowBySessionId.keys())
.map((sessionId) => sessionById.get(sessionId))
.filter((session): session is Session => session !== undefined);

return [
...sortSessionsByActivity(matchedSessions).flatMap((session) => {
const row = rowBySessionId.get(session.session_id);
return row ? [row] : [];
}),
...unmatchedRows,
];
}

/**
* Cloud scope has three top-level sections: the pinned rows the viewer lifted
* out, shared team sessions, and everything else of theirs. The *date*
* grouping separators are removed so every ordinary local row belongs to the
* single "My sessions" section — but Pinned is not a date bucket, it is user
* intent, and pinning is a capability of every session in every org. Dropping
* its header with the date headers left a pinned row indistinguishable from
* the rest of the list, which read as "cloud orgs cannot pin".
* the rest of the list, which read as "cloud orgs cannot pin". Unpinned local
* rows form one canonical newest-activity-first queue before pagination.
*/
export function buildCloudScopedMenuItems({
cloudMenuItems,
sessionMenuItems,
sessionById,
mySessionsLabel,
pinnedLabel = "Pinned",
mySessionsVisibleCount = CLOUD_SESSION_SECTION_PAGE_SIZE,
Expand All @@ -93,7 +123,7 @@ export function buildCloudScopedMenuItems({
// the same rule then works for a teammate's row, which lives in a
// different section entirely.
const pinnedItems: NavigationMenuItem[] = [];
const localRows: NavigationMenuItem[] = [];
const unorderedLocalRows: NavigationMenuItem[] = [];
const backendPaginationItems: NavigationMenuItem[] = [];
for (const item of sessionMenuItems) {
if (item.id.startsWith("separator-")) continue;
Expand All @@ -104,7 +134,7 @@ export function buildCloudScopedMenuItems({
// A date group's own "show more" pager is meaningless once that group is
// flattened into My sessions — the section's own pager governs from here.
if (item.id.startsWith(LOCAL_GROUP_PAGER_PREFIX)) continue;
(item.pinned ? pinnedItems : localRows).push(item);
(item.pinned ? pinnedItems : unorderedLocalRows).push(item);
}
// Team rows keep their section, except the ones the viewer pinned: pinning
// means "keep this where I can see it", which is not a per-section promise.
Expand All @@ -113,6 +143,10 @@ export function buildCloudScopedMenuItems({
if (item.pinned) pinnedItems.push(item);
else teamItems.push(item);
}
const localRows = orderSessionMenuRowsByActivity(
unorderedLocalRows,
sessionById
);
const visibleLocalRows = localRows.slice(0, mySessionsVisibleCount);
const hasHiddenLoadedRows = localRows.length > visibleLocalRows.length;
const readyBackendPaginationItem = backendPaginationItems.find(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export function useWorkstationSidebarMenuDecoration({
// Cloud rows already carry Replay/Fork actions, so only local rows
// use the regular session action decoration.
sessionMenuItems: decorateSessionRowActions(sessionSidebarMenuItems),
sessionById: sessionMap,
mySessionsLabel: t("cloud.sidebar.mySessions"),
pinnedLabel: tCommon("sessions:chat.historyPinned", "Pinned"),
mySessionsVisibleCount: cloudMySessionsVisibleCount,
Expand All @@ -198,6 +199,7 @@ export function useWorkstationSidebarMenuDecoration({
cloudSessionMenuItems,
cloudMySessionsVisibleCount,
decorateSessionRowActions,
sessionMap,
sessionSidebarMenuItems,
t,
tCommon,
Expand Down
20 changes: 9 additions & 11 deletions src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
NavigationMenuRowAction,
} from "../components/NavigationMenu/config";
import type { SidebarTab } from "../types";
import { isNavigationSectionCollapsed } from "./navigationSectionCollapse";

// ============================================
// Types
Expand Down Expand Up @@ -519,10 +520,11 @@ const NavigationSidebar: React.FC<NavigationSidebarProps> = React.memo(
/>
) : (
sections.map((section) => {
const isSectionCollapsed =
!hasSearchInput &&
collapsibleSections &&
collapsedSections.has(section.id);
const isSectionCollapsed = isNavigationSectionCollapsed({
collapsibleSections,
collapsedSectionIds: collapsedSections,
sectionId: section.id,
});

return (
<div key={section.id} data-sidebar-section-id={section.id}>
Expand All @@ -534,9 +536,7 @@ const NavigationSidebar: React.FC<NavigationSidebarProps> = React.memo(
tabIndex={0}
aria-expanded={!isSectionCollapsed}
className={`${isSectionCollapsed ? "" : "mb-px"} group/section-title flex h-7 cursor-pointer items-center gap-2 pl-2`}
onClick={() => {
if (!hasSearchInput) toggleSection(section.id);
}}
onClick={() => toggleSection(section.id)}
onKeyDown={(event) => {
if (
event.target !== event.currentTarget ||
Expand All @@ -545,7 +545,7 @@ const NavigationSidebar: React.FC<NavigationSidebarProps> = React.memo(
return;
}
event.preventDefault();
if (!hasSearchInput) toggleSection(section.id);
toggleSection(section.id);
}}
>
<span className="min-w-0 truncate text-[11px] font-medium uppercase tracking-wider text-text-2">
Expand All @@ -557,9 +557,7 @@ const NavigationSidebar: React.FC<NavigationSidebarProps> = React.memo(
isSectionCollapsed ? ChevronRight : ChevronDown
}
label={section.title ?? section.id}
onClick={() => {
if (!hasSearchInput) toggleSection(section.id);
}}
onClick={() => toggleSection(section.id)}
/>
</span>
{section.headerActions && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";

import { isNavigationSectionCollapsed } from "./navigationSectionCollapse";

describe("isNavigationSectionCollapsed", () => {
it("preserves a collapsed section while its rows are filtered", () => {
expect(
isNavigationSectionCollapsed({
collapsibleSections: true,
collapsedSectionIds: new Set(["cloud-my-sessions"]),
sectionId: "cloud-my-sessions",
})
).toBe(true);
});

it("does not collapse sections when section collapsing is disabled", () => {
expect(
isNavigationSectionCollapsed({
collapsibleSections: false,
collapsedSectionIds: new Set(["cloud-my-sessions"]),
sectionId: "cloud-my-sessions",
})
).toBe(false);
});
});
Loading
Loading