Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1c7e6a9
fix(knowledge-base): lock background scrolling when sidedrawer is open
daniilperkin Sep 22, 2026
ab5971b
fix(scroll-lock): lock documentElement and prevent background wheel e…
daniilperkin Sep 22, 2026
f7259e6
feat(knowledge-base): add SourceLinkBadge component
daniilperkin Sep 23, 2026
8d0f0fb
feat(knowledge-base): show SourceLinkBadge in ArtifactViewerDrawer he…
daniilperkin Sep 23, 2026
94b1d12
fix(knowledge-base): preserve scroll position when artifact param cha…
daniilperkin Sep 23, 2026
0f5ec89
fix(ui): add preventScroll:true to SidePanel focus calls
daniilperkin Sep 23, 2026
8c92f21
fix(layout): prevent sidebar from disappearing when a SidePanel opens
daniilperkin Sep 23, 2026
1fa9af2
feat(knowledge-base): server-side pagination and faceted search
daniilperkin Sep 23, 2026
d987a19
fix(knowledge-base): restore pagination controls and bind totalElements
daniilperkin Sep 23, 2026
362cc3b
style(knowledge-base): widen artifact viewer sidedrawer
daniilperkin Sep 23, 2026
e2785b7
upgrade(knowledge-base): finish the server-side pagination upgrade
daniilperkin Sep 23, 2026
da20b83
upgrade(knowledge-base): make the URL the source of truth for filters
daniilperkin Sep 24, 2026
d326696
upgrade(knowledge-base): debounce search before it reaches the URL an…
daniilperkin Sep 24, 2026
f7b561b
upgrade(knowledge-base): make large result sets navigable
daniilperkin Sep 24, 2026
f5066d1
upgrade(knowledge-base): let readers choose the list order
daniilperkin Sep 24, 2026
7a2c6f3
upgrade(knowledge-base): filter artifacts by the date they were added
daniilperkin Sep 24, 2026
4c5898b
upgrade(knowledge-base): filter artifacts by programming language
daniilperkin Sep 24, 2026
9e38726
upgrade(knowledge-base): delete several uploads at once
daniilperkin Sep 24, 2026
2734761
upgrade(knowledge-base): show each artifact's AI index status
daniilperkin Sep 24, 2026
0f44bb7
upgrade(knowledge-base): filter the date window on last activity
daniilperkin Sep 24, 2026
2528577
upgrade(knowledge-base): offer Select only while Uploads is picked
daniilperkin Sep 24, 2026
9cd5081
upgrade(knowledge-base): cache repository lookups and normalise org l…
daniilperkin Sep 24, 2026
709d4a2
upgrade(knowledge-base): fix 1-based result range, let the scroll loc…
daniilperkin Sep 25, 2026
4debd8b
upgrade(knowledge-base): address David's review - scroll-lock gesture…
daniilperkin Sep 25, 2026
466a1b2
Merge remote-tracking branch 'origin/dev' into feature/KB-updates-final
daniilperkin Sep 26, 2026
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
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ coverage
node_modules
package-lock.json

# Local per-task worktrees (git worktree add .worktrees/<name>) carry full
# copies of the tree, each formatted against its own branch state; checking
# them here makes format:check walk and flag four extra repos.
.worktrees

# Vendored Keycloak theme. `keycloakify sync-extensions` runs on every
# `npm install` and rewrites everything except the handful of "owned" files
# listed in src/keycloak-theme/.gitignore — formatting the rest is churn that
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default defineConfig([
"public/keycloak-theme/**",
"public/keycloakify-dev-resources/**",
"storybook-static/**",
".worktrees/**",
]),

