diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c7b5a89 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ + +# Dual-Graph Context Policy + +This project uses a local dual-graph MCP server for efficient context retrieval. + +## MANDATORY: Adaptive graph_continue rule + +**Call `graph_continue` ONLY when you do NOT already know the relevant files.** + +### Call `graph_continue` when: +- This is the first message of a new task / conversation +- The task shifts to a completely different area of the codebase +- You need files you haven't read yet in this session + +### SKIP `graph_continue` when: +- You already identified the relevant files earlier in this conversation +- You are doing follow-up work on files already read (verify, refactor, test, docs, cleanup, commit) +- The task is pure text (writing a commit message, summarising, explaining) + +**If skipping, go directly to `graph_read` on the already-known `file::symbol`.** + +## When you DO call graph_continue + +1. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with `pwd`. Do NOT ask the user. + +2. **If `graph_continue` returns `skip=true`**: fewer than 5 files - read only specifically named files. + +3. **Read `recommended_files`** using `graph_read`. + - Always use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) - never read whole files. + - `recommended_files` entries that already contain `::` must be passed verbatim. + +4. **Obey confidence caps:** + - `confidence=high` -> Stop. Do NOT grep or explore further. + - `confidence=medium` -> `fallback_rg` at most `max_supplementary_greps` times, then `graph_read` at most `max_supplementary_files` more symbols. Stop. + - `confidence=low` -> same as medium. Stop. + +## Session State (compact, update after every turn) + +Maintain a short JSON block in your working memory. Update it after each turn: + +```json +{ + "files_identified": ["path/to/file.py"], + "symbols_changed": ["module::function"], + "fix_applied": true, + "features_added": ["description"], + "open_issues": ["one-line note"] +} +``` + +Use this state - not prose summaries - to remember what's been done across turns. + +## Token Usage + +A `token-counter` MCP is available for tracking live token usage. + +- Before reading a large file: `count_tokens({text: ""})` to check cost first. +- To show running session cost: `get_session_stats()` +- To log completed task: `log_usage({input_tokens: N, output_tokens: N, description: "task"})` + +## Rules + +- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue` (when required). +- Do NOT do broad/recursive exploration at any confidence level. +- `max_supplementary_greps` and `max_supplementary_files` are hard caps - never exceed them. +- Do NOT call `graph_continue` more than once per turn. +- Always use `file::symbol` notation with `graph_read` - never bare filenames. +- After edits, call `graph_register_edit` with changed files using `file::symbol` notation. + +## Context Store + +Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`. + +**Entry format:** +```json +{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"} +``` + +**To append:** Read the file -> add the new entry to the array -> Write it back -> call `graph_register_edit` on `.dual-graph/context-store.json`. + +**Rules:** +- Only log things worth remembering across sessions (not every minor detail) +- `content` must be under 15 words +- `files` lists the files this decision/task relates to (can be empty) +- Log immediately when the item arises - not at session end + +## Session End + +When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with: +- **Current Task**: one sentence on what was being worked on +- **Key Decisions**: bullet list, max 3 items +- **Next Steps**: bullet list, max 3 items + +Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation - only what's needed to resume next session. diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(modules)/users/components/UserSheet.tsx b/src/app/(modules)/users/components/UserSheet.tsx index eec88af..5eb01ba 100644 --- a/src/app/(modules)/users/components/UserSheet.tsx +++ b/src/app/(modules)/users/components/UserSheet.tsx @@ -5,7 +5,7 @@ import type { FieldErrors, UseFormRegister } from 'react-hook-form'; import { Loader2 } from 'lucide-react'; import { FormField } from '@/components/form'; -import { RoleCombobox } from '@/components/lookups/RoleCombobox'; +import { RoleSelect } from '@/components/lookups/RoleSelect'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -60,14 +60,17 @@ export function UserSheet({ return ( - + {userId ? 'Edit User' : 'Create User'} Assign user details and a role. -
+
- -
- + +
); diff --git a/src/components/async-select/AsyncSelect.tsx b/src/components/async-select/AsyncSelect.tsx new file mode 100644 index 0000000..b973880 --- /dev/null +++ b/src/components/async-select/AsyncSelect.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import type { Virtualizer } from '@tanstack/react-virtual'; + +import { Button } from '@/components/ui/button'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxList, + ComboboxTrigger, + ComboboxValue, +} from '@/components/ui/combobox'; + +import type { + AsyncSelectOption, + AsyncSelectProps, +} from './AsyncSelect.types'; +import { VirtualSelectList } from './VirtualSelectList'; + +type VirtualizerHandle = Virtualizer; + +export function AsyncSelect({ + lookup, + onValueChange, + placeholder = 'Select option', + searchPlaceholder = 'Search...', + emptyMessage = 'No items found.', + disabled = false, + portalContainer, +}: AsyncSelectProps) { + const [open, setOpen] = useState(false); + const virtualizerRef = useRef(null); + const { + options, + selectedOption, + search, + setSearch, + isLoading, + isFetchingNextPage, + isError, + errorMessage, + hasNextPage, + fetchNextPage, + activateList, + } = lookup; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen) { + // Kick off the first page only when the dropdown is actually opened. + activateList(); + } else { + setSearch(''); + } + }, + [activateList, setSearch], + ); + + const handleValueChange = useCallback( + (item: AsyncSelectOption | null) => { + if (item?.value) { + onValueChange(item.value); + } + }, + [onValueChange], + ); + + const handleItemHighlighted = useCallback( + ( + _item: AsyncSelectOption | undefined, + event: { reason: string; index: number }, + ) => { + const virtualizer = virtualizerRef.current; + if (!virtualizer) { + return; + } + + const { reason, index } = event; + const isStart = index === 0; + const isEnd = index === virtualizer.options.count - 1; + const shouldScroll = + reason === 'none' || (reason === 'keyboard' && (isStart || isEnd)); + + if (shouldScroll) { + queueMicrotask(() => { + virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' }); + }); + } + }, + [], + ); + + const handleLoadMore = useCallback(() => { + void fetchNextPage(); + }, [fetchNextPage]); + + const isDisabled = disabled || isLoading; + const triggerPlaceholder = isLoading ? 'Loading...' : placeholder; + + return ( + item.label} + isItemEqualToValue={(item, currentValue) => + item.value === currentValue.value + } + onItemHighlighted={handleItemHighlighted} + > + + {isLoading && !selectedOption ? ( + + + Loading... + + ) : ( + + )} + + } + /> + +
+ +
+ {isError ? ( +
+ {errorMessage || 'Unable to load options.'} +
+ ) : ( + <> + {emptyMessage} + + + + + )} +
+
+ ); +} diff --git a/src/components/async-select/AsyncSelect.types.ts b/src/components/async-select/AsyncSelect.types.ts new file mode 100644 index 0000000..1b9c2d0 --- /dev/null +++ b/src/components/async-select/AsyncSelect.types.ts @@ -0,0 +1,51 @@ +import type { RefObject } from 'react'; + +export type AsyncSelectOption = { + value: string; + label: string; +}; + +export type PaginatedResult = { + items: T[]; + total: number; +}; + +export type PaginatedLookupConfig = { + enabled?: boolean; + pageSize?: number; + debounceMs?: number; + selectedValue?: string; + queryKey: (searchTerm: string) => readonly unknown[]; + queryFn: (args: { + searchTerm: string; + skip: number; + limit: number; + }) => Promise>; + mapOption: (item: T) => AsyncSelectOption; + resolveSelected?: (value: string) => Promise; + resolveSelectedQueryKey?: (value: string) => readonly unknown[]; +}; + +export interface PaginatedLookupState { + options: AsyncSelectOption[]; + selectedOption: AsyncSelectOption | null; + search: string; + setSearch: (value: string) => void; + isLoading: boolean; + isFetchingNextPage: boolean; + isError: boolean; + errorMessage?: string; + hasNextPage: boolean; + fetchNextPage: () => void | Promise; + activateList: () => void; +} + +export interface AsyncSelectProps { + lookup: PaginatedLookupState; + onValueChange: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + disabled?: boolean; + portalContainer?: RefObject; +} diff --git a/src/components/async-select/VirtualSelectList.tsx b/src/components/async-select/VirtualSelectList.tsx new file mode 100644 index 0000000..b927565 --- /dev/null +++ b/src/components/async-select/VirtualSelectList.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { + useCallback, + useEffect, + useImperativeHandle, + useRef, + type RefObject, +} from 'react'; +import { Combobox } from '@base-ui/react/combobox'; +import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'; +import { Loader2 } from 'lucide-react'; + +import { ComboboxItem } from '@/components/ui/combobox'; +import { cn } from '@/lib/utils'; + +import type { AsyncSelectOption } from './AsyncSelect.types'; + +type VirtualizerHandle = Virtualizer; +const LOAD_MORE_THRESHOLD = 3; + +interface VirtualSelectListProps { + open: boolean; + virtualizerRef: RefObject; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + estimateSize?: number; +} + +export function VirtualSelectList({ + open, + virtualizerRef, + hasNextPage, + isFetchingNextPage, + onLoadMore, + estimateSize = 36, +}: VirtualSelectListProps) { + const filteredItems = Combobox.useFilteredItems(); + const scrollElementRef = useRef(null); + const itemCount = filteredItems.length + (hasNextPage ? 1 : 0); + + const virtualizer = useVirtualizer({ + enabled: open, + count: itemCount, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => estimateSize, + overscan: 8, + paddingStart: 4, + paddingEnd: 4, + }); + + useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]); + + const handleScrollElementRef = useCallback( + (element: HTMLDivElement | null) => { + scrollElementRef.current = element; + if (element) { + virtualizer.measure(); + } + }, + [virtualizer], + ); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const lastVirtualIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; + + useEffect(() => { + if (lastVirtualIndex < 0) { + return; + } + + if ( + lastVirtualIndex >= filteredItems.length - LOAD_MORE_THRESHOLD && + hasNextPage && + !isFetchingNextPage + ) { + onLoadMore(); + } + }, [ + filteredItems.length, + hasNextPage, + isFetchingNextPage, + lastVirtualIndex, + onLoadMore, + ]); + + if (!filteredItems.length && !hasNextPage) { + return null; + } + + return ( +
+
+ {virtualItems.map((virtualItem) => { + const isLoaderRow = virtualItem.index >= filteredItems.length; + const item = filteredItems[virtualItem.index]; + + return ( +
+ {isLoaderRow ? ( +
+ +
+ ) : ( + + {item.label} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/async-select/index.ts b/src/components/async-select/index.ts new file mode 100644 index 0000000..09888af --- /dev/null +++ b/src/components/async-select/index.ts @@ -0,0 +1,9 @@ +export { AsyncSelect } from './AsyncSelect'; +export { usePaginatedSelect } from './usePaginatedSelect'; +export type { + AsyncSelectProps, + AsyncSelectOption, + PaginatedLookupState, + PaginatedLookupConfig, + PaginatedResult, +} from './AsyncSelect.types'; diff --git a/src/components/async-select/usePaginatedSelect.ts b/src/components/async-select/usePaginatedSelect.ts new file mode 100644 index 0000000..5d517a8 --- /dev/null +++ b/src/components/async-select/usePaginatedSelect.ts @@ -0,0 +1,119 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import { useDebounce } from '@/hooks/useDebounce'; + +import type { + AsyncSelectOption, + PaginatedLookupConfig, +} from './AsyncSelect.types'; + +export function usePaginatedSelect({ + enabled = true, + pageSize = 20, + debounceMs = 400, + selectedValue = '', + queryKey, + queryFn, + mapOption, + resolveSelected, + resolveSelectedQueryKey, +}: PaginatedLookupConfig) { + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounce(search.trim(), debounceMs); + + // The list is fetched lazily: only once the dropdown has been opened at + // least once. The selected-value resolution below still runs on `enabled` + // (e.g. dialog open) so the current label always renders. + const [listActivated, setListActivated] = useState(false); + const activateList = useCallback(() => setListActivated(true), []); + + const infiniteQuery = useInfiniteQuery({ + queryKey: queryKey(debouncedSearch), + queryFn: ({ pageParam }) => + queryFn({ + searchTerm: debouncedSearch, + skip: pageParam, + limit: pageSize, + }), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.items.length === 0) { + return undefined; + } + + const loadedCount = allPages.reduce( + (count, page) => count + page.items.length, + 0, + ); + return loadedCount < lastPage.total ? loadedCount : undefined; + }, + enabled: enabled && listActivated, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + }); + + // `cancelRefetch: false` so a concurrent scroll trigger is ignored rather + // than cancelling the in-flight page and re-requesting the same offset. + const rawFetchNextPage = infiniteQuery.fetchNextPage; + const fetchNextPage = useCallback( + () => rawFetchNextPage({ cancelRefetch: false }), + [rawFetchNextPage], + ); + + const options = useMemo(() => { + const items = infiniteQuery.data?.pages.flatMap((page) => page.items) ?? []; + return items.map(mapOption); + }, [infiniteQuery.data?.pages, mapOption]); + + const selectedInList = useMemo( + () => options.find((option) => option.value === selectedValue) ?? null, + [options, selectedValue], + ); + + const resolvedSelectedQuery = useQuery({ + queryKey: resolveSelectedQueryKey?.(selectedValue) ?? [ + ...queryKey(''), + 'selected', + selectedValue, + ], + queryFn: async () => { + if (!selectedValue || !resolveSelected) { + return null; + } + + const item = await resolveSelected(selectedValue); + return item ? mapOption(item) : null; + }, + enabled: + enabled && + Boolean(selectedValue) && + !selectedInList && + Boolean(resolveSelected), + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + }); + + const selectedOption = selectedInList ?? resolvedSelectedQuery.data ?? null; + + return { + search, + setSearch, + options, + selectedOption, + isLoading: infiniteQuery.isLoading, + isError: infiniteQuery.isError || resolvedSelectedQuery.isError, + errorMessage: + infiniteQuery.error instanceof Error + ? infiniteQuery.error.message + : resolvedSelectedQuery.error instanceof Error + ? resolvedSelectedQuery.error.message + : undefined, + isFetchingNextPage: infiniteQuery.isFetchingNextPage, + hasNextPage: infiniteQuery.hasNextPage ?? false, + fetchNextPage, + activateList, + }; +} diff --git a/src/components/data-table/TableHeader.tsx b/src/components/data-table/TableHeader.tsx index 55a70f0..568c1e0 100644 --- a/src/components/data-table/TableHeader.tsx +++ b/src/components/data-table/TableHeader.tsx @@ -39,10 +39,9 @@ const TableHeader = ({ table }: { table: Table }) => { canSort ? header.column.getToggleSortingHandler() : undefined } className={cn( - 'h-10 max-w-0 overflow-hidden bg-card px-2 text-foreground transition-colors', + 'h-10 max-w-0 overflow-hidden border-b border-border bg-card px-2 text-foreground transition-colors', canSort && 'cursor-pointer select-none hover:bg-muted/60', sortDirection && 'bg-muted/70', - header.column.getIsPinned() && 'shadow-sm', )} > {header.isPlaceholder ? null : ( @@ -61,8 +60,8 @@ const TableHeader = ({ table }: { table: Table }) => { ? 'text-foreground' : 'text-muted-foreground/60', )} - /> - ) : null} + /> + ) : null} {canResize ? (