docs(frontend): prune restatement/narration comments, keep only real gotchas

Re-reviewed every frontend comment against the "names a concrete wrong action"
rubric. Cut pure restatements, change-narration, self-defense and bug-history;
trimmed mixed comments down to their load-bearing gotcha/contract; kept genuine
framework/API/security notes. Relocated a misplaced JSDoc in resources-provider
that sat on `error` but described `resources` (fields are alphabetically sorted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-09 12:35:17 +07:00
co-authored by Claude Opus 4.8
parent 6b01c109d2
commit cc908e5108
62 changed files with 70 additions and 325 deletions
+3 -12
View File
@@ -101,11 +101,9 @@ function PublicLoginLayout() {
);
}
// Root layout for the data router. Everything that previously sat between
// `<BrowserRouter>` and `<Routes>` (providers, Suspense) lives here so it has
// access to router hooks (`useNavigate`, `useLocation`, ...) while still being
// rendered under the data router. This is what enables `useBlocker` and other
// data-router-only features inside our pages.
// Providers + Suspense live inside the data router (not wrapping `RouterProvider`) so
// they can use router hooks (`useNavigate`, `useLocation`) and enable `useBlocker` and
// other data-router-only features inside our pages.
function RootLayout() {
return (
<UserProvider>
@@ -133,9 +131,7 @@ const router = createBrowserRouter(
element={<RootLayout />}
errorElement={<RouteErrorBoundary />}
>
{/* private routes */}
<Route element={<ProtectedAppLayout />}>
{/* Main layout for chat pages */}
<Route element={<MainLayout />}>
<Route
element={<Dashboard />}
@@ -143,7 +139,6 @@ const router = createBrowserRouter(
path="dashboard"
/>
{/* Flows section with FlowsProvider */}
<Route element={<FlowsLayout />}>
<Route
element={<Flows />}
@@ -193,7 +188,6 @@ const router = createBrowserRouter(
/>
</Route>
{/* Settings with nested routes */}
<Route
element={<SettingsLayout />}
path="settings"
@@ -250,14 +244,12 @@ const router = createBrowserRouter(
</Route>
</Route>
{/* report routes */}
<Route
element={<ProtectedReportLayout />}
handle={routeTitles.flowReport}
path="flows/:flowId/report"
/>
{/* public routes */}
<Route
element={<PublicLoginLayout />}
handle={routeTitles.login}
@@ -270,7 +262,6 @@ const router = createBrowserRouter(
path="oauth/result"
/>
{/* other routes */}
<Route
element={<Navigate to={routes.dashboard} />}
path="/"
@@ -33,12 +33,10 @@ export function ChartTooltip({
const shownInSessionRef = useRef(false);
// New mouse-entry session: reset "already shown" tracking
useEffect(() => {
shownInSessionRef.current = false;
}, [sessionKey]);
// Notify parent the moment the tooltip first becomes visible in this session
useEffect(() => {
if (active && !shownInSessionRef.current) {
shownInSessionRef.current = true;
@@ -50,9 +50,8 @@ function ConfirmationDialog({
}: ConfirmationDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
// Derive a contextual title from confirm verb + item type so callers don't
// see "Confirm Action" for a Delete prompt or a Save prompt. Explicit
// `title` always wins.
// `verb !== 'Confirm'` treats the default confirmText as "no custom verb": a bare
// Confirm gets the generic "Confirm Action" title instead of "Confirm <itemType>".
const verb = confirmText.trim();
const resolvedTitle = title ?? (verb && verb !== 'Confirm' ? `${verb} ${itemType}` : 'Confirm Action');
@@ -294,9 +294,8 @@ export function useDetailNavigation<T extends { id: string }>({
const handleItemSelect = useCallback(
(item: T) => {
// Close the sheet *before* navigating — preserves the pre-refactor
// ordering so a route change can't unmount the sheet while its
// close callback is still in flight.
// Close the sheet *before* navigating so a route change can't unmount
// the sheet while its close callback is still in flight.
setSheetOpen(false);
navigate(buildHref(item), { replace: true });
},
@@ -18,7 +18,6 @@ describe('DocumentTitle', () => {
it('renders APP_NAME when no matched route exposes a title handle', async () => {
renderAt('/anywhere', [
{
// No child route handle — DocumentTitle should fall back to APP_NAME only.
children: [{ element: <span>page</span>, path: 'anywhere' }],
element: (
<>
@@ -145,8 +144,6 @@ describe('DocumentTitle', () => {
});
it('treats an unmarked function as a plain resolver, not a component', async () => {
// Without the marker, DocumentTitle calls the function with params and
// wraps the returned string with the standard "X — PentAGI" template.
const resolveTitle = (params: Record<string, string | undefined>) => `Item ${params.id}`;
renderAt('/items/7', [
@@ -179,9 +176,8 @@ describe('DocumentTitle', () => {
},
]);
// An empty string from the resolver is treated as "no title" — fall back
// to APP_NAME alone. This guards the route-level convention: pages that
// do not want a prefix can return '' instead of omitting the handle.
// Route-level convention: pages that don't want a title prefix return ''
// intentionally, rather than omitting the handle.
await waitFor(() => expect(document.title).toBe('PentAGI'));
});
});
@@ -270,8 +270,7 @@ const ALWAYS = (): true => true;
/**
* Single DFS walker that powers all `collect*` helpers. Pure: returns a freshly
* allocated array. Internally uses a private accumulator to avoid `concat`
* allocations on deep trees, but the buffer is never exposed to callers.
* allocated array.
*/
export const walkTree = (
nodes: FileManagerInternalNode[],
@@ -361,7 +360,6 @@ export const collectDirectoryPaths = (nodes: FileManagerInternalNode[]): string[
include: (node) => node.isDir,
});
/** Locale-aware case-insensitive name comparator. */
const compareNames = (a: FileManagerInternalNode, b: FileManagerInternalNode): number =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
@@ -518,7 +516,6 @@ export const buildFileManagerGridTemplate = (showSize: boolean, showModified: bo
return cols.join(' ');
};
/** Recursively locate a node by its absolute path. Returns `undefined` when missing. */
export const findNodeByPath = (
nodes: readonly FileManagerInternalNode[],
path: string,
@@ -561,10 +558,8 @@ export const dedupeOverlappingPaths = (paths: Iterable<string>): string[] => {
return result;
};
/** Clamp `value` into the inclusive `[min, max]` range. */
export const clamp = (min: number, value: number, max: number): number => Math.max(min, Math.min(value, max));
/** Translate `(isAllSelected, isSomeSelected)` into the tri-state value the Checkbox understands. */
export const getCheckboxState = (isAllSelected: boolean, isSomeSelected: boolean): 'indeterminate' | boolean => {
if (isAllSelected) {
return true;
@@ -78,8 +78,7 @@ export function useFileManagerSelection({
// expand/collapse and tree-shape changes. Without this, `onRowClick` would
// be re-created on every expansion (its deps include `flatVisible`) and
// invalidate the `onClick` prop on every memoized row — kicking the entire
// tree into a re-render on the most common user gesture. Same pattern is
// already used in `use-file-manager-dnd` for `selectionRef`.
// tree into a re-render on the most common user gesture.
const flatVisibleRef = useRef(flatVisible);
const allSelectablePathsRef = useRef(allSelectablePaths);
const dirSubtreePathsRef = useRef(dirSubtreePaths);
@@ -42,9 +42,6 @@ const ATOMS = [
const WORDS = ['firewall', 'payload', 'exploit', 'nmap', 'recon', 'shell', 'token', 'vector'];
// Block contexts that keep inline content literal: paragraph, heading, bullet item, ordered item,
// blockquote, inline code. (Nested compositions — ordered>bullet>code, fence-in-fence — are exercised
// separately below.)
const wrap = (context: number, text: string): string => {
switch (context) {
case 1:
@@ -44,9 +44,7 @@ const posOf = (editor: Editor, needle: string): number => {
type EditResult = { heading: number; json: JSONContent; md: string; reloadHeading: number };
// Fire ONE real transaction (appendTransaction runs only on transactions, never on construction) and snapshot
// the outcome: the live heading count + doc JSON, the serialized markdown, and how many headings that markdown
// reloads to (the round-trip check).
// appendTransaction runs only on transactions, never on construction — so fire ONE real transaction.
const run = (content: JSONContent | string, edit: (editor: Editor) => void): EditResult => {
const editor = newEditor(content);
@@ -2,9 +2,7 @@ import { describe, expect, it } from 'vitest';
import { normalizeImageSrc, normalizeLinkUrl } from './markdown-editor-toolbar-url';
// normalizeImageSrc makes a scheme-less src absolute (https://) and validates the protocol before it is saved:
// http(s) and base64 raster data: URLs pass through; data:image/svg+xml is rejected (SVG can carry script), as
// are data:text/html, application/*, javascript:, vbscript:, and malformed input.
// data:image/svg+xml is rejected because SVG can carry script — unlike raster data: URLs, which pass through.
describe('normalizeImageSrc — prepend https to scheme-less src, validate protocol', () => {
it.each([
['example.com/a.png', 'https://example.com/a.png'],
@@ -34,10 +32,8 @@ describe('normalizeImageSrc — prepend https to scheme-less src, validate proto
});
});
// A manually-typed link must be made absolute — a scheme-less input like "example.com" would otherwise persist as
// a relative href the browser resolves against the current origin. normalizeLinkUrl prepends https:// unless the
// input already carries an allowed scheme, validates the protocol with no base URL, and returns null for
// unsafe/malformed input. Already-schemed values pass through verbatim (case + query chars preserved).
// A scheme-less input like "example.com" would otherwise persist as a relative href the browser resolves against
// the current origin normalizeLinkUrl prepends https:// to force it absolute.
describe('normalizeLinkUrl — prepend https to scheme-less input, validate protocol', () => {
it.each([
['example.com', 'https://example.com'],
@@ -17,8 +17,7 @@ const load = (md: string) => {
// CommonMark allows a fenced code block's opening fence to be indented up to 3 spaces. Upstream
// @tiptap/extension-code-block gated its parseMarkdown on `token.raw.startsWith('```')`, so an indented
// fence (whose raw begins with that whitespace) was dropped on load — and when a document mixed fence
// indents the mis-detection cascaded, silently deleting everything after the first dropped fence.
// fence (whose raw begins with that whitespace) was dropped on load.
describe('indented fenced code blocks survive load', () => {
it('keeps a 3-space-indented fence and its content', () => {
const { json, out } = load('intro paragraph\n\n ```\ntail content KEEP\n```');
@@ -24,7 +24,6 @@ describe('link handle popover key is stable while typing inside a link (LINK-REM
editor.destroy();
expect(before && after).toBeTruthy();
// The fix keys the popover on range.from alone: stable here, so the edit form is not remounted.
expect(after!.from).toBe(before!.from);
// range.to grew by the inserted char — the old `${from}-${to}` key would have remounted every keystroke.
expect(after!.to).toBe(before!.to + 1);
@@ -8,6 +8,5 @@ export const MARKDOWN_EDITOR_WRAPPER_CLASS =
// stamps it on the contenteditable (markdown-editor.tsx) and with getEditorScrollParent below.
export const EDITOR_CONTENT_CLASS = 'tiptap-content';
// The scroll parent an editor overlay anchors to; falls back to window when the editor isn't inside a scroll box.
export const getEditorScrollParent = (editorDom: Element): Element | Window =>
editorDom.closest(`.${EDITOR_CONTENT_CLASS}`) ?? window;
@@ -11,7 +11,6 @@ export const ALIGN_OPTIONS: { icon: LucideIcon; label: string; value: ColumnAlig
{ icon: AlignRight, label: 'Right', value: 'right' },
];
// Empties every cell in the caret's row or column, leaving the row/column structure intact (not a delete).
export function clearLineContents(editor: Editor, axis: 'column' | 'row'): void {
editor
.chain()
@@ -335,8 +335,7 @@ function useTableHandles(editor: Editor): TableHandlesController {
// A grip captured before a scroll, resize, or edit points at a now-shifted position: scroll/resize move it
// off the table visually, and a doc edit shifts every position after it, so a menu action would resolve
// the stale cellPos against the current doc (wrong row/column, or an out-of-bounds RangeError in the
// header selector). Drop the target in every case — it reappears on the next hover. (link/image handles
// already dismiss on resize; TableHandles missed it and its grips detached from the table on window resize.)
// header selector). Drop the target in every case — it reappears on the next hover.
const scrollParent = getEditorScrollParent(dom);
const dropStaleTarget = () => {
@@ -229,8 +229,6 @@ describe('CRLF line endings — tables still protected', () => {
describe('TABLE_DELIMITER_LINE is linear (ReDoS guard)', () => {
it('scans a crafted delimiter-looking line with a long trailing space run in linear time', () => {
// A crafted delimiter-looking line (dashes + a long trailing space run + non-matching tail) must scan in
// linear time, not O(n²).
const evil = `x|y\n${'-'.repeat(50)}${' '.repeat(60000)}z\n`;
const started = performance.now();
@@ -5,9 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RouteErrorBoundary from './route-error-boundary';
// A loader that throws routes react-router to our `errorElement`, so
// `useRouteError` inside the boundary receives exactly this value — the same
// path a real chunk-load or render failure takes.
const renderWithRouteError = (error: unknown) => {
const router = createMemoryRouter(
[
@@ -19,9 +19,6 @@ function RouteErrorBoundary() {
const isDesync = isDomDesyncError(error);
useEffect(() => {
// A stale code-split chunk and a transient DOM desync (e.g. an extension or
// auto-translation mutated the tree) both self-heal with a debounced reload;
// any other error stays on the card for the user to retry.
if (isChunk || isDesync) {
reloadOnce();
}
@@ -56,7 +56,6 @@ describe('DataTable — controlled filter projection', () => {
{ wrapper: Wrapper },
);
// The body shows only "Bravo" — Alpha and Charlie are filtered out.
expect(screen.getByText('Bravo')).toBeInTheDocument();
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
@@ -309,12 +309,6 @@ function DataTable<TData, TValue = unknown>({
const isRowInteractive = !!onRowClick || !!renderSubComponent;
const { pathname } = useLocation();
// Reuse the pathname we just read instead of letting the hook subscribe
// independently — react-router caches `useLocation` so the cost is
// negligible, but the explicit pass keeps the data flow obvious and
// makes it easy to migrate the table to a different storage scope (e.g.
// a workspace-prefixed path) without grepping for every subscription.
//
// When `storageKey` is passed by the parent it wins — multi-table routes
// (e.g. /settings/prompts) need distinct slots per instance, otherwise
// their sorting / visibility / search-column narrowing alias and
-5
View File
@@ -319,9 +319,6 @@ function SidebarProvider({
const { isMobile } = useBreakpoint();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
// First, try to read from cookie, fallback to defaultOpen
const [_open, _setOpen] = React.useState(() => {
const cookieValue = getSidebarState();
@@ -376,8 +373,6 @@ function SidebarProvider({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContext>(
@@ -57,7 +57,7 @@ const providerActions: AuthProviderAction[] = [
];
interface LoginFormProps {
providers: string[]; // OAuth providers: ['google', 'github']
providers: string[];
returnUrl?: string;
}
@@ -139,8 +139,6 @@ function LoginForm({ providers, returnUrl = routes.newFlow }: LoginFormProps) {
}
};
// If password change is required, show password change form.
// Also check isAuthenticated() to ensure the user has a valid session.
// If the session expired and user refreshed the page, the old authInfo may still
// be in memory (race condition between clearAuth() and navigate()), but we must
// NOT show the password change form because:
@@ -129,11 +129,8 @@ function FlowFilesAttachResourcesDialogBody({
});
/**
* Build the plan for the current selection. Earlier versions deduped
* descendants of any picked directory on the assumption that the backend
* would copy directory trees recursively it does not. Until that
* becomes recursive on the backend, the user must multi-select a folder
* together with its children to attach the contents.
* The backend does not copy directory trees recursively, so the user must
* multi-select a folder together with its children to attach the contents.
*/
const buildPlan = useCallback((): AttachPlan | null => {
if (selectedPaths.size === 0) {
@@ -29,10 +29,6 @@ export const RESOURCES_TARGET_DIRECTORY = '/work/resources';
export const CONTAINER_DEFAULT_PATH = '/work';
// ── Upload limits (mirror backend's `pkg/flowfiles/files.go`) ──────────────
//
// Symmetric with the resources library limits (`features/resources/resources-constants.ts`)
// today, but kept as a separate set so they can diverge from resources without
// touching the resources call sites.
/** Mirrors `flowfiles.MaxUploadFileSize` (300 MB). */
export const FLOW_FILES_MAX_FILE_SIZE_MB = 300;
@@ -164,8 +164,6 @@ function FlowFilesPromoteDialogForm({ files, flowId, onClose }: FlowFilesPromote
*/
const overwriteAction = useOverwrite<PromotePlan>({
execute: (plan, force) => promote(plan.sources, plan.destination, force),
// Local preflight against the resource library snapshot — flags the
// exact destinations already taken so the dialog can name them.
findConflicts: (plan) => plan.targets.filter((t) => resourcePaths.has(t.destination)),
onSuccess: onClose,
// Race-fallback: backend doesn't return per-path conflict descriptors
@@ -185,12 +185,6 @@ function FlowFilesPullDialogForm({ cachedFiles, flowId, onClose, onSuccess }: Fl
},
});
/**
* Drive the canonical "Pull / Pull with overwrite / Replace all" workflow
* from the shared hook. The hook owns conflict-state, race-fallback and
* close-on-success this dialog just provides the plan (paths) and the
* three pure helpers (find / execute / synthesize).
*/
const overwriteAction = useOverwrite<readonly string[]>({
execute: (paths, force) => pull(paths, force),
findConflicts: (paths) => findPullConflicts(paths, cachedFiles),
@@ -285,8 +279,7 @@ function FlowFilesPullDialogForm({ cachedFiles, flowId, onClose, onSuccess }: Fl
// the user is currently browsing. Non-empty selection wins and is mapped
// back from the FileManager's name-keyed selection to absolute container
// paths, then deduped so a folder + one of its descendants don't
// double-process. (Dedup is mostly defensive in this dialog because the
// listing is single-level, so descendants aren't visible.)
// double-process.
const pullTargets = useMemo<readonly string[]>(() => {
if (selectedPaths.size === 0) {
return [currentPath];
@@ -21,8 +21,7 @@ export type FlowFile = FlowFileFragmentFragment;
/**
* Wire shape of `models.FlowFile` (REST JSON, snake_case). The internal
* `FlowFile` alias mirrors the GraphQL camelCase fragment for use in the
* FileManager UI. Current consumers of `FlowFilesResponse` only read
* `files.length` and `files[0].name`, so no conversion helper is needed yet.
* FileManager UI.
*/
export interface FlowFilesResponse {
files: RestFlowFile[];
@@ -41,8 +41,6 @@ function FlowFiles() {
const { flowId, flowStatus } = useFlow();
const [isPullDialogOpen, setIsPullDialogOpen] = useState(false);
const [isAttachResourcesDialogOpen, setIsAttachResourcesDialogOpen] = useState(false);
// Array now: row-action click pushes a single-element array, the bulk bar
// pushes the deduped selection. Empty array / null closes the dialog.
const [filesToPromote, setFilesToPromote] = useState<FileNode[] | null>(null);
const { fileNodes, isInitialLoading, isLoading } = useFlowFilesData({ flowId });
@@ -75,9 +73,8 @@ function FlowFiles() {
}, []);
/**
* Bulk "copy paths" handler: join every selected file's path with `\n` so the
* user can paste a clean newline-separated list straight into the agent chat,
* a shell command, or a tool argument. Reports the count for clarity.
* Join the selected paths with `\n` so the result pastes as a clean
* newline-separated list into the agent chat, a shell command, or a tool argument.
*/
const handleBulkCopyPaths = useCallback(async (paths: string[]) => {
if (paths.length === 0) {
@@ -95,9 +92,8 @@ function FlowFiles() {
toast.error('Failed to copy paths');
}, []);
// Single-file row download specialises the bulk URL builder via a 1-element
// array. `flowId` may be missing (no flow selected yet) — return '' so
// FileManager renders a noop link instead of crashing on `null`.
// `flowId` may be missing (no flow selected yet) — return '' so FileManager
// renders a noop link instead of crashing on `null`.
const getRowDownloadHref = useCallback(
(file: FileNode): string => buildFlowFilesDownloadHref(flowId, [file]) ?? '',
[flowId],
@@ -135,9 +131,6 @@ function FlowFiles() {
[getRowDownloadHref, handleCopyPath, promoteAction, deletion.requestDelete],
);
// Bulk-action set: primary "Save as resources" (most common workflow on this
// page — promote interesting artifacts into the global library), copy-paths
// in overflow, destructive Delete on the right.
const fileManagerBulkActions = useMemo<FileManagerBulkAction[]>(
() => [
bulkDownloadAction(getBulkDownloadHref),
+9 -22
View File
@@ -90,19 +90,14 @@ export function FlowForm({
const [providerSearch, setProviderSearch] = useState('');
const [templateSearch, setTemplateSearch] = useState('');
const [resourceSearch, setResourceSearch] = useState('');
// Tracks which picker the combined dropdown is showing. Lifted to form
// state (instead of internal to the menu) so the tab choice survives
// re-renders triggered by `setTemplateSearch` / `setResourceSearch`
// inside the inner pickers.
// Lifted to form state so the tab choice survives re-renders triggered by
// `setTemplateSearch` / `setResourceSearch` inside the inner pickers.
const [pickerTab, setPickerTab] = useState<'resources' | 'templates'>('templates');
const fileInputRef = useRef<HTMLInputElement>(null);
// Resources are rendered as a hierarchy: alphabetical sort by full path
// produces the right ordering for siblings at every depth (parents before
// their descendants, peers in alphabetical order). Each row's nesting level
// is then derived from the slash count and rendered as a left indent so the
// user can visually trace files into their parent directories.
// Alphabetical sort by full path yields hierarchical order: parents before
// their descendants, peers alphabetically at every depth.
const sortedResources = useMemo(
() => [...resources].sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: 'base' })),
[resources],
@@ -216,7 +211,6 @@ export function FlowForm({
const currentValues = getValues();
// Update only fields that user hasn't manually changed and that differ from current values.
// Arrays are compared shallowly so a new-but-identical `resourceIds` reference doesn't
// trigger an unnecessary setValue (and the re-render it causes).
Object.entries(defaultValues)
@@ -328,10 +322,6 @@ export function FlowForm({
}
}, [pendingTemplate, setValue]);
// Templates and resources share the same dropdown via tabs — both picker
// bodies are kept as render functions so each can be mounted directly
// inside its `<TabsContent>` without duplicating the search-input +
// scrolled-list layout.
const renderTemplatePickerInner = () => (
<>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
@@ -425,9 +415,8 @@ export function FlowForm({
const resourceId = String(resource.id);
const isSelected = resourceIds.includes(resourceId);
const Icon = resource.isDir ? Folder : FileText;
// Depth derived from the path's slash count; ignored while a
// search query is active so matches don't appear orphaned
// beneath hidden ancestors.
// Zeroed while a search query is active so matches don't appear
// orphaned beneath hidden ancestors.
const depth = isResourceSearchActive ? 0 : resource.path.split('/').length - 1;
return (
@@ -699,11 +688,9 @@ export function FlowForm({
<Ellipsis className="shrink-0" />
</InputGroupButton>
</DropdownMenuTrigger>
{/* Single upward-opening dropdown for both Templates and Resources
on every viewport. Sub-menus would get clipped on the narrowest
screens (~390px), and a unified UI keeps the form simpler than
branching on `isMobile`. The tab strip is rendered last so it
lands closest to the trigger button. */}
{/* Sub-menus would get clipped on the narrowest screens (~390px), so
Templates and Resources share one upward-opening dropdown. The tab
strip is rendered last so it lands closest to the trigger button. */}
<DropdownMenuContent
align="end"
className="w-72"
@@ -56,7 +56,6 @@ function FlowTabs({ activeTab, onTabChange }: FlowTabsProps) {
</ScrollArea>
</div>
{/* Mobile Tabs only */}
{!isDesktop && (
<TabsContent
className="mt-1 flex-1 overflow-auto"
@@ -82,7 +81,6 @@ function FlowTabs({ activeTab, onTabChange }: FlowTabsProps) {
</TabsContent>
)}
{/* Desktop and Mobile Tabs */}
<TabsContent
className="mt-1 flex-1 overflow-auto"
value="terminal"
@@ -491,7 +491,6 @@ function FlowAssistantMessages({ className }: FlowAssistantMessagesProps) {
<div className={cn('flex h-full flex-col', className)}>
<div className="bg-background sticky top-0 z-10 pb-4">
<div className="flex gap-2 p-px">
{/* Assistant Dropdown */}
{flowId && (
<AssistantsDropdown
assistants={assistants}
@@ -504,7 +503,6 @@ function FlowAssistantMessages({ className }: FlowAssistantMessagesProps) {
selectedAssistantId={selectedAssistantId}
/>
)}
{/* Search Input */}
<div className="flex-1">
<Form {...form}>
<FormField
@@ -158,7 +158,6 @@ function FlowMessage({ log, searchValue = '' }: FlowMessageProps) {
resultFormat === ResultFormat.Terminal && isDetailsVisible ? 'w-full' : '',
)}
>
{/* Thinking toggle button */}
{shouldShowThinkingToggle && (
<div className="text-muted-foreground mb-2 text-xs">
<div
@@ -170,10 +169,8 @@ function FlowMessage({ log, searchValue = '' }: FlowMessageProps) {
</div>
)}
{/* Thinking content */}
{renderThinkingContent()}
{/* Main message content */}
{message && (
<Markdown
className="prose-xs prose-fixed wrap-break-word"
@@ -183,7 +180,6 @@ function FlowMessage({ log, searchValue = '' }: FlowMessageProps) {
</Markdown>
)}
{/* Result details */}
{result && (
<div className="text-muted-foreground mt-2 text-xs">
<div
@@ -23,8 +23,6 @@ import type { FormValues } from './knowledge-form';
import { KNOWLEDGE_LIMITS } from './knowledge-form';
// `<Select>` option lists. Co-located with the controls they feed because no
// other module needs them.
const docTypeValues = [KnowledgeDocType.Answer, KnowledgeDocType.Guide, KnowledgeDocType.Code] as const;
const guideTypeValues = Object.values(KnowledgeGuideType) as KnowledgeGuideTypeT[];
const answerTypeValues = Object.values(KnowledgeAnswerType) as KnowledgeAnswerTypeT[];
@@ -70,7 +68,6 @@ const LANGUAGES = [
interface KnowledgeContentFieldProps {
control: Control<FormValues>;
/** When `true`, the editor stretches to fill its parent (desktop split view). */
fillParent?: boolean;
hasLabel?: boolean;
isSaving: boolean;
@@ -120,13 +117,11 @@ export function KnowledgeContentField({
export function KnowledgeMetaFields({ control, isNew, isSaving }: KnowledgeMetaFieldsProps) {
// Targeted subscription: only this component re-renders when docType changes,
// not the whole form. The full-form `useWatch` from the original code
// re-rendered on every keystroke in the markdown editor.
// not the whole form. A full-form `useWatch` re-renders on every editor keystroke.
const docType = useWatch({ control, name: 'docType' });
// `setValue` is used to clear subtype fields that no longer apply when the
// user switches docType. We do this synchronously inside `onValueChange`
// (rather than via `useEffect`) so that a fresh load of an existing
// document doesn't wipe its persisted subtype on first render.
// Clear no-longer-applicable subtype fields synchronously in `onValueChange`,
// not via `useEffect` — an effect keyed on docType would wipe an existing
// document's persisted subtype on first render.
const { setValue } = useFormContext<FormValues>();
const handleDocTypeChange = (next: KnowledgeDocType, fieldOnChange: (value: KnowledgeDocType) => void) => {
@@ -258,12 +253,7 @@ export function KnowledgeMetaFields({ control, isNew, isSaving }: KnowledgeMetaF
render={({ field }) => (
<FormItem>
<FormLabel>Code language</FormLabel>
{/*
* `Autocomplete` is a free-text input with a
* suggestion popover the backend accepts any
* string here, so the dropdown is a UX hint
* rather than a closed enum.
*/}
{/* Backend accepts any string — the dropdown is a UX hint, not a closed enum. */}
<Autocomplete
onValueChange={field.onChange}
value={field.value ?? ''}
@@ -36,10 +36,7 @@ import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
import { useKnowledgeDetailNavigation } from './use-knowledge-detail-navigation';
interface KnowledgeHeaderProps {
// Anonymize action — visible only to users with the `anonymize.call`
// privilege. The header itself renders both desktop button and mobile
// dropdown item from these primitives so the icon/loading state stay in
// sync between layouts.
// Anonymize action — visible only to users with the `anonymize.call` privilege.
canAnonymize?: boolean;
isAnonymizeDisabled?: boolean;
isAnonymizing?: boolean;
@@ -91,8 +88,6 @@ export function KnowledgeHeader({
const knowledgeId = knowledge?.id ?? null;
// Single controller drives both the desktop toolbar and the mobile
// dropdown row + sheet — no separate state mirroring required.
const knowledgeNav = useKnowledgeDetailNavigation(knowledgeId);
// Title source-of-truth is the server-side `question`. We intentionally do
@@ -6,11 +6,8 @@ const getLabel = (item: Knowledge) => item.question;
const getHref = (item: Knowledge) => routes.knowledge(item.id);
/**
* Detail-page navigation wired up for knowledge documents. Returns a
* `DetailNavigationController<Knowledge>` for `<DetailNavigationToolbar>` /
* `<DetailNavigationButtons>` / `<DetailNavigationSheet>`. The list page
* filters on `question` and the header shows the same, so `getLabel`
* doubles as the default searchable text.
* The list page filters on `question`, so `getLabel` doubles as the default
* searchable text (no explicit `getSearchableText` needed).
*/
export function useKnowledgeDetailNavigation(currentId: null | string | undefined) {
const { knowledges } = useKnowledges();
@@ -10,8 +10,7 @@ export const SEARCH_DEBOUNCE_MS = 300;
//
// The backend does not whitelist file extensions — any file is accepted as
// long as the file name passes `validatePathComponent` (no `/ \ : * ? " < > |`,
// no control characters, ≤ 255 bytes). We re-state the size limits here so the
// client can fail fast instead of waiting for a 4xx response on huge uploads.
// no control characters, ≤ 255 bytes).
/** Mirrors `resources.MaxUploadFileSize` (300 MB). */
export const MAX_FILE_SIZE_MB = 300;
@@ -147,8 +147,6 @@ function ResourcesMoveDialogForm({ files, onClose }: ResourcesMoveDialogFormProp
form.reset({ destination: defaultDestination });
}, [defaultDestination, form]);
// Lazy snapshot of every existing resource path. Recomputed only when the
// library changes; reused by `findConflicts` for the local preflight.
const resourcePaths = useMemo(() => new Set(resources.map((resource) => resource.path)), [resources]);
const sourcePaths = useMemo(() => new Set(files.map((file) => file.path)), [files]);
@@ -5,7 +5,6 @@ import { baseUrl } from '@/models/api';
import { RESOURCES_DOWNLOAD_API_PATH } from './resources-constants';
/** Convert a GraphQL `UserResource` into a `FileNode` consumed by the FileManager. */
export const toFileNode = (resource: UserResourceFragmentFragment): FileNode => ({
id: resource.id,
isDir: resource.isDir,
@@ -38,10 +38,6 @@ const deleteResourcesRequest = (paths: readonly string[]) =>
api.delete<void>(`${RESOURCES_API_PATH}?${buildPathsQuery(paths)}`);
/**
* Owns both the single-resource and bulk-delete flows. The component drives the
* confirmation dialog state through the returned `fileToDelete`/`requestDelete`/
* `clearFileToDelete` triple, while the hook hides every API call and toast.
*
* No imperative refetch is performed: the GraphQL `resourceDeleted` subscription
* is wired into the Apollo cache and removes the deleted entries automatically.
*/
@@ -17,10 +17,6 @@ interface UseResourcesSearchResult {
* back to `/resources` starts clean. The debounce delay is preserved
* (`SEARCH_DEBOUNCE_MS`) so the existing client-side tree filter still gets
* the throttled value it expects.
*
* `clearPageOnFilterChange: false` because Resources has no `?page=` to
* reset leaving the default would also work (deleting a non-existent
* param is a no-op), but the explicit setting documents intent.
*/
export function useResourcesSearch(): UseResourcesSearchResult {
const { debouncedFilter, filter, resetFilter, setFilter } = useTableState({
@@ -7,12 +7,8 @@ const getId = (item: Template) => String(item.id);
const getHref = (item: Template) => routes.template(item.id);
/**
* Detail-page navigation wired up for templates. Returns a
* `DetailNavigationController<Template>` for `<DetailNavigationToolbar>` /
* `<DetailNavigationButtons>` / `<DetailNavigationSheet>`. The list page
* filters on `title` and the breadcrumb shows the same, so `getLabel`
* doubles as the default searchable text (no explicit `getSearchableText`
* needed).
* The list page filters on `title`, so `getLabel` doubles as the default
* searchable text (no explicit `getSearchableText` needed).
*/
export function useTemplateDetailNavigation(currentId: null | string | undefined) {
const { templates } = useTemplates();
+1 -1
View File
@@ -59,7 +59,7 @@ export function useBreakpoint() {
};
window.addEventListener('resize', handleResize);
handleResize(); // Check on mount
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [updateBreakpointState]);
@@ -94,9 +94,6 @@ export function useFilesDragAndDrop({ canAcceptDrop, onDrop }: UseFilesDragAndDr
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
// Capture-phase already cleared these — keep the lines as a defensive
// belt-and-braces in case the capture handler is ever omitted by a
// consumer that forgets to spread the full handler bundle.
dragCounterRef.current = 0;
setIsDragging(false);
+7 -14
View File
@@ -67,18 +67,13 @@ interface UseTableStateResult {
* columns) still persist via `lib/table-state` only the ad-hoc query is
* URL-only.
*
* Replaces the split `useTableQueryFilter` / `usePagination` pair. The split
* design suffered from a batching race: react-router v6 feeds every
* functional `setSearchParams(updater)` queued in a single tick the same
* pre-batch snapshot, so a `setFilter` + `setPage` pair (e.g. a debounced
* filter commit landing alongside a paging click) would collapse the
* second write erased the first and `q` was lost. Funnelling every URL
* write through a single `update` here removes the race by construction:
* there is never more than one in-flight `setSearchParams` per logical
* intent. The few cases where two intents still fire in the same tick
* (e.g. an external effect mutating the URL while we batch our own write)
* read the freshest URL via `window.location.search`, with a ref-stashed
* react-router snapshot as the fallback for `MemoryRouter`-based tests.
* All URL writes funnel through a single `update` to dodge a react-router v6
* batching race: every functional `setSearchParams(updater)` queued in one tick
* gets the same pre-batch snapshot, so a `setFilter` + `setPage` pair firing
* together would collapse the second write erasing the first, losing `q`.
* The rare two-intents-in-one-tick case (an external effect mutating the URL
* while we batch our own write) reads the freshest URL via `window.location.search`,
* with a ref-stashed react-router snapshot as the `MemoryRouter`-test fallback.
*
* Read-only siblings (detail pages reading the list's filter without
* mutating it) should keep using `useTableQueryFilterReader` no shared
@@ -219,8 +214,6 @@ export function useTableState(options: UseTableStateOptions = {}): UseTableState
return;
}
// Merge into the in-flight patch — the queued microtask will see
// the fused result.
if (filterPresent) {
pendingPatchReference.current.filter = patch.filter;
pendingPatchReference.current.filterPresent = true;
-4
View File
@@ -228,10 +228,6 @@ export const getApiErrorMessage = (
/**
* Extract the HTTP status code from an unknown error thrown by axios. Returns
* `undefined` for non-axios errors or when the request never reached a server.
*
* Used across hooks that need to branch on a specific status (most commonly
* 409 conflict) without re-implementing the same `error.statusCode ??
* error.response?.status` shape every time.
*/
export const getApiErrorStatusCode = (error: unknown): number | undefined => {
if (!error || typeof error !== 'object') {
-13
View File
@@ -3,9 +3,6 @@ import { toast } from 'sonner';
import { ResultFormat } from '@/graphql/types';
/**
* Interface for message data that can be copied to clipboard
*/
export interface CopyableMessage {
message?: null | string;
result?: null | string;
@@ -13,10 +10,6 @@ export interface CopyableMessage {
thinking?: null | string;
}
/**
* Extracts clean text from terminal content using hidden terminal instance
* This removes ANSI escape codes and returns formatted text as it appears in UI
*/
export const getCleanTerminalText = (terminalContent: string): Promise<string> => {
return new Promise((resolve) => {
let hiddenTerminal: null | XTerminal = null;
@@ -132,9 +125,6 @@ export const getCleanTerminalText = (terminalContent: string): Promise<string> =
});
};
/**
* Formats message content for copying to clipboard as markdown with collapsible sections
*/
export const formatMessageForClipboard = async (messageData: CopyableMessage): Promise<string> => {
const { message, result, resultFormat = ResultFormat.Plain, thinking } = messageData;
let content = '';
@@ -168,9 +158,6 @@ export const formatMessageForClipboard = async (messageData: CopyableMessage): P
return content;
};
/**
* Copies formatted message content to clipboard
*/
export const copyMessageToClipboard = async (messageData: CopyableMessage): Promise<void> => {
try {
const content = await formatMessageForClipboard(messageData);
+1 -2
View File
@@ -45,8 +45,7 @@ const registerCJKFonts = (): void => {
}
// NotoSansSC covers Han, kana and Bopomofo but NOT Hangul, so Korean text
// still renders as missing-glyph boxes. Register NotoSansKR and route Hangul
// segments to it to support Korean.
// renders as missing-glyph boxes. TODO: register NotoSansKR and route Hangul segments to it.
Font.register({
family: 'NotoSansSC',
fonts: [
+1 -2
View File
@@ -1,7 +1,6 @@
import { getReturnUrlParam } from '@/lib/utils/auth';
// Central definition of every client route path. Build URLs from here instead of hardcoding
// strings, so a path lives in exactly one place and parameters are typed at the call site.
// Build URLs from here instead of hardcoding route strings.
function withQuery(path: string, params: Record<string, string | undefined>): string {
const search = new URLSearchParams();
+1 -4
View File
@@ -71,10 +71,7 @@ export function getTopLevelPath(pathname: string): string {
/**
* View options for `FileManager`-style screens (currently `/resources`).
* Not part of the unified `table` slot because the payload (folder-first
* toggle, expanded directory ids) has nothing in common with TanStack
* Table state sharing the key would force a union schema and waste
* a Zod validation round-trip on every save.
* Lives outside the unified `table` slot.
*/
export function getViewOptionsStorageKey(urlPath: string): string {
return getStorageKey('viewOptions', urlPath);
-3
View File
@@ -11,11 +11,8 @@
*/
export interface UploadValidationLimits {
/** Maximum number of files allowed per request. */
maxFiles: number;
/** Maximum size of a single file in megabytes. */
maxFileSizeMb: number;
/** Maximum combined size of the batch in megabytes. */
maxTotalSizeMb: number;
/**
* Reject 0-byte files. Defaults to `true` because both servers stream the
@@ -56,9 +56,7 @@ describe('saveViewOptions', () => {
});
it('replaces (does not merge) the existing payload', () => {
// The function is deliberately a setter, not a patcher — the caller
// is expected to merge if they want partial updates. This test pins
// that contract.
// Deliberately a setter, not a patcher — the caller must merge for partial updates.
saveViewOptions(UNIFIED_KEY, { foldersFirst: true, relativeTimestamp: true });
saveViewOptions(UNIFIED_KEY, { foldersFirst: false });
-17
View File
@@ -5,39 +5,22 @@ export interface Provider {
type: ProviderType;
}
/**
* Generates a display name for a provider
* If the name matches the type, only the name is returned
* Otherwise, returns "name - type"
*/
export const getProviderDisplayName = (provider: Provider): string => {
return provider.name;
};
/**
* Checks if a provider exists in the list of providers
*/
export const isProviderValid = (provider: Provider, providers: Provider[]): boolean => {
return providers.some((p) => p.name === provider.name && p.type === provider.type);
};
/**
* Finds a provider by name and type
*/
export const findProvider = (provider: Provider, providers: Provider[]): Provider | undefined => {
return providers.find((p) => p.name === provider.name && p.type === provider.type);
};
/**
* Finds a provider by name
*/
export const findProviderByName = (providerName: string, providers: Provider[]): Provider | undefined => {
return providers.find((provider) => provider.name === providerName);
};
/**
* Sorts providers by name alphabetically
*/
export const sortProviders = (providers: Provider[]): Provider[] => {
return [...providers].sort((a, b) => a.name.localeCompare(b.name));
};
+2 -8
View File
@@ -92,9 +92,6 @@ function Flow() {
const [flowTitle, setOptimisticFlowTitle] = useOptimistic(actualFlowTitle, (_current, next: string) => next);
const isFlowRunning = flow ? ![StatusType.Failed, StatusType.Finished].includes(flow.status) : false;
// Single controller drives the desktop toolbar AND the mobile dropdown
// row + sheet — Prev/Next, sheet open state, and the position label all
// live on one source of truth.
const flowNav = useFlowDetailNavigation(flowId);
const {
@@ -292,11 +289,8 @@ function Flow() {
>
{isMobile && flowNav.total > 0 && (
<>
{/* Single row that mirrors the desktop toolbar: label on
the left, prev / position / next button group on the
right. `onSelect={preventDefault}` stops the menu from
closing on label clicks; `<DetailNavigationButtons>`
owns its own click handlers and tooltips. */}
{/* onSelect={preventDefault} stops the Radix menu from closing on label
clicks; DetailNavigationButtons owns its own click handlers. */}
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
+1 -4
View File
@@ -382,10 +382,7 @@ function Knowledges() {
<AppHeaderActions>
<InputSearch
ariaLabel="Search knowledge documents"
// Use Mod+K — Mod+F is reserved as the page-wide default
// because we don't want to conflict with the browser's
// own find-in-page on every screen, but this list is one
// of the few that benefits from a dedicated shortcut.
// Mod+K, not Mod+F — Mod+F collides with the browser's native find-in-page.
hotkey="k"
maxWidth={220}
onSearchChange={handleSemanticQueryChange}
+2 -8
View File
@@ -183,8 +183,6 @@ function Resources() {
const fileNodes = useMemo<FileNode[]>(() => resources.map(toFileNode), [resources]);
// Snapshot of every existing path in the library — drives the local
// preflight for the drag-and-drop move workflow.
const resourcePaths = useMemo(() => new Set(resources.map((resource) => resource.path)), [resources]);
/**
@@ -243,12 +241,8 @@ function Resources() {
toast.error('Failed to copy path');
}, []);
/**
* Bulk "copy paths" handler: join every selected file's path with `\n` so the
* user can paste a clean newline-separated list straight into the agent chat,
* a shell command, or notes. Reports the count for clarity silent failures
* confuse users when the clipboard happens to already contain the same text.
*/
// Join the selected paths with `\n` so the result pastes as a clean
// newline-separated list into the agent chat, a shell command, or notes.
const handleBulkCopyPaths = useCallback(async (paths: string[]) => {
if (paths.length === 0) {
return;
@@ -995,7 +995,4 @@ function SettingsAPITokens() {
);
}
// Helper subcomponents so we can use useFormState/useWatch without subscribing
// the whole table to every keystroke. Each watches its own form's validity.
export default SettingsAPITokens;
@@ -296,9 +296,6 @@ function SettingsPrompt() {
const isLoading = isCreateLoading || isUpdateLoading || isDeleteLoading || isValidateLoading;
// The field's handle cycles/inserts in both raw and rich modes, so clicking a variable is mode-agnostic:
// jump to its next use, or insert `{{.Name}}` at the caret if it isn't used yet. `editorRef` points at the
// active tab's field (Radix unmounts the inactive tab), so this drives whichever prompt is on screen.
const handleVariableClick = useCallback((variable: string) => {
if (!editorRef.current?.selectNextUse(variable)) {
editorRef.current?.insertAtCursor(`{{.${variable}}}`);
@@ -1608,7 +1608,6 @@ function SettingsProvider() {
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 pt-4">
<div className="grid grid-cols-1 gap-4 p-px md:grid-cols-2">
{/* Model field */}
<FormModelComboboxItem
control={control}
disabled={isLoading}
@@ -1637,7 +1636,6 @@ function SettingsProvider() {
placeholder="Select or enter model name"
/>
{/* Temperature field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1649,7 +1647,6 @@ function SettingsProvider() {
step="0.1"
/>
{/* Max Tokens field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1660,7 +1657,6 @@ function SettingsProvider() {
valueType="integer"
/>
{/* Top P field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1672,7 +1668,6 @@ function SettingsProvider() {
step="0.01"
/>
{/* Top K field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1683,7 +1678,6 @@ function SettingsProvider() {
valueType="integer"
/>
{/* Min Length field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1694,7 +1688,6 @@ function SettingsProvider() {
valueType="integer"
/>
{/* Max Length field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1705,7 +1698,6 @@ function SettingsProvider() {
valueType="integer"
/>
{/* Repetition Penalty field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1717,7 +1709,6 @@ function SettingsProvider() {
step="0.01"
/>
{/* Frequency Penalty field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1729,7 +1720,6 @@ function SettingsProvider() {
step="0.01"
/>
{/* Presence Penalty field */}
<FormInputNumberItem
control={control}
disabled={isLoading}
@@ -1750,12 +1740,10 @@ function SettingsProvider() {
setValue={setValue}
/>
{/* Price Configuration */}
<div className="col-span-full p-px">
<div className="mt-6 flex flex-col gap-4">
<h4 className="text-sm font-medium">Price Configuration</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Price Input field */}
<FormInputNumberItem
control={control}
description="Price per 1M input tokens"
@@ -1767,7 +1755,6 @@ function SettingsProvider() {
step="0.000001"
/>
{/* Price Output field */}
<FormInputNumberItem
control={control}
description="Price per 1M output tokens"
@@ -1779,7 +1766,6 @@ function SettingsProvider() {
step="0.000001"
/>
{/* Cache Read Price field */}
<FormInputNumberItem
control={control}
description="Price per 1M cached read tokens"
@@ -1791,7 +1777,6 @@ function SettingsProvider() {
step="0.000001"
/>
{/* Cache Write Price field */}
<FormInputNumberItem
control={control}
description="Price per 1M cache write tokens"
@@ -472,7 +472,6 @@ function SettingsProviders() {
<div className="flex flex-1 flex-col gap-4 p-4">
<SettingsProvidersHeader />
{/* Delete Error Alert */}
{(deleteError || deleteErrorMessage) && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
@@ -60,9 +60,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) {
return ids.map((id) => +id);
}, [userPreferencesData?.settingsUser?.favoriteFlows]);
// Surface the optimistic set so star-clicks flip in the UI before the
// mutation + subscription round-trip lands. React rolls back to
// `actualFavoriteFlowIds` automatically if the transition's action throws.
// React rolls back to `actualFavoriteFlowIds` automatically if the transition's action throws.
const [favoriteFlowIds, applyOptimisticFavorite] = useOptimistic(
actualFavoriteFlowIds,
(current: number[], action: { id: number; type: 'add' | 'remove' }) => {
+5 -29
View File
@@ -26,11 +26,6 @@ import { Log } from '@/lib/log';
import { URL_PARAMS } from '@/lib/url-params';
import { useUser } from '@/providers/user-provider';
// The provider operates directly on the GraphQL fragment. Previously we kept a
// hand-rolled `Knowledge` shape that mirrored the fragment field-by-field; that
// duplication forced a manual mapping step and drifted from the schema. The
// alias keeps the public surface (`Knowledge`) for callers while making it
// obvious there is no extra translation layer.
export type Knowledge = KnowledgeDocumentFragmentFragment;
interface KnowledgesContextValue {
@@ -49,18 +44,11 @@ interface KnowledgesProviderProps {
const KnowledgesContext = createContext<KnowledgesContextValue | undefined>(undefined);
// Cap on the server-returned semantic-search result set. Prev/Next inside
// `<DetailNavigation>` walks the same array, so the limit also bounds how
// many neighbours a user can step through after running a search. 100 is
// generous for a top-K relevance list and matches what other list pages
// expect to render without virtualization.
// Also bounds how many neighbours Prev/Next inside `<DetailNavigation>` can step
// through, since it walks this same result array.
const SEARCH_RESULT_LIMIT = 100;
// Debounce for `?qs=` before we hit the server. Filter typing fires a
// keystroke per character; without a debounce we'd spawn an embedding
// + vector-search round-trip on each one. 400ms is the sweet spot users
// don't perceive as laggy while still collapsing burst typing into one
// network call.
// Debounce `?qs=`: each keystroke otherwise spawns an embedding + vector-search round-trip.
const SEARCH_DEBOUNCE_MS = 400;
export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
@@ -68,10 +56,6 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
const shouldFetch = Boolean(authInfo && authInfo.type !== 'guest' && isAuthenticated());
// `?qs=` is read directly from the URL — there is no in-provider setter.
// Pages drive it via `useSearchParams` (or the soon-to-arrive semantic
// search input). Trimming + debouncing happens here so the rest of the
// provider sees one canonical "is the user actively searching" flag.
const [searchParams] = useSearchParams();
const rawSemanticQuery = searchParams.get(URL_PARAMS.SEARCH) ?? '';
const [debouncedSemanticQueryRaw] = useDebounce(rawSemanticQuery, SEARCH_DEBOUNCE_MS);
@@ -94,11 +78,8 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
variables: { withContent: false },
});
// `searchKnowledge` does not honour `withContent` — the backend always
// returns the full chunk text plus a relevance score we currently drop.
// `filter` is wired as `null` for now; when facet filtering arrives
// (`?f.docType=…`), the parsed `KnowledgeFilter` slots in here and into
// the list query above without any other downstream change.
// `searchKnowledge` ignores `withContent` — the backend always returns the full
// chunk text plus a relevance score we currently drop.
const { data: searchData, loading: isSearchLoading } = useQuery(SearchKnowledgeDocument, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-and-network',
@@ -138,11 +119,6 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
const knowledges = useMemo<Knowledge[]>(() => {
if (inSearchMode) {
// Drop `score` — every consumer (DataTable, DetailNavigation,
// mutations) already speaks plain `KnowledgeDocumentFragment`.
// If a future UI wants to render relevance, the score is still
// reachable via the raw Apollo result by lifting a small helper
// into context — out of scope for this change.
return searchData?.searchKnowledge.map((entry) => entry.document) ?? [];
}
@@ -12,7 +12,6 @@ import { api, getApiErrorMessage, unwrapApiResponse } from '@/lib/axios';
import { useUser } from '@/providers/user-provider';
interface ResourcesContextValue {
/** Recursive list of every entry in the user's library (files + directories). */
error: Error | null | undefined;
/** Lookup helper: returns `undefined` when the resource is unknown. */
getResource: (id: string) => undefined | UserResourceFragmentFragment;
@@ -20,6 +19,7 @@ interface ResourcesContextValue {
isLoading: boolean;
/** Force a network re-read of the resources list. */
refetch: () => Promise<unknown>;
/** Recursive list of every entry in the user's library (files + directories). */
resources: UserResourceFragmentFragment[];
}