{
Expand Down
10 changes: 9 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,15 @@ function AppContent() {
{/* `data-moment-stage`: the area the page-scoped moments (the
onboarding launch and landing) cover, instead of the whole
screen — see momentStage.ts in the moments feature. */}
<main data-moment-stage className="relative min-h-screen min-w-0 flex-1 pt-[64px] lg:pt-0">
<main
data-moment-stage
className={`relative min-h-screen min-w-0 flex-1 pt-[64px] lg:pt-0 ${
// The sidebar is `fixed` from `lg` up (see SideBar), so it is out of
// flow and the page has to leave its width free itself. In focus mode
// it slides away over the content, so the margin goes with it.
signedIn && !isFocused ? "lg:ml-[var(--app-sidebar-width)]" : ""
}`}
>
<AppRouter />
</main>

Expand Down
3 changes: 3 additions & 0 deletions src/components/layout/DetailsSideDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type DetailsSideDrawerProps = {
contentClassName?: string;
headerClassName?: string;
closeAriaLabel?: string;
lockScroll?: boolean;
};

export function DetailsSideDrawer({
Expand All @@ -35,6 +36,7 @@ export function DetailsSideDrawer({
contentClassName = "mx-3 px-3 pb-8 pt-4 sm:mx-5 sm:px-4 sm:pb-10 sm:pt-5 lg:px-5 lg:pt-6",
headerClassName = "mx-3 px-3 pb-4 pt-4 sm:mx-5 sm:px-4 sm:pb-5 sm:pt-5 lg:px-5 lg:pt-6",
closeAriaLabel = "Close details",
lockScroll = true,
}: DetailsSideDrawerProps) {
return (
<SidePanel
Expand All @@ -52,6 +54,7 @@ export function DetailsSideDrawer({
contentClassName={contentClassName}
headerClassName={headerClassName}
closeAriaLabel={closeAriaLabel}
lockScroll={lockScroll}
>
{children}
</SidePanel>
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export function SideBar() {
<>
<aside
aria-label="Desktop Sidebar"
className="sticky top-0 hidden h-screen w-[286px] shrink-0 flex-col border-r border-app-border bg-app-bg lg:flex"
className="fixed top-0 bottom-0 left-0 hidden w-[var(--app-sidebar-width)] flex-col border-r border-app-border bg-app-bg lg:flex"
>
<SidebarContent
aria-label="Desktop Navigation"
Expand Down Expand Up @@ -510,7 +510,7 @@ export function SideBar() {
className={[
// The cubic-bezier is the iOS sheet curve: fast out of the
// gate, long soft settle — reads as gliding, not snapping.
"fixed top-0 bottom-0 left-0 z-[60] flex w-[286px] flex-col border-r border-app-border bg-app-bg transition-transform duration-[420ms] ease-[cubic-bezier(0.32,0.72,0,1)] lg:hidden",
"fixed top-0 bottom-0 left-0 z-[60] flex w-[var(--app-sidebar-width)] flex-col border-r border-app-border bg-app-bg transition-transform duration-[420ms] ease-[cubic-bezier(0.32,0.72,0,1)] lg:hidden",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full",
].join(" ")}
>
Expand Down
125 changes: 122 additions & 3 deletions src/components/ui/MultiSelectFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ChevronDown, SlidersHorizontal } from "lucide-react";
import { ChevronDown, Search, SlidersHorizontal } from "lucide-react";
import { Badge } from "./Badge";
import { Button } from "./Button";
import { Checkbox } from "./Checkbox";
import { Collapsible } from "./Collapsible";
import { Input } from "./Input";
import { buttonHoverMotion, buttonHoverMotionDisabled } from "../../styles/tokens";
import { MENU_Z_INDEX, menuTransition, usePopoverMenu } from "./usePopoverMenu";

Expand All @@ -34,8 +35,24 @@ export type MultiSelectFilterSection<TValue extends string> = {
id: string;
label: string;
options: MultiSelectFilterOption<TValue>[];
/**
* Adds a filter box above the options once the section holds more than
* {@link SECTION_SEARCH_THRESHOLD} of them. A repository list from a large
* organisation is otherwise a scroll hunt; a four-option list does not need one.
*/
searchable?: boolean;
/**
* Shows only the first `visibleLimit` options until the reader presses
* "Show all". Ticked options are always shown, wherever they sit in the list,
* so a selection can never be hidden behind the fold. Ignored while the
* section's filter box holds text: then every match is shown.
*/
visibleLimit?: number;
};

/** Sections with at most this many options never show a filter box, even when `searchable`. */
export const SECTION_SEARCH_THRESHOLD = 8;

/** Trigger height, on the same scale as `ui/Field` and `ui/Button`. */
export type MultiSelectFilterSize = "sm" | "md";

Expand Down Expand Up @@ -66,6 +83,11 @@ type MultiSelectFilterProps<TValue extends string> = {
* an inner dropdown/collapsible button.
*/
collapsible?: boolean;
/**
* Muted line at the foot of the menu. For what the counts mean, which a
* reader otherwise has to guess ("is 12 the total, or what I would get?").
*/
footnote?: string;
className?: string;
/** Prefix for the control's `data-testid`s. */
testId?: string;
Expand Down Expand Up @@ -107,6 +129,7 @@ export function MultiSelectFilter<TValue extends string>({
size = "sm",
disabled = false,
collapsible = true,
footnote,
className = "",
testId = "multiselect-filter",
}: MultiSelectFilterProps<TValue>) {
Expand All @@ -119,6 +142,21 @@ export function MultiSelectFilter<TValue extends string>({
new Set<string>(),
);

// Per-section filter text and "Show all" state, keyed by section id. Both
// outlive a close/reopen on purpose: the filter box shows its text, so a
// narrowed list is never a mystery.
const [sectionQueries, setSectionQueries] = useState<Readonly<Record<string, string>>>({});
const [expandedSections, setExpandedSections] = useState<ReadonlySet<string>>(new Set<string>());

const setSectionExpanded = (sectionId: string, expanded: boolean) => {
setExpandedSections((current) => {
const next = new Set(current);
if (expanded) next.add(sectionId);
else next.delete(sectionId);
return next;
});
};

const toggleSection = (sectionId: string) => {
setCollapsedSections((current) => {
const next = new Set(current);
Expand Down Expand Up @@ -278,6 +316,78 @@ export function MultiSelectFilter<TValue extends string>({
</label>
);

const renderSectionBody = () => {
const query = sectionQueries[section.id] ?? "";
const showSearch =
section.searchable === true &&
section.options.length > SECTION_SEARCH_THRESHOLD;
const needle = showSearch ? query.trim().toLowerCase() : "";
const matching = needle
? section.options.filter((option) =>
option.label.toLowerCase().includes(needle),
)
: section.options;
const limit = section.visibleLimit;
const isOverLimit =
limit !== undefined && needle === "" && matching.length > limit;
const isExpanded = expandedSections.has(section.id);
const visible =
isOverLimit && !isExpanded
? matching.filter(
(option, index) => index < limit || selected.has(option.value),
)
: matching;
const sectionTestId = `${testId}-section-${section.id}`;

return (
<>
{showSearch && (
<div className="px-1 pb-1">
<Input
type="search"
size="sm"
value={query}
onChange={(event) =>
setSectionQueries((current) => ({
...current,
[section.id]: event.target.value,
}))
}
placeholder={`Filter ${section.label.toLowerCase()}…`}
aria-label={`Filter ${section.label.toLowerCase()}`}
icon={<Search className="h-3.5 w-3.5" />}
data-testid={`${sectionTestId}-search`}
/>
</div>
)}

{visible.map(renderOption)}

{needle !== "" && visible.length === 0 && (
<p
className="px-2.5 py-1.5 text-sm text-app-text-muted"
data-testid={`${sectionTestId}-no-matches`}
>
No matches
</p>
)}

{isOverLimit && (
<Button
variant="ghost"
size="xs"
fullWidth
aria-expanded={isExpanded}
onClick={() => setSectionExpanded(section.id, !isExpanded)}
data-testid={`${sectionTestId}-show-all`}
>
{isExpanded ? "Show fewer" : `Show all (${matching.length})`}
</Button>
)}
</>
);
};

if (!collapsible) {
return (
<div key={section.id} className="py-0.5">
Expand All @@ -287,7 +397,7 @@ export function MultiSelectFilter<TValue extends string>({
</div>
)}
<div role="group" aria-label={section.label}>
{section.options.map(renderOption)}
{renderSectionBody()}
</div>
{sectionIndex < sections.length - 1 && (
<div className="my-1 border-t border-app-border/40" />
Expand Down Expand Up @@ -326,12 +436,21 @@ export function MultiSelectFilter<TValue extends string>({

<Collapsible open={!isCollapsed}>
<div id={panelId} role="group" aria-labelledby={headerId}>
{section.options.map(renderOption)}
{renderSectionBody()}
</div>
</Collapsible>
</div>
);
})}

{footnote && (
<p
className="mt-1 border-t border-app-border/40 px-2.5 pt-2 pb-1 text-xs text-app-text-muted"
data-testid={`${testId}-footnote`}
>
{footnote}
</p>
)}
</motion.div>
)}
</AnimatePresence>,
Expand Down
13 changes: 10 additions & 3 deletions src/components/ui/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useContext, useEffect, useId, useRef, type ReactNode } from "react";
import { SWIPE_IGNORE_ATTRIBUTE } from "../../hooks/useHorizontalWheelNavigation";
import { PanelPresenceContext } from "./panelPresenceContext";
import { sidePanelSlideToken } from "../../styles/tokens";
import { useScrollLock } from "./useScrollLock";

type SidePanelProps = {
isOpen: boolean;
Expand All @@ -27,6 +28,7 @@ type SidePanelProps = {
footerClassName?: string;
closeAriaLabel?: string;
closeOnEscape?: boolean;
lockScroll?: boolean;
};

const focusableSelector = [
Expand Down Expand Up @@ -72,6 +74,7 @@ export function SidePanel({
footerClassName = "border-t border-app-border bg-app-bg px-6 py-5",
closeAriaLabel = "Close details",
closeOnEscape = true,
lockScroll = true,
}: SidePanelProps) {
const panelRef = useRef<HTMLDivElement>(null);
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
Expand All @@ -83,12 +86,16 @@ export function SidePanel({
const presence = useContext(PanelPresenceContext);
const isOpen = presence ? presence.isOpen : isOpenProp;

// Freezes the background page (body and any scroll containers) while the
// panel is open so only the panel insides scroll under the pointer.
useScrollLock(isOpen && lockScroll);

const prefersReducedMotion = useReducedMotion();
const panelTransition = prefersReducedMotion ? { duration: 0 } : sidePanelSlideToken;

useEffect(() => {
if (!isOpen) {
previouslyFocusedElement.current?.focus();
previouslyFocusedElement.current?.focus({ preventScroll: true });
return;
}

Expand All @@ -100,7 +107,7 @@ export function SidePanel({
if (!panel) return;

const [firstFocusable] = getFocusableElements(panel);
(firstFocusable ?? panel).focus();
(firstFocusable ?? panel).focus({ preventScroll: true });
});

return () => {
Expand Down Expand Up @@ -252,7 +259,7 @@ export function SidePanel({
</div>
)}

<div className="flex-1 [scrollbar-gutter:auto] overflow-y-auto">
<div className="flex-1 [scrollbar-gutter:auto] overflow-y-auto overscroll-contain">
<div className={contentClassName}>{children}</div>
</div>

Expand Down
Loading
Loading