diff --git a/aidbox-ts-sdk b/aidbox-ts-sdk index 6c26b19..659ccf3 160000 --- a/aidbox-ts-sdk +++ b/aidbox-ts-sdk @@ -1 +1 @@ -Subproject commit 6c26b19aee1754b1861824120cafd0a8cde8463c +Subproject commit 659ccf3d1ba59c3b6029bb443e3de56986533942 diff --git a/package.json b/package.json index 059bc24..ebe4455 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@replit/codemirror-vim": "^6.3.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.166.7", + "@tanstack/react-virtual": "^3.13.25", "@xyflow/react": "^12.10.2", "fuse.js": "^7.1.0", "js-yaml": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20342cb..19bdeab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@tanstack/react-router': specifier: ^1.166.7 version: 1.167.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': + specifier: ^3.13.25 + version: 3.13.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@xyflow/react': specifier: ^12.10.2 version: 12.10.2(@types/react@19.2.0)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1851,6 +1854,12 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.25': + resolution: {integrity: sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.167.3': resolution: {integrity: sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw==} engines: {node: '>=20.19'} @@ -1891,6 +1900,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.15.0': + resolution: {integrity: sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==} + '@tanstack/virtual-file-routes@1.161.6': resolution: {integrity: sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ==} engines: {node: '>=20.19'} @@ -4959,6 +4971,12 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@tanstack/react-virtual@3.13.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.15.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@tanstack/router-core@1.167.3': dependencies: '@tanstack/history': 1.161.6 @@ -5021,6 +5039,8 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.15.0': {} + '@tanstack/virtual-file-routes@1.161.6': {} '@types/babel__core@7.20.5': diff --git a/src/components/ResourceBrowser/browser.tsx b/src/components/ResourceBrowser/browser.tsx index cf9b01b..7e85981 100644 --- a/src/components/ResourceBrowser/browser.tsx +++ b/src/components/ResourceBrowser/browser.tsx @@ -2,9 +2,10 @@ import { useLocalStorage } from "@aidbox-ui/hooks/useLocalStorage"; import * as HSComp from "@health-samurai/react-components"; import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { useVirtualizer } from "@tanstack/react-virtual"; import Fuse from "fuse.js"; import { Pin, Search, X } from "lucide-react"; -import React, { useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { type AidboxClientR5, useAidboxClient } from "../../AidboxClient"; import { createFuzzySearch } from "../../utils/fuzzy-search"; import { @@ -22,14 +23,6 @@ const CATEGORY_EXT_URL = const STATUS_EXT_URL = "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status"; -const STATUS_VALUES = new Set([ - "normative", - "trial-use", - "draft", - "informative", - "deprecated", -]); - type SDExtension = { url?: string; valueString?: string; @@ -98,13 +91,7 @@ function filterByTags(items: SDItem[], tagTokens: string[]): SDItem[] { }); } -function chipStyleFor(text: string): string { - const slug = tagSlug(text); - if (slug === "aidbox") return "bg-bg-brand-secondary text-text-brand-primary"; - if (slug === "fhir") return "bg-green-50 text-text-success-primary"; - if (STATUS_VALUES.has(slug)) return "bg-yellow-50 text-text-warning-primary"; - return "bg-blue-50 text-text-info-primary"; -} +const CHIP_STYLE = "bg-blue-50 text-text-info-primary"; type StructureDefinitionResource = { resourceType: "StructureDefinition"; @@ -127,7 +114,10 @@ function useStructureDefinitions(client: AidboxClientR5) { queryFn: async () => { const response = await client.rawRequest({ method: "GET", - url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&_count=1000&_elements=type,name,url,description,extension,publisher", + url: "/fhir/StructureDefinition?kind=resource&derivation=specialization&abstract=false&_count=1000&_elements=type,name,url,description,extension,publisher", + headers: { + "Cache-Control": "max-age=300", + }, }); const bundle: StructureDefinitionBundle = await response.response.json(); return (bundle.entry ?? []).flatMap((entry) => { @@ -168,25 +158,22 @@ function Badge({ text, onClick }: { text: string; onClick: () => void }) { ); } -function ItemCard({ +const ItemCard = React.memo(function ItemCard({ item, isFavorite, onTagClick, onToggleFavorite, focused, - rowRef, }: { item: SDItem; isFavorite: boolean; onTagClick: (text: string) => void; - onToggleFavorite: () => void; + onToggleFavorite: (resourceType: string) => void; focused: boolean; - rowRef?: React.Ref; }) { return (
  • { e.preventDefault(); e.stopPropagation(); - onToggleFavorite(); + onToggleFavorite(item.resourceType); }} className={`absolute top-2 right-2 size-7 flex items-center justify-center rounded hover:bg-bg-tertiary transition-opacity ${isFavorite ? "opacity-100 text-text-info-primary" : "opacity-0 group-hover/row:opacity-100 text-text-secondary"}`} > @@ -246,7 +233,7 @@ function ItemCard({
  • ); -} +}); function SearchBar({ chips, @@ -274,7 +261,7 @@ function SearchBar({ type="button" aria-label={`Remove tag ${chip}`} onClick={() => onRemoveChip(chip)} - className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] leading-4 whitespace-nowrap cursor-pointer ${chipStyleFor(chip)}`} + className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] leading-4 whitespace-nowrap cursor-pointer ${CHIP_STYLE}`} > #{chip} @@ -333,25 +320,59 @@ export function Browser() { const search = useSearch({ from: "/resource/" }); const navigate = useNavigate(); - const text = search.q ?? ""; + const urlText = search.q ?? ""; const chips = search.tags ?? []; const tagTokens = chips.map(tagSlug); + + // Local input state for instant typing; URL sync is debounced + non-blocking. + const [inputText, setInputText] = React.useState(urlText); + const text = React.useDeferredValue(inputText); const hasQuery = chips.length > 0 || Boolean(text); - const setText = (next: string) => { - navigate({ - from: "/resource/", - search: (prev) => ({ ...prev, q: next || undefined }), - replace: true, - }); - }; - const setTags = (next: string[]) => { - navigate({ - from: "/resource/", - search: (prev) => ({ ...prev, tags: next.length > 0 ? next : undefined }), - replace: true, - }); - }; + // Pull external URL changes (back/forward, navigate from outside) back into local state. + const lastUrlTextRef = useRef(urlText); + useEffect(() => { + if (urlText !== lastUrlTextRef.current) { + lastUrlTextRef.current = urlText; + setInputText(urlText); + } + }, [urlText]); + + const urlSyncTimerRef = useRef | null>(null); + const setText = useCallback( + (next: string) => { + setInputText(next); + if (urlSyncTimerRef.current) clearTimeout(urlSyncTimerRef.current); + urlSyncTimerRef.current = setTimeout(() => { + lastUrlTextRef.current = next; + navigate({ + from: "/resource/", + search: (prev) => ({ ...prev, q: next || undefined }), + replace: true, + }); + }, 200); + }, + [navigate], + ); + useEffect( + () => () => { + if (urlSyncTimerRef.current) clearTimeout(urlSyncTimerRef.current); + }, + [], + ); + const setTags = useCallback( + (next: string[]) => { + navigate({ + from: "/resource/", + search: (prev) => ({ + ...prev, + tags: next.length > 0 ? next : undefined, + }), + replace: true, + }); + }, + [navigate], + ); const [favoritesArray, setFavoritesArray] = useLocalStorage({ key: "resource-browser-favorites", @@ -399,11 +420,17 @@ export function Browser() { : sortByName(tagFiltered); const items = applyFavorites(textFiltered, favorites, hasQuery); - const handleTagClick = (tagText: string) => { - const slug = tagSlug(tagText); - if (chips.some((c) => tagSlug(c) === slug)) return; - setTags([...chips, tagText]); - }; + const chipsRef = useRef(chips); + chipsRef.current = chips; + const handleTagClick = useCallback( + (tagText: string) => { + const slug = tagSlug(tagText); + const current = chipsRef.current; + if (current.some((c) => tagSlug(c) === slug)) return; + setTags([...current, tagText]); + }, + [setTags], + ); const removeChip = (tag: string) => { setTags(chips.filter((c) => c !== tag)); }; @@ -443,16 +470,19 @@ export function Browser() { setText(""); }; - const toggleFavorite = (resourceType: string) => { - setFavoritesArray((prev) => - prev.includes(resourceType) - ? prev.filter((x) => x !== resourceType) - : [...prev, resourceType], - ); - }; + const toggleFavorite = useCallback( + (resourceType: string) => { + setFavoritesArray((prev) => + prev.includes(resourceType) + ? prev.filter((x) => x !== resourceType) + : [...prev, resourceType], + ); + }, + [setFavoritesArray], + ); const [focusedIndex, setFocusedIndex] = React.useState(-1); - const focusedRowRef = useRef(null); + const scrollRef = useRef(null); const didFocus = useRef(false); const setSearchInputRef = React.useCallback((el: HTMLInputElement | null) => { if (el && !didFocus.current) { @@ -461,10 +491,17 @@ export function Browser() { } }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: focusedIndex triggers scroll - React.useEffect(() => { - focusedRowRef.current?.scrollIntoView({ block: "nearest" }); - }, [focusedIndex]); + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 90, + overscan: 8, + }); + + useEffect(() => { + if (focusedIndex < 0) return; + rowVirtualizer.scrollToIndex(focusedIndex, { align: "auto" }); + }, [focusedIndex, rowVirtualizer]); React.useEffect(() => { if (text && items.length > 0) setFocusedIndex(0); @@ -539,12 +576,12 @@ export function Browser() { }; return ( -
    +
    ) : ( -
      - {items.map((it, index) => ( - toggleFavorite(it.resourceType)} - focused={index === focusedIndex} - rowRef={index === focusedIndex ? focusedRowRef : undefined} - /> - ))} -
    +
    +
      + {rowVirtualizer.getVirtualItems().map((vi) => { + const it = items[vi.index]; + if (!it) return null; + return ( +
      + +
      + ); + })} +
    +
    )}
    ); diff --git a/src/components/db-console/result-content.tsx b/src/components/db-console/result-content.tsx index e2b80e5..7712dd1 100644 --- a/src/components/db-console/result-content.tsx +++ b/src/components/db-console/result-content.tsx @@ -4,29 +4,30 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, + Tabs, + TabsBrowserList, + TabsContent, + TabsListDropdown, + TabsTrigger, Tooltip, TooltipContent, TooltipTrigger, } from "@health-samurai/react-components"; import { + AlertCircle, Check, ChevronDown, Download, Loader2, - Maximize2, - Minimize2, } from "lucide-react"; import type React from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { QueryResultItem } from "../../webmcp/db-console-context"; import { LIMIT_PRESETS, TIMEOUT_PRESETS } from "./utils"; @@ -203,52 +204,13 @@ function downloadFile(content: string, filename: string, mimeType: string) { URL.revokeObjectURL(url); } -// QueryResultHeader (33px) + Table header h-8 (32px) + 3 rows h-7 (3 * 28 = 84px) -const MIN_PANEL_PX = 33 + 32 + 3 * 28; - // ── Components ── -function QueryResultHeader({ - index, - isMaximized, - onToggleMaximize, -}: { - index: number; - isMaximized: boolean; - onToggleMaximize: () => void; -}) { - return ( -
    - Query {index + 1} - - - - - {isMaximized ? "Minimize" : "Maximize"} - -
    - ); -} - function QueryResult({ result, - index, - totalCount, - isMaximized, - onToggleMaximize, viewMode = "table", }: { result: QueryResultItem; - index: number; - totalCount: number; - isMaximized: boolean; - onToggleMaximize: () => void; viewMode?: "table" | "list"; }) { const rows = result.result ?? []; @@ -260,13 +222,6 @@ function QueryResult({ if (result.error) { return (
    - {totalCount > 1 && ( - - )}
     						{result.error}
    @@ -278,13 +233,6 @@ function QueryResult({
     
     	return (
     		
    - {totalCount > 1 && ( - - )} {rows.length === 0 ? (
    @@ -694,7 +642,8 @@ export function ResultContent({ onCancel: () => void; viewMode?: "table" | "list"; }) { - const [maximizedIndex, setMaximizedIndex] = useState(null); + const [activeTab, setActiveTab] = useState(0); + const triggerRefs = useRef>(new Map()); if (isLoading) { return ( @@ -741,73 +690,94 @@ export function ResultContent({ ); } - if (maximizedIndex !== null && results[maximizedIndex]) { - return ( -
    -
    - setMaximizedIndex(null)} - viewMode={viewMode} - /> -
    -
    - ); - } - if (results.length === 1) { const singleResult = results[0]; if (!singleResult) return null; return (
    - {}} - viewMode={viewMode} - /> +
    ); } - const n = results.length; + const safeActive = Math.min(activeTab, results.length - 1); return (
    -
    - - {results.flatMap((result, index) => { - const key = `${result.query}-${index}`; - const panel = ( - - setMaximizedIndex(index)} - viewMode={viewMode} - /> - - ); - if (index === 0) return [panel]; - return [, panel]; - })} - -
    + setActiveTab(Number(v))} + className="flex flex-col flex-1 min-h-0 h-full items-stretch" + > +
    + + {results.map((r, i) => { + const rowCount = r.result?.length ?? 0; + const ariaLabel = r.error + ? `Query ${i + 1}, error` + : `Query ${i + 1}, ${rowCount} rows`; + return ( + { + if (el) triggerRefs.current.set(i, el); + else triggerRefs.current.delete(i); + }} + > + Query {i + 1} + {r.error && ( + + + error + + )} + + ); + })} + +
    + ({ + id: String(i), + content: ( + + Query {i + 1} + {r.error && ( + + + error + + )} + + ), + }))} + handleTabSelect={(tabId) => { + const idx = Number(tabId); + setActiveTab(idx); + triggerRefs.current.get(idx)?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + }} + /> +
    +
    + {results.map((r, i) => ( + + + + ))} +
    ); } diff --git a/src/components/notebook-editor.tsx b/src/components/notebook-editor.tsx index e60afb1..9410ea7 100644 --- a/src/components/notebook-editor.tsx +++ b/src/components/notebook-editor.tsx @@ -1,15 +1,21 @@ import * as HSComp from "@health-samurai/react-components"; import { useMutation } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { Loader2, Plus, Save, Trash2, User } from "lucide-react"; +import { + Eye, + Loader2, + Plus, + Save, + TextInitial, + Trash2, + User, + X, +} from "lucide-react"; import { useEffect, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { useAidboxClient } from "../AidboxClient"; import { type Cell, - MD_COMPONENTS, - normalizeMarkdown, + CellView, RestCellView, SqlCellView, SqlQueryCellView, @@ -51,7 +57,7 @@ const CELL_TYPES: { value: CellType; label: string }[] = [ ]; const DEFAULT_CELL_VALUE: Record = { - rest: "GET /fhir/Patient", + rest: "GET /fhir/Patient\nContent-Type: application/json\nAccept: application/json", rpc: "POST /rpc\ncontent-type: application/json\n\n{}", sql: "SELECT 1;", markdown: "", @@ -110,47 +116,24 @@ function MarkdownEditCell({ onChange: (value: string) => void; }) { const [value, setValue] = useState(cell.value); - const [mode, setMode] = useState<"edit" | "preview">("edit"); const update = (v: string) => { setValue(v); onChange(v); }; return (
    - setMode(v as "edit" | "preview")} - className="flex flex-col" - > -
    -
    - - Markdown - - - Edit - Preview - -
    -
    - {mode === "edit" ? ( -