mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 20:46:31 +00:00
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:
co-authored by
Claude Opus 4.8
parent
6b01c109d2
commit
cc908e5108
+3
-12
@@ -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);
|
||||
|
||||
-3
@@ -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:
|
||||
|
||||
+1
-3
@@ -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'],
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }) => {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user