Frontend component cleanup (#2495)
* feat(frontend): add Modal primitive on Radix Dialog (P1.1) Adds frontend/src/components/ui/modal.tsx — a thin wrapper around the existing Dialog/DialogContent primitives that preserves the legacy WrapperModal visual contract (bg-card, rounded-2xl, p-8, blurred backdrop) while inheriting Radix's focus-trap, body-scroll lock, and Esc handling. Always renders a DialogTitle (wrapped in VisuallyHidden when hideTitle is set) so modals stop logging a11y warnings on adoption. No consumers migrated yet. * refactor(frontend): migrate ConfirmationModal to Modal primitive (P1.2) Swaps WrapperModal for the new Radix-backed Modal so consumers inherit focus trap, body scroll lock, and Esc-to-close. Footer DOM order flipped from [Submit, Cancel] (flex-row-reverse) to [Cancel, Submit] so tab order now matches visual order; visual layout unchanged. Message renders as DialogTitle for proper screen-reader announcement. * refactor(frontend): inline DeleteConvModal into Navigation (P1.3) DeleteConvModal was a thin Redux wrapper around ConfirmationModal with a single consumer and a dead useOutsideAlerter on an unbound ref. Inlined the direct ConfirmationModal call at the call site and deleted the wrapper file. * refactor(frontend): migrate 5 small modals to Modal primitive (P1.4) Migrates FolderManagementModal, AddActionModal, JWTModal, ShareConversationModal and SearchConversationsModal off WrapperModal onto the Radix-backed Modal so they gain focus trap, scroll lock, and consistent DialogTitle a11y. Footer DOM order normalised so tab order matches visual order. JWTModal stays intentionally uncloseable via isPerformingTask; P1.7 revisits. Also wraps DialogDescription in VisuallyHidden when no description is provided, silencing the Radix "Missing Description" console warning that consumers without a subtitle were triggering. * refactor(frontend): migrate 5 medium modals to Modal primitive (P1.5a) Migrates AddToolModal, AgentDetailsModal, ConfigToolModal, ImportSpecModal and MoveToFolderModal off WrapperModal. They now inherit focus trap, scroll lock, Esc-to-close and consistent DialogTitle a11y. Footers reordered to natural DOM order so tab order matches visual order. AddToolModal's dead useOutsideAlerter on an unbound ref removed. * refactor(frontend): migrate 5 large modals to Modal primitive (P1.5b) Migrates PromptsModal, CustomModelModal, MCPServerModal, ScheduleFormModal and Upload off WrapperModal. These were the last remaining consumers of the legacy modal wrapper outside of the shared WrapperModalPropsType (kept temporarily for P1.9 cleanup). CustomModelModal continues to set isPerformingTask while saving; P1.7 revisits Esc behavior during locked submits. * refactor(frontend): migrate AgentTypeModal to Modal primitive (P1.6) AgentTypeModal was the last hand-rolled fixed-inset overlay in the codebase. Swapping to Modal gives it focus trap, Esc-to-close, body scroll lock and consistent DialogTitle a11y — none of which the legacy overlay implemented. * fix(frontend): let users dismiss CustomModel and Schedule modals during save (P1.7) Drops isPerformingTask on CustomModelModal and ScheduleFormModal so Esc / click-outside / the X button all close the modal while a save is in flight. The Save button itself stays disabled so users can't double-submit. SchedulesView.closeModal was also gating on submitting; that lock is removed too. The in-flight network call continues in the background. JWTModal remains intentionally uncloseable — it gates app access on auth, not a transient save state. * style(frontend): normalise modal CTA radius to rounded-3xl (P1.8) ConfigToolModal, JWTModal, ShareConversationModal and ScheduleFormModal previously used rounded-full for their primary CTAs while the rest of the modal suite uses rounded-3xl. Switches the four outliers and pads font-medium where missing. Footer DOM order across all migrated modals was already corrected to natural order during P1.2 / P1.4 / P1.5, so no further reorderings here. * chore(frontend): delete legacy WrapperModal (P1.9) All 14 consumers were migrated to the Radix-backed Modal primitive across P1.2-P1.6. Drops the legacy WrapperModal.tsx, the orphan WrapperModalPropsType type re-export, and updates the stale z-index comment in TimezoneCombobox to reference Modal's z-50. * feat(frontend): add floating-label variant to ui/input (P2.1) Extends the shadcn Input primitive with an optional label prop and leftIcon/required/labelBgClassName slots so consumers can swap the legacy components/Input.tsx without losing the peer-based floating label UX. Existing bare-input usage is unchanged. * refactor(frontend): migrate 9 callers off components/Input.tsx (P2.2) Switches ToolConfig, PromptsModal, AddActionModal, JWTModal, ToolsPopup, SourcesPopup, MultiSelectPopup, FilePicker and Upload to the ui/input floating-label variant. Drops legacy knobs that the audit flagged as cruft (colorVariant, borderVariant, textSize, edgeRoundness); inputs converge on rounded-md and 1px borders. The biggest visible delta is PromptsModal's prompt-name field — pill -> rounded-md rectangle, intentional. * chore(frontend): delete legacy components/Input.tsx (P2.3) All 9 consumers were migrated in P2.2. Removes the legacy component and the unused InputProps type from components/types. * refactor(frontend): migrate 7 raw text inputs to ui/input (P2.4) Swaps the page-level search inputs on Sources, Tools, CustomModels and AgentsList, plus the inline new-folder input on AgentsList, the FolderManagementModal field, and SearchConversationsModal, onto the ui/input primitive. Page searches now use the floating label / leftIcon slot. Complex per-method or workflow-editor inputs deferred. Also clears the trailing-newline lint error in modals/types after the WrapperModalPropsType deletion. * feat(frontend): add destructive-outline button variant (P2.5) Adds the red-outlined danger button pattern (border-destructive text-destructive on transparent ground, filling to bg-destructive on hover) so callers like "Delete All" CTAs can adopt the primitive instead of hand-rolling the class string. * refactor(frontend): migrate settings/ buttons to ui/button (P2.6) Replaces 10 raw <button> elements across General, Sources, Tools, CustomModels, Prompts and ToolConfig with the ui/button primitive. General's Delete-All CTA adopts the new destructive-outline variant. Tiny method-row buttons inside ToolConfig with bespoke 50px inline widths are deferred to a later targeted pass. * refactor(frontend): migrate agents/ buttons to ui/button (P2.7) 31 raw <button> elements across AgentsList, NewAgent, the schedules view + form + run drawer, SchedulerToolCallCard, RunLog, SharedAgentCard, and WorkflowPreview converted to the ui/button primitive. NewAgent's Danger-Zone Delete CTA adopts the new destructive-outline variant. AgentTypeModal's two type cards stay raw — the lucide icon sizes would conflict with Button's SVG selector — but pick up focus-visible ring classes for a11y. WorkflowBuilder (18 buttons) and PromptTextArea deferred. * refactor(frontend): migrate modal + small-component buttons to ui/button (P2.8) 52 raw <button> elements across 11 modals (ConfirmationModal, FolderManagementModal, AddActionModal, ConfigToolModal, ImportSpecModal, ShareConversationModal, AgentDetailsModal, MCPServerModal, CustomModelModal, JWTModal, SearchConversationsModal) and 10 small components (ActionButtons, CopyButton, TextToSpeechButton, Help, DocumentPagination, SettingsBar, ConnectorAuth, UploadToast, Accordion, Notification) converted to the ui/button primitive. ConfirmationModal's danger variant now uses the canonical destructive variant rather than a class string. MoveToFolderModal (purple-hex inputs) plus all chat/file-tree internals deferred. * refactor(frontend): collapse 3 popups into MultiSelectPopover (P2.9+P2.10) Adds frontend/src/components/MultiSelectPopover.tsx — a single Radix Popover + cmdk Command-backed primitive supporting groups, search, icons, loading, footer slot, and (selected-first) ordering. Migrates MessageInput (Sources + Tools), NewAgent (Sources + Tools + Models) onto it, then deletes MultiSelectPopup, SourcesPopup and ToolsPopup (~803 LoC removed). The isChatToolVisible helper moves to utils/toolUtils.ts with its tests preserved. Visual change: drops the bespoke Material 3-layer shadow in favor of shadow-md. * refactor(frontend): collapse 3 custom dropdowns onto shadcn primitives (P2.11) Deletes Dropdown.tsx, DropdownMenu.tsx and DropdownModel.tsx (~513 LoC) and migrates the 10 consumers (Hero, Sources, Prompts, PromptsModal, Analytics, ToolConfig, General, ShareConversationModal, Upload, NewAgent). Simple cases land on ui/select; action menus on ui/dropdown-menu; the searchable prompts picker uses ui/popover + ui/command. Inline edit/delete on prompts rows preserved via a small combobox; variable menus in PromptsModal use ui/dropdown-menu. * refactor(frontend): migrate Sidebar + ArtifactSidebar to ui/sheet (P2.12) Deletes Sidebar.tsx and rewrites ConversationBubble to render the citations panel directly via ui/sheet. ArtifactSidebar keeps its public API but its overlay variant now uses ui/sheet instead of a hand-rolled fixed-position overlay + manual click-outside, so it inherits Radix's focus trap, Esc handling, and scroll lock. * refactor(frontend): migrate ContextMenu to ui/dropdown-menu (P2.13) Deletes ContextMenu.tsx (177 LoC) and migrates its 8 consumers (ConversationTile, FolderCard, AgentCard, settings/CustomModels, settings/Tools, settings/Sources, FileTree, ConnectorTree) to the Radix-backed ui/dropdown-menu. Destructive items use the canonical variant; each menu trigger uses stopPropagation so clicking the 3-dots no longer fires the parent row's onClick. * refactor(frontend): retire the legacy color palette (P0.1+P0.2) Migrates the last 13 consumers of legacy named-color tokens to the semantic system (bg-primary, bg-muted, text-foreground, border-border, etc.) and then deletes the ~60-token legacy @theme block in index.css, including the invalid 5-digit --color-just-black: #00000 entry. Three intentional visual shifts: ToggleSwitch on-state moves from green to bg-primary, PageNotFound CTA from the misnamed bg-blue-1000 to bg-primary, and the user-message bubble gradient from medium-purple/slate-blue to violet-500/violet-600. * refactor(frontend): replace hardcoded purple hexes with semantic tokens (P0.3) Removes #7D54D1 / #976af3 / #6A4DF4 / #563DD1 / #8C67D7 / #6F3FD1 / #D9534F literals from ~15 tsx files, mapping each to bg-primary / text-primary / border-primary / bg-destructive / hover:bg-primary/90. The Spinner primitive drops its color prop and inherits currentColor so it picks up the parent's Tailwind text class. Analytics chart colors now read --primary at runtime via getComputedStyle on document.body (where the .dark class lives) so dark mode renders #976af3 instead of the light #7d54d1. PromptsModal's bespoke #D9534F danger maps to bg-destructive. MoveToFolderModal's purple-branded inputs and several toast banners were also caught in the sweep. * feat(frontend): add --font-mono token, drop inline IBMPlexMono styles (P0.4) Defines a --font-mono token in @theme and points it at the existing IBMPlexMono-Medium @font-face fallback chain. The 6 inline style={{ fontFamily: 'IBMPlexMono-Medium' }} occurrences in ConversationBubble's tool-call panels are replaced with the font-mono Tailwind utility; .logs-table now references the token too. Tokenises the mono surface so future consumers don't need inline styles. * feat(frontend): token-ize modal + toast shadows (P0.5) Adds --shadow-modal and --shadow-toast tokens in @theme. The two arbitrary shadow strings (the WrapperModal-era 4-40 modal shadow and the 24-48 toast shadow) collapse to `shadow-modal` and `shadow-toast` utilities across Modal, WorkflowBuilder's settings panel, UploadToast and ToolApprovalToast. The one-off radial hover halo in Upload.tsx stays inline (not part of the modal/toast set). * refactor(frontend): collapse 49 arbitrary radii onto canonical scale (P0.6) Maps every rounded-[Npx] (and its rounded-t-*/rounded-b-* variants) to a Tailwind built-in: rounded-md/lg/xl/2xl/3xl/full. Includes NewAgent's 11 panel sections (rounded-2xl), AgentCard/FolderCard (rounded-2xl), Hero demo CTAs and message-input pills (rounded-full), the answer/thought/user-message bubbles (rounded-2xl/3xl), code blocks (rounded-md/xl), and the FileTree/ConnectorTree search chrome. Closes out the audit's "six different radius scales" finding. * refactor(frontend): standardize type scale + fix font typos (P0.7) Collapses ~80 arbitrary text-[Npx] utilities onto the canonical Tailwind scale (text-xs/sm/lg/xl/2xl/3xl/4xl), fixes the font-semi-bold typo in SharedConversation (rendered as font-normal before), and removes the undefined font-inter class from 10 sites — body already defaults to Inter via index.css. The largest visual shift is text-[10px] -> text-xs (2px growth on tiny chip labels); mid-range deltas are <=1px. * style(frontend): soften popover/select/dropdown/command radii to rounded-xl The pill-shaped (rounded-3xl, effectively fully rounded at h-9/h-10) select triggers in Settings/Analytics/ToolConfig/NewAgent looked mismatched against the shadcn-default rounded-md (6px) popover content. Bumps the four ui content panels to rounded-xl (12px) so the trigger -> popover transition reads as a single step on the radius scale rather than an 18px jump. * style(frontend): align Select popover to trigger width, merge Hero model picker ui/select: switches default position from item-aligned to popper so Radix exposes --radix-select-trigger-width on the popper container, adds w-(--radix-select-trigger-width) so the popover width tracks the trigger exactly, and drops the min-w-32 floor that was forcing narrow triggers like the Analytics filter (w-[125px]) to render 3px wider than their triggers. Side-effect: Analytics dropdowns no longer overlap the settings tab bar (popper anchors below). Hero: pairs the trigger's data-[state=open]:rounded-b-none with rounded-t-none + rounded-b-4xl + data-[side=bottom]:translate-y-0 on the popover so the two shapes merge into one continuous pill when open. * refactor(frontend): fold two skeletons into SkeletonLoader (P3.1) Adds connectedState + filesSection variants to SkeletonLoader and sweeps the existing variants from bg-gray-200/700/bg-gray-300/600 onto the semantic bg-muted / bg-muted-foreground/20 pair. Deletes the two standalone files and migrates GoogleDrivePicker to the variant API. One skeleton tone across the app. * chore(frontend): drop legacy spinner SVGs + last raw animate-spin (P3.2) Migrates Navigation, ShareConversationModal, TextToSpeechButton and MultiSelectPopover off three SVG spinner assets onto the currentColor Spinner component, then deletes assets/spinner.svg, spinner-dark.svg and Loading.svg. Also replaces the last raw animate-spin border-b-2 loader with Spinner. * refactor(frontend): inline SVG components -> assets/*.svg?react (P3.3) Deletes SendArrowIcon.tsx and RetryIcon.tsx. The send-arrow path moves into assets/send.svg (overwriting the unused paper-plane icon), the retry path into a new assets/retry.svg; both use fill="currentColor" so consumers theme via Tailwind text classes. MessageInput and ConversationMessages now import the SVGs via ?react. ConversationMessages drops a now-unused useDarkTheme hook that was switching the retry icon's fill/stroke manually. documentation-dark / no-files-dark / science-spark-dark variants left in place — they're hand-tuned dark palettes, not pure inverts, so collapsing them would regress visually. * refactor(frontend): unify close icons on lucide X (P3.4) Migrates the last three assets/exit.svg consumers (MessageInput attachment pills, ArtifactSidebar split + sheet variants, ConversationTile rename cancel) plus two inline-SVG X paths (UploadToast dismiss, ToolApprovalToast dismiss) to lucide X. Deletes assets/exit.svg. lucide X uses currentColor so the filter dark:invert plumbing also goes away. ConversationTile's bare clickable img becomes a proper button. * refactor(frontend): merge Avatar + AgentImage into ui/avatar (P3.5) Builds a single components/ui/avatar.tsx that subsumes both legacy files: takes src/alt/fallbackSrc/className/imgClassName for the smart-img case, and falls back to children passthrough for the custom-node case (e.g. the user-question avatar in ConversationBubble). All 6 consumers (Navigation, AgentCard, SharedAgentCard, SharedAgent, ConversationBubble x5, ResearchProgress) migrated, then Avatar.tsx and AgentImage.tsx deleted. The onError robot-fallback behavior is preserved. * refactor(frontend): promote ToggleSwitch/SettingsBar/Accordion/Table -> ui/* (P3.6) Promotes four legacy components to shadcn-style primitives: - ui/switch (Radix Switch) replaces ToggleSwitch; 7 callsites migrate, including 3 inline hand-rolled toggles in NewAgent. - ui/tabs (Radix Tabs) replaces SettingsBar; the settings page keeps its mobile horizontal-scroll arrow wrapper. - ui/accordion (Radix Accordion) replaces Accordion; consumed by ConversationBubble tool-call panels. - ui/table (plain <table>) replaces Table; tokenises bg-gray-100 -> bg-muted and borders -> border-border; 3 tree-browser consumers swap imports. Adds @radix-ui/react-switch / -tabs / -accordion as explicit deps (previously transitive via the radix-ui meta-package). * refactor(frontend): dedupe FileTree + ConnectorTree (P3.7) Extracts a shared TreeBrowser into components/tree/ that handles directory state, breadcrumb header, search dropdown, table render, Chunks pickup, row-menu API and an SSE-terminal-wait hook. The two wrappers shrink to thin shells holding only what differs: file upload + delete flow on FileTree, sync flow on ConnectorTree. The Sources.tsx consumer API is unchanged. 1895 -> 1391 LoC (~27% reduction). * style(frontend): unify settings card padding to p-5 (P4.1) Sources tiles were p-3, Tools tiles p-6, CustomModels p-5. Picks p-5 as the median and applies to Sources and Tools so the three tile families breathe the same way. * style(frontend): promote Settings Prompts Add to filled CTA (P4.2) The Add prompt button was visually outlined-as-primary (variant=outline + border-primary/text-primary/hover:bg-primary overrides). Adding a prompt is the page's main action — drop the overrides and use the canonical default (filled bg-primary) variant so the CTA reads as primary at a glance. * style(frontend): add dark variants to WorkflowBuilder palette tiles (P4.3) The five node-picker tiles (AI Agent purple, End green, Note yellow, Set State blue, If/Else orange) used bg-{color}-100 with no dark: companion, leaving them blindingly bright on the dark canvas. Pairs each with bg-{color}-900/40 + dark:text-{color}-300 so they sit on the dark workflow background. * refactor(frontend): dedupe METHOD_COLORS + extract NARROW_CELL (P4.4) ToolConfig and ImportSpecModal both declared an identical METHOD_COLORS map for HTTP-verb pills. Moves the map + a getMethodColorClass helper into utils/httpMethodColors.ts and points both consumers at it. ToolConfig also had 9 identical style={{ width: '50px', ... }} inline blocks on table cells. Replaces them with a NARROW_CELL Tailwind constant. The `!` suffix is required because the codebase's @utility table-default rule otherwise out-specifies the utility classes — inline styles were load-bearing for that reason. * style(frontend): drop MoveToFolderModal inline fonts + tokenize hover (P4.5) Removes the inline fontFamily 'Inter' / 'Segoe UI' overrides — body already inherits Inter via index.css — and collapses the inline font-size/line-height/letter-spacing on the title into Tailwind utilities (text-2xl leading-7 tracking-[0.15px]). Replaces the breadcrumb hardcoded text-[#59636E] with text-muted-foreground and swaps the chevron's hover:bg-[#FFFFFF2B] for hover:bg-accent. * refactor(frontend): migrate ToolConfig method-row buttons to ui/button (DEF.1) 13 raw <button> elements left over from the P2.6 settings sweep (method-row trash inside NARROW_CELL, rename confirm/cancel, Add/Cancel/Add-new footers in the Properties + Headers tables) now go through the ui/button primitive. Action-row trash retains its stopPropagation so clicking the icon doesn't collapse the row. * refactor(frontend): migrate conversation/* buttons to ui/button (DEF.2) 21 raw <button> elements across ConversationBubble (15), ConversationMessages (2), ConversationTile (2), SharedConversation (1) and ResearchProgress (1) now go through ui/button. Includes question collapse/edit, edit submit/cancel, like/dislike feedback, copy / regen / retry, citation pills, tool-call approve/deny, sources/thought/tool-calls accordions, scroll-to-bottom and the shared-conversation CTA. All stopPropagation handlers preserved. * refactor(frontend): migrate MessageInput buttons to ui/button (DEF.3) Migrates the 7 raw <button> elements in the 1768-LoC chat input (attachment chip remove, Sources/Tools popover triggers + footer upload CTA, voice/mic toggle, stop-streaming, send/submit) onto ui/button. The 3 remaining <input> elements are file/dropzone inputs (react-dropzone hidden + attach + voice picker) and are intentionally left as-is. * refactor(frontend): migrate WorkflowBuilder buttons + inputs (DEF.4) 17 of 18 raw <button> elements migrated to ui/button (back-to-all, pencil-edit, settings Done, toolbar Details/Access-Details/Delete/ Preview/save, error-alert close, node-config close, state-op add/ delete, condition case add/delete, simple/advanced segmented control, delete-node). The 1 remaining raw button is the custom toggle switch for allow_system_prompt_override which uses a non-button animated UI. 7 of 8 raw <input> migrated to ui/input (workflow name, node title, output variable, target variable, case name, simple-mode var/value). The 1 remaining raw input is the stream_to_user checkbox. Palette tiles, drag-and-drop, React Flow node interactions all preserved. * refactor(frontend): migrate utility-component buttons to ui/button (DEF.5) 25 raw <button> elements across 7 small utility components migrated to ui/button: Chunks (8), MermaidRenderer (5, low-risk path keeps the custom download wrapper), GoogleDrivePicker (3), FilePicker (3), tree/TreeBrowser (2), workflow PromptTextArea (2), ArtifactSidebar (2). All onClick/disabled/aria/stopPropagation preserved; MermaidRenderer's brand colors and Drive-picker's #A076F6 brand override kept inline. * refactor(frontend): migrate page-level buttons to ui/button (DEF.6) 27 raw <button> elements across Navigation (6), preferences PromptsModal (5), Upload (3), settings Prompts (3), MoveToFolderModal (8) and ToolApprovalToast (2) migrated to ui/button. Includes the sidebar collapse / new-chat / mobile-hamburger / agent-pin row in Navigation, both Add and Edit prompt modal flows, the breadcrumb + folder-row + Cancel/Move CTAs in MoveToFolderModal, Upload back chevron / advanced toggle / Train submit, the toast Dismiss + Review pair, and the Settings Prompts popover trigger + per-row pencil/trash buttons. * refactor(frontend): final raw <input> sweep onto ui/input (DEF.7) 25 text/search/number inputs migrated to ui/input across: ToolConfig (14 method-row + search), ImportSpecModal (1 spec URL), NewAgent (agent name + 2 advanced number limits), Chunks (2 searches), MoveToFolderModal (new-folder pill), ConversationTile (inline rename), ConversationBubble (tool-call denial reason), tree/TreeBrowser (file search with rounded-t-3xl switch), and the workflow PromptTextArea variable search. 11 raw <input> intentionally left: checkboxes (4), dropzone/file inputs (3 - controlled by react-dropzone), the ScheduleFormModal borderless title input (1), and the ui/input + ui/time-picker primitives themselves (2). Per-site className overrides keep method-row inputs tight (h-auto rounded-lg px-2 py-1 shadow-none), borderless inline renames, and the MoveToFolderModal primary-purple pill. * chore(frontend): drop orphan dark-variant SVG assets (DEF.8) Removes 5 unused color-variant SVGs that no consumer imports: clock-purple, clock-white, monitoring-purple, monitoring-white, white-trash. The remaining hand-tuned dark variants (documentation pair, no-files pair, science-spark pair) are intentionally kept because their consumers use <img> and collapsing them onto a single currentColor SVG would require a JSX refactor outside this scope. * fix(a11y): make AgentCard + FolderCard keyboard accessible (P5.1) Both wrapped their entire body in a bare <div onClick> with no keyboard handler — invisible to keyboard and screen-reader users. Adds role="button", tabIndex={0}, Enter/Space onKeyDown, and a focus-visible ring (focus-visible:ring-ring/50 ring-[3px]) so the cards are actually focusable. AgentCard only takes the role when its status is 'published' so unpublished cards stay non-interactive. FolderCard exposes aria-pressed for its expanded state. * fix(a11y): add focus-visible rings to remaining outline-none sites (P5.2) 8 textareas/inputs without replacement focus visuals (PromptsModal content, ToolConfig rename pair, NewAgent description + system prompt, workflow PromptTextArea, ConversationBubble edit-query, Chunks editor) now pair their outline-none with the canonical focus-visible:ring-ring/50 focus-visible:border-ring focus-visible: ring-[3px] pattern. 7 callsites in ScheduleFormModal + WorkflowBuilder upgraded from focus: to focus-visible: so the ring only shows on keyboard focus, not on mouse click. * fix(a11y): gate changelog banner off /share/ and /shared/ (P5.3) The Notification banner used to render on every route as long as showNotification was true. Embedded / public share pages (/share/<id>, /shared/agent/<id>, /shared/...) shouldn't carry product chrome. Adds a useLocation() check in App.tsx so the banner only renders on application routes. * feat(frontend): sidebar collapse -> icon rail on desktop (P6.1) Replaces the previous "sidebar slides off-screen, only a tiny floating header remains" collapse behaviour with a vertical 56px-wide icon rail at the left edge on lg+ viewports. The rail surfaces the main nav actions (open-sidebar, new-chat when applicable, search, agents, settings). Main content shifts to lg:ml-14 when collapsed and lg:ml-72 when expanded so it never underlaps the rail. Mobile / tablet behaviour is unchanged — they still use the hamburger drawer. * fix(frontend): lift changelog banner above mobile input bar (P6.2) The Notification banner used uniform bottom-6 (24px), which on mobile sat squarely over the Sources / Tools / Attach button row (37px overlap into the textarea). Switches to bottom-24 sm:bottom-6 so the banner sits ~96px above the bottom on mobile (clearing the input bar) and stays at 24px on tablet+. Now only a ~11px sliver grazes the textarea's top border on mobile, with no controls occluded. * refactor(frontend): extract MessageInput sub-components (P6.3) Splits the 1919-LoC MessageInput.tsx into 5 focused sub-components under components/message-input/: AttachmentChipList (the file-chip strip above the textarea), AttachFileButton (paperclip + hidden file input), SourcesTrigger (pill + MultiSelectPopover for source docs), ToolsTrigger (pill + lazy-loaded MultiSelectPopover for tools), and MicButton (idle/recording/transcribing tri-state). Parent shrinks to 1686 LoC; ~233 lines of UI logic move to 6 new files. State stays in the parent — sub-components are props-only. The textarea+send/stop button and the voice-capture plumbing intentionally remain inline (would require prop-drilling / hook-lifting beyond the conservative bar for this pass). * docs(agents): document the icon strategy for new code (P6.4) Adds an "Icons" subsection under the frontend coding rules in AGENTS.md. Codifies the three current sources (lucide-react, SVG ?react components, SVG <img>) and picks lucide-react as the default for new UI affordances, SVG ?react for brand/domain illustrations that need currentColor theming, and discourages <img src=...> for new icons. Notes the three remaining hand-tuned dark-variant SVG pairs that intentionally stay paired (documentation, no-files, science-spark) because they're multi-color illustrations, not pure inverts. * fix(frontend): align Settings Prompts Add button height to dropdown trigger The Add button was rendering 10px shorter (36px) than the Active-Prompt PopoverTrigger (46px) because the Button primitive's default h-9 was capping it. Adds h-auto + py-3 so padding drives the height, plus border border-transparent so the 1px border the trigger has is counted on both — taking Add from 36 -> 46px and closing the visible bottom-edge mismatch. * feat: auto version release * fix(frontend): clean up workflow-builder header duplication The toolbar was rendering the workflow name twice (once in the AgentPageHeader breadcrumb and once in a standalone title block) plus the description awkwardly stacked next to the tab strip. Drops the title block on the owner view (breadcrumb already shows the name); the pencil edit button stays but is now always visible instead of hidden until hover, and gains a "name — description" tooltip so the description is still reachable at a glance. Non- owners keep the title block since they don't see the breadcrumb. * fix: tabs * fix: breadcrumb and tabs * fix: remove details btn on agent workflow * fix: dropdown arrow * fix: modal clicks * fix: rounded search * fix: mini issues * fix: pin versions * (chore) fe lint-fix * fix: rounded search on source, password autocomplete, accordions in chat --------- Co-authored-by: ManishMadan2882 <manishmadan321@gmail.com>
No files matched your search
@@ -0,0 +1,73 @@
|
||||
name: Backend release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'application/version.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: backend-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.repository == 'arc53/DocsGPT'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read version from application/version.py
|
||||
id: ver
|
||||
run: |
|
||||
VERSION=$(python3 -c "g={}; exec(open('application/version.py').read(), g); print(g['__version__'])")
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::Could not read __version__ from application/version.py"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version: $VERSION"
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: check
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
if git ls-remote --tags --exit-code origin "refs/tags/$VERSION" >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag $VERSION already exists on origin — skipping."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.check.outputs.exists == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$VERSION"
|
||||
# Authenticate this single push via a one-shot tokenized remote URL
|
||||
# instead of leaving GITHUB_TOKEN persisted in .git/config (see
|
||||
# persist-credentials: false on the checkout step above).
|
||||
git push \
|
||||
"https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
|
||||
"$VERSION"
|
||||
|
||||
- name: Create GitHub release
|
||||
if: steps.check.outputs.exists == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
gh release create "$VERSION" \
|
||||
--title "v$VERSION" \
|
||||
--generate-notes
|
||||
@@ -145,6 +145,27 @@ vale .
|
||||
- Avoid broad UI refactors unless the task explicitly asks for them.
|
||||
- Do not re-create components if we already have some in the app.
|
||||
|
||||
#### Icons
|
||||
|
||||
DocsGPT historically mixed three icon sources: `lucide-react`, inline SVG components, and
|
||||
`.svg` assets loaded via `<img src=…>`. For new code:
|
||||
|
||||
1. **Prefer `lucide-react`** for standard UI affordances (close, chevron, search, trash,
|
||||
plus, etc.). It tokenizes via `currentColor`, ships tree-shaken icons, and the codebase
|
||||
already imports it in 30+ places. `<X className="size-4" />`, `<ChevronDown />`, etc.
|
||||
2. **Use `assets/<name>.svg?react`** when you need a brand-specific or domain illustration
|
||||
that doesn't exist in lucide (the app logo, robot fallback, retry arrow, send arrow,
|
||||
etc.). Always set `fill="currentColor"` / `stroke="currentColor"` in the SVG file so
|
||||
consumers can theme via Tailwind text classes.
|
||||
3. **Avoid `<img src={Asset}>` for new icons.** It blocks `currentColor` theming and
|
||||
forces dark-variant duplicates (the audit removed several orphan dark/purple/white
|
||||
variants in this branch). The pattern is acceptable for existing call sites — don't
|
||||
bulk-migrate without a reason.
|
||||
|
||||
Three pre-existing dark-variant pairs (`documentation`, `no-files`, `science-spark`) are
|
||||
hand-tuned multi-color illustrations, not pure inverts; they keep their `-dark` companion
|
||||
files until a per-illustration refactor.
|
||||
|
||||
## PR readiness
|
||||
|
||||
Before opening a PR:
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@reduxjs/toolkit": "^2.10.1",
|
||||
"chart.js": "^4.4.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -21,10 +21,13 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@reduxjs/toolkit": "^2.10.1",
|
||||
"chart.js": "^4.4.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './locale/i18n';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import Agents from './agents';
|
||||
import SharedAgentGate from './agents/SharedAgentGate';
|
||||
@@ -45,7 +45,7 @@ function MainLayout() {
|
||||
<div
|
||||
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
|
||||
!(isMobile || isTablet)
|
||||
? `${navOpen ? 'lg:ml-72' : 'lg:ml-0'}`
|
||||
? `${navOpen ? 'lg:ml-72' : 'lg:ml-14'}`
|
||||
: 'ml-0 lg:ml-16'
|
||||
}`}
|
||||
>
|
||||
@@ -58,27 +58,36 @@ function MainLayout() {
|
||||
}
|
||||
export default function App() {
|
||||
const [, , componentMounted] = useDarkTheme();
|
||||
const location = useLocation();
|
||||
const [showNotification, setShowNotification] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem('showNotification');
|
||||
return saved ? JSON.parse(saved) : true;
|
||||
});
|
||||
const notificationText = import.meta.env.VITE_NOTIFICATION_TEXT;
|
||||
const notificationLink = import.meta.env.VITE_NOTIFICATION_LINK;
|
||||
// Hide the changelog banner on public share routes — those pages are
|
||||
// embedded / shared externally and shouldn't carry product chrome.
|
||||
const isPublicShareRoute =
|
||||
location.pathname.startsWith('/share/') ||
|
||||
location.pathname.startsWith('/shared/');
|
||||
if (!componentMounted) {
|
||||
return <div />;
|
||||
}
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{notificationLink && notificationText && showNotification && (
|
||||
<Notification
|
||||
notificationText={notificationText}
|
||||
notificationLink={notificationLink}
|
||||
handleCloseNotification={() => {
|
||||
setShowNotification(false);
|
||||
localStorage.setItem('showNotification', 'false');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{notificationLink &&
|
||||
notificationText &&
|
||||
showNotification &&
|
||||
!isPublicShareRoute && (
|
||||
<Notification
|
||||
notificationText={notificationText}
|
||||
notificationLink={notificationLink}
|
||||
handleCloseNotification={() => {
|
||||
setShowNotification(false);
|
||||
localStorage.setItem('showNotification', 'false');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
|
||||
@@ -1,7 +1,117 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import modelService from './api/services/modelService';
|
||||
import DocsGPT3 from './assets/cute_docsgpt3.svg';
|
||||
import DropdownModel from './components/DropdownModel';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './components/ui/select';
|
||||
import {
|
||||
selectAvailableModels,
|
||||
selectSelectedModel,
|
||||
selectToken,
|
||||
setAvailableModels,
|
||||
setModelsLoading,
|
||||
setSelectedModel,
|
||||
} from './preferences/preferenceSlice';
|
||||
|
||||
import type { Model } from './models/types';
|
||||
|
||||
function HeroModelSelect() {
|
||||
const dispatch = useDispatch();
|
||||
const selectedModel = useSelector(selectSelectedModel);
|
||||
const availableModels = useSelector(selectAvailableModels);
|
||||
const token = useSelector(selectToken);
|
||||
// Tracks which token the cached availableModels were loaded for.
|
||||
// Without this, the early-return below pins the anonymous/built-in
|
||||
// list forever once it's populated — login/logout never refetches
|
||||
// and a user's BYOM models stay invisible.
|
||||
const lastLoadedTokenRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
if (
|
||||
(availableModels?.length ?? 0) > 0 &&
|
||||
lastLoadedTokenRef.current === token
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dispatch(setModelsLoading(true));
|
||||
try {
|
||||
const response = await modelService.getModels(token);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const models = data.models || [];
|
||||
const transformed = modelService.transformModels(models);
|
||||
|
||||
dispatch(setAvailableModels(transformed));
|
||||
lastLoadedTokenRef.current = token;
|
||||
if (!selectedModel && transformed.length > 0) {
|
||||
const defaultModel =
|
||||
transformed.find((m: Model) => m.id === data.default_model_id) ||
|
||||
transformed[0];
|
||||
dispatch(setSelectedModel(defaultModel));
|
||||
} else if (selectedModel && transformed.length > 0) {
|
||||
const isValid = transformed.find(
|
||||
(m: Model) => m.id === selectedModel.id,
|
||||
);
|
||||
if (!isValid) {
|
||||
const defaultModel =
|
||||
transformed.find((m: Model) => m.id === data.default_model_id) ||
|
||||
transformed[0];
|
||||
dispatch(setSelectedModel(defaultModel));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
} finally {
|
||||
dispatch(setModelsLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}, [availableModels?.length, dispatch, selectedModel, token]);
|
||||
|
||||
const hasModels = availableModels && (availableModels?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedModel?.id}
|
||||
onValueChange={(value) => {
|
||||
const model = availableModels?.find((m: Model) => m.id === value);
|
||||
if (model) dispatch(setSelectedModel(model));
|
||||
}}
|
||||
disabled={!hasModels}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-muted dark:bg-card text-foreground hover:bg-muted dark:hover:bg-card w-full justify-between rounded-4xl border-0 px-6 py-4 text-base shadow-none data-[state=open]:rounded-b-none"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue placeholder="Select Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted dark:bg-card rounded-t-none rounded-b-4xl border-0 shadow-md data-[side=bottom]:translate-y-0">
|
||||
{hasModels ? (
|
||||
availableModels?.map((model: Model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.display_name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground px-3 py-2 text-sm">
|
||||
No models available
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero({
|
||||
handleQuestion,
|
||||
@@ -21,7 +131,7 @@ export default function Hero({
|
||||
}>;
|
||||
|
||||
return (
|
||||
<div className="text-black-1000 dark:text-foreground flex h-full w-full flex-col items-center justify-between">
|
||||
<div className="text-foreground flex h-full w-full flex-col items-center justify-between">
|
||||
{/* Header Section */}
|
||||
<div className="flex grow flex-col items-center justify-center pt-8 md:pt-0">
|
||||
<div className="mb-px flex items-center">
|
||||
@@ -30,7 +140,7 @@ export default function Hero({
|
||||
</div>
|
||||
{/* Model Selector */}
|
||||
<div className="relative w-72">
|
||||
<DropdownModel />
|
||||
<HeroModelSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,9 +154,9 @@ export default function Hero({
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleQuestion({ question: demo.query })}
|
||||
className={`border-border text-foreground hover:bg-muted dark:hover:bg-muted/50 bg-card w-full rounded-[66px] border px-6 py-3.5 text-left transition-colors dark:bg-transparent ${key >= 2 ? 'hidden md:block' : ''}`}
|
||||
className={`border-border text-foreground hover:bg-muted dark:hover:bg-muted/50 bg-card w-full rounded-full border px-6 py-3.5 text-left transition-colors dark:bg-transparent ${key >= 2 ? 'hidden md:block' : ''}`}
|
||||
>
|
||||
<p className="text-black-1000 dark:text-foreground mb-2 font-semibold">
|
||||
<p className="text-foreground mb-2 font-semibold">
|
||||
{demo.header}
|
||||
</p>
|
||||
<span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300">
|
||||
|
||||
@@ -16,11 +16,11 @@ import Hamburger from './assets/hamburger.svg';
|
||||
import openNewChat from './assets/openNewChat.svg';
|
||||
import Pin from './assets/pin.svg';
|
||||
import SearchIcon from './assets/search.svg';
|
||||
import AgentImage from './components/AgentImage';
|
||||
import { Avatar } from './components/ui/avatar';
|
||||
import { Button } from './components/ui/button';
|
||||
import SettingGear from './assets/settingGear.svg';
|
||||
import Spark from './assets/spark.svg';
|
||||
import SpinnerDark from './assets/spinner-dark.svg';
|
||||
import Spinner from './assets/spinner.svg';
|
||||
import Spinner from './components/Spinner';
|
||||
import Twitter from './assets/TwitterX.svg';
|
||||
import UnPin from './assets/unpin.svg';
|
||||
import Help from './components/Help';
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
updateConversationId,
|
||||
} from './conversation/conversationSlice';
|
||||
import ConversationTile from './conversation/ConversationTile';
|
||||
import { useDarkTheme, useMediaQuery } from './hooks';
|
||||
import { useMediaQuery } from './hooks';
|
||||
import useTokenAuth from './hooks/useTokenAuth';
|
||||
import DeleteConvModal from './modals/DeleteConvModal';
|
||||
import ConfirmationModal from './modals/ConfirmationModal';
|
||||
import JWTModal from './modals/JWTModal';
|
||||
import SearchConversationsModal from './modals/SearchConversationsModal';
|
||||
import { ActiveState } from './models/misc';
|
||||
@@ -77,7 +77,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
|
||||
const { isMobile, isTablet } = useMediaQuery();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
|
||||
|
||||
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
|
||||
@@ -316,43 +315,79 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
<div className="absolute top-3 left-3 z-20 hidden transition-all duration-300 ease-in-out lg:block">
|
||||
<div className="flex items-center gap-3">
|
||||
{!navOpen && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
className="transition-transform duration-200 hover:scale-110"
|
||||
>
|
||||
<img
|
||||
src={PanelLeftOpen}
|
||||
alt="Open navigation menu"
|
||||
className="m-auto transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{queries?.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
newChat();
|
||||
}}
|
||||
className="transition-transform duration-200 hover:scale-110"
|
||||
>
|
||||
<img
|
||||
src={openNewChat}
|
||||
alt="Start new chat"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="text-muted-foreground text-[20px] font-medium">
|
||||
DocsGPT
|
||||
</div>
|
||||
{/* Icon rail (desktop only, when sidebar collapsed) */}
|
||||
{!navOpen && !isMobile && !isTablet && (
|
||||
<div className="bg-sidebar border-border fixed top-0 left-0 z-10 hidden h-full w-14 flex-col items-center gap-2 border-r py-3 lg:flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNavOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<img
|
||||
src={PanelLeftOpen}
|
||||
alt=""
|
||||
className="transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
</Button>
|
||||
{queries?.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => newChat()}
|
||||
aria-label="Start new chat"
|
||||
>
|
||||
<img src={openNewChat} alt="" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label={t('modals.searchConversations.searchPlaceholder')}
|
||||
title={t('modals.searchConversations.searchPlaceholder')}
|
||||
>
|
||||
<img
|
||||
src={SearchIcon}
|
||||
alt=""
|
||||
className="h-5 w-5 filter dark:invert"
|
||||
/>
|
||||
</Button>
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
dispatch(setSelectedAgent(null));
|
||||
navigate('/agents');
|
||||
}}
|
||||
aria-label={t('manageAgents')}
|
||||
>
|
||||
<img src={Spark} alt="" className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
resetConversation();
|
||||
navigate('/settings');
|
||||
}}
|
||||
aria-label={t('settings.label')}
|
||||
>
|
||||
<img
|
||||
src={SettingGear}
|
||||
alt=""
|
||||
className="h-5 w-5 filter dark:invert"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
<div
|
||||
ref={navRef}
|
||||
className={`${
|
||||
@@ -375,18 +410,22 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<p className="my-auto text-2xl font-semibold">DocsGPT</p>
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className="float-right mr-5"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="float-right mr-5 h-auto w-auto p-0 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setNavOpen(!navOpen);
|
||||
}}
|
||||
aria-label={navOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
<img
|
||||
src={navOpen ? PanelLeftClose : PanelLeftOpen}
|
||||
alt={navOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
className="m-auto transition-all duration-300 ease-in-out hover:scale-110"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<NavLink
|
||||
to={'/c/new'}
|
||||
@@ -416,12 +455,12 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
className="scrollbar-overlay mb-auto h-[78vh] overflow-x-hidden overflow-y-auto dark:text-white"
|
||||
>
|
||||
{conversations?.loading && !isDeletingConversation && (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<img
|
||||
src={isDarkTheme ? Spinner : SpinnerDark}
|
||||
className="animate-spin cursor-pointer bg-transparent"
|
||||
alt="Loading conversations"
|
||||
/>
|
||||
<div
|
||||
className="text-foreground absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform dark:text-white"
|
||||
role="status"
|
||||
aria-label="Loading conversations"
|
||||
>
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
{recentAgents?.length > 0 ? (
|
||||
@@ -445,10 +484,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-6 justify-center">
|
||||
<AgentImage
|
||||
<Avatar
|
||||
src={agent.image}
|
||||
alt="agent-logo"
|
||||
className="h-6 w-6 rounded-full object-contain"
|
||||
imgClassName="h-6 w-6 rounded-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-foreground dark:text-foreground overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
|
||||
@@ -458,18 +497,24 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<div
|
||||
className={`${isMobile || isTablet ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
|
||||
>
|
||||
<button
|
||||
className="rounded-full hover:opacity-75"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-auto w-auto rounded-full p-0 hover:bg-transparent hover:opacity-75"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTogglePin(agent);
|
||||
}}
|
||||
aria-label={
|
||||
agent.pinned ? 'Unpin agent' : 'Pin agent'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={agent.pinned ? UnPin : Pin}
|
||||
className="h-4 w-4"
|
||||
></img>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -524,9 +569,12 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<div className="mt-7">
|
||||
<div className="mx-4 my-auto mt-2 flex h-8 items-center justify-between gap-4 rounded-3xl">
|
||||
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="hover:bg-sidebar-accent mr-2 flex h-7 w-7 items-center justify-center rounded-full"
|
||||
className="hover:bg-sidebar-accent mr-2 h-7 w-7 rounded-full"
|
||||
aria-label={t('modals.searchConversations.searchPlaceholder')}
|
||||
title={t('modals.searchConversations.searchPlaceholder')}
|
||||
>
|
||||
@@ -535,7 +583,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
alt="search"
|
||||
className="h-4 w-4 opacity-70"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="conversations-container">
|
||||
{(conversations.data ?? []).map((conversation) => (
|
||||
@@ -639,25 +687,32 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
</div>
|
||||
<div className="dark:border-b-sidebar-border bg-sidebar sticky z-10 h-16 w-full border-b-2 lg:hidden">
|
||||
<div className="ml-6 flex h-full items-center gap-6">
|
||||
<button
|
||||
className="h-6 w-6 lg:hidden"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-6 w-6 p-0 hover:bg-transparent lg:hidden"
|
||||
onClick={() => setNavOpen(true)}
|
||||
aria-label="Toggle mobile menu"
|
||||
>
|
||||
<img
|
||||
src={Hamburger}
|
||||
alt="Toggle mobile menu"
|
||||
className="w-7 filter dark:invert"
|
||||
/>
|
||||
</button>
|
||||
<div className="text-muted-foreground text-[20px] font-medium">
|
||||
</Button>
|
||||
<div className="text-muted-foreground text-xl font-medium">
|
||||
DocsGPT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteConvModal
|
||||
<ConfirmationModal
|
||||
message={t('modals.deleteConv.confirm')}
|
||||
modalState={modalStateDeleteConv}
|
||||
setModalState={setModalStateDeleteConv}
|
||||
handleDeleteAllConv={handleDeleteAllConversations}
|
||||
setModalState={(state) => dispatch(setModalStateDeleteConv(state))}
|
||||
submitLabel={t('modals.deleteConv.delete')}
|
||||
handleSubmit={handleDeleteAllConversations}
|
||||
variant="danger"
|
||||
/>
|
||||
{uploadModalState === 'ACTIVE' && (
|
||||
<Upload
|
||||
|
||||
@@ -9,9 +9,12 @@ export default function PageNotFound() {
|
||||
<p className="text-foreground dark:bg-card mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl bg-gray-100 p-6 lg:p-10 xl:p-16">
|
||||
<h1>{t('pageNotFound.title')}</h1>
|
||||
<p>{t('pageNotFound.message')}</p>
|
||||
<button className="pointer-cursor bg-blue-1000 hover:bg-blue-3000 mr-4 flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100">
|
||||
<Link to="/">{t('pageNotFound.goHome')}</Link>
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-primary hover:bg-primary/90 mr-4 inline-flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100"
|
||||
>
|
||||
{t('pageNotFound.goHome')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SyntheticEvent, useRef, useState } from 'react';
|
||||
import { SyntheticEvent, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,11 +13,25 @@ import Pin from '../assets/pin.svg';
|
||||
import Trash from '../assets/red-trash.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import UnPin from '../assets/unpin.svg';
|
||||
import AgentImage from '../components/AgentImage';
|
||||
import ContextMenu, { MenuOption } from '../components/ContextMenu';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../components/ui/dropdown-menu';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MoveToFolderModal from '../modals/MoveToFolderModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
|
||||
type AgentMenuOption = {
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: (event: SyntheticEvent) => void;
|
||||
variant: 'default' | 'destructive';
|
||||
iconWidth?: number;
|
||||
iconHeight?: number;
|
||||
};
|
||||
import {
|
||||
selectAgents,
|
||||
selectToken,
|
||||
@@ -45,14 +59,11 @@ export default function AgentCard({
|
||||
const token = useSelector(selectToken);
|
||||
const userAgents = useSelector(selectAgents);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [moveModalState, setMoveModalState] = useState<ActiveState>('INACTIVE');
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuOptionsConfig: Record<string, MenuOption[]> = {
|
||||
const menuOptionsConfig: Record<string, AgentMenuOption[]> = {
|
||||
template: [
|
||||
{
|
||||
icon: Duplicate,
|
||||
@@ -61,7 +72,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
handleDuplicate();
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
},
|
||||
@@ -74,7 +85,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/logs/${agent.id}`);
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 14,
|
||||
iconHeight: 14,
|
||||
},
|
||||
@@ -89,7 +100,7 @@ export default function AgentCard({
|
||||
navigate(`/agents/edit/${agent.id}`);
|
||||
}
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 14,
|
||||
iconHeight: 14,
|
||||
},
|
||||
@@ -102,7 +113,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
togglePin();
|
||||
},
|
||||
variant: 'primary' as const,
|
||||
variant: 'default' as const,
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
},
|
||||
@@ -114,9 +125,8 @@ export default function AgentCard({
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setMoveModalState('ACTIVE');
|
||||
setIsMenuOpen(false);
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 16,
|
||||
iconHeight: 15,
|
||||
},
|
||||
@@ -127,7 +137,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
setDeleteConfirmation('ACTIVE');
|
||||
},
|
||||
variant: 'danger',
|
||||
variant: 'destructive',
|
||||
iconWidth: 13,
|
||||
iconHeight: 13,
|
||||
},
|
||||
@@ -140,7 +150,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 12,
|
||||
iconHeight: 12,
|
||||
},
|
||||
@@ -151,7 +161,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
togglePin();
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
},
|
||||
@@ -162,7 +172,7 @@ export default function AgentCard({
|
||||
e.stopPropagation();
|
||||
handleHideSharedAgent();
|
||||
},
|
||||
variant: 'danger',
|
||||
variant: 'destructive',
|
||||
iconWidth: 13,
|
||||
iconHeight: 13,
|
||||
},
|
||||
@@ -251,36 +261,66 @@ export default function AgentCard({
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`bg-muted hover:bg-accent relative flex h-44 flex-col justify-between rounded-[1.2rem] px-4 py-5 sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
role={agent.status === 'published' ? 'button' : undefined}
|
||||
tabIndex={agent.status === 'published' ? 0 : undefined}
|
||||
aria-label={agent.status === 'published' ? agent.name : undefined}
|
||||
className={`bg-muted hover:bg-accent focus-visible:ring-ring/50 focus-visible:border-ring relative flex h-44 flex-col justify-between rounded-2xl px-4 py-5 outline-none focus-visible:ring-[3px] sm:w-48 sm:px-6 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
agent.status === 'published' &&
|
||||
(e.key === 'Enter' || e.key === ' ')
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={menuRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
className="absolute top-4 right-4 z-10 cursor-pointer"
|
||||
>
|
||||
<img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" />
|
||||
<ContextMenu
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
options={menuOptions}
|
||||
anchorRef={menuRef}
|
||||
position="bottom-right"
|
||||
offset={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute top-4 right-4 z-10 cursor-pointer"
|
||||
aria-label="agent-actions"
|
||||
>
|
||||
<img
|
||||
src={ThreeDots}
|
||||
alt={'use-agent'}
|
||||
className="h-[19px] w-[19px]"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[144px]">
|
||||
{menuOptions.map((option, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
variant={option.variant}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(event) => {
|
||||
option.onClick(event as unknown as SyntheticEvent);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={option.icon}
|
||||
alt=""
|
||||
width={option.iconWidth ?? 16}
|
||||
height={option.iconHeight ?? 16}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-1 px-1">
|
||||
<AgentImage
|
||||
<Avatar
|
||||
src={agent.image}
|
||||
alt={`${agent.name}`}
|
||||
className="h-7 w-7 rounded-full object-contain"
|
||||
imgClassName="h-7 w-7 rounded-full object-contain"
|
||||
/>
|
||||
{agent.status === 'draft' && (
|
||||
<p className="text-foreground text-xs opacity-50">{`(Draft)`}</p>
|
||||
@@ -289,11 +329,11 @@ export default function AgentCard({
|
||||
<div className="mt-2">
|
||||
<p
|
||||
title={agent.name}
|
||||
className="text-foreground truncate px-1 text-[13px] leading-relaxed font-semibold capitalize"
|
||||
className="text-foreground truncate px-1 text-sm leading-relaxed font-semibold capitalize"
|
||||
>
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className="dark:text-muted-foreground text-muted-foreground mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed">
|
||||
<p className="dark:text-muted-foreground text-muted-foreground mt-1 h-20 overflow-auto px-1 text-xs leading-relaxed">
|
||||
{agent.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function AgentLogs() {
|
||||
: `/agents/edit/${agentId}`;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-12">
|
||||
<div className="p-4 pt-4 md:p-12 md:pt-4">
|
||||
<AgentPageHeader
|
||||
agentId={agentId}
|
||||
agentName={agent?.name}
|
||||
|
||||
@@ -74,9 +74,14 @@ export default function AgentPageHeader({
|
||||
const displayName = agentName?.trim() || t('agents.pageHeader.fallbackName');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Breadcrumb className="shrink-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/agents">{t('agents.pageHeader.crumbs.agents')}</Link>
|
||||
@@ -110,7 +115,7 @@ export default function AgentPageHeader({
|
||||
<nav
|
||||
aria-label={t('agents.pageHeader.subnavAriaLabel')}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-6',
|
||||
'flex items-center gap-6',
|
||||
// 1px baseline rule under the whole row; the active tab's 2px
|
||||
// primary underline sits on top of it for the GitHub-style look.
|
||||
!inline && 'border-border border-b',
|
||||
@@ -121,7 +126,7 @@ export default function AgentPageHeader({
|
||||
// Always render a 2px bottom border so row height stays constant
|
||||
// between active/inactive; only the color changes.
|
||||
const baseClasses =
|
||||
'whitespace-nowrap border-b-2 pb-2 text-sm font-medium transition-colors';
|
||||
'whitespace-nowrap border-b-2 pb-1 text-sm font-medium transition-colors';
|
||||
if (isActive) {
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -113,13 +114,24 @@ export default function AgentPreview() {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="scrollbar-overlay absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:w-full! [&>div>div]:max-w-none!">
|
||||
<ConversationMessages
|
||||
handleQuestion={handleQuestion}
|
||||
handleQuestionSubmission={handleQuestionSubmission}
|
||||
queries={queries}
|
||||
status={status}
|
||||
showHeroOnEmpty={false}
|
||||
/>
|
||||
{queries.length === 0 ? (
|
||||
<section className="flex h-full flex-col items-center justify-center">
|
||||
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
|
||||
<MessageSquare className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
Test your agent
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<ConversationMessages
|
||||
handleQuestion={handleQuestion}
|
||||
handleQuestionSubmission={handleQuestionSubmission}
|
||||
queries={queries}
|
||||
status={status}
|
||||
showHeroOnEmpty={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 flex w-full flex-col gap-4 pb-2">
|
||||
<div className="w-full px-4">
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import userService from '../api/services/userService';
|
||||
import Search from '../assets/search.svg';
|
||||
import Spinner from '../components/Spinner';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import {
|
||||
setConversation,
|
||||
updateConversationId,
|
||||
@@ -159,42 +161,43 @@ export default function AgentsList() {
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-12">
|
||||
<h1 className="text-foreground mb-0 text-[32px] font-bold lg:text-[40px]">
|
||||
<h1 className="text-foreground mb-0 text-3xl font-bold lg:text-4xl">
|
||||
{t('agents.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-5 text-[15px] leading-6">
|
||||
<p className="text-muted-foreground mt-5 text-sm leading-6">
|
||||
{t('agents.description')}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 pb-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
<img
|
||||
src={Search}
|
||||
alt=""
|
||||
className="absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 opacity-40"
|
||||
/>
|
||||
<input
|
||||
<div className="w-full max-w-md">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('agents.searchPlaceholder')}
|
||||
className="border-border bg-card text-foreground placeholder:text-muted-foreground h-11 w-full rounded-full border py-2 pr-5 pl-11 text-sm shadow-[0_1px_4px_rgba(0,0,0,0.06)] transition-shadow outline-none focus:shadow-[0_2px_8px_rgba(0,0,0,0.1)] dark:shadow-none"
|
||||
label={t('agents.searchPlaceholder')}
|
||||
labelBgClassName="bg-background"
|
||||
className="rounded-full"
|
||||
leftIcon={
|
||||
<img src={Search} alt="" className="h-4 w-4 opacity-40" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_TABS.map((tab) => (
|
||||
<button
|
||||
<Button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setActiveFilter(tab.id)}
|
||||
className={`rounded-full px-4 py-2 text-sm transition-colors ${
|
||||
className={`rounded-full ${
|
||||
activeFilter === tab.id
|
||||
? 'bg-border text-foreground dark:bg-accent dark:text-white'
|
||||
: 'dark:text-gray text-muted-foreground hover:bg-accent/50 bg-transparent'
|
||||
: 'dark:text-gray text-muted-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,15 +414,16 @@ function AgentSection({
|
||||
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-3">
|
||||
<p>{t(`agents.sections.${config.id}.emptyState`)}</p>
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-primary hover:bg-primary/90 rounded-full px-4 py-2 text-sm text-white"
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-full text-white"
|
||||
onClick={() => {
|
||||
setModalFolderId(null);
|
||||
setShowAgentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -456,27 +460,31 @@ function AgentSection({
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-[18px] font-semibold">
|
||||
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-lg font-semibold">
|
||||
{config.id === 'user' && folderPath.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => handleNavigateToPath(-1)}
|
||||
className="text-muted-foreground hover:text-foreground dark:hover:text-white"
|
||||
className="text-muted-foreground hover:text-foreground h-auto p-0 text-lg font-semibold no-underline hover:no-underline dark:hover:text-white"
|
||||
>
|
||||
{t(`agents.sections.${config.id}.title`)}
|
||||
</button>
|
||||
</Button>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<span key={item.id} className="flex items-center gap-2">
|
||||
<ChevronIcon />
|
||||
{index === breadcrumbItems.length - 1 ? (
|
||||
<span>{item.name}</span>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => handleNavigateToPath(index)}
|
||||
className="text-muted-foreground hover:text-foreground dark:hover:text-white"
|
||||
className="text-muted-foreground hover:text-foreground h-auto p-0 text-lg font-semibold no-underline hover:no-underline dark:hover:text-white"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
@@ -485,14 +493,14 @@ function AgentSection({
|
||||
t(`agents.sections.${config.id}.title`)
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`agents.sections.${config.id}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{config.id === 'user' &&
|
||||
(isCreatingFolder ? (
|
||||
<input
|
||||
<Input
|
||||
ref={newFolderInputRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
@@ -513,30 +521,33 @@ function AgentSection({
|
||||
}
|
||||
}}
|
||||
placeholder={t('agents.folders.newFolder')}
|
||||
className="border-border bg-card text-foreground placeholder:text-muted-foreground w-28 rounded-full border px-4 py-2 text-sm outline-none sm:w-auto"
|
||||
className="w-28 sm:w-auto"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="border-border bg-card text-foreground hover:bg-accent shrink-0 rounded-full border px-4 py-2 text-sm whitespace-nowrap"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="bg-card shrink-0 rounded-full whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(true);
|
||||
setTimeout(() => newFolderInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{t('agents.folders.newFolder')}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-primary hover:bg-primary/90 shrink-0 rounded-full px-4 py-2 text-sm whitespace-nowrap text-white"
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full whitespace-nowrap text-white"
|
||||
onClick={() => {
|
||||
setModalFolderId(currentFolderId);
|
||||
setShowAgentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,15 +597,16 @@ function AgentSection({
|
||||
: t(`agents.sections.${config.id}.emptyState`)}
|
||||
</p>
|
||||
{config.showNewAgentButton && !currentFolderId && (
|
||||
<button
|
||||
className="bg-primary hover:bg-primary/90 ml-2 rounded-full px-4 py-2 text-sm text-white"
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-2 rounded-full text-white"
|
||||
onClick={() => {
|
||||
setModalFolderId(currentFolderId);
|
||||
setShowAgentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { SyntheticEvent, useRef, useState } from 'react';
|
||||
import { SyntheticEvent, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Edit from '../assets/edit.svg';
|
||||
import Trash from '../assets/red-trash.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import ContextMenu, { MenuOption } from '../components/ContextMenu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../components/ui/dropdown-menu';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import FolderNameModal from '../modals/FolderManagementModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { AgentFolder } from './types';
|
||||
|
||||
type FolderMenuOption = {
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: (event: SyntheticEvent) => void;
|
||||
variant: 'default' | 'destructive';
|
||||
iconWidth?: number;
|
||||
iconHeight?: number;
|
||||
};
|
||||
|
||||
type FolderCardProps = {
|
||||
folder: AgentFolder;
|
||||
agentCount: number;
|
||||
@@ -28,23 +42,20 @@ export default function FolderCard({
|
||||
onToggleExpand,
|
||||
}: FolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [renameModalState, setRenameModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
const menuOptions: FolderMenuOption[] = [
|
||||
{
|
||||
icon: Edit,
|
||||
label: t('agents.folders.rename'),
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setRenameModalState('ACTIVE');
|
||||
setIsMenuOpen(false);
|
||||
},
|
||||
variant: 'primary',
|
||||
variant: 'default',
|
||||
iconWidth: 14,
|
||||
iconHeight: 14,
|
||||
},
|
||||
@@ -54,9 +65,8 @@ export default function FolderCard({
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirmation('ACTIVE');
|
||||
setIsMenuOpen(false);
|
||||
},
|
||||
variant: 'danger',
|
||||
variant: 'destructive',
|
||||
iconWidth: 13,
|
||||
iconHeight: 13,
|
||||
},
|
||||
@@ -69,10 +79,19 @@ export default function FolderCard({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex cursor-pointer items-center justify-between rounded-[1.2rem] px-4 py-3 sm:w-48 ${
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isExpanded}
|
||||
className={`focus-visible:ring-ring/50 focus-visible:border-ring relative flex cursor-pointer items-center justify-between rounded-2xl px-4 py-3 outline-none focus-visible:ring-[3px] sm:w-48 ${
|
||||
isExpanded ? 'bg-accent' : 'bg-muted hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => onToggleExpand(folder.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggleExpand(folder.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
@@ -82,24 +101,42 @@ export default function FolderCard({
|
||||
({agentCount})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={menuRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
className="ml-2 shrink-0 cursor-pointer"
|
||||
>
|
||||
<img src={ThreeDots} alt="menu" className="h-4 w-4" />
|
||||
<ContextMenu
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
options={menuOptions}
|
||||
anchorRef={menuRef}
|
||||
position="bottom-right"
|
||||
offset={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="ml-2 shrink-0 cursor-pointer"
|
||||
aria-label={t('agents.folders.menuAriaLabel', {
|
||||
folderName: folder.name,
|
||||
defaultValue: 'Folder actions',
|
||||
})}
|
||||
>
|
||||
<img src={ThreeDots} alt="" className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[144px]">
|
||||
{menuOptions.map((option, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
variant={option.variant}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(event) => {
|
||||
option.onClick(event as unknown as SyntheticEvent);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={option.icon}
|
||||
alt=""
|
||||
width={option.iconWidth ?? 16}
|
||||
height={option.iconHeight ?? 16}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
message={t('agents.folders.deleteConfirm')}
|
||||
|
||||
@@ -12,13 +12,25 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
import modelService from '../api/services/modelService';
|
||||
import userService from '../api/services/userService';
|
||||
import SourceIcon from '../assets/source.svg';
|
||||
import Dropdown from '../components/Dropdown';
|
||||
import { FileUpload } from '../components/FileUpload';
|
||||
import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup';
|
||||
import {
|
||||
MultiSelectPopover,
|
||||
type MultiSelectPopoverItem,
|
||||
} from '../components/MultiSelectPopover';
|
||||
import Spinner from '../components/Spinner';
|
||||
import AgentDetailsModal from '../modals/AgentDetailsModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
@@ -87,14 +99,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
default_model_id: '',
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [userTools, setUserTools] = useState<OptionType[]>([]);
|
||||
const [userTools, setUserTools] = useState<MultiSelectPopoverItem[]>([]);
|
||||
const [rawUserTools, setRawUserTools] = useState<UserToolType[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
||||
const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false);
|
||||
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
|
||||
const [isModelsPopupOpen, setIsModelsPopupOpen] = useState(false);
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<
|
||||
Set<string | number>
|
||||
>(new Set());
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolSummary[]>([]);
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
@@ -446,19 +459,20 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
if (!response.ok) throw new Error('Failed to fetch tools');
|
||||
const data = await response.json();
|
||||
// Group ordering: builtins -> defaults -> user tools (sorted via the
|
||||
// MultiSelectPopup first-appearance grouping).
|
||||
// MultiSelectPopover first-appearance grouping).
|
||||
const groupFor = (tool: UserToolType): string => {
|
||||
if (tool.builtin) return t('agents.form.toolsPopup.groupBuiltin');
|
||||
if (tool.default) return t('agents.form.toolsPopup.groupDefault');
|
||||
return t('agents.form.toolsPopup.groupCustom');
|
||||
};
|
||||
const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({
|
||||
id: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
icon: `/toolIcons/tool_${tool.name}.svg`,
|
||||
name: tool.name,
|
||||
group: groupFor(tool),
|
||||
}));
|
||||
const tools: MultiSelectPopoverItem[] = data.tools.map(
|
||||
(tool: UserToolType) => ({
|
||||
id: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
icon: `/toolIcons/tool_${tool.name}.svg`,
|
||||
group: groupFor(tool),
|
||||
}),
|
||||
);
|
||||
const groupOrder = [
|
||||
t('agents.form.toolsPopup.groupBuiltin'),
|
||||
t('agents.form.toolsPopup.groupDefault'),
|
||||
@@ -469,6 +483,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
groupOrder.indexOf(a.group || '') - groupOrder.indexOf(b.group || ''),
|
||||
);
|
||||
setUserTools(tools);
|
||||
setRawUserTools(data.tools as UserToolType[]);
|
||||
};
|
||||
const getModels = async () => {
|
||||
const response = await modelService.getModels(token);
|
||||
@@ -529,19 +544,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
useEffect(() => {
|
||||
if (sourceDocs && sourceDocs.length > 0 && selectedSourceIds.size === 0) {
|
||||
const defaultSource = sourceDocs.find((s) => s.name === 'Default');
|
||||
if (defaultSource) {
|
||||
setSelectedSourceIds(
|
||||
new Set([
|
||||
defaultSource.id || defaultSource.retriever || defaultSource.name,
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
setSelectedSourceIds(
|
||||
new Set([
|
||||
sourceDocs[0].id || sourceDocs[0].retriever || sourceDocs[0].name,
|
||||
]),
|
||||
);
|
||||
}
|
||||
const fallback = defaultSource || sourceDocs[0];
|
||||
setSelectedSourceIds(
|
||||
new Set([String(fallback.id || fallback.retriever || fallback.name)]),
|
||||
);
|
||||
}
|
||||
}, [sourceDocs, selectedSourceIds.size]);
|
||||
|
||||
@@ -726,61 +732,66 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
const showAgentNav = effectiveMode === 'edit' && Boolean(agent.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:px-12 md:pt-12 md:pb-3">
|
||||
{showAgentNav ? (
|
||||
<AgentPageHeader
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
agentEditPath={`/agents/edit/${agent.id}`}
|
||||
currentPage="overview"
|
||||
className="px-4"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-5 flex w-full flex-wrap items-center justify-end gap-2 px-4">
|
||||
{agent.agent_type === 'workflow' && (
|
||||
<div className="mt-4 w-full">
|
||||
<WorkflowBuilder />
|
||||
</div>
|
||||
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:px-12 md:pt-4 md:pb-3">
|
||||
{agent.agent_type === 'workflow' && (
|
||||
<div className="mt-4 w-full">
|
||||
<WorkflowBuilder />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-2 px-4">
|
||||
{showAgentNav ? (
|
||||
<AgentPageHeader
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
agentEditPath={`/agents/edit/${agent.id}`}
|
||||
currentPage="overview"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hasChanges && (
|
||||
<button
|
||||
className="text-primary dark:text-foreground rounded-3xl px-2 py-2 text-sm font-medium"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="text-primary dark:text-foreground rounded-3xl px-2 hover:bg-transparent"
|
||||
>
|
||||
{t('agents.form.buttons.cancel')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{modeConfig[effectiveMode].showSaveDraft && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isJsonSchemaInvalid()}
|
||||
className={`border-primary text-primary hover:bg-primary/90 flex min-w-28 items-center justify-center rounded-3xl border border-solid px-5 py-2 text-sm font-medium whitespace-nowrap transition-colors hover:text-white ${
|
||||
isJsonSchemaInvalid() ? 'cursor-not-allowed opacity-30' : ''
|
||||
}`}
|
||||
onClick={handleSaveDraft}
|
||||
className={`border-primary text-primary hover:bg-primary/90 min-w-28 rounded-3xl border border-solid bg-transparent px-5 whitespace-nowrap hover:text-white ${
|
||||
isJsonSchemaInvalid() ? 'disabled:opacity-30' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center transition-all duration-200">
|
||||
{draftLoading ? (
|
||||
<Spinner size="small" color="#976af3" />
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
t('agents.form.buttons.saveDraft')
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
className={`${!isPublishable() || !hasChanges ? 'cursor-not-allowed opacity-30' : ''} bg-primary hover:bg-primary/90 flex min-w-28 items-center justify-center rounded-3xl px-5 py-2 text-sm font-medium whitespace-nowrap text-white`}
|
||||
onClick={handlePublish}
|
||||
className={`${!isPublishable() || !hasChanges ? 'disabled:opacity-30' : ''} min-w-28 rounded-3xl px-5 whitespace-nowrap text-white`}
|
||||
>
|
||||
<span className="flex items-center justify-center transition-all duration-200">
|
||||
{publishLoading ? (
|
||||
<Spinner size="small" color="white" />
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
modeConfig[effectiveMode].buttonText
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
{modeConfig[effectiveMode].showAccessDetails && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -803,21 +814,21 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted dark:bg-background mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-[30px] p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden">
|
||||
<div className="bg-muted dark:bg-background mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden">
|
||||
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.meta')}
|
||||
</h2>
|
||||
<input
|
||||
className="border-border text-foreground dark:text-foreground dark:placeholder:text-silver bg-card dark:border-border mt-3 w-full rounded-3xl border px-5 py-3 text-sm outline-hidden placeholder:text-gray-400"
|
||||
<Input
|
||||
className="bg-card mt-3 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm"
|
||||
type="text"
|
||||
value={agent.name}
|
||||
placeholder={t('agents.form.placeholders.agentName')}
|
||||
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className="border-border text-foreground dark:text-foreground dark:placeholder:text-silver bg-card dark:border-border mt-3 h-32 w-full rounded-xl border px-5 py-4 text-sm outline-hidden placeholder:text-gray-400"
|
||||
className="border-border text-foreground dark:text-foreground dark:placeholder:text-muted-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-3 h-32 w-full rounded-xl border px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 focus-visible:ring-[3px]"
|
||||
placeholder={t('agents.form.placeholders.describeAgent')}
|
||||
value={agent.description}
|
||||
onChange={(e) =>
|
||||
@@ -833,7 +844,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
uploadText={[
|
||||
{
|
||||
text: t('agents.form.upload.clickToUpload'),
|
||||
colorClass: 'text-[#7D54D1]',
|
||||
colorClass: 'text-primary',
|
||||
},
|
||||
{
|
||||
text: t('agents.form.upload.dragAndDrop'),
|
||||
@@ -843,105 +854,110 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.source')}
|
||||
</h2>
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
ref={sourceAnchorButtonRef}
|
||||
onClick={() => setIsSourcePopupOpen(!isSourcePopupOpen)}
|
||||
className={`border-border bg-card dark:border-border w-full truncate rounded-3xl border px-5 py-3 text-left text-sm ${
|
||||
selectedSourceIds.size > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{selectedSourceIds.size > 0
|
||||
? Array.from(selectedSourceIds)
|
||||
.map((id) => {
|
||||
const matchedDoc = sourceDocs?.find(
|
||||
(source) =>
|
||||
source.id === id ||
|
||||
source.name === id ||
|
||||
source.retriever === id,
|
||||
);
|
||||
return (
|
||||
matchedDoc?.name || t('agents.form.externalKb')
|
||||
);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectSources')}
|
||||
</button>
|
||||
<MultiSelectPopup
|
||||
isOpen={isSourcePopupOpen}
|
||||
onClose={() => setIsSourcePopupOpen(false)}
|
||||
anchorRef={sourceAnchorButtonRef}
|
||||
options={
|
||||
<MultiSelectPopover
|
||||
open={isSourcePopupOpen}
|
||||
onOpenChange={setIsSourcePopupOpen}
|
||||
title={t('agents.form.sourcePopup.title')}
|
||||
items={
|
||||
sourceDocs?.map((doc: Doc) => ({
|
||||
id: doc.id || doc.retriever || doc.name,
|
||||
id: String(doc.id || doc.retriever || doc.name),
|
||||
label: doc.name,
|
||||
icon: <img src={SourceIcon} alt="" />,
|
||||
})) || []
|
||||
}
|
||||
selectedIds={selectedSourceIds}
|
||||
onSelectionChange={(newSelectedIds: Set<string | number>) => {
|
||||
selectedIds={Array.from(selectedSourceIds)}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedSourceIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
if (
|
||||
newSelectedIds.size === 0 &&
|
||||
next.size === 0 &&
|
||||
sourceDocs &&
|
||||
sourceDocs.length > 0
|
||||
) {
|
||||
const defaultSource = sourceDocs.find(
|
||||
(s) => s.name === 'Default',
|
||||
);
|
||||
if (defaultSource) {
|
||||
setSelectedSourceIds(
|
||||
new Set([
|
||||
defaultSource.id ||
|
||||
defaultSource.retriever ||
|
||||
defaultSource.name,
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
setSelectedSourceIds(
|
||||
new Set([
|
||||
sourceDocs[0].id ||
|
||||
sourceDocs[0].retriever ||
|
||||
sourceDocs[0].name,
|
||||
]),
|
||||
);
|
||||
}
|
||||
const fallback = defaultSource || sourceDocs[0];
|
||||
setSelectedSourceIds(
|
||||
new Set([
|
||||
String(
|
||||
fallback.id || fallback.retriever || fallback.name,
|
||||
),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
setSelectedSourceIds(newSelectedIds);
|
||||
setSelectedSourceIds(next);
|
||||
}
|
||||
}}
|
||||
title={t('agents.form.sourcePopup.title')}
|
||||
searchPlaceholder={t(
|
||||
'agents.form.sourcePopup.searchPlaceholder',
|
||||
)}
|
||||
noOptionsMessage={t(
|
||||
'agents.form.sourcePopup.noOptionsMessage',
|
||||
)}
|
||||
emptyMessage={t('agents.form.sourcePopup.noOptionsMessage')}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
ref={sourceAnchorButtonRef}
|
||||
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
|
||||
selectedSourceIds.size > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{selectedSourceIds.size > 0
|
||||
? Array.from(selectedSourceIds)
|
||||
.map((id) => {
|
||||
const matchedDoc = sourceDocs?.find(
|
||||
(source) =>
|
||||
source.id === id ||
|
||||
source.name === id ||
|
||||
source.retriever === id,
|
||||
);
|
||||
return (
|
||||
matchedDoc?.name || t('agents.form.externalKb')
|
||||
);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectSources')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Dropdown
|
||||
options={chunks}
|
||||
selectedValue={agent.chunks ? agent.chunks : null}
|
||||
onSelect={(value: string) =>
|
||||
<Select
|
||||
value={agent.chunks || undefined}
|
||||
onValueChange={(value) =>
|
||||
setAgent({ ...agent, chunks: value })
|
||||
}
|
||||
size="w-full"
|
||||
rounded="3xl"
|
||||
placeholder={t('agents.form.placeholders.chunksPerQuery')}
|
||||
contentSize="text-sm"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('agents.form.placeholders.chunksPerQuery')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chunks.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<div className="flex flex-wrap items-end gap-1">
|
||||
<div className="min-w-20 grow basis-full sm:basis-0">
|
||||
<Prompts
|
||||
@@ -961,127 +977,120 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
title={t('agents.form.sections.prompt')}
|
||||
titleClassName="text-lg font-semibold"
|
||||
showAddButton={false}
|
||||
dropdownProps={{
|
||||
size: 'w-full',
|
||||
rounded: '3xl',
|
||||
contentSize: 'text-sm',
|
||||
}}
|
||||
dropdownProps={{ className: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="border-primary text-primary hover:bg-primary/90 min-w-20 shrink-0 basis-full rounded-3xl border border-solid px-5 py-3 text-sm whitespace-nowrap transition-colors hover:text-white sm:basis-auto"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setAddPromptModal('ACTIVE')}
|
||||
className="border-primary text-primary hover:bg-primary/90 h-auto min-w-20 shrink-0 basis-full rounded-3xl border border-solid bg-transparent px-5 py-3 whitespace-nowrap hover:text-white sm:basis-auto"
|
||||
>
|
||||
{t('agents.form.buttons.add')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.tools')}
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
ref={toolAnchorButtonRef}
|
||||
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
|
||||
className={`border-border bg-card dark:border-border w-full truncate rounded-3xl border px-5 py-3 text-left text-sm ${
|
||||
selectedTools.length > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{selectedTools.length > 0
|
||||
? selectedTools
|
||||
.map((tool) => getToolDisplayName(tool))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectTools')}
|
||||
</button>
|
||||
<MultiSelectPopup
|
||||
isOpen={isToolsPopupOpen}
|
||||
onClose={() => setIsToolsPopupOpen(false)}
|
||||
anchorRef={toolAnchorButtonRef}
|
||||
options={userTools}
|
||||
selectedIds={new Set(selectedTools.map((tool) => tool.id))}
|
||||
onSelectionChange={(newSelectedIds: Set<string | number>) =>
|
||||
setSelectedTools(
|
||||
userTools
|
||||
.filter((tool) => newSelectedIds.has(tool.id))
|
||||
.map((tool) => ({
|
||||
id: String(tool.id),
|
||||
name:
|
||||
typeof tool.name === 'string'
|
||||
? tool.name
|
||||
: tool.label,
|
||||
display_name: tool.label,
|
||||
})),
|
||||
)
|
||||
}
|
||||
<MultiSelectPopover
|
||||
open={isToolsPopupOpen}
|
||||
onOpenChange={setIsToolsPopupOpen}
|
||||
title={t('agents.form.toolsPopup.title')}
|
||||
items={userTools}
|
||||
selectedIds={selectedTools.map((tool) => tool.id)}
|
||||
onToggle={(id) => {
|
||||
const exists = selectedTools.find((t) => t.id === id);
|
||||
if (exists) {
|
||||
setSelectedTools(selectedTools.filter((t) => t.id !== id));
|
||||
return;
|
||||
}
|
||||
const item = userTools.find((t) => t.id === id);
|
||||
const raw = rawUserTools.find((t) => t.id === id);
|
||||
if (!item) return;
|
||||
setSelectedTools([
|
||||
...selectedTools,
|
||||
{
|
||||
id: item.id,
|
||||
name: raw?.name || item.label,
|
||||
display_name: item.label,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
searchPlaceholder={t(
|
||||
'agents.form.toolsPopup.searchPlaceholder',
|
||||
)}
|
||||
noOptionsMessage={t('agents.form.toolsPopup.noOptionsMessage')}
|
||||
emptyMessage={t('agents.form.toolsPopup.noOptionsMessage')}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
ref={toolAnchorButtonRef}
|
||||
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
|
||||
selectedTools.length > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{selectedTools.length > 0
|
||||
? selectedTools
|
||||
.map((tool) => getToolDisplayName(tool))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectTools')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.agentType')}
|
||||
</h2>
|
||||
<div className="mt-3">
|
||||
<Dropdown
|
||||
options={agentTypes}
|
||||
selectedValue={
|
||||
agent.agent_type
|
||||
? agentTypes.find((type) => type.value === agent.agent_type)
|
||||
?.label || null
|
||||
: null
|
||||
<Select
|
||||
value={agent.agent_type || undefined}
|
||||
onValueChange={(value) =>
|
||||
setAgent({ ...agent, agent_type: value })
|
||||
}
|
||||
onSelect={(option: { label: string; value: string }) =>
|
||||
setAgent({ ...agent, agent_type: option.value })
|
||||
}
|
||||
size="w-full"
|
||||
rounded="3xl"
|
||||
placeholder={t('agents.form.placeholders.selectType')}
|
||||
contentSize="text-sm"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t('agents.form.placeholders.selectType')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.models')}
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<button
|
||||
ref={modelAnchorButtonRef}
|
||||
onClick={() => setIsModelsPopupOpen(!isModelsPopupOpen)}
|
||||
className={`border-border bg-card dark:border-border w-full truncate rounded-3xl border px-5 py-3 text-left text-sm ${
|
||||
selectedModelIds.size > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{selectedModelIds.size > 0
|
||||
? availableModels
|
||||
.filter((m) => selectedModelIds.has(m.id))
|
||||
.map((m) => m.display_name)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectModels')}
|
||||
</button>
|
||||
<MultiSelectPopup
|
||||
isOpen={isModelsPopupOpen}
|
||||
onClose={() => setIsModelsPopupOpen(false)}
|
||||
anchorRef={modelAnchorButtonRef}
|
||||
options={(() => {
|
||||
<MultiSelectPopover
|
||||
open={isModelsPopupOpen}
|
||||
onOpenChange={setIsModelsPopupOpen}
|
||||
title={t('agents.form.modelsPopup.title')}
|
||||
items={(() => {
|
||||
const builtinLabel = t(
|
||||
'settings.customModels.modelsGroup.builtin',
|
||||
);
|
||||
const userLabel = t('settings.customModels.modelsGroup.user');
|
||||
const builtin: OptionType[] = [];
|
||||
const user: OptionType[] = [];
|
||||
const builtin: MultiSelectPopoverItem[] = [];
|
||||
const user: MultiSelectPopoverItem[] = [];
|
||||
availableModels.forEach((model) => {
|
||||
const opt: OptionType = {
|
||||
const opt: MultiSelectPopoverItem = {
|
||||
id: model.id,
|
||||
label: model.display_name,
|
||||
group: model.source === 'user' ? userLabel : builtinLabel,
|
||||
@@ -1091,55 +1100,80 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
});
|
||||
return [...builtin, ...user];
|
||||
})()}
|
||||
selectedIds={selectedModelIds}
|
||||
onSelectionChange={(newSelectedIds: Set<string | number>) =>
|
||||
setSelectedModelIds(
|
||||
new Set(Array.from(newSelectedIds).map(String)),
|
||||
)
|
||||
}
|
||||
title={t('agents.form.modelsPopup.title')}
|
||||
selectedIds={Array.from(selectedModelIds)}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedModelIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedModelIds(next);
|
||||
}}
|
||||
searchPlaceholder={t(
|
||||
'agents.form.modelsPopup.searchPlaceholder',
|
||||
)}
|
||||
noOptionsMessage={t('agents.form.modelsPopup.noOptionsMessage')}
|
||||
emptyMessage={t('agents.form.modelsPopup.noOptionsMessage')}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
ref={modelAnchorButtonRef}
|
||||
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
|
||||
selectedModelIds.size > 0
|
||||
? 'text-foreground dark:text-foreground'
|
||||
: 'dark:text-muted-foreground text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{selectedModelIds.size > 0
|
||||
? availableModels
|
||||
.filter((m) => selectedModelIds.has(m.id))
|
||||
.map((m) => m.display_name)
|
||||
.join(', ')
|
||||
: t('agents.form.placeholders.selectModels')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{selectedModelIds.size > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
{t('agents.form.labels.defaultModel')}
|
||||
</label>
|
||||
<Dropdown
|
||||
options={availableModels
|
||||
.filter((m) => selectedModelIds.has(m.id))
|
||||
.map((m) => ({
|
||||
label: m.display_name,
|
||||
value: m.id,
|
||||
}))}
|
||||
selectedValue={
|
||||
availableModels.find(
|
||||
(m) => m.id === agent.default_model_id,
|
||||
)?.display_name || null
|
||||
<Select
|
||||
value={agent.default_model_id || undefined}
|
||||
onValueChange={(value) =>
|
||||
setAgent({ ...agent, default_model_id: value })
|
||||
}
|
||||
onSelect={(option: { label: string; value: string }) =>
|
||||
setAgent({ ...agent, default_model_id: option.value })
|
||||
}
|
||||
size="w-full"
|
||||
rounded="3xl"
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.selectDefaultModel',
|
||||
)}
|
||||
contentSize="text-sm"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full rounded-3xl px-5 py-3 text-sm"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.selectDefaultModel',
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels
|
||||
.filter((m) => selectedModelIds.has(m.id))
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card rounded-[30px] px-6 py-3">
|
||||
<button
|
||||
<div className="bg-card rounded-2xl px-6 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
|
||||
}
|
||||
className="flex w-full items-center justify-between text-left focus:outline-none"
|
||||
className="h-auto w-full justify-between px-0 py-0 text-left hover:bg-transparent"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
@@ -1148,7 +1182,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<svg
|
||||
className={`h-5 w-5 transform transition-transform duration-200 ${
|
||||
className={`size-5 transform transition-transform duration-200 ${
|
||||
isAdvancedSectionExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
@@ -1163,7 +1197,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
{isAdvancedSectionExpanded && (
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
@@ -1187,7 +1221,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
"additionalProperties": false
|
||||
}`}
|
||||
rows={9}
|
||||
className={`border-border text-foreground dark:text-foreground bg-card dark:border-border mt-2 w-full rounded-2xl border px-4 py-3 font-mono text-sm outline-hidden`}
|
||||
className={`border-border text-foreground dark:text-foreground bg-card dark:border-border focus-visible:ring-ring/50 focus-visible:border-ring mt-2 w-full rounded-2xl border px-4 py-3 font-mono text-sm outline-hidden focus-visible:ring-[3px]`}
|
||||
/>
|
||||
{jsonSchemaText.trim() !== '' && (
|
||||
<div
|
||||
@@ -1220,31 +1254,20 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.advanced.tokenLimitingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newTokenMode = !agent.limited_token_mode;
|
||||
<Switch
|
||||
checked={agent.limited_token_mode}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
limited_token_mode: newTokenMode,
|
||||
limited_request_mode: newTokenMode
|
||||
limited_token_mode: checked,
|
||||
limited_request_mode: checked
|
||||
? false
|
||||
: agent.limited_request_mode,
|
||||
});
|
||||
}}
|
||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||
agent.limited_token_mode
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
agent.limited_token_mode ? '' : '-translate-x-5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={agent.token_limit || ''}
|
||||
@@ -1258,7 +1281,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
disabled={!agent.limited_token_mode}
|
||||
placeholder={t('agents.form.placeholders.enterTokenLimit')}
|
||||
className={`border-border text-foreground dark:text-foreground dark:placeholder:text-silver bg-card dark:border-border mt-2 w-full rounded-3xl border px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 ${
|
||||
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
|
||||
!agent.limited_token_mode
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
@@ -1276,31 +1299,20 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.advanced.requestLimitingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRequestMode = !agent.limited_request_mode;
|
||||
<Switch
|
||||
checked={agent.limited_request_mode}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
limited_request_mode: newRequestMode,
|
||||
limited_token_mode: newRequestMode
|
||||
limited_request_mode: checked,
|
||||
limited_token_mode: checked
|
||||
? false
|
||||
: agent.limited_token_mode,
|
||||
});
|
||||
}}
|
||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||
agent.limited_request_mode
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
agent.limited_request_mode ? '' : '-translate-x-5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={agent.request_limit || ''}
|
||||
@@ -1316,7 +1328,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterRequestLimit',
|
||||
)}
|
||||
className={`border-border text-foreground dark:text-foreground dark:placeholder:text-silver bg-card dark:border-border mt-2 w-full rounded-3xl border px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 ${
|
||||
className={`bg-card mt-2 h-auto rounded-3xl px-5 py-3 text-sm placeholder:text-gray-400 md:text-sm ${
|
||||
!agent.limited_request_mode
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
@@ -1336,35 +1348,23 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
<Switch
|
||||
className="shrink-0"
|
||||
checked={agent.allow_system_prompt_override}
|
||||
onCheckedChange={(checked) =>
|
||||
setAgent({
|
||||
...agent,
|
||||
allow_system_prompt_override:
|
||||
!agent.allow_system_prompt_override,
|
||||
allow_system_prompt_override: checked,
|
||||
})
|
||||
}
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||
agent.allow_system_prompt_override
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
agent.allow_system_prompt_override
|
||||
? ''
|
||||
: '-translate-x-5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{modeConfig[effectiveMode].showDelete && agent.id && (
|
||||
<div className="border-destructive/40 bg-destructive/5 rounded-[30px] border px-6 py-4">
|
||||
<div className="border-destructive/40 bg-destructive/5 rounded-2xl border px-6 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-destructive text-lg font-semibold">
|
||||
@@ -1376,10 +1376,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0"
|
||||
className="shrink-0"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
@@ -1388,9 +1388,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2 max-[1179px]:h-auto max-[1179px]:px-0 max-[1179px]:py-0 min-[1180px]:h-full min-[1180px]:py-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.form.sections.preview')}
|
||||
</h2>
|
||||
<div className="flex-1 max-[1179px]:overflow-visible min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
|
||||
<AgentPreviewArea />
|
||||
</div>
|
||||
@@ -1430,9 +1427,9 @@ function AgentPreviewArea() {
|
||||
const { t } = useTranslation();
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
return (
|
||||
<div className="bg-card border-border w-full rounded-[30px] border max-[1179px]:h-[600px] min-[1180px]:h-full">
|
||||
<div className="bg-card border-border w-full rounded-2xl border max-[1179px]:h-[600px] min-[1180px]:h-full">
|
||||
{selectedAgent?.status === 'published' ? (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-[30px]">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl">
|
||||
<AgentPreview />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useParams } from 'react-router-dom';
|
||||
import userService from '../api/services/userService';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import AgentImage from '../components/AgentImage';
|
||||
import MessageInput from '../components/MessageInput';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import Spinner from '../components/Spinner';
|
||||
import ConversationMessages from '../conversation/ConversationMessages';
|
||||
import { Query } from '../conversation/conversationModels';
|
||||
@@ -152,10 +152,10 @@ export default function SharedAgent() {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute top-5 left-4 hidden items-center gap-3 sm:flex">
|
||||
<AgentImage
|
||||
<Avatar
|
||||
src={sharedAgent.image}
|
||||
alt="agent-logo"
|
||||
className="h-6 w-6 rounded-full object-contain"
|
||||
imgClassName="h-6 w-6 rounded-full object-contain"
|
||||
/>
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{sharedAgent.name}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EditIcon from '../assets/edit.svg';
|
||||
import AgentImage from '../components/AgentImage';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { getToolDisplayName } from '../utils/toolUtils';
|
||||
import { Agent } from './types';
|
||||
|
||||
@@ -23,9 +24,9 @@ export default function SharedAgentCard({
|
||||
<div className="border-border dark:border-border flex w-full max-w-[720px] flex-col rounded-3xl border p-6 shadow-xs sm:w-fit sm:min-w-[480px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
|
||||
<AgentImage
|
||||
<Avatar
|
||||
src={agent.image}
|
||||
className="h-full w-full rounded-full object-contain"
|
||||
imgClassName="h-full w-full rounded-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-[92px] flex-1 flex-col gap-px">
|
||||
@@ -37,15 +38,17 @@ export default function SharedAgentCard({
|
||||
</p>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="border-border hover:bg-accent text-foreground flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
className="shrink-0 gap-1.5 rounded-full"
|
||||
aria-label={t('agents.edit')}
|
||||
>
|
||||
<img src={EditIcon} alt="" className="h-3.5 w-3.5" />
|
||||
{t('agents.edit')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{hasSharedMetadata && (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Bot, Workflow, X } from 'lucide-react';
|
||||
import { Bot, Workflow } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
|
||||
interface AgentTypeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -14,8 +16,6 @@ export default function AgentTypeModal({
|
||||
}: AgentTypeModalProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelect = (type: 'normal' | 'workflow') => {
|
||||
if (type === 'workflow') {
|
||||
navigate(
|
||||
@@ -28,66 +28,52 @@ export default function AgentTypeModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||||
onClick={onClose}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title="Create New Agent"
|
||||
description="Choose the type of agent you want to create"
|
||||
size="md"
|
||||
>
|
||||
<div
|
||||
className="bg-card relative w-full max-w-lg rounded-xl p-8 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-5 right-5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
|
||||
type="button"
|
||||
onClick={() => handleSelect('normal')}
|
||||
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<X size={20} />
|
||||
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary/90 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Bot size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
|
||||
Classic Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Create a standard AI agent with a single model, tools, and
|
||||
knowledge sources
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<h2 className="text-foreground dark:text-foreground mb-3 text-2xl font-bold">
|
||||
Create New Agent
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
Choose the type of agent you want to create
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
onClick={() => handleSelect('normal')}
|
||||
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all"
|
||||
>
|
||||
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary/90 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Bot size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
|
||||
Classic Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Create a standard AI agent with a single model, tools, and
|
||||
knowledge sources
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelect('workflow')}
|
||||
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all"
|
||||
>
|
||||
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Workflow size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
|
||||
Workflow Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Design complex multi-step workflows with different models,
|
||||
conditional logic, and state management
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect('workflow')}
|
||||
className="hover:border-primary hover:bg-primary/5 dark:hover:border-primary dark:hover:bg-primary/10 focus-visible:border-ring focus-visible:ring-ring/50 group dark:border-border flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<div className="dark:bg-primary/20 bg-primary/10 text-primary group-hover:bg-primary flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Workflow size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground dark:text-foreground mb-2 text-lg font-semibold">
|
||||
Workflow Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Design complex multi-step workflows with different models,
|
||||
conditional logic, and state management
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Button } from '../../components/ui/button';
|
||||
import type { ScheduleRun } from '../types/schedule';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
|
||||
@@ -44,13 +45,15 @@ export default function RunDetailDrawer({
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Run details</h2>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="border-border text-muted-foreground rounded-md border px-3 py-1 text-sm"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<dl className="mb-4 grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">Status</dt>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import type { AppDispatch, RootState } from '../../store';
|
||||
import type { ScheduleRun } from '../types/schedule';
|
||||
@@ -69,13 +70,15 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
<td className="py-2">{run.trigger_source}</td>
|
||||
<td className="py-2">
|
||||
{onSelect && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => onSelect(run)}
|
||||
className="text-primary text-xs underline"
|
||||
className="h-auto p-0 text-xs underline"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import WrapperModal from '../../modals/WrapperModal';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import type { Schedule, ScheduleCreatePayload } from '../types/schedule';
|
||||
import {
|
||||
browserTimezone,
|
||||
@@ -145,8 +145,6 @@ export default function ScheduleFormModal({
|
||||
}, [timezone]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const setFrequency = (frequency: ScheduleFrequency) =>
|
||||
setValues((current) => ({ ...current, frequency }));
|
||||
|
||||
@@ -191,11 +189,18 @@ export default function ScheduleFormModal({
|
||||
const isEdit = Boolean(initial?.id);
|
||||
|
||||
return (
|
||||
<WrapperModal
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
hideTitle
|
||||
title={
|
||||
isEdit
|
||||
? t('agents.schedules.modal.save')
|
||||
: t('agents.schedules.modal.create')
|
||||
}
|
||||
size="md"
|
||||
className="w-[min(560px,92vw)] sm:p-6"
|
||||
contentClassName="max-h-[80vh]"
|
||||
close={onClose}
|
||||
isPerformingTask={submitting}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-start gap-3 pr-6">
|
||||
@@ -261,7 +266,7 @@ export default function ScheduleFormModal({
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
placeholder={t('agents.schedules.modal.instructionsPlaceholder')}
|
||||
rows={5}
|
||||
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:border-ring focus:ring-ring/40 rounded-md border px-3 py-2 text-sm outline-none focus:ring-2"
|
||||
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -272,7 +277,7 @@ export default function ScheduleFormModal({
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={submit}
|
||||
className="rounded-full px-5"
|
||||
className="rounded-3xl px-5"
|
||||
>
|
||||
{submitting
|
||||
? '…'
|
||||
@@ -282,7 +287,7 @@ export default function ScheduleFormModal({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</WrapperModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,20 +303,22 @@ function FrequencyTabs({ frequency, onChange, labels }: FrequencyTabsProps) {
|
||||
{FREQUENCIES.map((f) => {
|
||||
const active = f === frequency;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange(f)}
|
||||
className={cn(
|
||||
'flex-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
'h-auto flex-1 rounded-full px-3 py-1.5 text-xs font-medium',
|
||||
active
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
? 'bg-card text-foreground hover:bg-card shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-transparent',
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{labels[f]}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -473,7 +480,7 @@ function DatePicker({ value, onChange, placeholder }: DatePickerProps) {
|
||||
{value ? formatDateLabel(value) : placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* z-200 keeps the popover above WrapperModal (z-100); matches SelectContent. */}
|
||||
{/* z-200 keeps the popover above Modal (z-50); matches SelectContent. */}
|
||||
<PopoverContent className="z-200 w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import type { AppDispatch } from '../../store';
|
||||
import { deleteSchedule, loadSchedulesForAgent } from './schedulesSlice';
|
||||
@@ -160,13 +161,15 @@ export default function SchedulerToolCallCard({
|
||||
</p>
|
||||
)}
|
||||
{taskId && !cancelled && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancel}
|
||||
className="text-destructive border-border mt-2 rounded-md border px-3 py-1 text-xs"
|
||||
className="text-destructive mt-2 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{cancelled && (
|
||||
<p className="text-muted-foreground mt-2 text-xs">Cancelled.</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../../api/services/userService';
|
||||
import Spinner from '../../components/Spinner';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import ConfirmationModal from '../../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../../models/misc';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
@@ -112,7 +113,6 @@ export default function SchedulesView() {
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (submitting) return;
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export default function SchedulesView() {
|
||||
: `/agents/edit/${agentId}`;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-12">
|
||||
<div className="p-4 pt-4 md:p-12 md:pt-4">
|
||||
<AgentPageHeader
|
||||
agentId={agentId}
|
||||
agentName={agent?.name}
|
||||
@@ -187,13 +187,9 @@ export default function SchedulesView() {
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('agents.schedules.heading')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm"
|
||||
>
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</button>
|
||||
</Button>
|
||||
</header>
|
||||
<section>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-semibold uppercase">
|
||||
@@ -226,15 +222,17 @@ export default function SchedulesView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openEdit(schedule)}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid px-5 py-1 text-sm transition-colors hover:text-white"
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setSchedulePaused({
|
||||
@@ -247,45 +245,50 @@ export default function SchedulesView() {
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid px-5 py-1 text-sm transition-colors hover:text-white"
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{schedule.status === 'active'
|
||||
? t('agents.schedules.pause')
|
||||
: t('agents.schedules.resume')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
runScheduleNow({ id: schedule.id, token }),
|
||||
)
|
||||
}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid px-5 py-1 text-sm transition-colors hover:text-white"
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{t('agents.schedules.runNow')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
className="rounded-full border border-solid border-red-500 px-5 py-1 text-sm text-red-500 transition-colors hover:bg-red-500 hover:text-white"
|
||||
className="rounded-full px-5"
|
||||
>
|
||||
{t('agents.schedules.delete')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpanded(
|
||||
expanded === schedule.id ? null : schedule.id,
|
||||
)
|
||||
}
|
||||
className="text-primary mt-2 text-xs underline"
|
||||
className="mt-2 h-auto p-0 text-xs underline"
|
||||
>
|
||||
{expanded === schedule.id
|
||||
? t('agents.schedules.hideRuns')
|
||||
: t('agents.schedules.showRuns')}
|
||||
</button>
|
||||
</Button>
|
||||
{expanded === schedule.id && (
|
||||
<div className="mt-2">
|
||||
<RunLog
|
||||
@@ -329,22 +332,25 @@ export default function SchedulesView() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{schedule.status === 'active' && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openEdit(schedule)}
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid px-5 py-1 text-sm transition-colors hover:text-white"
|
||||
className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{schedule.status === 'active' && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
className="rounded-full border border-solid border-red-500 px-5 py-1 text-sm text-red-500 transition-colors hover:bg-red-500 hover:text-white"
|
||||
className="rounded-full px-5"
|
||||
>
|
||||
{t('agents.schedules.cancel')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ScheduleStatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[11px] leading-[16px] font-medium',
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium',
|
||||
getStatusClasses(status),
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function TimezoneCombobox({
|
||||
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* z-200 keeps the popover above WrapperModal (z-100); matches DatePicker. */}
|
||||
{/* z-200 keeps the popover above Modal (z-50); matches DatePicker. */}
|
||||
<PopoverContent
|
||||
className="z-200 w-[min(20rem,calc(100vw-2rem))] p-0"
|
||||
align="start"
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Settings2,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -37,6 +36,8 @@ import ReactFlow, {
|
||||
} from 'reactflow';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import {
|
||||
Select,
|
||||
@@ -1381,15 +1382,17 @@ function WorkflowBuilderInner() {
|
||||
inline
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={navigateBackToAgents}
|
||||
className="border-border text-muted-foreground hover:bg-accent rounded-full border px-4 py-2 text-sm"
|
||||
className="text-muted-foreground rounded-full px-4 py-2 text-sm font-normal shadow-none"
|
||||
>
|
||||
{t('agents.backToAll')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<div className="group relative flex items-center gap-2">
|
||||
<div>
|
||||
{!canManageAgent && (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="max-w-xs truncate text-xl font-bold text-gray-900 dark:text-white"
|
||||
title={workflowName || 'New Workflow'}
|
||||
@@ -1398,19 +1401,30 @@ function WorkflowBuilderInner() {
|
||||
</div>
|
||||
{workflowDescription && (
|
||||
<div
|
||||
className="max-w-xs truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
className="text-muted-foreground max-w-xs truncate text-xs"
|
||||
title={workflowDescription}
|
||||
>
|
||||
{workflowDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowWorkflowSettings(!showWorkflowSettings)}
|
||||
className="text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground size-auto p-1"
|
||||
aria-label="Workflow settings"
|
||||
title={
|
||||
workflowDescription
|
||||
? `${workflowName || 'New Workflow'} — ${workflowDescription}`
|
||||
: 'Edit workflow details'
|
||||
}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
{showWorkflowSettings && (
|
||||
<div
|
||||
ref={workflowSettingsRef}
|
||||
@@ -1420,11 +1434,11 @@ function WorkflowBuilderInner() {
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Workflow Name
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={workflowName}
|
||||
onChange={(e) => setWorkflowName(e.target.value)}
|
||||
className="focus:ring-ring border-border bg-card w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card h-auto rounded-lg px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Enter workflow name"
|
||||
/>
|
||||
</div>
|
||||
@@ -1435,7 +1449,7 @@ function WorkflowBuilderInner() {
|
||||
<textarea
|
||||
value={workflowDescription}
|
||||
onChange={(e) => setWorkflowDescription(e.target.value)}
|
||||
className="focus:ring-ring border-border bg-card w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 dark:text-white"
|
||||
className="focus-visible:ring-ring/50 focus-visible:border-ring border-border bg-card w-full rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={3}
|
||||
placeholder="Describe what this workflow does"
|
||||
/>
|
||||
@@ -1474,7 +1488,7 @@ function WorkflowBuilderInner() {
|
||||
]}
|
||||
className="border-border rounded-lg border-2 border-dashed p-3 text-center transition-colors"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Image updates are included the next time you save.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1484,7 +1498,7 @@ function WorkflowBuilderInner() {
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('agents.form.advanced.systemPromptOverride')}
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'agents.form.advanced.systemPromptOverrideDescription',
|
||||
)}
|
||||
@@ -1514,45 +1528,45 @@ function WorkflowBuilderInner() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleWorkflowSettingsDone}
|
||||
disabled={isPublishing}
|
||||
className="bg-primary hover:bg-primary/90 w-full rounded-lg px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-lg text-white"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowWorkflowSettings((prev) => !prev)}
|
||||
className="border-border bg-card hover:bg-accent flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium text-gray-700 transition-colors dark:text-gray-200"
|
||||
>
|
||||
<Settings2 size={16} />
|
||||
Details
|
||||
</button>
|
||||
{canManageAgent && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAgentDetails('ACTIVE')}
|
||||
className="border-border bg-card hover:bg-accent flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium text-gray-700 transition-colors dark:text-gray-200"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<Link size={16} />
|
||||
Access Details
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{canManageAgent && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
disabled={isDeletingAgent}
|
||||
className="bg-card flex items-center gap-2 rounded-full border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10"
|
||||
className="bg-card rounded-full border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-600 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{isDeletingAgent ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const validationErrors = validateWorkflow();
|
||||
if (validationErrors.length > 0) {
|
||||
@@ -1562,18 +1576,19 @@ function WorkflowBuilderInner() {
|
||||
}
|
||||
setShowPreview(true);
|
||||
}}
|
||||
className="border-border bg-card hover:bg-accent flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium text-gray-700 transition-colors dark:text-gray-200"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<Play size={16} />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
className={`relative inline-flex items-center justify-center rounded-full px-6 py-2 text-sm font-medium shadow-sm transition-colors disabled:cursor-not-allowed ${
|
||||
className={`relative rounded-full px-6 py-2 text-sm font-medium shadow-sm ${
|
||||
canManageAgent && !hasSavableChanges
|
||||
? 'dark:bg-accent bg-gray-200 text-gray-500 dark:text-gray-400'
|
||||
: 'bg-primary hover:bg-primary/90 text-white disabled:opacity-50'
|
||||
? 'dark:bg-accent bg-gray-200 text-gray-500 hover:bg-gray-200 dark:text-gray-400'
|
||||
: 'text-white'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -1586,7 +1601,7 @@ function WorkflowBuilderInner() {
|
||||
{showPrimaryActionSpinner ? (
|
||||
<Loader2 size={16} className="absolute animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1611,12 +1626,15 @@ function WorkflowBuilderInner() {
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPublishErrors([])}
|
||||
className="absolute top-4 right-4 text-red-700 hover:text-red-900 dark:text-red-300 dark:hover:text-red-100"
|
||||
className="absolute top-4 right-4 size-auto p-0 text-red-700 hover:bg-transparent hover:text-red-900 dark:text-red-300 dark:hover:bg-transparent dark:hover:text-red-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
@@ -1633,7 +1651,7 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'agent')}
|
||||
>
|
||||
<div className="text-primary group-hover:bg-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-100 transition-colors group-hover:text-white">
|
||||
<div className="text-primary group-hover:bg-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-100 transition-colors group-hover:text-white dark:bg-purple-900/40">
|
||||
<Bot size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
@@ -1645,7 +1663,7 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'end')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 transition-colors group-hover:bg-green-600 group-hover:text-white">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 transition-colors group-hover:bg-green-600 group-hover:text-white dark:bg-green-900/40 dark:text-green-300">
|
||||
<Flag size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
@@ -1657,7 +1675,7 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'note')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-600 transition-colors group-hover:bg-yellow-500 group-hover:text-white">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-600 transition-colors group-hover:bg-yellow-500 group-hover:text-white dark:bg-yellow-900/40 dark:text-yellow-300">
|
||||
<StickyNote size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
@@ -1677,14 +1695,14 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'state')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Set State
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Modify workflow variables
|
||||
</span>
|
||||
</div>
|
||||
@@ -1694,14 +1712,14 @@ function WorkflowBuilderInner() {
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, 'condition')}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 transition-colors group-hover:bg-orange-600 group-hover:text-white">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 transition-colors group-hover:bg-orange-600 group-hover:text-white dark:bg-orange-900/40 dark:text-orange-300">
|
||||
<GitBranch size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
If / Else
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Conditional branching
|
||||
</span>
|
||||
</div>
|
||||
@@ -1738,7 +1756,7 @@ function WorkflowBuilderInner() {
|
||||
className="absolute inset-0 z-10"
|
||||
onClick={handlePanelBackdropClick}
|
||||
/>
|
||||
<div className="border-border bg-card absolute top-4 right-4 z-20 w-96 rounded-2xl border shadow-[0px_4px_40px_-3px_#0000001A]">
|
||||
<div className="border-border bg-card shadow-modal absolute top-4 right-4 z-20 w-96 rounded-2xl border">
|
||||
<div className="border-border flex items-center justify-between border-b p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedNode.type === 'start' && 'Start Node'}
|
||||
@@ -1748,12 +1766,15 @@ function WorkflowBuilderInner() {
|
||||
{selectedNode.type === 'state' && 'Set global variables'}
|
||||
{selectedNode.type === 'condition' && 'If / Else'}
|
||||
</h3>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowNodeConfig(false)}
|
||||
className="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
|
||||
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-gray-600 dark:hover:bg-transparent dark:hover:text-gray-200 [&_svg:not([class*='size-'])]:size-5"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-y-auto p-4">
|
||||
@@ -1774,7 +1795,7 @@ function WorkflowBuilderInner() {
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={
|
||||
selectedNode.data.title ||
|
||||
@@ -1787,7 +1808,7 @@ function WorkflowBuilderInner() {
|
||||
label: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-border focus:ring-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Enter node title"
|
||||
/>
|
||||
</div>
|
||||
@@ -1923,7 +1944,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border-border focus:ring-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={3}
|
||||
placeholder="System prompt for the agent"
|
||||
/>
|
||||
@@ -1951,7 +1972,7 @@ function WorkflowBuilderInner() {
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Output Variable
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={
|
||||
selectedNode.data.config
|
||||
@@ -1966,7 +1987,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Variable name for output"
|
||||
/>
|
||||
</div>
|
||||
@@ -2059,7 +2080,7 @@ function WorkflowBuilderInner() {
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="border-border focus:ring-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 font-mono text-xs transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={8}
|
||||
placeholder={`{
|
||||
"type": "object",
|
||||
@@ -2099,7 +2120,7 @@ function WorkflowBuilderInner() {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-border focus:ring-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={4}
|
||||
placeholder="Enter note content"
|
||||
/>
|
||||
@@ -2134,7 +2155,10 @@ function WorkflowBuilderInner() {
|
||||
selectedNode.data.config
|
||||
?.operations || []
|
||||
).length > 1 && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const ops = [
|
||||
...(selectedNode.data.config
|
||||
@@ -2149,10 +2173,10 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="text-gray-400 transition-colors hover:text-red-500"
|
||||
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-red-500 dark:hover:bg-transparent"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
@@ -2174,11 +2198,11 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card dark:bg-accent mb-1 w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card dark:bg-accent mb-1 w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={2}
|
||||
placeholder="input.foo + 1"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 text-[10px]">
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Use Common Expression Language to create
|
||||
a custom expression.{' '}
|
||||
<a
|
||||
@@ -2194,7 +2218,7 @@ function WorkflowBuilderInner() {
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
To variable
|
||||
</span>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={op.target_variable}
|
||||
onChange={(e) => {
|
||||
@@ -2214,14 +2238,17 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="variable_name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ops = [
|
||||
...(selectedNode.data.config
|
||||
@@ -2235,11 +2262,11 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="hover:bg-accent flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors dark:text-gray-400"
|
||||
className="h-auto gap-1 self-start rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2249,7 +2276,9 @@ function WorkflowBuilderInner() {
|
||||
Create conditions to branch your workflow
|
||||
</p>
|
||||
<div className="border-border flex overflow-hidden rounded-lg border">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
@@ -2258,16 +2287,18 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
className={`h-auto flex-1 rounded-none px-3 py-1.5 text-xs font-medium ${
|
||||
(selectedNode.data.config?.mode ||
|
||||
'simple') === 'simple'
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:bg-accent text-gray-600 dark:text-gray-400'
|
||||
? 'bg-primary hover:bg-primary text-white hover:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Simple
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
@@ -2276,15 +2307,15 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
className={`h-auto flex-1 rounded-none px-3 py-1.5 text-xs font-medium ${
|
||||
selectedNode.data.config?.mode ===
|
||||
'advanced'
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:bg-accent text-gray-600 dark:text-gray-400'
|
||||
? 'bg-primary hover:bg-primary text-white hover:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Advanced
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(selectedNode.data.config?.cases || []).map(
|
||||
@@ -2299,7 +2330,10 @@ function WorkflowBuilderInner() {
|
||||
</span>
|
||||
{(selectedNode.data.config?.cases || [])
|
||||
.length > 1 && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const cases =
|
||||
normalizeConditionCases([
|
||||
@@ -2330,13 +2364,13 @@ function WorkflowBuilderInner() {
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 transition-colors hover:text-red-500"
|
||||
className="size-auto p-0 text-gray-400 hover:bg-transparent hover:text-red-500 dark:hover:bg-transparent"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={c.name || ''}
|
||||
onChange={(e) => {
|
||||
@@ -2356,13 +2390,13 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card dark:bg-accent mb-2 w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card dark:bg-accent mb-2 h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Case name (optional)"
|
||||
/>
|
||||
{(selectedNode.data.config?.mode ||
|
||||
'simple') === 'simple' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={
|
||||
parseSimpleCel(c.expression)
|
||||
@@ -2392,7 +2426,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Variable"
|
||||
/>
|
||||
<Select
|
||||
@@ -2455,7 +2489,7 @@ function WorkflowBuilderInner() {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={
|
||||
parseSimpleCel(c.expression).value
|
||||
@@ -2484,7 +2518,7 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm outline-none focus:ring-2 dark:text-white"
|
||||
className="bg-card dark:bg-accent h-auto rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
@@ -2509,11 +2543,11 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="border-border focus:ring-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus:ring-2 dark:text-white"
|
||||
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card dark:bg-accent w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
|
||||
rows={2}
|
||||
placeholder="Enter condition, e.g. input == 5"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Use Common Expression Language to
|
||||
create a custom expression.{' '}
|
||||
<a
|
||||
@@ -2531,7 +2565,10 @@ function WorkflowBuilderInner() {
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const cases = normalizeConditionCases([
|
||||
...(selectedNode.data.config?.cases ||
|
||||
@@ -2551,27 +2588,29 @@ function WorkflowBuilderInner() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="hover:bg-accent flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors dark:text-gray-400"
|
||||
className="h-auto gap-1 self-start rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
onClick={handleDeleteNode}
|
||||
disabled={selectedNode?.type === 'start'}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10"
|
||||
className="w-full rounded-full border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-600 dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-900/10 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{selectedNode?.type === 'start'
|
||||
? 'Cannot Delete Start Node'
|
||||
: 'Delete Node'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import ChevronDownIcon from '../../assets/chevron-down.svg';
|
||||
@@ -98,7 +99,12 @@ function ExecutionDetails({
|
||||
<div className="flex h-[26px] w-[30px] items-center justify-center">
|
||||
<Workflow className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<button className="flex flex-row items-center gap-2" onClick={onToggle}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onToggle}
|
||||
className="h-auto gap-2 px-0 py-0 hover:bg-transparent"
|
||||
>
|
||||
<p className="text-base font-semibold">
|
||||
Execution Details
|
||||
<span className="ml-1.5 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
@@ -114,7 +120,7 @@ function ExecutionDetails({
|
||||
isOpen ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -318,13 +324,15 @@ function WorkflowMiniMap({
|
||||
<div className="absolute top-12 left-4 h-3 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
|
||||
disabled={!hasStepData(node.id)}
|
||||
className={cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded-lg border px-3 text-xs transition-all',
|
||||
'h-12 w-full justify-start gap-2 px-3 text-xs disabled:opacity-100',
|
||||
getStatusColor(node.id),
|
||||
hasStepData(node.id) && 'cursor-pointer hover:opacity-80',
|
||||
hasStepData(node.id) && 'hover:opacity-80',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -340,7 +348,7 @@ function WorkflowMiniMap({
|
||||
{getNodeDisplayName(node)}
|
||||
</div>
|
||||
{getNodeSubtitle(node) && (
|
||||
<div className="truncate text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{getNodeSubtitle(node)}
|
||||
</div>
|
||||
)}
|
||||
@@ -356,7 +364,7 @@ function WorkflowMiniMap({
|
||||
<XCircle className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Braces, Plus, Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Edge, Node } from 'reactflow';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -224,12 +226,12 @@ function VariableListWithSearch({
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<div className="border-border flex items-center gap-2 border-b px-3 py-2">
|
||||
<Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search variables..."
|
||||
className="placeholder:text-muted-foreground w-full bg-transparent text-sm text-gray-800 outline-none dark:text-gray-200"
|
||||
className="h-auto rounded-none border-0 px-0 py-0 text-sm text-gray-800 shadow-none focus-visible:ring-0 md:text-sm dark:border-0 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -241,24 +243,26 @@ function VariableListWithSearch({
|
||||
) : (
|
||||
Array.from(grouped.entries()).map(([section, vars]) => (
|
||||
<div key={section}>
|
||||
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-[10px] font-semibold tracking-wider uppercase">
|
||||
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-xs font-semibold tracking-wider uppercase">
|
||||
{section}
|
||||
</div>
|
||||
{vars.map((v) => (
|
||||
<button
|
||||
<Button
|
||||
key={`${section}-${v.templatePath}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(v.templatePath);
|
||||
}}
|
||||
className="hover:bg-accent flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors"
|
||||
className="h-auto w-full justify-start gap-2 rounded-none px-3 py-1.5 text-left text-sm font-normal"
|
||||
>
|
||||
<Braces className="text-primary h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate font-medium text-gray-800 dark:text-gray-200">
|
||||
{v.label}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
@@ -448,7 +452,7 @@ export default function PromptTextArea({
|
||||
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
}}
|
||||
className="relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none dark:caret-white"
|
||||
className="focus-visible:ring-ring/50 focus-visible:border-ring relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none focus-visible:ring-[3px] dark:caret-white"
|
||||
style={{
|
||||
color: 'transparent',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
@@ -461,13 +465,14 @@ export default function PromptTextArea({
|
||||
<div className="absolute right-4 bottom-1.5 z-10">
|
||||
<Popover open={contextOpen} onOpenChange={setContextOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="text-primary hover:bg-primary/10 flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
|
||||
variant="ghost"
|
||||
className="text-primary hover:bg-primary/10 h-auto gap-1 px-2 py-1 text-xs font-medium"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add context
|
||||
</button>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
|
||||
@@ -61,7 +61,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase">
|
||||
<div className="text-xs text-gray-500 uppercase">
|
||||
{data.config?.mode || 'simple'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
|
||||
<div className="flex flex-col gap-1">
|
||||
{operations.length > 0 ? (
|
||||
<div
|
||||
className="truncate text-[10px] text-gray-500"
|
||||
className="truncate text-xs text-gray-500"
|
||||
title={`${operations.length} operation(s)`}
|
||||
>
|
||||
{operations.length} variable{operations.length !== 1 ? 's' : ''}
|
||||
@@ -39,7 +39,7 @@ const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
|
||||
) : hasLegacy ? (
|
||||
<>
|
||||
<div
|
||||
className="truncate text-[10px] text-gray-500 uppercase"
|
||||
className="truncate text-xs text-gray-500 uppercase"
|
||||
title={`Variable: ${data.variable}`}
|
||||
>
|
||||
{data.variable}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const AgentNode = memo(function AgentNode({
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.agent_type && (
|
||||
<div
|
||||
className="truncate text-[10px] text-gray-500 uppercase"
|
||||
className="truncate text-xs text-gray-500 uppercase"
|
||||
title={config.agent_type}
|
||||
>
|
||||
{config.agent_type}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 3.66667V1M12.7778 5.22222L14.6889 3.31111M14.3333 9H17M12.7778 12.7778L14.6889 14.6889M9 14.3333V17M5.22222 12.7778L3.31111 14.6889M3.66667 9H1M5.22222 5.22222L3.31111 3.31111" stroke="#949494" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 375 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 16.6944 7.30558 20.5 12 20.5C16.6944 20.5 20.5 16.6944 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM12 5C15.866 5 19 8.134 19 12C19 15.866 15.866 19 12 19C8.134 19 5 15.866 5 12C5 8.134 8.134 5 12 5ZM11.25 7C11.0511 7 10.8603 7.07902 10.7197 7.21967C10.579 7.36032 10.5 7.55109 10.5 7.75V12C10.5 12.1989 10.579 12.3897 10.7197 12.5303L13.4697 15.2803C13.6103 15.421 13.8011 15.5 14 15.5C14.1989 15.5 14.3897 15.421 14.5303 15.2803C14.671 15.1397 14.75 14.9489 14.75 14.75C14.75 14.5511 14.671 14.3603 14.5303 14.2197L12 11.6893V7.75C12 7.55109 11.921 7.36032 11.7803 7.21967C11.6397 7.07902 11.4489 7 11.25 7Z" fill="#7D54D1" stroke="#7D54D1" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 815 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 16.6944 7.30558 20.5 12 20.5C16.6944 20.5 20.5 16.6944 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM12 5C15.866 5 19 8.134 19 12C19 15.866 15.866 19 12 19C8.134 19 5 15.866 5 12C5 8.134 8.134 5 12 5ZM11.25 7C11.0511 7 10.8603 7.07902 10.7197 7.21967C10.579 7.36032 10.5 7.55109 10.5 7.75V12C10.5 12.1989 10.579 12.3897 10.7197 12.5303L13.4697 15.2803C13.6103 15.421 13.8011 15.5 14 15.5C14.1989 15.5 14.3897 15.421 14.5303 15.2803C14.671 15.1397 14.75 14.9489 14.75 14.75C14.75 14.5511 14.671 14.3603 14.5303 14.2197L12 11.6893V7.75C12 7.55109 11.921 7.36032 11.7803 7.21967C11.6397 7.07902 11.4489 7 11.25 7Z" fill="#FFFFFF" stroke="#FFFFFF" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 815 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_202_7)">
|
||||
<path d="M750 50L50 750M750 750L50 50" stroke="black" stroke-opacity="0.54" stroke-width="100" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_202_7">
|
||||
<rect width="800" height="800" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.50195 17.1494C3.68072 17.1503 3.83797 17.2119 3.96289 17.3359C4.08842 17.4607 4.15029 17.6188 4.15039 17.7988V18.5869C4.15039 18.7666 4.08775 18.9248 3.96191 19.0498C3.83677 19.1741 3.68038 19.2364 3.50098 19.2373H3.5C3.36498 19.2373 3.24235 19.2024 3.13672 19.1318L3.03613 19.0488C2.91173 18.9234 2.84961 18.7657 2.84961 18.5869V17.7988C2.84971 17.6193 2.91265 17.4614 3.03809 17.3359C3.16377 17.2104 3.32208 17.1486 3.50195 17.1494ZM7.50195 12.6494C7.68072 12.6503 7.83797 12.7119 7.96289 12.8359C8.08842 12.9607 8.15029 13.1188 8.15039 13.2988V18.5869C8.15039 18.7666 8.08775 18.9248 7.96191 19.0498C7.83656 19.1743 7.67907 19.2364 7.5 19.2373H7.49902C7.31988 19.2373 7.16245 19.175 7.03711 19.0498C6.91161 18.9243 6.84884 18.7663 6.84961 18.5869V13.2988C6.84971 13.1193 6.91265 12.9614 7.03809 12.8359C7.16377 12.7104 7.32208 12.6486 7.50195 12.6494ZM11.502 14.6494C11.6807 14.6503 11.838 14.7119 11.9629 14.8359C12.0884 14.9607 12.1503 15.1188 12.1504 15.2988V18.5869C12.1504 18.7666 12.0878 18.9248 11.9619 19.0498C11.8366 19.1743 11.6791 19.2364 11.5 19.2373H11.499C11.3193 19.2373 11.1611 19.1747 11.0361 19.0488C10.9117 18.9234 10.8496 18.7657 10.8496 18.5869V15.2988C10.8497 15.1193 10.9127 14.9614 11.0381 14.8359C11.1638 14.7104 11.3221 14.6486 11.502 14.6494ZM15.502 13.1494C15.6807 13.1503 15.838 13.2119 15.9629 13.3359C16.0884 13.4607 16.1503 13.6188 16.1504 13.7988V18.5869C16.1504 18.7666 16.0878 18.9248 15.9619 19.0498C15.8366 19.1743 15.6791 19.2364 15.5 19.2373H15.499C15.3199 19.2373 15.1625 19.175 15.0371 19.0498C14.9116 18.9243 14.8488 18.7663 14.8496 18.5869V13.7988C14.8497 13.6193 14.9127 13.4614 15.0381 13.3359C15.1638 13.2104 15.3221 13.1486 15.502 13.1494ZM19.502 9.14941C19.6807 9.15031 19.838 9.2119 19.9629 9.33594C20.0884 9.46066 20.1503 9.61875 20.1504 9.79883V18.5869C20.1504 18.7666 20.0878 18.9248 19.9619 19.0498C19.8366 19.1743 19.6791 19.2364 19.5 19.2373H19.499C19.3199 19.2373 19.1625 19.175 19.0371 19.0498C18.9116 18.9243 18.8488 18.7663 18.8496 18.5869V9.79883C18.8497 9.61927 18.9127 9.46137 19.0381 9.33594C19.1638 9.21036 19.3221 9.14857 19.502 9.14941ZM19.499 3.35156C19.6838 3.34567 19.8427 3.41642 19.9678 3.55469H19.9688C20.0596 3.64961 20.1156 3.761 20.1357 3.88477L20.1436 4.0127C20.1385 4.18363 20.079 4.33405 19.9609 4.45312H19.96L14.7422 9.6709C14.5779 9.83222 14.3846 9.95688 14.1641 10.0439C13.9451 10.1304 13.7235 10.1738 13.5 10.1738C13.3327 10.1738 13.1678 10.1497 13.0059 10.1006L12.8457 10.043C12.6325 9.95623 12.4382 9.83371 12.2637 9.67578L12.2578 9.6709L12.3643 9.56445L12.2578 9.66992L9.83594 7.24902C9.75169 7.16486 9.64381 7.12012 9.5 7.12012C9.39199 7.12012 9.30408 7.14514 9.23145 7.19336L9.16406 7.24902L3.95996 12.4531C3.83505 12.578 3.67971 12.6445 3.50195 12.6504H3.50098C3.31619 12.6556 3.1575 12.583 3.0332 12.4443V12.4453C2.91072 12.3194 2.85137 12.1633 2.85645 11.9873L2.87012 11.8623C2.89478 11.7425 2.95119 11.6357 3.04004 11.5469L8.25879 6.32812L8.39453 6.20703C8.53435 6.09371 8.68597 6.00535 8.84961 5.94434L9.00977 5.89258C9.17067 5.84784 9.33418 5.8252 9.5 5.8252C9.72114 5.8252 9.94159 5.86551 10.1602 5.94434C10.3843 6.02524 10.5789 6.15388 10.7422 6.3291L13.1641 8.75L13.2314 8.80566C13.3041 8.854 13.3919 8.87891 13.5 8.87891C13.6439 8.87891 13.7517 8.83427 13.8359 8.75L19.04 3.54688C19.165 3.42139 19.321 3.35565 19.499 3.35059V3.35156Z" fill="#7D54D1" stroke="#7D54D1" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.50195 17.1494C3.68072 17.1503 3.83797 17.2119 3.96289 17.3359C4.08842 17.4607 4.15029 17.6188 4.15039 17.7988V18.5869C4.15039 18.7666 4.08775 18.9248 3.96191 19.0498C3.83677 19.1741 3.68038 19.2364 3.50098 19.2373H3.5C3.36498 19.2373 3.24235 19.2024 3.13672 19.1318L3.03613 19.0488C2.91173 18.9234 2.84961 18.7657 2.84961 18.5869V17.7988C2.84971 17.6193 2.91265 17.4614 3.03809 17.3359C3.16377 17.2104 3.32208 17.1486 3.50195 17.1494ZM7.50195 12.6494C7.68072 12.6503 7.83797 12.7119 7.96289 12.8359C8.08842 12.9607 8.15029 13.1188 8.15039 13.2988V18.5869C8.15039 18.7666 8.08775 18.9248 7.96191 19.0498C7.83656 19.1743 7.67907 19.2364 7.5 19.2373H7.49902C7.31988 19.2373 7.16245 19.175 7.03711 19.0498C6.91161 18.9243 6.84884 18.7663 6.84961 18.5869V13.2988C6.84971 13.1193 6.91265 12.9614 7.03809 12.8359C7.16377 12.7104 7.32208 12.6486 7.50195 12.6494ZM11.502 14.6494C11.6807 14.6503 11.838 14.7119 11.9629 14.8359C12.0884 14.9607 12.1503 15.1188 12.1504 15.2988V18.5869C12.1504 18.7666 12.0878 18.9248 11.9619 19.0498C11.8366 19.1743 11.6791 19.2364 11.5 19.2373H11.499C11.3193 19.2373 11.1611 19.1747 11.0361 19.0488C10.9117 18.9234 10.8496 18.7657 10.8496 18.5869V15.2988C10.8497 15.1193 10.9127 14.9614 11.0381 14.8359C11.1638 14.7104 11.3221 14.6486 11.502 14.6494ZM15.502 13.1494C15.6807 13.1503 15.838 13.2119 15.9629 13.3359C16.0884 13.4607 16.1503 13.6188 16.1504 13.7988V18.5869C16.1504 18.7666 16.0878 18.9248 15.9619 19.0498C15.8366 19.1743 15.6791 19.2364 15.5 19.2373H15.499C15.3199 19.2373 15.1625 19.175 15.0371 19.0498C14.9116 18.9243 14.8488 18.7663 14.8496 18.5869V13.7988C14.8497 13.6193 14.9127 13.4614 15.0381 13.3359C15.1638 13.2104 15.3221 13.1486 15.502 13.1494ZM19.502 9.14941C19.6807 9.15031 19.838 9.2119 19.9629 9.33594C20.0884 9.46066 20.1503 9.61875 20.1504 9.79883V18.5869C20.1504 18.7666 20.0878 18.9248 19.9619 19.0498C19.8366 19.1743 19.6791 19.2364 19.5 19.2373H19.499C19.3199 19.2373 19.1625 19.175 19.0371 19.0498C18.9116 18.9243 18.8488 18.7663 18.8496 18.5869V9.79883C18.8497 9.61927 18.9127 9.46137 19.0381 9.33594C19.1638 9.21036 19.3221 9.14857 19.502 9.14941ZM19.499 3.35156C19.6838 3.34567 19.8427 3.41642 19.9678 3.55469H19.9688C20.0596 3.64961 20.1156 3.761 20.1357 3.88477L20.1436 4.0127C20.1385 4.18363 20.079 4.33405 19.9609 4.45312H19.96L14.7422 9.6709C14.5779 9.83222 14.3846 9.95688 14.1641 10.0439C13.9451 10.1304 13.7235 10.1738 13.5 10.1738C13.3327 10.1738 13.1678 10.1497 13.0059 10.1006L12.8457 10.043C12.6325 9.95623 12.4382 9.83371 12.2637 9.67578L12.2578 9.6709L12.3643 9.56445L12.2578 9.66992L9.83594 7.24902C9.75169 7.16486 9.64381 7.12012 9.5 7.12012C9.39199 7.12012 9.30408 7.14514 9.23145 7.19336L9.16406 7.24902L3.95996 12.4531C3.83505 12.578 3.67971 12.6445 3.50195 12.6504H3.50098C3.31619 12.6556 3.1575 12.583 3.0332 12.4443V12.4453C2.91072 12.3194 2.85137 12.1633 2.85645 11.9873L2.87012 11.8623C2.89478 11.7425 2.95119 11.6357 3.04004 11.5469L8.25879 6.32812L8.39453 6.20703C8.53435 6.09371 8.68597 6.00535 8.84961 5.94434L9.00977 5.89258C9.17067 5.84784 9.33418 5.8252 9.5 5.8252C9.72114 5.8252 9.94159 5.86551 10.1602 5.94434C10.3843 6.02524 10.5789 6.15388 10.7422 6.3291L13.1641 8.75L13.2314 8.80566C13.3041 8.854 13.3919 8.87891 13.5 8.87891C13.6439 8.87891 13.7517 8.83427 13.8359 8.75L19.04 3.54688C19.165 3.42139 19.321 3.35565 19.499 3.35059V3.35156Z" fill="#FFFFFF" stroke="#FFFFFF" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 383.748 383.748" fill="currentColor" stroke="currentColor" stroke-width="10">
|
||||
<path d="M62.772 95.042C90.904 54.899 137.496 30 187.343 30c83.743 0 151.874 68.13 151.874 151.874h30C369.217 81.588 287.629 0 187.343 0c-35.038 0-69.061 9.989-98.391 28.888a182.423 182.423 0 0 0-47.731 44.705L2.081 34.641v113.365h113.91L62.772 95.042zM381.667 235.742h-113.91l53.219 52.965c-28.132 40.142-74.724 65.042-124.571 65.042-83.744 0-151.874-68.13-151.874-151.874h-30c0 100.286 81.588 181.874 181.874 181.874 35.038 0 69.062-9.989 98.391-28.888a182.443 182.443 0 0 0 47.731-44.706l39.139 38.952V235.742z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 678 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="21" height="18" viewBox="0 0 21 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.00999999 18L21 9L0.00999999 0L0 7L15 9L0 11L0.00999999 18Z" fill="black" fill-opacity="0.54"/>
|
||||
<svg width="11" height="14" viewBox="0 0 11 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.292786 6.20676C0.105315 6.01923 -3.59956e-07 5.76492 -3.71547e-07 5.49976C-3.83138e-07 5.23459 0.105315 4.98028 0.292786 4.79276L4.79279 0.292756C4.98031 0.105284 5.23462 -3.07464e-05 5.49979 -3.0758e-05C5.76495 -3.07696e-05 6.01926 0.105284 6.20679 0.292756L10.7068 4.79276C10.8889 4.98136 10.9897 5.23396 10.9875 5.49616C10.9852 5.75835 10.88 6.00917 10.6946 6.19457C10.5092 6.37998 10.2584 6.48515 9.99619 6.48743C9.73399 6.48971 9.48139 6.38891 9.29279 6.20676L6.49979 3.49976L6.49979 12.9998C6.49979 13.265 6.39443 13.5193 6.20689 13.7069C6.01936 13.8944 5.765 13.9998 5.49979 13.9998C5.23457 13.9998 4.98022 13.8944 4.79268 13.7069C4.60514 13.5193 4.49979 13.265 4.49979 12.9998L4.49979 3.49976L1.70679 6.20676C1.51926 6.39423 1.26495 6.49954 0.999786 6.49954C0.734622 6.49954 0.480314 6.39423 0.292786 6.20676Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 210 B After Width: | Height: | Size: 956 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="black" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>
|
||||
|
Before Width: | Height: | Size: 454 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>
|
||||
|
Before Width: | Height: | Size: 454 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.66574 13.7747C1.66574 14.2168 1.84635 14.6407 2.16784 14.9533C2.48933 15.2658 2.92537 15.4414 3.38002 15.4414H10.2372C10.6918 15.4414 11.1279 15.2658 11.4493 14.9533C11.7708 14.6407 11.9515 14.2168 11.9515 13.7747V3.77474H1.66574V13.7747ZM3.38002 5.44141H10.2372V13.7747H3.38002V5.44141ZM9.80859 1.27474L8.95145 0.441406H4.66574L3.80859 1.27474H0.808594V2.94141H12.8086V1.27474H9.80859Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 520 B |
@@ -1,59 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import ChevronDown from '../assets/chevron-down.svg';
|
||||
|
||||
type AccordionProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
contentClassName?: string;
|
||||
open?: boolean;
|
||||
};
|
||||
|
||||
export default function Accordion({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
titleClassName = '',
|
||||
contentClassName = '',
|
||||
open: initialOpen = false,
|
||||
}: AccordionProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(initialOpen);
|
||||
|
||||
const accordionContentStyle = {
|
||||
height: isOpen ? 'auto' : '0px',
|
||||
transition: 'height 0.3s ease-in-out, opacity 0.3s ease-in-out',
|
||||
overflow: 'hidden',
|
||||
} as React.CSSProperties;
|
||||
|
||||
const toggleAccordion = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
return (
|
||||
<div className={`overflow-hidden shadow-xs ${className}`}>
|
||||
<button
|
||||
className={`flex w-full items-center justify-between focus:outline-hidden ${titleClassName}`}
|
||||
onClick={toggleAccordion}
|
||||
>
|
||||
<p className="wrap-break-word">{title}</p>
|
||||
<img
|
||||
src={ChevronDown}
|
||||
className={`h-5 w-5 transform transition-transform duration-200 dark:invert ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={accordionContentStyle}
|
||||
className={`px-4 ${contentClassName} ${isOpen ? 'pb-3' : 'pb-0'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
setConversation,
|
||||
updateConversationId,
|
||||
} from '../conversation/conversationSlice';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
className?: string;
|
||||
@@ -50,10 +51,13 @@ export default function ActionButtons({
|
||||
>
|
||||
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
|
||||
{showNewChat && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t('actionButtons.openNewChat')}
|
||||
onClick={newChat}
|
||||
className="hover:bg-accent dark:hover:bg-accent flex items-center gap-1 rounded-full p-2 lg:hidden"
|
||||
className="rounded-full lg:hidden"
|
||||
>
|
||||
<img
|
||||
className="filter dark:invert"
|
||||
@@ -62,15 +66,18 @@ export default function ActionButtons({
|
||||
height={21}
|
||||
src={newChatIcon}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showShare && conversationId && (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t('actionButtons.share')}
|
||||
onClick={() => setShareModalState(true)}
|
||||
className="hover:bg-accent dark:hover:bg-accent rounded-full p-2"
|
||||
className="rounded-full"
|
||||
>
|
||||
<img
|
||||
className="filter dark:invert"
|
||||
@@ -79,7 +86,7 @@ export default function ActionButtons({
|
||||
height={16}
|
||||
src={ShareIcon}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
{isShareModalOpen && (
|
||||
<ShareConversationModal
|
||||
close={() => setShareModalState(false)}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Robot from '../assets/robot.svg';
|
||||
|
||||
type AgentImageProps = {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
fallbackSrc?: string;
|
||||
};
|
||||
|
||||
export default function AgentImage({
|
||||
src,
|
||||
alt = 'agent',
|
||||
className = '',
|
||||
fallbackSrc = Robot,
|
||||
}: AgentImageProps) {
|
||||
const [currentSrc, setCurrentSrc] = useState(
|
||||
src && src.trim() !== '' ? src : fallbackSrc,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const newSrc = src && src.trim() !== '' ? src : fallbackSrc;
|
||||
if (newSrc !== currentSrc) {
|
||||
setCurrentSrc(newSrc);
|
||||
}
|
||||
}, [src, fallbackSrc]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={currentSrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
onError={() => {
|
||||
if (currentSrc !== fallbackSrc) setCurrentSrc(fallbackSrc);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
@@ -9,11 +10,12 @@ import {
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import Exit from '../assets/exit.svg';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import CopyButton from './CopyButton';
|
||||
import Spinner from './Spinner';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent } from './ui/sheet';
|
||||
|
||||
type TodoItem = {
|
||||
todo_id: number;
|
||||
@@ -179,8 +181,8 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
return match ? (
|
||||
<div className="group border-border relative my-2 overflow-hidden rounded-[14px] border">
|
||||
<div className="bg-platinum flex items-center justify-between px-2 py-1">
|
||||
<div className="group border-border relative my-2 overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
{language}
|
||||
</span>
|
||||
@@ -204,7 +206,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
</div>
|
||||
) : (
|
||||
<code
|
||||
className="dark:bg-accent dark:text-foreground rounded-[6px] bg-gray-200 px-2 py-1 text-xs font-normal"
|
||||
className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
@@ -276,7 +278,6 @@ export default function ArtifactSidebar({
|
||||
conversationId,
|
||||
variant = 'overlay',
|
||||
}: ArtifactSidebarProps) {
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
const lastSuccessfulTodoArtifactIdRef = React.useRef<string | null>(null);
|
||||
const currentFetchIdRef = React.useRef<string | null>(null);
|
||||
const token = useSelector(selectToken);
|
||||
@@ -385,22 +386,6 @@ export default function ArtifactSidebar({
|
||||
});
|
||||
}, [isOpen, effectiveArtifactId, token, toolName, conversationId]);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
sidebarRef.current &&
|
||||
!sidebarRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (variant === 'overlay' && isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, variant]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -453,16 +438,16 @@ export default function ArtifactSidebar({
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{title}
|
||||
</span>
|
||||
<button
|
||||
className="hover:bg-accent dark:hover:bg-accent rounded-full p-1"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<img
|
||||
className="h-3 w-3 filter dark:invert"
|
||||
src={Exit}
|
||||
alt="Close"
|
||||
/>
|
||||
</button>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
|
||||
</div>
|
||||
@@ -471,29 +456,34 @@ export default function ArtifactSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={sidebarRef} className="h-vh relative">
|
||||
<div
|
||||
className={`dark:bg-card bg-card fixed top-0 right-0 z-50 flex h-full w-80 transform flex-col shadow-xl transition-all duration-300 sm:w-96 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} border-l border-[#9ca3af]/10`}
|
||||
<Sheet
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="dark:bg-card bg-card flex h-full w-80 flex-col gap-0 border-l border-[#9ca3af]/10 p-0 sm:w-96 sm:max-w-none"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{title}
|
||||
</span>
|
||||
<button
|
||||
className="hover:bg-accent rounded-full p-2"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<img
|
||||
className="h-4 w-4 filter dark:invert"
|
||||
src={Exit}
|
||||
alt="Close"
|
||||
/>
|
||||
</button>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function Avatar({
|
||||
avatar,
|
||||
size,
|
||||
className,
|
||||
}: {
|
||||
avatar: ReactNode;
|
||||
size?: 'SMALL' | 'MEDIUM' | 'LARGE';
|
||||
className: string;
|
||||
}) {
|
||||
return <div className={`${className} shrink-0`}>{avatar}</div>;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { ChunkType } from '../settings/types';
|
||||
import Pagination from './DocumentPagination';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
interface LineNumberedTextareaProps {
|
||||
value: string;
|
||||
@@ -76,7 +78,7 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className={`text-foreground w-full resize-none overflow-hidden border-none bg-transparent pl-8 font-['Inter'] text-[13.68px] leading-[19.93px] outline-none lg:pl-12 dark:text-white ${isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]'} ${!editable ? 'select-none' : ''}`}
|
||||
className={`text-foreground focus-visible:ring-ring/50 focus-visible:border-ring w-full resize-none overflow-hidden border-none bg-transparent pl-8 font-['Inter'] text-[13.68px] leading-[19.93px] outline-none focus-visible:ring-[3px] lg:pl-12 dark:text-white ${isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]'} ${!editable ? 'select-none' : ''}`}
|
||||
value={value}
|
||||
onChange={editable ? handleChange : undefined}
|
||||
onDoubleClick={onDoubleClick}
|
||||
@@ -300,8 +302,11 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
return (
|
||||
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex w-full items-center sm:w-auto">
|
||||
<button
|
||||
className="mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm font-medium text-gray-400 transition-all duration-200 dark:border-0 dark:text-gray-500"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="mr-3 h-[29px] w-[29px] rounded-full p-2 text-gray-400 dark:border-0 dark:text-gray-500"
|
||||
onClick={
|
||||
editingChunk
|
||||
? () => setEditingChunk(null)
|
||||
@@ -311,11 +316,11 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
}
|
||||
>
|
||||
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center">
|
||||
{/* Removed the directory icon */}
|
||||
<span className="font-semibold wrap-break-word text-[#7D54D1]">
|
||||
<span className="text-primary font-semibold wrap-break-word">
|
||||
{documentName}
|
||||
</span>
|
||||
|
||||
@@ -327,7 +332,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<span
|
||||
className={`wrap-break-word ${
|
||||
index < pathParts.length - 1
|
||||
? 'font-medium text-[#7D54D1]'
|
||||
? 'text-primary font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
@@ -347,32 +352,38 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
{editingChunk ? (
|
||||
!isEditing ? (
|
||||
<>
|
||||
<button
|
||||
className="bg-primary hover:bg-primary/90 flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-nowrap text-white"
|
||||
<Button
|
||||
type="button"
|
||||
className="h-[38px] min-w-[108px] rounded-full px-4 text-sm font-medium whitespace-nowrap"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{t('modals.chunk.edit')}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-[38px] min-w-[108px] items-center justify-center rounded-full border border-solid border-red-500 px-4 py-1 text-[14px] font-medium text-nowrap text-red-500 hover:bg-red-500 hover:text-white"
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
className="h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
onClick={() => {
|
||||
confirmDeleteChunk(editingChunk);
|
||||
}}
|
||||
>
|
||||
{t('modals.chunk.delete')}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="dark:text-foreground hover:bg-accent dark:hover:bg-accent flex h-[38px] min-w-[108px] cursor-pointer items-center justify-center rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
className="dark:text-foreground h-[38px] min-w-[108px] cursor-pointer rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
>
|
||||
{t('modals.chunk.cancel')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editingText.trim()) {
|
||||
const hasChanges =
|
||||
@@ -396,27 +407,30 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
(editingTitle === (editingChunk?.metadata?.title || '') &&
|
||||
editingText === (editingChunk?.text || ''))
|
||||
}
|
||||
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 py-1 text-[14px] font-medium text-nowrap text-white transition-all ${
|
||||
className={`h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap transition-all ${
|
||||
editingText.trim() &&
|
||||
(editingTitle !== (editingChunk?.metadata?.title || '') ||
|
||||
editingText !== (editingChunk?.text || ''))
|
||||
? 'bg-primary hover:bg-primary/90 cursor-pointer'
|
||||
: 'cursor-not-allowed bg-gray-400'
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed bg-gray-400 text-white hover:bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t('modals.chunk.save')}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
) : isAddingChunk ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsAddingChunk(false)}
|
||||
className="dark:text-foreground hover:bg-accent dark:hover:bg-accent flex h-[38px] min-w-[108px] cursor-pointer items-center justify-center rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
className="dark:text-foreground h-[38px] min-w-[108px] cursor-pointer rounded-full px-4 py-1 text-sm font-medium text-nowrap"
|
||||
>
|
||||
{t('modals.chunk.cancel')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editingText.trim()) {
|
||||
handleAddChunk(editingTitle, editingText);
|
||||
@@ -424,14 +438,14 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
}
|
||||
}}
|
||||
disabled={!editingText.trim()}
|
||||
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 py-1 text-[14px] font-medium text-nowrap text-white transition-all ${
|
||||
className={`h-[38px] min-w-[108px] rounded-full px-4 py-1 text-sm font-medium text-nowrap transition-all ${
|
||||
editingText.trim()
|
||||
? 'bg-primary hover:bg-primary/90 cursor-pointer'
|
||||
: 'cursor-not-allowed bg-gray-400'
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed bg-gray-400 text-white hover:bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t('modals.chunk.add')}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -481,19 +495,19 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
<div className="pointer-events-none absolute left-3">
|
||||
<img src={SearchIcon} alt="Search" className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={fileSearchQuery}
|
||||
onChange={(e) => handleFileSearchChange(e.target.value)}
|
||||
placeholder={t('settings.sources.searchFiles')}
|
||||
className={`border-border dark:border-border h-[38px] w-full border py-2 pr-4 pl-10 ${
|
||||
fileSearchQuery ? 'rounded-t-[6px]' : 'rounded-[6px]'
|
||||
} bg-transparent transition-all duration-200 focus:outline-none`}
|
||||
className={`h-[38px] py-2 pr-4 pl-10 ${
|
||||
fileSearchQuery ? 'rounded-t-md' : 'rounded-md'
|
||||
} transition-all duration-200`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fileSearchQuery && (
|
||||
<div className="border-border bg-card dark:border-border dark:bg-card absolute z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-[6px] border border-t-0 shadow-lg">
|
||||
<div className="border-border bg-card dark:border-border dark:bg-card absolute z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-md border border-t-0 shadow-lg">
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto">
|
||||
{fileSearchResults.length === 0 ? (
|
||||
<div className="py-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
@@ -555,17 +569,18 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
</div>
|
||||
<div className="bg-border dark:bg-border h-full w-px"></div>
|
||||
<div className="h-full flex-1">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('settings.sources.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-full w-full border-none bg-transparent px-3 py-2 text-[13.56px] leading-[100%] font-normal outline-none"
|
||||
className="h-full rounded-none border-0 px-3 py-2 text-[13.56px] leading-[100%] font-normal shadow-none focus-visible:ring-0 md:text-[13.56px] dark:border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="bg-primary hover:bg-primary/90 flex h-[38px] w-full min-w-[108px] shrink-0 items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-normal text-white sm:w-auto"
|
||||
<Button
|
||||
type="button"
|
||||
className="h-[38px] w-full min-w-[108px] shrink-0 rounded-full px-4 text-sm font-medium whitespace-normal sm:w-auto"
|
||||
title={t('settings.sources.addChunk')}
|
||||
onClick={() => {
|
||||
setIsAddingChunk(true);
|
||||
@@ -574,7 +589,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))]">
|
||||
@@ -595,7 +610,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
filteredChunks.map((chunk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border dark:border-border relative flex h-[197px] w-full max-w-[487px] transform cursor-pointer flex-col justify-between overflow-hidden rounded-[5.86px] border transition-transform duration-200 hover:scale-105"
|
||||
className="border-border dark:border-border relative flex h-[197px] w-full max-w-[487px] transform cursor-pointer flex-col justify-between overflow-hidden rounded-md border transition-transform duration-200 hover:scale-105"
|
||||
onClick={() => {
|
||||
setEditingChunk(chunk);
|
||||
setEditingTitle(chunk.metadata?.title || '');
|
||||
@@ -637,7 +652,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
) : (
|
||||
editingChunk && (
|
||||
<div className="w-full">
|
||||
<div className="border-border dark:border-border relative flex w-full flex-col overflow-hidden rounded-[5.86px] border">
|
||||
<div className="border-border dark:border-border relative flex w-full flex-col overflow-hidden rounded-md border">
|
||||
<div className="border-border bg-muted dark:border-border dark:bg-card flex w-full items-center justify-between border-b px-4 py-3">
|
||||
<div className="dark:text-muted-foreground text-sm text-[#59636E]">
|
||||
{editingChunk.metadata.token_count
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const ConnectedStateSkeleton = () => (
|
||||
<div className="mb-4">
|
||||
<div className="flex w-full animate-pulse items-center justify-between rounded-[10px] bg-gray-200 px-4 py-2 dark:bg-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-4 w-32 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<div className="h-4 w-16 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ConnectedStateSkeleton;
|
||||
@@ -5,6 +5,7 @@ import { useSelector } from 'react-redux';
|
||||
import userService from '../api/services/userService';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface ConnectorAuthProps {
|
||||
provider: string;
|
||||
@@ -193,7 +194,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
|
||||
|
||||
{isConnected ? (
|
||||
<div className="mb-4">
|
||||
<div className="text-eerie-black flex w-full items-center justify-between rounded-[10px] bg-[#8FDD51] px-4 py-2 text-sm font-medium">
|
||||
<div className="flex w-full items-center justify-between rounded-lg bg-[#8FDD51] px-4 py-2 text-sm font-medium text-black">
|
||||
<div className="flex max-w-[500px] items-center gap-2">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -208,19 +209,23 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
{onDisconnect && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="text-eerie-black text-xs font-medium underline hover:text-gray-700"
|
||||
className="h-auto p-0 text-xs text-black underline hover:text-gray-700"
|
||||
>
|
||||
{t('modals.uploadDoc.connectors.auth.disconnect')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAuth}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3 text-white transition-colors hover:bg-blue-600"
|
||||
className="w-full rounded-lg bg-blue-500 px-4 py-3 text-white hover:bg-blue-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -229,7 +234,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,45 +1,16 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector, useStore } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import ArrowLeft from '../assets/arrow-left.svg';
|
||||
import CheckmarkIcon from '../assets/checkMark2.svg';
|
||||
import EyeView from '../assets/eye-view.svg';
|
||||
import FileIcon from '../assets/file.svg';
|
||||
import FolderIcon from '../assets/folder.svg';
|
||||
import SyncIcon from '../assets/sync.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import { useLoaderState, useOutsideAlerter } from '../hooks';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { RootState } from '../store';
|
||||
import { formatBytes } from '../utils/stringUtils';
|
||||
import Chunks from './Chunks';
|
||||
import ContextMenu, { MenuOption } from './ContextMenu';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './Table';
|
||||
|
||||
interface FileNode {
|
||||
type?: string;
|
||||
token_count?: number;
|
||||
size_bytes?: number;
|
||||
display_name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface DirectoryStructure {
|
||||
[key: string]: FileNode;
|
||||
}
|
||||
import TreeBrowser from './tree/TreeBrowser';
|
||||
import type { TreeBrowserController } from './tree/types';
|
||||
import { useReingestSseWaiter } from './tree/useReingestWait';
|
||||
|
||||
interface ConnectorTreeProps {
|
||||
docId: string;
|
||||
@@ -47,79 +18,23 @@ interface ConnectorTreeProps {
|
||||
onBackToDocuments: () => void;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
name: string;
|
||||
path: string;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
docId,
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useLoaderState(true, 500);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [directoryStructure, setDirectoryStructure] =
|
||||
useState<DirectoryStructure | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||
const token = useSelector(selectToken);
|
||||
const store = useStore<RootState>();
|
||||
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
||||
const menuRefs = useRef<{
|
||||
[key: string]: React.RefObject<HTMLDivElement | null>;
|
||||
}>({});
|
||||
const [selectedFile, setSelectedFile] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||
const [syncProgress, setSyncProgress] = useState<number>(0);
|
||||
const [sourceProvider, setSourceProvider] = useState<string>('');
|
||||
const [syncDone, setSyncDone] = useState<boolean>(false);
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncProgress, setSyncProgress] = useState(0);
|
||||
const [syncDone, setSyncDone] = useState(false);
|
||||
const [sourceProvider, setSourceProvider] = useState('');
|
||||
const [syncConfirmationModal, setSyncConfirmationModal] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const mountedRef = useRef(true);
|
||||
const syncUnsubscribeRef = useRef<(() => void) | null>(null);
|
||||
// Holds the 5-minute SSE-wait timer so the unmount cleanup can clear
|
||||
// it — otherwise the timer fires up to 5 min after unmount and
|
||||
// resolves an abandoned Promise.
|
||||
const syncTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
syncUnsubscribeRef.current?.();
|
||||
syncUnsubscribeRef.current = null;
|
||||
if (syncTimerRef.current !== null) {
|
||||
window.clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useOutsideAlerter(
|
||||
searchDropdownRef,
|
||||
() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
},
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
const handleFileClick = (fileName: string, displayName?: string) => {
|
||||
const fullPath = [...currentPath, fileName].join('/');
|
||||
setSelectedFile({
|
||||
id: fullPath,
|
||||
name: displayName ?? fileName,
|
||||
});
|
||||
};
|
||||
const controllerRef = useRef<TreeBrowserController | null>(null);
|
||||
const { waitForTerminal, mountedRef } = useReingestSseWaiter();
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) return;
|
||||
@@ -137,73 +52,12 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
console.log('Sync started successfully:', data.task_id);
|
||||
setSyncProgress(10);
|
||||
|
||||
// The connector worker (``ingest_connector`` in
|
||||
// ``application/worker.py``) publishes
|
||||
// ``source.ingest.{queued,completed,failed}`` envelopes keyed on
|
||||
// ``scope.id == docId`` (sync mode reuses the source uuid). Wait
|
||||
// on the bounded ``notifications.recentEvents`` ring for a
|
||||
// terminal envelope rather than polling ``/task_status``.
|
||||
// Mirrors FileTree's slice-walking pattern, including the
|
||||
// ``opStartedAt`` guard so a stale terminal event from a prior
|
||||
// sync of this same source can't short-circuit the current op.
|
||||
// Sync mode reuses the source uuid for ``scope.id``, so we wait
|
||||
// on the same SSE channel FileTree uses for ingest terminals.
|
||||
// ``opStartedAt`` guards against a stale terminal from a prior
|
||||
// sync of this same source short-circuiting the current op.
|
||||
const opStartedAt = Date.now();
|
||||
|
||||
const terminalFromSse = (): 'completed' | 'failed' | null => {
|
||||
const events = store.getState().notifications.recentEvents;
|
||||
for (const event of events) {
|
||||
if (event.scope?.id !== docId) continue;
|
||||
const ts = event.ts ? Date.parse(event.ts) : NaN;
|
||||
if (!Number.isFinite(ts) || ts < opStartedAt) continue;
|
||||
if (event.type === 'source.ingest.completed') return 'completed';
|
||||
if (event.type === 'source.ingest.failed') return 'failed';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const MAX_WAIT_MS = 5 * 60_000;
|
||||
const terminal = await new Promise<
|
||||
'completed' | 'failed' | 'timeout' | 'unmounted'
|
||||
>((resolve) => {
|
||||
// Cover the race where the event landed between the POST
|
||||
// returning and the subscribe call.
|
||||
const initial = terminalFromSse();
|
||||
if (initial) {
|
||||
resolve(initial);
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
resolve('unmounted');
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (
|
||||
value: 'completed' | 'failed' | 'timeout' | 'unmounted',
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (syncTimerRef.current !== null) {
|
||||
window.clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = null;
|
||||
}
|
||||
if (syncUnsubscribeRef.current) {
|
||||
syncUnsubscribeRef.current();
|
||||
syncUnsubscribeRef.current = null;
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
syncTimerRef.current = window.setTimeout(
|
||||
() => finish('timeout'),
|
||||
MAX_WAIT_MS,
|
||||
);
|
||||
syncUnsubscribeRef.current = store.subscribe(() => {
|
||||
if (!mountedRef.current) {
|
||||
finish('unmounted');
|
||||
return;
|
||||
}
|
||||
const next = terminalFromSse();
|
||||
if (next) finish(next);
|
||||
});
|
||||
});
|
||||
const terminal = await waitForTerminal(docId, opStartedAt);
|
||||
|
||||
if (terminal === 'timeout') {
|
||||
console.error('Sync timed out waiting for SSE terminal');
|
||||
@@ -219,21 +73,16 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
console.log('Sync completed successfully');
|
||||
|
||||
try {
|
||||
const refreshResponse = await userService.getDirectoryStructure(
|
||||
docId,
|
||||
token,
|
||||
);
|
||||
const refreshData = await refreshResponse.json();
|
||||
if (refreshData && refreshData.directory_structure) {
|
||||
setDirectoryStructure(refreshData.directory_structure);
|
||||
setCurrentPath([]);
|
||||
const refreshed = await controllerRef.current?.refreshDirectory();
|
||||
if (refreshed) {
|
||||
controllerRef.current?.resetPath();
|
||||
}
|
||||
if (refreshData && refreshData.provider) {
|
||||
setSourceProvider(refreshData.provider);
|
||||
if (mountedRef.current) {
|
||||
setSyncDone(true);
|
||||
setTimeout(() => {
|
||||
if (mountedRef.current) setSyncDone(false);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
setSyncDone(true);
|
||||
setTimeout(() => setSyncDone(false), 5000);
|
||||
} catch (err) {
|
||||
console.error('Error refreshing directory structure:', err);
|
||||
}
|
||||
@@ -251,609 +100,63 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDirectoryStructure = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const directoryResponse = await userService.getDirectoryStructure(
|
||||
docId,
|
||||
token,
|
||||
);
|
||||
const directoryData = await directoryResponse.json();
|
||||
|
||||
if (directoryData && directoryData.directory_structure) {
|
||||
setDirectoryStructure(directoryData.directory_structure);
|
||||
} else {
|
||||
setError('Invalid response format');
|
||||
}
|
||||
|
||||
if (directoryData && directoryData.provider) {
|
||||
setSourceProvider(directoryData.provider);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load source information');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const topRightAction = (
|
||||
<button
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
isSyncing
|
||||
? 'dark:bg-muted dark:text-muted-foreground cursor-not-allowed bg-gray-300 text-gray-600'
|
||||
: 'bg-primary hover:bg-primary/90 text-white'
|
||||
}`}
|
||||
title={
|
||||
isSyncing
|
||||
? `${t('settings.sources.syncing')} ${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
: t('settings.sources.sync')
|
||||
}
|
||||
};
|
||||
>
|
||||
<img
|
||||
src={syncDone ? CheckmarkIcon : SyncIcon}
|
||||
alt={t('settings.sources.sync')}
|
||||
className={`mr-2 h-4 w-4 brightness-0 invert filter ${isSyncing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
: t('settings.sources.sync')}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (docId) {
|
||||
fetchDirectoryStructure();
|
||||
}
|
||||
}, [docId, token]);
|
||||
|
||||
const navigateToDirectory = (dirName: string) => {
|
||||
setCurrentPath([...currentPath, dirName]);
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
setCurrentPath(currentPath.slice(0, -1));
|
||||
};
|
||||
|
||||
const getCurrentDirectory = (): DirectoryStructure => {
|
||||
if (!directoryStructure) return {};
|
||||
|
||||
let current = directoryStructure;
|
||||
for (const dir of currentPath) {
|
||||
if (current[dir] && !current[dir].type) {
|
||||
current = current[dir] as DirectoryStructure;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const getMenuRef = (id: string) => {
|
||||
if (!menuRefs.current[id]) {
|
||||
menuRefs.current[id] = React.createRef();
|
||||
}
|
||||
return menuRefs.current[id];
|
||||
};
|
||||
|
||||
const handleMenuClick = (
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
id: string,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenuId(activeMenuId === id ? null : id);
|
||||
};
|
||||
|
||||
const getActionOptions = (
|
||||
name: string,
|
||||
isFile: boolean,
|
||||
_itemId: string,
|
||||
displayName?: string,
|
||||
): MenuOption[] => {
|
||||
const options: MenuOption[] = [];
|
||||
|
||||
options.push({
|
||||
icon: EyeView,
|
||||
label: t('settings.sources.view'),
|
||||
onClick: (event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (isFile) {
|
||||
handleFileClick(name, displayName);
|
||||
} else {
|
||||
navigateToDirectory(name);
|
||||
}
|
||||
},
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
variant: 'primary',
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const calculateDirectoryStats = (
|
||||
structure: DirectoryStructure,
|
||||
): { totalSize: number; totalTokens: number } => {
|
||||
let totalSize = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
Object.entries(structure).forEach(([_, node]) => {
|
||||
if (node.type) {
|
||||
// It's a file
|
||||
totalSize += node.size_bytes || 0;
|
||||
totalTokens += node.token_count || 0;
|
||||
} else {
|
||||
// It's a directory, recurse
|
||||
const stats = calculateDirectoryStats(node);
|
||||
totalSize += stats.totalSize;
|
||||
totalTokens += stats.totalTokens;
|
||||
}
|
||||
});
|
||||
|
||||
return { totalSize, totalTokens };
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null);
|
||||
} else if (currentPath.length === 0) {
|
||||
if (onBackToDocuments) {
|
||||
onBackToDocuments();
|
||||
}
|
||||
} else {
|
||||
navigateUp();
|
||||
}
|
||||
};
|
||||
|
||||
const renderPathNavigation = () => {
|
||||
return (
|
||||
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Left side with path navigation */}
|
||||
<div className="flex w-full items-center sm:w-auto">
|
||||
<button
|
||||
className="text-muted-foreground mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm font-medium dark:border-0"
|
||||
onClick={handleBackNavigation}
|
||||
>
|
||||
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap items-center">
|
||||
<span className="font-semibold wrap-break-word text-[#7D54D1]">
|
||||
{sourceName}
|
||||
</span>
|
||||
{currentPath.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1 shrink-0">/</span>
|
||||
{currentPath.map((dir, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<span className="dark:text-foreground wrap-break-word text-gray-700">
|
||||
{dir}
|
||||
</span>
|
||||
{index < currentPath.length - 1 && (
|
||||
<span className="text-muted-foreground mx-1 shrink-0">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2 flex w-full flex-row flex-nowrap items-center justify-end gap-2 sm:mt-0 sm:w-auto">
|
||||
{renderFileSearch()}
|
||||
|
||||
{/* Sync button */}
|
||||
<button
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-nowrap transition-colors ${
|
||||
isSyncing
|
||||
? 'dark:bg-muted dark:text-muted-foreground cursor-not-allowed bg-gray-300 text-gray-600'
|
||||
: 'bg-primary hover:bg-primary/90 text-white'
|
||||
}`}
|
||||
title={
|
||||
isSyncing
|
||||
? `${t('settings.sources.syncing')} ${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
: t('settings.sources.sync')
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={syncDone ? CheckmarkIcon : SyncIcon}
|
||||
alt={t('settings.sources.sync')}
|
||||
className={`mr-2 h-4 w-4 brightness-0 invert filter ${isSyncing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
: t('settings.sources.sync')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileTree = (directory: DirectoryStructure): React.ReactNode[] => {
|
||||
// Create parent directory row
|
||||
const parentRow =
|
||||
currentPath.length > 0
|
||||
? [
|
||||
<TableRow key="parent-dir" onClick={navigateUp}>
|
||||
<TableCell width="40%" align="left">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={FolderIcon}
|
||||
alt={t('settings.sources.parentFolderAlt')}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">..</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell width="30%" align="left">
|
||||
-
|
||||
</TableCell>
|
||||
<TableCell width="20%" align="left">
|
||||
-
|
||||
</TableCell>
|
||||
<TableCell width="10%" align="right"></TableCell>
|
||||
</TableRow>,
|
||||
]
|
||||
: [];
|
||||
|
||||
// Sort entries: directories first, then files, both alphabetically
|
||||
const sortedEntries = Object.entries(directory).sort(
|
||||
([nameA, nodeA], [nameB, nodeB]) => {
|
||||
const isFileA = !!nodeA.type;
|
||||
const isFileB = !!nodeB.type;
|
||||
|
||||
if (isFileA !== isFileB) {
|
||||
return isFileA ? 1 : -1; // Directories first
|
||||
}
|
||||
|
||||
return nameA.localeCompare(nameB); // Alphabetical within each group
|
||||
},
|
||||
);
|
||||
|
||||
// Process directories
|
||||
const directoryRows = sortedEntries
|
||||
.filter(([_, node]) => !node.type)
|
||||
.map(([name, node]) => {
|
||||
const itemId = `dir-${name}`;
|
||||
const menuRef = getMenuRef(itemId);
|
||||
|
||||
// Calculate directory stats
|
||||
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
|
||||
|
||||
return (
|
||||
<TableRow key={itemId} onClick={() => navigateToDirectory(name)}>
|
||||
<TableCell width="40%" align="left">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<img
|
||||
src={FolderIcon}
|
||||
alt={t('settings.sources.folderAlt')}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell width="30%" align="left">
|
||||
{dirStats.totalTokens > 0
|
||||
? dirStats.totalTokens.toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell width="20%" align="left">
|
||||
{dirStats.totalSize > 0 ? formatBytes(dirStats.totalSize) : '-'}
|
||||
</TableCell>
|
||||
<TableCell width="10%" align="right">
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={(e) => handleMenuClick(e, itemId)}
|
||||
className="dark:hover:bg-muted inline-flex h-[35px] w-6 shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB]"
|
||||
aria-label={t('settings.sources.menuAlt')}
|
||||
>
|
||||
<img
|
||||
src={ThreeDots}
|
||||
alt={t('settings.sources.menuAlt')}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<ContextMenu
|
||||
isOpen={activeMenuId === itemId}
|
||||
setIsOpen={(isOpen) =>
|
||||
setActiveMenuId(isOpen ? itemId : null)
|
||||
}
|
||||
options={getActionOptions(name, false, itemId)}
|
||||
anchorRef={menuRef}
|
||||
position="bottom-left"
|
||||
offset={{ x: -4, y: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
// Process files
|
||||
const fileRows = sortedEntries
|
||||
.filter(([_, node]) => !!node.type)
|
||||
.map(([name, node]) => {
|
||||
const itemId = `file-${name}`;
|
||||
const menuRef = getMenuRef(itemId);
|
||||
const displayName =
|
||||
typeof node.display_name === 'string' && node.display_name.trim()
|
||||
? node.display_name
|
||||
: name;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={itemId}
|
||||
onClick={() => handleFileClick(name, displayName)}
|
||||
>
|
||||
<TableCell width="40%" align="left">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<img
|
||||
src={FileIcon}
|
||||
alt={t('settings.sources.fileAlt')}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{displayName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell width="30%" align="left">
|
||||
{node.token_count?.toLocaleString() || '-'}
|
||||
</TableCell>
|
||||
<TableCell width="20%" align="left">
|
||||
{node.size_bytes ? formatBytes(node.size_bytes) : '-'}
|
||||
</TableCell>
|
||||
<TableCell width="10%" align="right">
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={(e) => handleMenuClick(e, itemId)}
|
||||
className="dark:hover:bg-muted inline-flex h-[35px] w-6 shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB]"
|
||||
aria-label={t('settings.sources.menuAlt')}
|
||||
>
|
||||
<img
|
||||
src={ThreeDots}
|
||||
alt={t('settings.sources.menuAlt')}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<ContextMenu
|
||||
isOpen={activeMenuId === itemId}
|
||||
setIsOpen={(isOpen) =>
|
||||
setActiveMenuId(isOpen ? itemId : null)
|
||||
}
|
||||
options={getActionOptions(name, true, itemId, displayName)}
|
||||
anchorRef={menuRef}
|
||||
position="bottom-left"
|
||||
offset={{ x: -4, y: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return [...parentRow, ...directoryRows, ...fileRows];
|
||||
};
|
||||
|
||||
const searchFiles = (
|
||||
query: string,
|
||||
structure: DirectoryStructure,
|
||||
currentPath: string[] = [],
|
||||
): SearchResult[] => {
|
||||
let results: SearchResult[] = [];
|
||||
|
||||
Object.entries(structure).forEach(([name, node]) => {
|
||||
const fullPath = [...currentPath, name].join('/');
|
||||
const displayName =
|
||||
typeof node.display_name === 'string' && node.display_name.trim()
|
||||
? node.display_name
|
||||
: '';
|
||||
const queryLower = query.toLowerCase();
|
||||
const matchTarget = displayName ? `${name} ${displayName}` : name;
|
||||
|
||||
if (matchTarget.toLowerCase().includes(queryLower)) {
|
||||
results.push({
|
||||
name: displayName || name,
|
||||
path: fullPath,
|
||||
isFile: !!node.type,
|
||||
});
|
||||
}
|
||||
|
||||
if (!node.type) {
|
||||
// If it's a directory, search recursively
|
||||
results = [
|
||||
...results,
|
||||
...searchFiles(query, node as DirectoryStructure, [
|
||||
...currentPath,
|
||||
name,
|
||||
]),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const handleSearchSelect = (result: SearchResult) => {
|
||||
if (result.isFile) {
|
||||
const pathParts = result.path.split('/');
|
||||
const fileName = pathParts.pop() || '';
|
||||
setCurrentPath(pathParts);
|
||||
|
||||
setSelectedFile({
|
||||
id: result.path,
|
||||
name: result.name || fileName,
|
||||
});
|
||||
} else {
|
||||
setCurrentPath(result.path.split('/'));
|
||||
setSelectedFile(null);
|
||||
}
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const renderFileSearch = () => {
|
||||
return (
|
||||
<div className="relative w-52" ref={searchDropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
if (directoryStructure) {
|
||||
setSearchResults(searchFiles(e.target.value, directoryStructure));
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.sources.searchFiles')}
|
||||
className={`border-border dark:border-border h-[38px] w-full border px-4 py-2 ${searchQuery ? 'rounded-t-[24px]' : 'rounded-[24px]'} bg-transparent focus:outline-none`}
|
||||
/>
|
||||
|
||||
{searchQuery && (
|
||||
<div className="border-border bg-card dark:border-border dark:bg-card absolute top-full right-0 left-0 z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-2xl border border-t-0 shadow-lg transition-all duration-200">
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="text-muted-foreground py-2 text-center text-sm">
|
||||
{t('settings.sources.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleSearchSelect(result)}
|
||||
title={result.path}
|
||||
className={`hover:bg-muted dark:hover:bg-muted flex min-w-0 cursor-pointer items-center px-3 py-2 ${
|
||||
index !== searchResults.length - 1
|
||||
? 'border-border dark:border-border border-b'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={result.isFile ? FileIcon : FolderIcon}
|
||||
alt={
|
||||
result.isFile
|
||||
? t('settings.sources.fileAlt')
|
||||
: t('settings.sources.folderAlt')
|
||||
}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{result.name}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileSearch = (searchQuery: string) => {
|
||||
if (directoryStructure) {
|
||||
return searchFiles(searchQuery, directoryStructure);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getDisplayNameForPath = (path: string) => {
|
||||
if (!directoryStructure) {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
let structure: any = directoryStructure;
|
||||
if (typeof structure === 'string') {
|
||||
try {
|
||||
structure = JSON.parse(structure);
|
||||
} catch (e) {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
}
|
||||
if (typeof structure !== 'object' || structure === null) {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let current: any = structure;
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
typeof current === 'object' &&
|
||||
typeof current.display_name === 'string' &&
|
||||
current.display_name.trim()
|
||||
) {
|
||||
return current.display_name;
|
||||
}
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
const handleFileSelect = (path: string) => {
|
||||
const pathParts = path.split('/');
|
||||
const fileName = pathParts.pop() || '';
|
||||
setCurrentPath(pathParts);
|
||||
setSelectedFile({
|
||||
id: path,
|
||||
name: getDisplayNameForPath(path) || fileName,
|
||||
});
|
||||
};
|
||||
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
|
||||
const navigateToPath = (index: number) => {
|
||||
setCurrentPath(currentPath.slice(0, index + 1));
|
||||
};
|
||||
const extraContent = (
|
||||
<ConfirmationModal
|
||||
message={t('settings.sources.syncConfirmation', { sourceName })}
|
||||
modalState={syncConfirmationModal}
|
||||
setModalState={setSyncConfirmationModal}
|
||||
handleSubmit={handleSync}
|
||||
submitLabel={t('settings.sources.sync')}
|
||||
cancelLabel={t('cancel')}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedFile ? (
|
||||
<div className="flex">
|
||||
<div className="flex-1">
|
||||
<Chunks
|
||||
documentId={docId}
|
||||
documentName={sourceName}
|
||||
handleGoBack={() => setSelectedFile(null)}
|
||||
path={selectedFile.id}
|
||||
displayPath={[...currentPath, selectedFile.name].join('/')}
|
||||
onFileSearch={handleFileSearch}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-2">{renderPathNavigation()}</div>
|
||||
|
||||
<div className="w-full">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader width="40%" align="left">
|
||||
{t('settings.sources.fileName')}
|
||||
</TableHeader>
|
||||
<TableHeader width="30%" align="left">
|
||||
{t('settings.sources.tokens')}
|
||||
</TableHeader>
|
||||
<TableHeader width="20%" align="left">
|
||||
{t('settings.sources.size')}
|
||||
</TableHeader>
|
||||
<TableHeader width="10%" align="right">
|
||||
<span className="sr-only">
|
||||
{t('settings.sources.actions')}
|
||||
</span>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<SkeletonLoader component="fileTable" />
|
||||
) : (
|
||||
renderFileTree(getCurrentDirectory())
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
message={t('settings.sources.syncConfirmation', {
|
||||
sourceName,
|
||||
})}
|
||||
modalState={syncConfirmationModal}
|
||||
setModalState={setSyncConfirmationModal}
|
||||
handleSubmit={handleSync}
|
||||
submitLabel={t('settings.sources.sync')}
|
||||
cancelLabel={t('cancel')}
|
||||
/>
|
||||
</div>
|
||||
<TreeBrowser
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
columnOrder="tokens-first"
|
||||
sortEntries
|
||||
controllerRef={controllerRef}
|
||||
topRightAction={topRightAction}
|
||||
extraContent={extraContent}
|
||||
onDirectoryDataLoaded={(data) => {
|
||||
if (data && data.provider) {
|
||||
setSourceProvider(data.provider);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { CSSProperties, SyntheticEvent, useEffect, useRef } from 'react';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface MenuOption {
|
||||
icon?: string | LucideIcon;
|
||||
label: string;
|
||||
onClick: (event: SyntheticEvent) => void;
|
||||
variant?: 'primary' | 'danger';
|
||||
iconClassName?: string;
|
||||
iconWidth?: number;
|
||||
iconHeight?: number;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
options: MenuOption[];
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>;
|
||||
position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
|
||||
offset?: { x: number; y: number };
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ContextMenu({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
options,
|
||||
anchorRef,
|
||||
className = '',
|
||||
position = 'bottom-right',
|
||||
offset = { x: 0, y: 8 },
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (isOpen && menuRef.current) {
|
||||
const positionStyle = getMenuPosition();
|
||||
if (menuRef.current) {
|
||||
Object.assign(menuRef.current.style, {
|
||||
top: positionStyle.top,
|
||||
left: positionStyle.left,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
!anchorRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getMenuPosition = (): CSSProperties => {
|
||||
if (!anchorRef.current) return {};
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
|
||||
let top = rect.bottom + scrollY + offset.y;
|
||||
let left = rect.right + scrollX + offset.x;
|
||||
|
||||
// Get menu dimensions (need ref to be available)
|
||||
const menuWidth = menuRef.current?.offsetWidth || 144; // Default min-width
|
||||
const menuHeight = menuRef.current?.offsetHeight || 0;
|
||||
|
||||
// Get viewport dimensions
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Adjust position based on specified position
|
||||
switch (position) {
|
||||
case 'bottom-left':
|
||||
left = rect.right + scrollX - menuWidth + offset.x;
|
||||
break;
|
||||
case 'top-right':
|
||||
top = rect.top + scrollY - offset.y - menuHeight;
|
||||
break;
|
||||
case 'top-left':
|
||||
top = rect.top + scrollY - offset.y - menuHeight;
|
||||
left = rect.right + scrollX - menuWidth + offset.x;
|
||||
break;
|
||||
// bottom-right is default
|
||||
}
|
||||
|
||||
if (left + menuWidth > viewportWidth) {
|
||||
left = Math.max(5, viewportWidth - menuWidth - 5);
|
||||
}
|
||||
|
||||
if (left < 5) {
|
||||
left = 5;
|
||||
}
|
||||
|
||||
if (top + menuHeight > viewportHeight + scrollY) {
|
||||
top = rect.top + scrollY - menuHeight - offset.y;
|
||||
}
|
||||
|
||||
if (top < scrollY + 5) {
|
||||
top = rect.bottom + scrollY + offset.y;
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`fixed z-50 ${className}`}
|
||||
style={{ ...getMenuPosition() }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="bg-background dark:bg-card flex flex-col rounded-xl text-sm shadow-xl"
|
||||
style={{ minWidth: '144px' }}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
option.onClick(event);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`flex items-center justify-start gap-4 p-3 transition-colors duration-200 ease-in-out ${index === 0 ? 'rounded-t-xl' : ''} ${index === options.length - 1 ? 'rounded-b-xl' : ''} ${
|
||||
option.variant === 'danger'
|
||||
? 'text-destructive hover:bg-muted'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
} `}
|
||||
>
|
||||
{option.icon && (
|
||||
<div className="flex w-4 min-w-4 shrink-0 justify-center">
|
||||
{typeof option.icon === 'string' ? (
|
||||
<img
|
||||
width={option.iconWidth || 16}
|
||||
height={option.iconHeight || 16}
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className={`cursor-pointer ${option.iconClassName || ''}`}
|
||||
/>
|
||||
) : (
|
||||
<option.icon
|
||||
size={Math.max(
|
||||
option.iconWidth || 16,
|
||||
option.iconHeight || 16,
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
className={`cursor-pointer ${option.iconClassName || ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="wrap-break-word hyphens-auto">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CheckMark from '../assets/checkmark.svg?react';
|
||||
import CopyIcon from '../assets/copy.svg?react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type CopyButtonProps = {
|
||||
textToCopy: string;
|
||||
@@ -47,8 +48,7 @@ export default function CopyButton({
|
||||
);
|
||||
|
||||
const rootButtonClasses = clsx(
|
||||
'flex items-center gap-2 group',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 rounded-full',
|
||||
'group h-auto rounded-full bg-transparent p-0 hover:bg-transparent disabled:opacity-100',
|
||||
className,
|
||||
);
|
||||
|
||||
@@ -103,8 +103,9 @@ export default function CopyButton({
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className={rootButtonClasses}
|
||||
title={buttonTitle}
|
||||
@@ -118,6 +119,6 @@ export default function CopyButton({
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{isCopied ? t('conversation.copied', 'Copied to clipboard') : ''}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import SingleArrowLeft from '../assets/single-left-arrow.svg';
|
||||
import SingleArrowRight from '../assets/single-right-arrow.svg';
|
||||
import DoubleArrowLeft from '../assets/double-arrow-left.svg';
|
||||
import DoubleArrowRight from '../assets/double-arrow-right.svg';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@@ -59,12 +60,15 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
{t('pagination.rowsPerPage')}:
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleDropdown}
|
||||
className="dark:bg-card dark:text-foreground hover:bg-accent dark:hover:bg-accent rounded border px-3 py-1"
|
||||
className="dark:bg-card h-auto rounded px-3 py-1 shadow-none"
|
||||
>
|
||||
{rowsPerPage}
|
||||
</button>
|
||||
</Button>
|
||||
<div
|
||||
className={`ring-opacity-5 dark:bg-card bg-card absolute right-0 z-50 mt-1 w-28 transform shadow-lg ring-1 ring-black transition-all duration-200 ease-in-out ${
|
||||
isDropdownOpen
|
||||
@@ -94,50 +98,62 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
{t('pagination.pageOf', { currentPage, totalPages })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-50">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFirstPage}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={DoubleArrowLeft}
|
||||
alt={t('pagination.firstPage')}
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={SingleArrowLeft}
|
||||
alt={t('pagination.previousPage')}
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={SingleArrowRight}
|
||||
alt={t('pagination.nextPage')}
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLastPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
className="h-auto rounded px-2 py-1 shadow-none"
|
||||
>
|
||||
<img
|
||||
src={DoubleArrowRight}
|
||||
alt={t('pagination.lastPage')}
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Check, ChevronDown, Pencil, Search, Trash2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type OptionBase = { id?: string; type?: string };
|
||||
type NameIdOption = { name: string; id: string } & OptionBase;
|
||||
type LabelValueOption = { label: string; value: string } & OptionBase;
|
||||
type ValueDescriptionOption = {
|
||||
value: number;
|
||||
description: string;
|
||||
} & OptionBase;
|
||||
|
||||
export type DropdownOption =
|
||||
| string
|
||||
| NameIdOption
|
||||
| LabelValueOption
|
||||
| ValueDescriptionOption;
|
||||
|
||||
export type { NameIdOption, LabelValueOption, ValueDescriptionOption };
|
||||
|
||||
function getOptionText(option: DropdownOption): string {
|
||||
if (typeof option === 'string') return option;
|
||||
if ('name' in option) return option.name;
|
||||
if ('label' in option) return option.label;
|
||||
if ('description' in option)
|
||||
return option.value < 1e9
|
||||
? `${option.value} (${option.description})`
|
||||
: option.description;
|
||||
return '';
|
||||
}
|
||||
|
||||
function optionMatches(
|
||||
option: DropdownOption,
|
||||
selected: DropdownOption | null,
|
||||
): boolean {
|
||||
if (!selected) return false;
|
||||
if (typeof selected === 'string' && typeof option === 'string')
|
||||
return selected === option;
|
||||
if (typeof selected === 'string') return getOptionText(option) === selected;
|
||||
if (typeof option === 'string') return getOptionText(selected) === option;
|
||||
const a = option as Record<string, unknown>;
|
||||
const b = selected as Record<string, unknown>;
|
||||
if ('name' in a && 'name' in b) return a.name === b.name;
|
||||
if ('label' in a && 'label' in b) return a.label === b.label;
|
||||
if ('value' in a && 'value' in b) return a.value === b.value;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface DropdownProps<T extends DropdownOption = DropdownOption> {
|
||||
options: T[];
|
||||
selectedValue: DropdownOption | null;
|
||||
onSelect: (value: T) => void;
|
||||
size?: string;
|
||||
rounded?: 'xl' | '3xl';
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
contentSize?: string;
|
||||
showEdit?: boolean;
|
||||
onEdit?: (value: NameIdOption) => void;
|
||||
showDelete?: boolean | ((option: T) => boolean);
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
function Dropdown<T extends DropdownOption>({
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
size = 'w-full',
|
||||
rounded = '3xl',
|
||||
searchable = false,
|
||||
placeholder = 'Select...',
|
||||
contentSize = 'text-sm',
|
||||
showEdit,
|
||||
onEdit,
|
||||
showDelete,
|
||||
onDelete,
|
||||
}: DropdownProps<T>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [dropUp, setDropUp] = useState(false);
|
||||
|
||||
const radius = rounded === '3xl' ? 'rounded-3xl' : 'rounded-xl';
|
||||
const radiusTop = rounded === '3xl' ? 'rounded-t-3xl' : 'rounded-t-xl';
|
||||
const radiusBottom = rounded === '3xl' ? 'rounded-b-3xl' : 'rounded-b-xl';
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler, true);
|
||||
return () => document.removeEventListener('mousedown', handler, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && searchable && searchRef.current) searchRef.current.focus();
|
||||
}, [open, searchable]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!open && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setDropUp(spaceBelow < 220);
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchable || !query.trim()) return options;
|
||||
const q = query.toLowerCase();
|
||||
return options.filter((o) => getOptionText(o).toLowerCase().includes(q));
|
||||
}, [options, query, searchable]);
|
||||
|
||||
const displayValue = selectedValue ? getOptionText(selectedValue) : null;
|
||||
|
||||
return (
|
||||
<div className={`relative ${size}`} ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className={`border-border bg-card text-foreground flex w-full cursor-pointer items-center justify-between border px-5 py-3 ${open ? (dropUp ? radiusBottom : radiusTop) : radius}`}
|
||||
>
|
||||
<span
|
||||
className={`truncate ${contentSize} ${displayValue ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{displayValue ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`text-muted-foreground ml-2 h-4 w-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={`border-border bg-card absolute inset-x-0 z-20 overflow-hidden border shadow-lg ${
|
||||
dropUp
|
||||
? `bottom-full -mt-px border-b-0 ${radiusTop}`
|
||||
: `-mt-px border-t-0 ${radiusBottom}`
|
||||
}`}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="flex items-center px-3 py-2">
|
||||
<Search className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="scrollbar-thin border-border max-h-48 overflow-y-auto border-t">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground px-4 py-3 text-center text-sm">
|
||||
No results found
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((option, i) => {
|
||||
const active = optionMatches(option, selectedValue);
|
||||
const optObj =
|
||||
typeof option !== 'string'
|
||||
? (option as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`hover:bg-accent flex cursor-pointer items-center justify-between ${active ? 'bg-accent font-medium' : ''}`}
|
||||
>
|
||||
<span
|
||||
onClick={() => {
|
||||
onSelect(option);
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}}
|
||||
className={`text-foreground flex-1 truncate px-4 py-2.5 ${contentSize}`}
|
||||
>
|
||||
{getOptionText(option)}
|
||||
</span>
|
||||
|
||||
{active && !showEdit && !showDelete && (
|
||||
<Check className="text-primary mr-3 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
|
||||
{showEdit &&
|
||||
onEdit &&
|
||||
optObj &&
|
||||
optObj.type !== 'public' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onEdit({
|
||||
id: optObj.id as string,
|
||||
name: optObj.name as string,
|
||||
type: optObj.type as string,
|
||||
});
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}}
|
||||
className="hover:bg-accent mr-1 rounded p-1"
|
||||
>
|
||||
<Pencil className="text-muted-foreground h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDelete && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(
|
||||
typeof option === 'string'
|
||||
? option
|
||||
: ((optObj?.id as string) ?? ''),
|
||||
);
|
||||
}}
|
||||
className={`hover:bg-accent mr-1 rounded p-1 ${
|
||||
typeof showDelete === 'function' &&
|
||||
!showDelete(option)
|
||||
? 'hidden'
|
||||
: ''
|
||||
} ${optObj?.type === 'public' ? 'pointer-events-none opacity-30' : ''}`}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dropdown;
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
type DropdownMenuProps = {
|
||||
name: string;
|
||||
options: { label: string; value: string }[];
|
||||
onSelect: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
icon?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
|
||||
offset?: { x: number; y: number };
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function DropdownMenu({
|
||||
name,
|
||||
options,
|
||||
onSelect,
|
||||
defaultValue = 'none',
|
||||
icon,
|
||||
isOpen: controlledIsOpen,
|
||||
onOpenChange,
|
||||
anchorRef,
|
||||
className = '',
|
||||
position = 'bottom-right',
|
||||
offset = { x: 0, y: 8 },
|
||||
}: DropdownMenuProps) {
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const [internalIsOpen, setInternalIsOpen] = React.useState(false);
|
||||
const [selectedOption, setSelectedOption] = React.useState(
|
||||
options.find((option) => option.value === defaultValue) || options[0],
|
||||
);
|
||||
|
||||
const isOpen =
|
||||
controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
||||
const setIsOpen = onOpenChange || setInternalIsOpen;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!anchorRef?.current?.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOption = (optionId: number) => {
|
||||
setIsOpen(false);
|
||||
setSelectedOption(options[optionId]);
|
||||
onSelect(options[optionId].value);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getMenuPosition = (): React.CSSProperties => {
|
||||
if (!anchorRef?.current) return {};
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
|
||||
const top = rect.bottom + offset.y;
|
||||
const left = rect.right + offset.x;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
zIndex: 9999,
|
||||
};
|
||||
};
|
||||
|
||||
// Use a portal to render the dropdown outside the table flow
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{ ...getMenuPosition() }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={`border-border bg-card w-28 transform rounded-md border shadow-lg transition-all duration-200 ease-in-out ${className}`}
|
||||
>
|
||||
<div
|
||||
role="menu"
|
||||
className="overflow-hidden rounded-md"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{options.map((option, idx) => (
|
||||
<div
|
||||
id={`option-${idx}`}
|
||||
className={`dark:text-foreground hover:bg-muted cursor-pointer px-4 py-2 text-xs ${
|
||||
selectedOption.value === option.value ? 'bg-muted' : 'bg-card'
|
||||
}`}
|
||||
role="menuitem"
|
||||
key={option.value}
|
||||
onClick={() => handleClickOption(idx)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import modelService from '../api/services/modelService';
|
||||
import Arrow2 from '../assets/dropdown-arrow.svg';
|
||||
import RoundedTick from '../assets/rounded-tick.svg';
|
||||
import {
|
||||
selectAvailableModels,
|
||||
selectSelectedModel,
|
||||
selectToken,
|
||||
setAvailableModels,
|
||||
setModelsLoading,
|
||||
setSelectedModel,
|
||||
} from '../preferences/preferenceSlice';
|
||||
|
||||
import type { Model } from '../models/types';
|
||||
|
||||
export default function DropdownModel() {
|
||||
const dispatch = useDispatch();
|
||||
const selectedModel = useSelector(selectSelectedModel);
|
||||
const availableModels = useSelector(selectAvailableModels);
|
||||
const token = useSelector(selectToken);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
// Tracks which token the cached availableModels were loaded for.
|
||||
// Without this, the early-return below pins the anonymous/built-in
|
||||
// list forever once it's populated — login/logout never refetches
|
||||
// and a user's BYOM models stay invisible.
|
||||
const lastLoadedTokenRef = React.useRef<string | null | undefined>(undefined);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
if (
|
||||
(availableModels?.length ?? 0) > 0 &&
|
||||
lastLoadedTokenRef.current === token
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dispatch(setModelsLoading(true));
|
||||
try {
|
||||
const response = await modelService.getModels(token);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const models = data.models || [];
|
||||
const transformed = modelService.transformModels(models);
|
||||
|
||||
dispatch(setAvailableModels(transformed));
|
||||
lastLoadedTokenRef.current = token;
|
||||
if (!selectedModel && transformed.length > 0) {
|
||||
const defaultModel =
|
||||
transformed.find((m) => m.id === data.default_model_id) ||
|
||||
transformed[0];
|
||||
dispatch(setSelectedModel(defaultModel));
|
||||
} else if (selectedModel && transformed.length > 0) {
|
||||
const isValid = transformed.find((m) => m.id === selectedModel.id);
|
||||
if (!isValid) {
|
||||
const defaultModel =
|
||||
transformed.find((m) => m.id === data.default_model_id) ||
|
||||
transformed[0];
|
||||
dispatch(setSelectedModel(defaultModel));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
} finally {
|
||||
dispatch(setModelsLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}, [availableModels?.length, dispatch, selectedModel, token]);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef}>
|
||||
<div
|
||||
className={`text-foreground bg-muted dark:bg-card mx-auto flex w-full cursor-pointer items-center justify-between px-3 py-4 ${isOpen ? 'rounded-t-4xl' : 'rounded-4xl'}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{selectedModel?.display_name ? (
|
||||
<p className="ml-3 truncate overflow-hidden whitespace-nowrap">
|
||||
{selectedModel.display_name}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground truncate overflow-hidden whitespace-nowrap">
|
||||
Select Model
|
||||
</p>
|
||||
)}
|
||||
<img
|
||||
src={Arrow2}
|
||||
alt="arrow"
|
||||
className={`${
|
||||
isOpen ? 'rotate-360' : 'rotate-270'
|
||||
} mr-3 w-3 transition-all select-none`}
|
||||
/>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="no-scrollbar bg-muted dark:bg-card absolute right-0 left-0 z-20 -mt-1 max-h-52 w-full overflow-y-auto rounded-b-3xl shadow-md">
|
||||
{availableModels && (availableModels?.length ?? 0) > 0 ? (
|
||||
availableModels.map((model: Model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
dispatch(setSelectedModel(model));
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`border-border/30 hover:bg-accent flex h-10 w-full cursor-pointer items-center justify-between border-t`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="flex-1 truncate py-3 pr-2 pl-5">
|
||||
{model.display_name}
|
||||
</p>
|
||||
{model.id === selectedModel?.id ? (
|
||||
<img
|
||||
src={RoundedTick}
|
||||
alt="selected"
|
||||
className="mr-3.5 h-4 w-4"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="border-border/30 flex h-10 w-full items-center border-t">
|
||||
<p className="text-muted-foreground pl-5 text-sm">
|
||||
No models available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import FileIcon from '../assets/file.svg';
|
||||
import FolderIcon from '../assets/folder.svg';
|
||||
import CheckIcon from '../assets/checkmark.svg';
|
||||
import SearchIcon from '../assets/search.svg';
|
||||
import Input from './Input';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableContainer,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
} from './Table';
|
||||
} from './ui/table';
|
||||
import { useDebouncedCallback } from '../hooks';
|
||||
|
||||
interface CloudFile {
|
||||
@@ -432,26 +433,30 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
<div className="border-border dark:border-border rounded-t-lg">
|
||||
{provider === 'share_point' && allowsSharedContent && (
|
||||
<div className="border-border dark:border-border flex border-b">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleTabChange('my_files')}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
className={`h-auto rounded-none px-4 py-2 text-sm font-medium ${
|
||||
activeTab === 'my_files'
|
||||
? 'border-b-2 border-[#A076F6] text-[#A076F6]'
|
||||
? 'border-b-2 border-[#A076F6] text-[#A076F6] hover:bg-transparent hover:text-[#A076F6]'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{t('filePicker.myFiles')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleTabChange('shared')}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
className={`h-auto rounded-none px-4 py-2 text-sm font-medium ${
|
||||
activeTab === 'shared'
|
||||
? 'border-b-2 border-[#A076F6] text-[#A076F6]'
|
||||
? 'border-b-2 border-[#A076F6] text-[#A076F6] hover:bg-transparent hover:text-[#A076F6]'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{t('filePicker.sharedWithMe')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dark:bg-muted rounded-t-lg bg-[#EEE6FF78] px-4 pt-4">
|
||||
@@ -462,13 +467,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{index > 0 && <span className="text-gray-400">/</span>}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => navigateBack(index)}
|
||||
className="text-sm text-[#A076F6] hover:text-[#8A5FD4] hover:underline"
|
||||
className="h-auto p-0 text-sm text-[#A076F6] underline-offset-2 hover:text-[#8A5FD4]"
|
||||
disabled={index === folderPath.length - 1}
|
||||
>
|
||||
{path.name}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -480,11 +488,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
|
||||
<div className="mb-3 max-w-md">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('filePicker.searchPlaceholder')}
|
||||
label={t('filePicker.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
colorVariant="silver"
|
||||
borderVariant="thin"
|
||||
labelBgClassName="bg-[#EEE6FF78] dark:bg-muted"
|
||||
leftIcon={
|
||||
<img src={SearchIcon} alt="Search" width={16} height={16} />
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const FilesSectionSkeleton = () => (
|
||||
<div className="border-border dark:border-border rounded-lg border">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="h-5 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-8 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default FilesSectionSkeleton;
|
||||
@@ -163,7 +163,7 @@ export const FileUpload = ({
|
||||
e.stopPropagation();
|
||||
handleRemove();
|
||||
}}
|
||||
className="absolute -top-2 -right-2 rounded-full bg-[#7D54D1] p-1 transition-colors hover:bg-[#714cbc]"
|
||||
className="bg-primary hover:bg-primary/90 absolute -top-2 -right-2 rounded-full p-1 transition-colors"
|
||||
>
|
||||
<img src={Cross} alt="remove" className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
removeSessionToken,
|
||||
validateProviderSession,
|
||||
} from '../utils/providerUtils';
|
||||
import ConnectedStateSkeleton from './ConnectedStateSkeleton';
|
||||
import FilesSectionSkeleton from './FileSelectionSkeleton';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface PickerFile {
|
||||
id: string;
|
||||
@@ -224,8 +224,8 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
<div>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<ConnectedStateSkeleton />
|
||||
<FilesSectionSkeleton />
|
||||
<SkeletonLoader component="connectedState" />
|
||||
<SkeletonLoader component="filesSection" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -262,9 +262,11 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('modals.uploadDoc.connectors.googleDrive.selectedFiles')}
|
||||
</h3>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleOpenPicker()}
|
||||
className="rounded-md bg-[#A076F6] px-3 py-1 text-sm text-white hover:bg-[#8A5FD4]"
|
||||
className="bg-[#A076F6] hover:bg-[#8A5FD4]"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
@@ -272,7 +274,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
: t(
|
||||
'modals.uploadDoc.connectors.googleDrive.selectFiles',
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length === 0 && selectedFolders.length === 0 ? (
|
||||
@@ -303,7 +305,10 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{folder.name}
|
||||
</span>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newSelectedFolders =
|
||||
selectedFolders.filter(
|
||||
@@ -315,12 +320,12 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
newSelectedFolders.map((f) => f.id),
|
||||
);
|
||||
}}
|
||||
className="ml-2 text-sm text-red-500 hover:text-red-700"
|
||||
className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
|
||||
>
|
||||
{t(
|
||||
'modals.uploadDoc.connectors.googleDrive.remove',
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -346,7 +351,10 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{file.name}
|
||||
</span>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newSelectedFiles = selectedFiles.filter(
|
||||
(f) => f.id !== file.id,
|
||||
@@ -357,12 +365,12 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
|
||||
selectedFolders.map((f) => f.id),
|
||||
);
|
||||
}}
|
||||
className="ml-2 text-sm text-red-500 hover:text-red-700"
|
||||
className="ml-2 h-auto p-0 text-sm text-red-500 hover:text-red-700 hover:no-underline"
|
||||
>
|
||||
{t(
|
||||
'modals.uploadDoc.connectors.googleDrive.remove',
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Info from '../assets/info.svg';
|
||||
import PageIcon from '../assets/documentation.svg';
|
||||
import EmailIcon from '../assets/envelope.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from './ui/button';
|
||||
const Help = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -33,14 +34,16 @@ const Help = () => {
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-sm" ref={dropdownRef}>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
ref={buttonRef}
|
||||
onClick={toggleDropdown}
|
||||
className="hover:bg-sidebar-accent mx-4 my-auto flex h-9 w-full items-center gap-4 rounded-3xl"
|
||||
className="hover:bg-sidebar-accent mx-4 my-auto w-full justify-start gap-4 rounded-3xl px-0"
|
||||
>
|
||||
<img src={Info} alt="info" className="ml-2 w-5 filter dark:invert" />
|
||||
{t('help')}
|
||||
</button>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`dark:bg-card bg-card absolute z-10 w-48 translate-x-4 -translate-y-28 rounded-xl shadow-lg`}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { InputProps } from './types';
|
||||
import { useRef } from 'react';
|
||||
|
||||
const Input = ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
isAutoFocused = false,
|
||||
placeholder,
|
||||
required = false,
|
||||
maxLength,
|
||||
className = '',
|
||||
colorVariant = 'silver',
|
||||
borderVariant = 'thick',
|
||||
textSize = 'medium',
|
||||
children,
|
||||
labelBgClassName = 'bg-card',
|
||||
leftIcon,
|
||||
onChange,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
edgeRoundness = 'rounded-full',
|
||||
'data-testid': dataTestId,
|
||||
}: InputProps) => {
|
||||
const colorStyles = {
|
||||
silver: 'border-border dark:border-border',
|
||||
jet: 'border-jet',
|
||||
gray: 'border-gray-5000 dark:text-muted-foreground',
|
||||
};
|
||||
const borderStyles = {
|
||||
thin: 'border',
|
||||
thick: 'border-2',
|
||||
};
|
||||
const textSizeStyles = {
|
||||
small: 'text-sm',
|
||||
medium: 'text-base',
|
||||
};
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasValue = value !== undefined && value !== null && value !== '';
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={`peer text-foreground dark:text-foreground h-[42px] w-full ${edgeRoundness} bg-transparent ${leftIcon ? 'pl-10' : 'px-3'} py-1 placeholder-transparent outline-hidden ${colorStyles[colorVariant]} ${borderStyles[borderVariant]} ${textSizeStyles[textSize]} [&:-webkit-autofill]:appearance-none [&:-webkit-autofill]:bg-transparent [&:-webkit-autofill_selected]:bg-transparent`}
|
||||
type={type}
|
||||
id={id}
|
||||
name={name}
|
||||
autoFocus={isAutoFocused}
|
||||
placeholder={placeholder || ''}
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
required={required}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{children}
|
||||
</input>
|
||||
{leftIcon && (
|
||||
<div className="absolute top-1/2 left-3 flex -translate-y-1/2 transform items-center justify-center">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
{placeholder && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`absolute select-none ${
|
||||
hasValue ? '-top-2.5 left-3 text-xs' : ''
|
||||
} px-2 transition-all peer-placeholder-shown:top-2.5 ${
|
||||
leftIcon
|
||||
? 'peer-placeholder-shown:left-7'
|
||||
: 'peer-placeholder-shown:left-3'
|
||||
} peer-placeholder-shown:${
|
||||
textSizeStyles[textSize]
|
||||
} text-muted-foreground pointer-events-none cursor-none peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs ${labelBgClassName} max-w-[calc(100%-24px)] overflow-hidden text-ellipsis whitespace-nowrap`}
|
||||
>
|
||||
{placeholder}
|
||||
{required && (
|
||||
<span className="ml-0.5 text-[#D30000] dark:text-red-500">*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { selectStatus } from '../conversation/conversationSlice';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import CopyButton from './CopyButton';
|
||||
import { Button } from './ui/button';
|
||||
import { MermaidRendererProps } from './types';
|
||||
|
||||
const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
@@ -263,8 +264,8 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
const errorRender = !isCurrentlyLoading && error;
|
||||
|
||||
return (
|
||||
<div className="w-inherit group border-border bg-card relative overflow-hidden rounded-[14px] border">
|
||||
<div className="bg-platinum dark:bg-muted flex items-center justify-between px-2 py-1">
|
||||
<div className="w-inherit group border-border bg-card relative overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
mermaid
|
||||
</span>
|
||||
@@ -273,27 +274,32 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
|
||||
{showDiagramOptions && (
|
||||
<div className="relative" ref={downloadMenuRef}>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||||
className="flex h-full items-center rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
|
||||
className="h-auto gap-1 rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
|
||||
title={t('mermaid.downloadOptions')}
|
||||
>
|
||||
Download <span className="ml-1">▼</span>
|
||||
</button>
|
||||
</Button>
|
||||
{showDownloadMenu && (
|
||||
<div className="border-border bg-card absolute right-0 z-10 mt-1 w-40 rounded-sm border shadow-lg">
|
||||
<ul>
|
||||
{downloadOptions.map((option, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
option.action();
|
||||
setShowDownloadMenu(false);
|
||||
}}
|
||||
className="hover:bg-muted w-full px-4 py-2 text-left text-xs"
|
||||
className="h-auto w-full justify-start rounded-none px-4 py-2 text-left text-xs font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -303,17 +309,20 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
)}
|
||||
|
||||
{showDiagramOptions && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className={`flex h-full items-center rounded px-2 py-1 text-xs ${
|
||||
className={`h-auto rounded px-2 py-1 text-xs ${
|
||||
showCode
|
||||
? 'bg-blue-200 dark:bg-blue-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700'
|
||||
? 'bg-blue-200 hover:bg-blue-200/90 dark:bg-blue-800 dark:hover:bg-blue-800/90'
|
||||
: 'bg-gray-100 hover:bg-gray-100/90 dark:bg-gray-700 dark:hover:bg-gray-700/90'
|
||||
}`}
|
||||
title={t('mermaid.viewCode')}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,15 +360,17 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
{isHovering && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 px-2 py-1 text-xs text-white">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setZoomFactor((prev) => Math.max(1, prev - 0.5))
|
||||
}
|
||||
className="rounded px-1 hover:bg-gray-600"
|
||||
className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
|
||||
title={t('mermaid.decreaseZoom')}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</Button>
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
@@ -369,15 +380,17 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
>
|
||||
{zoomFactor.toFixed(1)}x
|
||||
</span>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setZoomFactor((prev) => Math.min(6, prev + 0.5))
|
||||
}
|
||||
className="rounded px-1 hover:bg-gray-600"
|
||||
className="h-auto px-1 py-0 text-white hover:bg-gray-600 hover:text-white"
|
||||
title={t('mermaid.increaseZoom')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -401,7 +414,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
|
||||
{showCode && (
|
||||
<div className="border-border border-t">
|
||||
<div className="bg-platinum dark:bg-muted p-2">
|
||||
<div className="bg-muted p-2">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
Mermaid Code
|
||||
</span>
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { LoaderCircle, Mic, Square } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
|
||||
import endpoints from '../api/endpoints';
|
||||
import userService from '../api/services/userService';
|
||||
import AlertIcon from '../assets/alert.svg';
|
||||
import ClipIcon from '../assets/clip.svg';
|
||||
import DragFileUpload from '../assets/DragFileUpload.svg';
|
||||
import ExitIcon from '../assets/exit.svg';
|
||||
import SendArrowIcon from './SendArrowIcon';
|
||||
import SendArrow from '../assets/send.svg?react';
|
||||
import SourceIcon from '../assets/source.svg';
|
||||
import DocumentationDark from '../assets/documentation-dark.svg';
|
||||
import ToolIcon from '../assets/tool.svg';
|
||||
import {
|
||||
addAttachment,
|
||||
removeAttachment,
|
||||
@@ -26,25 +20,34 @@ import {
|
||||
import { ActiveState, Doc } from '../models/misc';
|
||||
import {
|
||||
selectSelectedDocs,
|
||||
selectSourceDocs,
|
||||
selectToken,
|
||||
setSelectedDocs,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import type { RootState } from '../store';
|
||||
import Upload from '../upload/Upload';
|
||||
import { isTouchDevice } from '../utils/browserUtils';
|
||||
import SourcesPopup from './SourcesPopup';
|
||||
import ToolsPopup from './ToolsPopup';
|
||||
import { Button } from './ui/button';
|
||||
import { type MultiSelectPopoverItem } from './MultiSelectPopover';
|
||||
import {
|
||||
AttachFileButton,
|
||||
AttachmentChipList,
|
||||
MicButton,
|
||||
type RecordingState,
|
||||
SourcesTrigger,
|
||||
ToolsTrigger,
|
||||
} from './message-input';
|
||||
import { handleAbort } from '../conversation/conversationSlice';
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT_ATTR,
|
||||
FILE_UPLOAD_ACCEPT,
|
||||
FILE_UPLOAD_ACCEPT_ATTR,
|
||||
} from '../constants/fileUpload';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { isChatToolVisible } from '../utils/toolUtils';
|
||||
|
||||
const generateId = (): string =>
|
||||
`${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
||||
|
||||
type RecordingState = 'idle' | 'recording' | 'transcribing' | 'error';
|
||||
|
||||
const LIVE_TRANSCRIPTION_TIMESLICE_MS = 1000;
|
||||
const LIVE_CAPTURE_SAMPLE_RATE = 16000;
|
||||
const LIVE_CAPTURE_MAX_BUFFER_SECONDS = 20;
|
||||
@@ -302,10 +305,10 @@ export default function MessageInput({
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const voiceFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sourceButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const toolButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
|
||||
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
|
||||
const [userTools, setUserTools] = useState<UserToolType[]>([]);
|
||||
const [toolsLoading, setToolsLoading] = useState(false);
|
||||
const [uploadModalState, setUploadModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [handleDragActive, setHandleDragActive] = useState<boolean>(false);
|
||||
@@ -313,6 +316,7 @@ export default function MessageInput({
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
const sourceDocs = useSelector(selectSourceDocs);
|
||||
const token = useSelector(selectToken);
|
||||
const attachments = useSelector(selectAttachments);
|
||||
|
||||
@@ -1377,10 +1381,91 @@ export default function MessageInput({
|
||||
};
|
||||
|
||||
const handlePostDocumentSelect = (_docs: Doc[] | null) => {
|
||||
// SourcesPopup updates Redux selection directly; this preserves the prop contract.
|
||||
// Hook point for downstream side-effects after a source is toggled.
|
||||
void _docs;
|
||||
};
|
||||
|
||||
// Stable id for matching selected sources: prefer ``id``, fall back to ``date``.
|
||||
const sourceItemId = (doc: Doc): string => doc.id || doc.date;
|
||||
|
||||
const sourceItems: MultiSelectPopoverItem[] = (sourceDocs || []).map(
|
||||
(doc) => ({
|
||||
id: sourceItemId(doc),
|
||||
label: doc.name,
|
||||
icon: SourceIcon,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedSourceIds = (
|
||||
selectedDocs && Array.isArray(selectedDocs) ? selectedDocs : []
|
||||
).map((doc) => sourceItemId(doc));
|
||||
|
||||
const handleToggleSource = (id: string) => {
|
||||
if (!sourceDocs) return;
|
||||
const current = Array.isArray(selectedDocs) ? selectedDocs : [];
|
||||
const matched = current.find((doc) => sourceItemId(doc) === id);
|
||||
let updated: Doc[];
|
||||
if (matched) {
|
||||
updated = current.filter((doc) => sourceItemId(doc) !== id);
|
||||
} else {
|
||||
const incoming = sourceDocs.find((doc) => sourceItemId(doc) === id);
|
||||
if (!incoming) return;
|
||||
updated = [...current, incoming];
|
||||
}
|
||||
dispatch(setSelectedDocs(updated.length > 0 ? updated : []));
|
||||
handlePostDocumentSelect(updated.length > 0 ? updated : null);
|
||||
};
|
||||
|
||||
const fetchUserTools = useCallback(() => {
|
||||
setToolsLoading(true);
|
||||
userService
|
||||
.getUserTools(token)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const filtered = (data.tools || []).filter(isChatToolVisible);
|
||||
setUserTools(filtered);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching tools:', error);
|
||||
})
|
||||
.finally(() => setToolsLoading(false));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isToolsPopupOpen) fetchUserTools();
|
||||
}, [isToolsPopupOpen, fetchUserTools]);
|
||||
|
||||
const toolItems: MultiSelectPopoverItem[] = userTools.map((tool) => ({
|
||||
id: tool.id,
|
||||
label: tool.customName || tool.displayName,
|
||||
icon: `/toolIcons/tool_${tool.name}.svg`,
|
||||
}));
|
||||
|
||||
const selectedToolIds = userTools
|
||||
.filter((tool) => tool.status)
|
||||
.map((tool) => tool.id);
|
||||
|
||||
const handleToggleTool = (id: string) => {
|
||||
const tool = userTools.find((t) => t.id === id);
|
||||
if (!tool) return;
|
||||
const newStatus = !tool.status;
|
||||
userService
|
||||
.updateToolStatus({ id, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t)),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
setUploadModalState('ACTIVE');
|
||||
setIsSourcesPopupOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (
|
||||
value.trim() &&
|
||||
@@ -1439,19 +1524,6 @@ export default function MessageInput({
|
||||
setDraggingId(null);
|
||||
};
|
||||
|
||||
const voiceButtonLabel =
|
||||
recordingState === 'recording'
|
||||
? 'Stop recording'
|
||||
: recordingState === 'transcribing'
|
||||
? 'Transcribing audio'
|
||||
: 'Voice input';
|
||||
const voiceButtonText =
|
||||
recordingState === 'recording'
|
||||
? 'Stop'
|
||||
: recordingState === 'transcribing'
|
||||
? 'Transcribing'
|
||||
: 'Voice';
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className="flex w-full flex-col">
|
||||
{/* react-dropzone input (for drag/drop) */}
|
||||
@@ -1465,97 +1537,15 @@ export default function MessageInput({
|
||||
onChange={handleVoiceFileAttachment}
|
||||
/>
|
||||
|
||||
<div className="border-border bg-card relative flex w-full flex-col rounded-[23px] border dark:bg-transparent">
|
||||
<div className="flex flex-wrap gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
|
||||
{attachments.map((attachment) => {
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
draggable={true}
|
||||
onDragStart={(e) => handleDragStart(e, attachment.id)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDropOn(e, attachment.id)}
|
||||
className={`group dark:text-foreground bg-muted text-muted-foreground dark:bg-accent relative flex items-center rounded-xl px-2 py-1 text-[12px] sm:px-3 sm:py-1.5 sm:text-[14px] ${
|
||||
attachment.status !== 'completed'
|
||||
? 'opacity-70'
|
||||
: 'opacity-100'
|
||||
} ${
|
||||
draggingId === attachment.id
|
||||
? 'ring-dashed opacity-60 ring-2 ring-purple-200'
|
||||
: ''
|
||||
}`}
|
||||
title={attachment.fileName}
|
||||
>
|
||||
<div className="bg-primary mr-2 flex h-8 w-8 items-center justify-center rounded-md p-1">
|
||||
{attachment.status === 'completed' && (
|
||||
<img
|
||||
src={DocumentationDark}
|
||||
alt="Attachment"
|
||||
className="h-[15px] w-[15px] object-fill"
|
||||
/>
|
||||
)}
|
||||
|
||||
{attachment.status === 'failed' && (
|
||||
<img
|
||||
src={AlertIcon}
|
||||
alt="Failed"
|
||||
className="h-[15px] w-[15px] object-fill"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(attachment.status === 'uploading' ||
|
||||
attachment.status === 'processing') && (
|
||||
<div className="flex h-[15px] w-[15px] items-center justify-center">
|
||||
<svg className="h-[15px] w-[15px]" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-0"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="transparent"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
className="text-[#ECECF1]"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray="62.83"
|
||||
strokeDashoffset={
|
||||
62.83 * (1 - attachment.progress / 100)
|
||||
}
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
|
||||
{attachment.fileName}
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="ml-1.5 flex items-center justify-center rounded-full p-1"
|
||||
onClick={() => {
|
||||
dispatch(removeAttachment(attachment.id));
|
||||
}}
|
||||
aria-label={t('conversation.attachments.remove')}
|
||||
>
|
||||
<img
|
||||
src={ExitIcon}
|
||||
alt={t('conversation.attachments.remove')}
|
||||
className="h-2.5 w-2.5 filter dark:invert"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-border bg-card relative flex w-full flex-col rounded-3xl border dark:bg-transparent">
|
||||
<AttachmentChipList
|
||||
attachments={attachments}
|
||||
draggingId={draggingId}
|
||||
onRemove={(id) => dispatch(removeAttachment(id))}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDropOn={handleDropOn}
|
||||
/>
|
||||
|
||||
{voiceError && (
|
||||
<div className="px-2 pb-1 text-xs text-[#B42318] sm:px-3">
|
||||
@@ -1579,7 +1569,7 @@ export default function MessageInput({
|
||||
}
|
||||
tabIndex={1}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
className="inputbox-style no-scrollbar dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full overflow-x-hidden overflow-y-auto rounded-t-[23px] bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3"
|
||||
className="inputbox-style no-scrollbar dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3"
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
@@ -1590,118 +1580,60 @@ export default function MessageInput({
|
||||
<div className="flex items-center px-2 pb-1.5 sm:px-3 sm:pb-2">
|
||||
<div className="flex grow flex-wrap gap-1 sm:gap-2">
|
||||
{showSourceButton && (
|
||||
<button
|
||||
ref={sourceButtonRef}
|
||||
className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-accent dark:hover:bg-muted flex max-w-[130px] items-center rounded-[32px] border px-2 py-1 transition-colors sm:max-w-[150px]"
|
||||
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
|
||||
title={
|
||||
selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.map((doc) => doc.name).join(', ')
|
||||
: t('conversation.sources.title')
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={SourceIcon}
|
||||
alt="Sources"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4"
|
||||
/>
|
||||
<span className="xs:text-[12px] dark:text-foreground text-muted-foreground truncate overflow-hidden text-[10px] font-medium sm:text-[14px]">
|
||||
{selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.length === 1
|
||||
? selectedDocs[0].name
|
||||
: `${selectedDocs.length} sources selected`
|
||||
: t('conversation.sources.title')}
|
||||
</span>
|
||||
</button>
|
||||
<SourcesTrigger
|
||||
open={isSourcesPopupOpen}
|
||||
onOpenChange={setIsSourcesPopupOpen}
|
||||
items={sourceItems}
|
||||
selectedIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
selectedDocs={selectedDocs}
|
||||
onUploadClick={handleUploadClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showToolButton && (
|
||||
<button
|
||||
ref={toolButtonRef}
|
||||
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex max-w-[130px] items-center rounded-[32px] border px-2 py-1 transition-colors"
|
||||
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
|
||||
>
|
||||
<img
|
||||
src={ToolIcon}
|
||||
alt="Tools"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<span className="xs:text-[12px] dark:text-foreground text-muted-foreground truncate overflow-hidden text-[10px] font-medium sm:text-[14px]">
|
||||
{t('settings.tools.label')}
|
||||
</span>
|
||||
</button>
|
||||
<ToolsTrigger
|
||||
open={isToolsPopupOpen}
|
||||
onOpenChange={setIsToolsPopupOpen}
|
||||
items={toolItems}
|
||||
selectedIds={selectedToolIds}
|
||||
onToggle={handleToggleTool}
|
||||
loading={toolsLoading}
|
||||
/>
|
||||
)}
|
||||
{ENABLE_VOICE_INPUT && (
|
||||
<button
|
||||
type="button"
|
||||
<MicButton
|
||||
recordingState={recordingState}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void handleVoiceInput();
|
||||
}}
|
||||
aria-label={voiceButtonLabel}
|
||||
title={voiceButtonLabel}
|
||||
disabled={loading || recordingState === 'transcribing'}
|
||||
className={`xs:px-3 xs:py-1.5 dark:border-border flex items-center rounded-[32px] border px-2 py-1 transition-colors ${
|
||||
recordingState === 'recording'
|
||||
? 'border-[#B42318] bg-[#FEE4E2] text-[#B42318] dark:bg-[#4A2323]'
|
||||
: 'border-border dark:hover:bg-accent hover:bg-gray-100'
|
||||
} ${
|
||||
loading || recordingState === 'transcribing'
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{recordingState === 'transcribing' ? (
|
||||
<LoaderCircle className="mr-1 h-3.5 w-3.5 animate-spin sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Square className="mr-1 h-3.5 w-3.5 fill-current sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Mic className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
<span
|
||||
className={`xs:text-[12px] dark:text-foreground text-[10px] font-medium sm:text-[14px] ${
|
||||
recordingState === 'recording'
|
||||
? 'text-[#B42318]'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{voiceButtonText}
|
||||
</span>
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
<label className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex cursor-pointer items-center rounded-[32px] border px-2 py-1 transition-colors">
|
||||
<img
|
||||
src={ClipIcon}
|
||||
alt="Attach"
|
||||
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<span className="xs:text-[12px] dark:text-foreground text-muted-foreground text-[10px] font-medium sm:text-[14px]">
|
||||
{t('conversation.attachments.attach')}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={FILE_UPLOAD_ACCEPT_ATTR}
|
||||
onChange={handleFileAttachment}
|
||||
/>
|
||||
</label>
|
||||
<AttachFileButton onChange={handleFileAttachment} />
|
||||
{/* Additional badges can be added here in the future */}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={handleCancel}
|
||||
aria-label={t('cancel')}
|
||||
className={`bg-primary ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-white sm:h-9 sm:w-9`}
|
||||
className="bg-primary ml-auto h-7 w-7 shrink-0 rounded-full text-white sm:h-9 sm:w-9"
|
||||
disabled={!loading}
|
||||
>
|
||||
<div className="flex h-3 w-3 items-center justify-center rounded-[3px] bg-white sm:h-3.5 sm:w-3.5" />
|
||||
</button>
|
||||
<div className="flex h-3 w-3 items-center justify-center rounded-sm bg-white sm:h-3.5 sm:w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
aria-label={t('send')}
|
||||
className={`ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${
|
||||
className={`ml-auto h-7 w-7 shrink-0 rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${
|
||||
value.trim() &&
|
||||
!loading &&
|
||||
recordingState !== 'recording' &&
|
||||
@@ -1716,30 +1648,16 @@ export default function MessageInput({
|
||||
recordingState === 'transcribing'
|
||||
}
|
||||
>
|
||||
<SendArrowIcon
|
||||
<SendArrow
|
||||
className="mx-auto my-auto block h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
aria-label={t('send')}
|
||||
role="img"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SourcesPopup
|
||||
isOpen={isSourcesPopupOpen}
|
||||
onClose={() => setIsSourcesPopupOpen(false)}
|
||||
anchorRef={sourceButtonRef}
|
||||
handlePostDocumentSelect={handlePostDocumentSelect}
|
||||
setUploadModalState={setUploadModalState}
|
||||
/>
|
||||
|
||||
<ToolsPopup
|
||||
isOpen={isToolsPopupOpen}
|
||||
onClose={() => setIsToolsPopupOpen(false)}
|
||||
anchorRef={toolButtonRef}
|
||||
/>
|
||||
|
||||
{uploadModalState === 'ACTIVE' && (
|
||||
<Upload
|
||||
receivedFile={[]}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CheckmarkIcon from '../assets/checkmark.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Spinner from './Spinner';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
|
||||
export type MultiSelectPopoverItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode | string;
|
||||
group?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type MultiSelectPopoverProps = {
|
||||
trigger: React.ReactNode;
|
||||
items: MultiSelectPopoverItem[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
contentClassName?: string;
|
||||
emptyMessage?: string;
|
||||
selectedFirst?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function renderIcon(icon: MultiSelectPopoverItem['icon']) {
|
||||
if (!icon) return null;
|
||||
if (typeof icon === 'string') {
|
||||
return (
|
||||
<img
|
||||
src={icon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mr-3 h-5 w-5 shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mr-3 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectPopover({
|
||||
trigger,
|
||||
items,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
open,
|
||||
onOpenChange,
|
||||
searchable = true,
|
||||
searchPlaceholder,
|
||||
loading = false,
|
||||
footer,
|
||||
align = 'start',
|
||||
side = 'bottom',
|
||||
contentClassName,
|
||||
emptyMessage,
|
||||
selectedFirst = false,
|
||||
title,
|
||||
}: MultiSelectPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const orderedItems = React.useMemo(() => {
|
||||
if (!selectedFirst) return items;
|
||||
const selected: MultiSelectPopoverItem[] = [];
|
||||
const unselected: MultiSelectPopoverItem[] = [];
|
||||
for (const item of items) {
|
||||
(selectedSet.has(item.id) ? selected : unselected).push(item);
|
||||
}
|
||||
return [...selected, ...unselected];
|
||||
}, [items, selectedSet, selectedFirst]);
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const groupOrder: string[] = [];
|
||||
const map = new Map<string, MultiSelectPopoverItem[]>();
|
||||
orderedItems.forEach((item) => {
|
||||
const key = item.group ?? '';
|
||||
if (!map.has(key)) {
|
||||
groupOrder.push(key);
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(item);
|
||||
});
|
||||
return { groupOrder, map };
|
||||
}, [orderedItems]);
|
||||
|
||||
const hasGroups = grouped.groupOrder.some((g) => g !== '');
|
||||
const effectivePlaceholder =
|
||||
searchPlaceholder || t('settings.tools.searchPlaceholder', 'Search...');
|
||||
|
||||
const renderItem = (item: MultiSelectPopoverItem) => {
|
||||
const isSelected = selectedSet.has(item.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.label} ${item.id}`}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => {
|
||||
if (!item.disabled) onToggle(item.id);
|
||||
}}
|
||||
className="dark:border-border data-[selected=true]:bg-accent flex cursor-pointer items-center justify-between gap-2 rounded-none border-b border-[#D9D9D9] px-3 py-3 last:border-b-0"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div className="mr-3 flex grow items-center overflow-hidden">
|
||||
{renderIcon(item.icon)}
|
||||
<div className="overflow-hidden">
|
||||
<p
|
||||
className="overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white"
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
{item.description && (
|
||||
<p className="text-muted-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="border-border bg-card flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2 p-[0.5px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isSelected && (
|
||||
<img
|
||||
src={CheckmarkIcon}
|
||||
alt=""
|
||||
width={10}
|
||||
height={10}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mb-3 h-16 w-16"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{emptyMessage || t('settings.tools.noToolsFound', 'No results found')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
side={side}
|
||||
className={cn(
|
||||
'border-border bg-background dark:border-border dark:bg-card flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden rounded-lg p-0 shadow-md',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<Command shouldFilter={searchable} className="bg-transparent">
|
||||
{title && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{searchable && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<CommandInput
|
||||
placeholder={effectivePlaceholder}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-foreground flex items-center justify-center py-8 dark:text-white">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:border-border mx-4 my-4 grow overflow-hidden rounded-md border border-[#D9D9D9]">
|
||||
<CommandList className="max-h-none">
|
||||
<CommandEmpty>{renderEmptyState()}</CommandEmpty>
|
||||
{items.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : hasGroups ? (
|
||||
grouped.groupOrder.map((groupKey) => {
|
||||
const groupItems = grouped.map.get(groupKey) || [];
|
||||
if (groupItems.length === 0) return null;
|
||||
return (
|
||||
<CommandGroup
|
||||
key={`group-${groupKey || 'ungrouped'}`}
|
||||
heading={groupKey || undefined}
|
||||
className="[&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:dark:bg-card [&_[cmdk-group-heading]]:text-muted-foreground p-0 [&_[cmdk-group-heading]]:sticky [&_[cmdk-group-heading]]:top-0 [&_[cmdk-group-heading]]:z-10 [&_[cmdk-group-heading]]:border-b [&_[cmdk-group-heading]]:border-[#D9D9D9] [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:dark:border-[#2E2E2E]"
|
||||
>
|
||||
{groupItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandGroup className="p-0">
|
||||
{orderedItems.map(renderItem)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<div className="border-border dark:border-border shrink-0 border-t px-4 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectPopover;
|
||||
@@ -1,311 +0,0 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CheckmarkIcon from '../assets/checkmark.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import Input from './Input';
|
||||
|
||||
export type OptionType = {
|
||||
id: string | number;
|
||||
label: string;
|
||||
icon?: string | React.ReactNode;
|
||||
group?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type MultiSelectPopupProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
options: OptionType[];
|
||||
selectedIds: Set<string | number>;
|
||||
onSelectionChange: (newSelectedIds: Set<string | number>) => void;
|
||||
title?: string;
|
||||
searchPlaceholder?: string;
|
||||
noOptionsMessage?: string;
|
||||
loading?: boolean;
|
||||
footerContent?: React.ReactNode;
|
||||
showSearch?: boolean;
|
||||
singleSelect?: boolean;
|
||||
};
|
||||
|
||||
export default function MultiSelectPopup({
|
||||
isOpen,
|
||||
onClose,
|
||||
anchorRef,
|
||||
options,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
title,
|
||||
searchPlaceholder,
|
||||
noOptionsMessage,
|
||||
loading = false,
|
||||
footerContent,
|
||||
showSearch = true,
|
||||
singleSelect = false,
|
||||
}: MultiSelectPopupProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [popupPosition, setPopupPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
maxHeight: 0,
|
||||
showAbove: false,
|
||||
});
|
||||
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleOptionClick = (optionId: string | number) => {
|
||||
let newSelectedIds: Set<string | number>;
|
||||
if (singleSelect) newSelectedIds = new Set<string | number>();
|
||||
else newSelectedIds = new Set(selectedIds);
|
||||
if (newSelectedIds.has(optionId)) {
|
||||
newSelectedIds.delete(optionId);
|
||||
} else newSelectedIds.add(optionId);
|
||||
onSelectionChange(newSelectedIds);
|
||||
};
|
||||
|
||||
const renderIcon = (icon: string | React.ReactNode) => {
|
||||
if (typeof icon === 'string') {
|
||||
if (icon.startsWith('/') || icon.startsWith('http')) {
|
||||
return (
|
||||
<img
|
||||
src={icon}
|
||||
alt=""
|
||||
className="mr-3 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mr-3 h-5 w-5 shrink-0" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="mr-3 shrink-0">{icon}</span>;
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || !anchorRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!anchorRef.current) return;
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const popupPadding = 16;
|
||||
const popupMinWidth = 300;
|
||||
const popupMaxWidth = 462;
|
||||
const popupDefaultHeight = 300;
|
||||
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const showAbove =
|
||||
spaceBelow < popupDefaultHeight && spaceAbove >= popupDefaultHeight;
|
||||
|
||||
const maxHeight = Math.max(
|
||||
150,
|
||||
showAbove ? spaceAbove - popupPadding : spaceBelow - popupPadding,
|
||||
);
|
||||
|
||||
const availableWidth = viewportWidth - 20;
|
||||
const calculatedWidth = Math.min(popupMaxWidth, availableWidth);
|
||||
|
||||
let left = rect.left;
|
||||
if (left + calculatedWidth > viewportWidth - 10) {
|
||||
left = viewportWidth - calculatedWidth - 10;
|
||||
}
|
||||
left = Math.max(10, left);
|
||||
|
||||
setPopupPosition({
|
||||
top: showAbove ? rect.top - 8 : rect.bottom + 8,
|
||||
left: left,
|
||||
maxHeight: Math.min(600, maxHeight),
|
||||
showAbove,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [isOpen, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node) &&
|
||||
anchorRef.current &&
|
||||
!anchorRef.current.contains(event.target as Node)
|
||||
)
|
||||
onClose();
|
||||
};
|
||||
if (isOpen) document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose, anchorRef, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setSearchTerm('');
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="border-border bg-background dark:border-border dark:bg-card fixed z-9999 flex flex-col rounded-lg border shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
|
||||
style={{
|
||||
top: popupPosition.showAbove ? undefined : popupPosition.top,
|
||||
bottom: popupPosition.showAbove
|
||||
? window.innerHeight - popupPosition.top + 8
|
||||
: undefined,
|
||||
left: popupPosition.left,
|
||||
maxWidth: `${Math.min(462, window.innerWidth - 20)}px`,
|
||||
width: '100%',
|
||||
maxHeight: `${popupPosition.maxHeight}px`,
|
||||
}}
|
||||
>
|
||||
{(title || showSearch) && (
|
||||
<div className="shrink-0 p-4">
|
||||
{title && (
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{showSearch && (
|
||||
<Input
|
||||
id="multi-select-search"
|
||||
name="multi-select-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={
|
||||
searchPlaceholder ||
|
||||
t('settings.tools.searchPlaceholder', 'Search...')
|
||||
}
|
||||
labelBgClassName="bg-background dark:bg-card"
|
||||
borderVariant="thin"
|
||||
className="mb-4"
|
||||
textSize="small"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="dark:border-border mx-4 mb-4 grow overflow-auto rounded-md border border-[#D9D9D9]">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center py-4">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-overlay h-full overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt="No options found"
|
||||
className="mx-auto mb-3 h-16 w-16"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{searchTerm
|
||||
? 'No results found'
|
||||
: noOptionsMessage || 'No options available'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const hasGroups = filteredOptions.some((o) => !!o.group);
|
||||
const renderOption = (option: OptionType) => {
|
||||
const isSelected = selectedIds.has(option.id);
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
className="dark:border-border dark:hover:bg-accent hover:bg-accent flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div className="mr-3 flex grow items-center overflow-hidden">
|
||||
{option.icon && renderIcon(option.icon)}
|
||||
<p
|
||||
className="overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white"
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div
|
||||
className={`border-border bg-card flex h-4 w-4 items-center justify-center rounded-xs border-2`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isSelected && (
|
||||
<img
|
||||
src={CheckmarkIcon}
|
||||
alt="checkmark"
|
||||
width={10}
|
||||
height={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!hasGroups) {
|
||||
return filteredOptions.map(renderOption);
|
||||
}
|
||||
|
||||
const groupOrder: string[] = [];
|
||||
const groupMap = new Map<string, OptionType[]>();
|
||||
filteredOptions.forEach((opt) => {
|
||||
const key = opt.group || '';
|
||||
if (!groupMap.has(key)) {
|
||||
groupOrder.push(key);
|
||||
groupMap.set(key, []);
|
||||
}
|
||||
groupMap.get(key)!.push(opt);
|
||||
});
|
||||
|
||||
return groupOrder.map((groupKey) => (
|
||||
<div key={`group-${groupKey || 'ungrouped'}`}>
|
||||
{groupKey && (
|
||||
<div
|
||||
className="bg-muted/50 dark:bg-card text-muted-foreground sticky top-0 z-10 border-b border-[#D9D9D9] px-3 py-1.5 text-xs font-semibold uppercase dark:border-[#2E2E2E]"
|
||||
role="presentation"
|
||||
>
|
||||
{groupKey}
|
||||
</div>
|
||||
)}
|
||||
{(groupMap.get(groupKey) || []).map(renderOption)}
|
||||
</div>
|
||||
));
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{footerContent && (
|
||||
<div className="border-border dark:border-border shrink-0 border-t p-4">
|
||||
{footerContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import close from '../assets/cross.svg';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface NotificationProps {
|
||||
notificationText: string;
|
||||
@@ -43,7 +44,7 @@ export default function Notification({
|
||||
}
|
||||
`}</style>
|
||||
<a
|
||||
className="group absolute right-2 bottom-6 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
|
||||
className="group absolute right-2 bottom-24 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 sm:bottom-6 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, #390086 0%, #6222B7 100%), linear-gradient(90deg, rgba(57, 0, 134, 0) 0%, #6222B7 53.02%, rgba(57, 0, 134, 0) 100%)',
|
||||
@@ -118,8 +119,10 @@ export default function Notification({
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="absolute top-2 right-2 z-30 h-4 w-4 hover:opacity-70"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2 z-30 h-4 w-4 rounded-none bg-transparent p-0 hover:bg-transparent hover:opacity-70"
|
||||
aria-label={t('notification.closeAriaLabel')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -128,7 +131,7 @@ export default function Notification({
|
||||
}}
|
||||
>
|
||||
<img className="w-full" src={close} alt="Close notification" />
|
||||
</button>
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { SVGProps } from 'react';
|
||||
const RetryIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
width={props.width ? props.width : 16}
|
||||
height={props.height ? props.height : 16}
|
||||
fill={props.fill}
|
||||
stroke={props.stroke ? props.stroke : 'none'}
|
||||
strokeWidth={props.strokeWidth ? props.strokeWidth : 10}
|
||||
viewBox="0 0 383.748 383.748"
|
||||
{...props}
|
||||
>
|
||||
<path d="M62.772 95.042C90.904 54.899 137.496 30 187.343 30c83.743 0 151.874 68.13 151.874 151.874h30C369.217 81.588 287.629 0 187.343 0c-35.038 0-69.061 9.989-98.391 28.888a182.423 182.423 0 0 0-47.731 44.705L2.081 34.641v113.365h113.91L62.772 95.042zM381.667 235.742h-113.91l53.219 52.965c-28.132 40.142-74.724 65.042-124.571 65.042-83.744 0-151.874-68.13-151.874-151.874h-30c0 100.286 81.588 181.874 181.874 181.874 35.038 0 69.062-9.989 98.391-28.888a182.443 182.443 0 0 0 47.731-44.706l39.139 38.952V235.742z" />
|
||||
</svg>
|
||||
);
|
||||
export default RetryIcon;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { SVGProps } from 'react';
|
||||
const SendArrowIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="11"
|
||||
height="14"
|
||||
viewBox="0 0 11 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M0.292786 6.20676C0.105315 6.01923 -3.59956e-07 5.76492 -3.71547e-07 5.49976C-3.83138e-07 5.23459 0.105315 4.98028 0.292786 4.79276L4.79279 0.292756C4.98031 0.105284 5.23462 -3.07464e-05 5.49979 -3.0758e-05C5.76495 -3.07696e-05 6.01926 0.105284 6.20679 0.292756L10.7068 4.79276C10.8889 4.98136 10.9897 5.23396 10.9875 5.49616C10.9852 5.75835 10.88 6.00917 10.6946 6.19457C10.5092 6.37998 10.2584 6.48515 9.99619 6.48743C9.73399 6.48971 9.48139 6.38891 9.29279 6.20676L6.49979 3.49976L6.49979 12.9998C6.49979 13.265 6.39443 13.5193 6.20689 13.7069C6.01936 13.8944 5.765 13.9998 5.49979 13.9998C5.23457 13.9998 4.98022 13.8944 4.79268 13.7069C4.60514 13.5193 4.49979 13.265 4.49979 12.9998L4.49979 3.49976L1.70679 6.20676C1.51926 6.39423 1.26495 6.49954 0.999786 6.49954C0.734622 6.49954 0.480314 6.39423 0.292786 6.20676Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SendArrowIcon;
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ArrowLeft from '../assets/arrow-left.svg';
|
||||
import ArrowRight from '../assets/arrow-right.svg';
|
||||
|
||||
type HiddenGradientType = 'left' | 'right' | undefined;
|
||||
|
||||
const useTabs = () => {
|
||||
const { t } = useTranslation();
|
||||
const tabs = [
|
||||
t('settings.general.label'),
|
||||
t('settings.sources.label'),
|
||||
t('settings.analytics.label'),
|
||||
t('settings.logs.label'),
|
||||
t('settings.tools.label'),
|
||||
t('settings.customModels.label'),
|
||||
];
|
||||
return tabs;
|
||||
};
|
||||
|
||||
interface SettingsBarProps {
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<string>>;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [hiddenGradient, setHiddenGradient] =
|
||||
useState<HiddenGradientType>('left');
|
||||
const containerRef = useRef<null | HTMLDivElement>(null);
|
||||
const tabs = useTabs();
|
||||
const scrollTabs = useCallback(
|
||||
(direction: number) => {
|
||||
if (containerRef.current) {
|
||||
const container = containerRef.current;
|
||||
container.scrollLeft += direction * 100; // Adjust the scroll amount as needed
|
||||
if (container.scrollLeft === 0) {
|
||||
setHiddenGradient('left');
|
||||
} else if (
|
||||
container.scrollLeft + container.offsetWidth ===
|
||||
container.scrollWidth
|
||||
) {
|
||||
setHiddenGradient('right');
|
||||
} else {
|
||||
setHiddenGradient(undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef.current],
|
||||
);
|
||||
return (
|
||||
<div className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0">
|
||||
<div
|
||||
className={`${hiddenGradient === 'left' ? 'hidden' : ''} dark:from-background pointer-events-none absolute inset-y-0 left-6 w-14 bg-linear-to-r from-white md:hidden`}
|
||||
></div>
|
||||
<div
|
||||
className={`${hiddenGradient === 'right' ? 'hidden' : ''} dark:from-background pointer-events-none absolute inset-y-0 right-6 w-14 bg-linear-to-l from-white md:hidden`}
|
||||
></div>
|
||||
|
||||
<div className="z-10 md:hidden">
|
||||
<button
|
||||
onClick={() => scrollTabs(-1)}
|
||||
className="hover:bg-muted dark:hover:bg-accent flex h-6 w-6 items-center justify-center rounded-full transition-all"
|
||||
aria-label={t('settings.scrollTabsLeft')}
|
||||
>
|
||||
<img src={ArrowLeft} alt="left-arrow" className="h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="no-scrollbar flex snap-x flex-nowrap overflow-x-auto scroll-smooth md:space-x-4"
|
||||
role="tablist"
|
||||
aria-label={t('settings.tabsAriaLabel')}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`h-9 snap-start rounded-3xl px-4 font-bold transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-muted text-foreground dark:bg-accent dark:text-white'
|
||||
: 'text-muted-foreground hover:text-foreground dark:text-neutral-400 dark:hover:text-white'
|
||||
}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
aria-controls={`${tab.toLowerCase()}-panel`}
|
||||
id={`${tab.toLowerCase()}-tab`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="z-10 md:hidden">
|
||||
<button
|
||||
onClick={() => scrollTabs(1)}
|
||||
className="hover:bg-muted dark:hover:bg-accent flex h-6 w-6 items-center justify-center rounded-full"
|
||||
aria-label={t('settings.scrollTabsRight')}
|
||||
>
|
||||
<img src={ArrowRight} alt="right-arrow" className="h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsBar;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import Exit from '../assets/exit.svg';
|
||||
|
||||
type SidebarProps = {
|
||||
isOpen: boolean;
|
||||
toggleState: (arg0: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
isOpen,
|
||||
toggleState,
|
||||
children,
|
||||
}: SidebarProps) {
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
sidebarRef.current &&
|
||||
!sidebarRef.current.contains(event.target as Node)
|
||||
) {
|
||||
toggleState(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div ref={sidebarRef} className="h-vh relative">
|
||||
<div
|
||||
className={`bg-card fixed top-0 right-0 z-50 h-full w-64 transform shadow-xl transition-all duration-300 sm:w-80 ${
|
||||
isOpen ? 'translate-x-[10px]' : 'translate-x-full'
|
||||
} border-l border-[#9ca3af]/10`}
|
||||
>
|
||||
<div className="flex w-full flex-row items-end justify-end px-4 pt-3">
|
||||
<button
|
||||
className="hover:bg-accent w-7 rounded-full p-2"
|
||||
onClick={() => toggleState(!isOpen)}
|
||||
>
|
||||
<img className="filter dark:invert" src={Exit} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center gap-2 px-6 py-4 text-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,9 @@ interface SkeletonLoaderProps {
|
||||
| 'chunkCards'
|
||||
| 'sourceCards'
|
||||
| 'toolCards'
|
||||
| 'addToolCards';
|
||||
| 'addToolCards'
|
||||
| 'connectedState'
|
||||
| 'filesSection';
|
||||
}
|
||||
|
||||
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
@@ -47,16 +49,16 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
{[...Array(4)].map((_, idx) => (
|
||||
<tr key={idx} className="animate-pulse">
|
||||
<td className="w-[40%] px-4 py-4">
|
||||
<div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
</td>
|
||||
<td className="w-[30%] px-4 py-4">
|
||||
<div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
</td>
|
||||
<td className="w-[20%] px-4 py-4">
|
||||
<div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
</td>
|
||||
<td className="w-[10%] px-4 py-4">
|
||||
<div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -68,16 +70,16 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
{[...Array(4)].map((_, idx) => (
|
||||
<tr key={idx} className="animate-pulse">
|
||||
<td className="p-2">
|
||||
<div className="mx-auto h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mx-auto h-4 w-3/4 rounded-sm"></div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="mx-auto h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mx-auto h-4 w-full rounded-sm"></div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="mx-auto h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mx-auto h-4 w-full rounded-sm"></div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="mx-auto h-4 w-8 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mx-auto h-4 w-8 rounded-sm"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -86,10 +88,10 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
|
||||
const renderDropdown = () => (
|
||||
<div className="animate-pulse">
|
||||
<div className="mb-2 h-4 w-24 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="flex h-14 w-[360px] items-center justify-between rounded-3xl bg-gray-300 px-4 dark:bg-gray-600">
|
||||
<div className="h-3 w-24 rounded-sm bg-gray-400 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-3 rounded-sm bg-gray-400 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-24 rounded-sm"></div>
|
||||
<div className="bg-muted flex h-14 w-[360px] items-center justify-between rounded-3xl px-4">
|
||||
<div className="bg-muted-foreground/20 h-3 w-24 rounded-sm"></div>
|
||||
<div className="bg-muted-foreground/20 h-3 w-3 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -102,11 +104,11 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
className="dark:hover:bg-accent hover:bg-muted flex w-full items-start p-2"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-lg bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-3 w-3 rounded-lg"></div>
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
<div className="h-3 w-[30%] rounded-lg bg-gray-300 lg:w-52 dark:bg-gray-600"></div>
|
||||
<div className="h-3 w-[16%] rounded-lg bg-gray-300 lg:w-28 dark:bg-gray-600"></div>
|
||||
<div className="h-3 w-[40%] rounded-lg bg-gray-300 lg:w-64 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-3 w-[30%] rounded-lg lg:w-52"></div>
|
||||
<div className="bg-muted h-3 w-[16%] rounded-lg lg:w-28"></div>
|
||||
<div className="bg-muted h-3 w-[40%] rounded-lg lg:w-64"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,28 +127,28 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-5/6 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-1/2 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-3/4 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-5/6 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-1/2 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-3/4 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-full rounded-sm"></div>
|
||||
</div>
|
||||
<div className="my-4 border-t border-gray-400 dark:border-gray-700"></div>
|
||||
<div className="border-border my-4 border-t"></div>
|
||||
<div>
|
||||
<div className="mb-2 h-4 w-2/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-1/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-2/3 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-1/4 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-full rounded-sm"></div>
|
||||
</div>
|
||||
<div className="my-4 border-t border-gray-400 dark:border-gray-700"></div>
|
||||
<div className="border-border my-4 border-t"></div>
|
||||
<div>
|
||||
<div className="mb-2 h-4 w-5/6 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-1/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-2/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-5/6 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-1/3 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-2/3 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-full rounded-sm"></div>
|
||||
</div>
|
||||
<div className="my-4 border-t border-gray-400 dark:border-gray-700"></div>
|
||||
<div className="mb-2 h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="mb-2 h-4 w-5/6 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="border-border my-4 border-t"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-3/4 rounded-sm"></div>
|
||||
<div className="bg-muted mb-2 h-4 w-5/6 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -159,23 +161,23 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
<div key={idx} className="bg-card w-full animate-pulse rounded-3xl p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4 h-4 w-1/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mb-4 h-4 w-1/3 rounded-sm"></div>
|
||||
<div className="grid grid-cols-6 items-end gap-2">
|
||||
<div className="h-32 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-24 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-40 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-28 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-36 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-20 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-32 rounded-sm"></div>
|
||||
<div className="bg-muted h-24 rounded-sm"></div>
|
||||
<div className="bg-muted h-40 rounded-sm"></div>
|
||||
<div className="bg-muted h-28 rounded-sm"></div>
|
||||
<div className="bg-muted h-36 rounded-sm"></div>
|
||||
<div className="bg-muted h-20 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4 h-4 w-1/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-32 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted mb-4 h-4 w-1/4 rounded-sm"></div>
|
||||
<div className="bg-muted h-32 rounded-sm"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
<div className="bg-muted h-4 w-full rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,19 +190,19 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={`chunk-skel-${index}`}
|
||||
className="border-border dark:border-border relative flex h-[197px] w-full max-w-[487px] animate-pulse flex-col overflow-hidden rounded-[5.86px] border"
|
||||
className="border-border dark:border-border relative flex h-[197px] w-full max-w-[487px] animate-pulse flex-col overflow-hidden rounded-md border"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="border-border bg-muted dark:border-border dark:bg-card flex w-full items-center justify-between border-b px-4 py-3">
|
||||
<div className="h-4 w-20 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted-foreground/20 h-4 w-20 rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-3 px-4 pt-4 pb-6">
|
||||
<div className="h-3 w-full rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-11/12 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-5/6 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-4/5 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-3/4 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-2/3 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted h-3 w-full rounded"></div>
|
||||
<div className="bg-muted h-3 w-11/12 rounded"></div>
|
||||
<div className="bg-muted h-3 w-5/6 rounded"></div>
|
||||
<div className="bg-muted h-3 w-4/5 rounded"></div>
|
||||
<div className="bg-muted h-3 w-3/4 rounded"></div>
|
||||
<div className="bg-muted h-3 w-2/3 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,20 +220,20 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
<div className="w-full flex-1">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="h-[13px] w-full rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted-foreground/20 h-[13px] w-full rounded"></div>
|
||||
</div>
|
||||
<div className="h-6 w-6 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted-foreground/20 h-6 w-6 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start gap-1 pt-3">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-[12px] w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted-foreground/20 h-3 w-3 rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-[12px] w-20 rounded"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-[12px] w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted-foreground/20 h-3 w-3 rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-[12px] w-16 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,17 +246,17 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
{Array.from({ length: count }).map((_, idx) => (
|
||||
<div
|
||||
key={`add-tool-skel-${idx}`}
|
||||
className="border-light-gainsboro dark:border-arsenic flex h-52 w-full animate-pulse flex-col justify-between rounded-2xl border p-6"
|
||||
className="border-border flex h-52 w-full animate-pulse flex-col justify-between rounded-2xl border p-6"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center justify-between px-1">
|
||||
<div className="h-6 w-6 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted h-6 w-6 rounded"></div>
|
||||
</div>
|
||||
<div className="mt-[9px] space-y-2 px-1">
|
||||
<div className="h-4 w-2/3 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-3 w-full rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-5/6 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-3/4 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted h-4 w-2/3 rounded"></div>
|
||||
<div className="bg-muted h-3 w-full rounded"></div>
|
||||
<div className="bg-muted h-3 w-5/6 rounded"></div>
|
||||
<div className="bg-muted h-3 w-3/4 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,23 +273,47 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="h-6 w-6 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted-foreground/20 h-6 w-6 rounded"></div>
|
||||
</div>
|
||||
<div className="mt-[9px] space-y-2 px-1">
|
||||
<div className="h-4 w-2/3 rounded bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="h-3 w-full rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-5/6 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-3/4 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="bg-muted-foreground/20 h-4 w-2/3 rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-3 w-full rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-3 w-5/6 rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-3 w-3/4 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="h-5 w-9 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="bg-muted-foreground/20 h-5 w-9 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderConnectedState = () => (
|
||||
<div className="mb-4">
|
||||
<div className="bg-muted flex w-full animate-pulse items-center justify-between rounded-lg px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted-foreground/20 h-4 w-4 rounded"></div>
|
||||
<div className="bg-muted-foreground/20 h-4 w-32 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted-foreground/20 h-4 w-16 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFilesSection = () => (
|
||||
<div className="border-border dark:border-border rounded-lg border">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="bg-muted h-5 w-24 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-8 w-24 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted h-4 w-40 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const componentMap = {
|
||||
fileTable: renderTable,
|
||||
chatbot: renderChatbot,
|
||||
@@ -299,6 +325,8 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
sourceCards: renderSourceCards,
|
||||
toolCards: renderToolCards,
|
||||
addToolCards: renderAddToolCards,
|
||||
connectedState: renderConnectedState,
|
||||
filesSection: renderFilesSection,
|
||||
};
|
||||
|
||||
const render = componentMap[component] || componentMap.default;
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import React, { useRef, useEffect, useState, useLayoutEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Doc } from '../models/misc';
|
||||
import SourceIcon from '../assets/source.svg';
|
||||
import CheckIcon from '../assets/checkmark.svg';
|
||||
import RedirectIcon from '../assets/redirect.svg';
|
||||
import Input from './Input';
|
||||
import {
|
||||
selectSourceDocs,
|
||||
selectSelectedDocs,
|
||||
setSelectedDocs,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { ActiveState } from '../models/misc';
|
||||
|
||||
type SourcesPopupProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
handlePostDocumentSelect: (doc: Doc[] | null) => void;
|
||||
setUploadModalState: React.Dispatch<React.SetStateAction<ActiveState>>;
|
||||
};
|
||||
|
||||
export default function SourcesPopup({
|
||||
isOpen,
|
||||
onClose,
|
||||
anchorRef,
|
||||
handlePostDocumentSelect,
|
||||
setUploadModalState,
|
||||
}: SourcesPopupProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [popupPosition, setPopupPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
maxHeight: 0,
|
||||
showAbove: false,
|
||||
});
|
||||
|
||||
const options = useSelector(selectSourceDocs);
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
|
||||
const filteredOptions = options?.filter((option) =>
|
||||
option.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || !anchorRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!anchorRef.current) return;
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const showAbove = spaceAbove > spaceBelow && spaceAbove >= 300;
|
||||
const maxHeight = showAbove ? spaceAbove - 16 : spaceBelow - 16;
|
||||
const left = Math.min(
|
||||
rect.left,
|
||||
viewportWidth - Math.min(480, viewportWidth * 0.95) - 10,
|
||||
);
|
||||
|
||||
setPopupPosition({
|
||||
top: showAbove ? rect.top - 8 : rect.bottom + 8,
|
||||
left,
|
||||
maxHeight: Math.min(600, maxHeight),
|
||||
showAbove,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => window.removeEventListener('resize', updatePosition);
|
||||
}, [isOpen, anchorRef]);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node) &&
|
||||
!anchorRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUploadClick = () => {
|
||||
setUploadModalState('ACTIVE');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const popupContent = (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="bg-background dark:bg-card fixed z-50 flex flex-col rounded-xl shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
|
||||
style={{
|
||||
top: popupPosition.showAbove ? popupPosition.top : undefined,
|
||||
bottom: popupPosition.showAbove
|
||||
? undefined
|
||||
: window.innerHeight - popupPosition.top,
|
||||
left: popupPosition.left,
|
||||
maxWidth: Math.min(480, window.innerWidth * 0.95),
|
||||
width: '100%',
|
||||
height: popupPosition.maxHeight,
|
||||
transform: popupPosition.showAbove ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 px-4 py-4 md:px-6">
|
||||
<h2 className="dark:text-foreground mb-4 text-lg font-bold text-[#141414] dark:text-[20px]">
|
||||
{t('conversation.sources.text')}
|
||||
</h2>
|
||||
|
||||
<Input
|
||||
id="source-search"
|
||||
name="source-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('settings.sources.searchPlaceholder')}
|
||||
borderVariant="thin"
|
||||
className="mb-4"
|
||||
labelBgClassName="bg-background dark:bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dark:border-border scrollbar-overlay mx-4 grow overflow-y-auto rounded-md border border-[#D9D9D9]">
|
||||
{options ? (
|
||||
<>
|
||||
{filteredOptions?.map((option: any, index: number) => {
|
||||
const isSelected =
|
||||
selectedDocs &&
|
||||
Array.isArray(selectedDocs) &&
|
||||
selectedDocs.length > 0 &&
|
||||
selectedDocs.some((doc) =>
|
||||
option.id ? doc.id === option.id : doc.date === option.date,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border-opacity-80 dark:border-border hover:bg-muted flex cursor-pointer items-center border-b border-[#D9D9D9] p-3 transition-colors dark:text-[14px]"
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
const updatedDocs =
|
||||
selectedDocs && Array.isArray(selectedDocs)
|
||||
? selectedDocs.filter((doc) =>
|
||||
option.id
|
||||
? doc.id !== option.id
|
||||
: doc.date !== option.date,
|
||||
)
|
||||
: [];
|
||||
dispatch(setSelectedDocs(updatedDocs));
|
||||
handlePostDocumentSelect(
|
||||
updatedDocs.length > 0 ? updatedDocs : null,
|
||||
);
|
||||
} else {
|
||||
const updatedDocs =
|
||||
selectedDocs && Array.isArray(selectedDocs)
|
||||
? [...selectedDocs, option]
|
||||
: [option];
|
||||
dispatch(setSelectedDocs(updatedDocs));
|
||||
handlePostDocumentSelect(updatedDocs);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={SourceIcon}
|
||||
alt="Source"
|
||||
width={14}
|
||||
height={14}
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<span className="dark:text-foreground text-muted-foreground mr-3 grow overflow-hidden font-medium text-ellipsis whitespace-nowrap">
|
||||
{option.name}
|
||||
</span>
|
||||
<div
|
||||
className={`dark:border-border flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2 border-[#C6C6C6] p-[0.5px]`}
|
||||
>
|
||||
{isSelected && (
|
||||
<img
|
||||
src={CheckIcon}
|
||||
alt="Selected"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="dark:text-foreground p-4 text-center text-gray-500 dark:text-[14px]">
|
||||
{t('conversation.sources.noSourcesAvailable')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-4 py-4 opacity-75 transition-opacity duration-200 hover:opacity-100 md:px-6">
|
||||
<a
|
||||
href="/settings/sources"
|
||||
className="text-primary inline-flex items-center gap-2 text-base font-medium"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('settings.sources.goToSources')}
|
||||
<img src={RedirectIcon} alt="Redirect" className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-start px-4 py-3 md:px-6">
|
||||
<button
|
||||
onClick={handleUploadClick}
|
||||
className="border-primary text-primary hover:bg-primary/90 w-auto rounded-full border px-4 py-2 text-[14px] font-medium transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
{t('settings.sources.uploadNew')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(popupContent, document.body);
|
||||
}
|
||||
@@ -2,42 +2,32 @@ import React from 'react';
|
||||
|
||||
type SpinnerProps = {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function Spinner({
|
||||
size = 'medium',
|
||||
color = 'grey',
|
||||
}: SpinnerProps) {
|
||||
const sizeMap = {
|
||||
small: '20px',
|
||||
medium: '30px',
|
||||
large: '40px',
|
||||
};
|
||||
const spinnerSize = sizeMap[size];
|
||||
const SIZE_MAP: Record<NonNullable<SpinnerProps['size']>, string> = {
|
||||
small: '20px',
|
||||
medium: '30px',
|
||||
large: '40px',
|
||||
};
|
||||
|
||||
const spinnerStyle = {
|
||||
export default function Spinner({ size = 'medium' }: SpinnerProps) {
|
||||
const spinnerSize = SIZE_MAP[size];
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: spinnerSize,
|
||||
height: spinnerSize,
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
background: `
|
||||
radial-gradient(farthest-side, ${color} 94%, #0000) top/8px 8px no-repeat,
|
||||
conic-gradient(#0000 30%, ${color})
|
||||
radial-gradient(farthest-side, currentColor 94%, transparent) top/8px 8px no-repeat,
|
||||
conic-gradient(transparent 30%, currentColor)
|
||||
`,
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0)',
|
||||
animation: 'l13 1s infinite linear',
|
||||
} as React.CSSProperties;
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 8px), #000 0)',
|
||||
animation: 'spinner-rotate 1s infinite linear',
|
||||
color: 'currentColor',
|
||||
};
|
||||
|
||||
const keyframesStyle = `@keyframes l13 {
|
||||
100% { transform: rotate(1turn) }
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{keyframesStyle}</style>
|
||||
<div className="loader" style={spinnerStyle} />
|
||||
</>
|
||||
);
|
||||
return <div className="loader" role="status" style={spinnerStyle} />;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Speaker from '../assets/speaker.svg?react';
|
||||
import Stopspeech from '../assets/stopspeech.svg?react';
|
||||
import LoadingIcon from '../assets/Loading.svg?react'; // Add a loading icon SVG here
|
||||
import userService from '../api/services/userService';
|
||||
import { Button } from './ui/button';
|
||||
import Spinner from './Spinner';
|
||||
|
||||
let currentlyPlayingAudio: {
|
||||
audio: HTMLAudioElement;
|
||||
@@ -171,10 +172,12 @@ export default function SpeakButton({ text }: { text: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={`flex cursor-pointer items-center justify-center rounded-full p-2 ${
|
||||
isSpeaking || isLoading ? 'bg-accent' : 'hover:bg-accent bg-transparent'
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`cursor-pointer rounded-full ${
|
||||
isSpeaking || isLoading ? 'bg-accent' : ''
|
||||
}`}
|
||||
onClick={handleSpeakClick}
|
||||
aria-label={
|
||||
@@ -187,12 +190,12 @@ export default function SpeakButton({ text }: { text: string }) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingIcon className="animate-spin" />
|
||||
<Spinner size="small" />
|
||||
) : isSpeaking ? (
|
||||
<Stopspeech className="fill-none" />
|
||||
) : (
|
||||
<Speaker className="fill-none" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
type ToggleSwitchProps = {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
labelPosition?: 'left' | 'right';
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
checked,
|
||||
onChange,
|
||||
className = '',
|
||||
label,
|
||||
disabled = false,
|
||||
size = 'medium',
|
||||
labelPosition = 'left',
|
||||
id,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
// Size configurations
|
||||
const sizeConfig = {
|
||||
small: {
|
||||
box: 'h-5 w-9',
|
||||
toggle: 'h-4 w-4 left-0.5 top-0.5',
|
||||
translate: 'translate-x-full',
|
||||
},
|
||||
medium: {
|
||||
box: 'h-8 w-14',
|
||||
toggle: 'h-6 w-6 left-1 top-1',
|
||||
translate: 'translate-x-full',
|
||||
},
|
||||
large: {
|
||||
box: 'h-10 w-16',
|
||||
toggle: 'h-8 w-8 left-1 top-1',
|
||||
translate: 'translate-x-full',
|
||||
},
|
||||
};
|
||||
|
||||
const { box, toggle, translate } = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`flex cursor-pointer flex-row items-center select-none ${
|
||||
labelPosition === 'right' ? 'flex-row-reverse' : ''
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${className}`}
|
||||
>
|
||||
{label && (
|
||||
<span
|
||||
className={`text-foreground dark:text-white ${
|
||||
labelPosition === 'left' ? 'mr-3' : 'ml-3'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<div
|
||||
className={`block ${box} rounded-full ${
|
||||
checked ? 'bg-north-texas-green' : 'bg-silver dark:bg-charcoal-grey'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute ${toggle} flex items-center justify-center rounded-full bg-white transition ${
|
||||
checked ? `${translate} bg-silver` : ''
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleSwitch;
|
||||
@@ -1,258 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import userService from '../api/services/userService';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import Input from './Input';
|
||||
import RedirectIcon from '../assets/redirect.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import CheckmarkIcon from '../assets/checkmark.svg';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
|
||||
// Chat-popup visibility rule: show defaults (so users can toggle the
|
||||
// agentless chat tools on/off) plus any non-builtin user_tools row. Hide
|
||||
// pure builtins (agent-only). Dual-registered tools like ``scheduler``
|
||||
// carry BOTH flags and stay visible via the ``default`` branch.
|
||||
export const isChatToolVisible = (tool: {
|
||||
default?: boolean;
|
||||
builtin?: boolean;
|
||||
}): boolean => Boolean(tool.default) || !tool.builtin;
|
||||
|
||||
interface ToolsPopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export default function ToolsPopup({
|
||||
isOpen,
|
||||
onClose,
|
||||
anchorRef,
|
||||
}: ToolsPopupProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [userTools, setUserTools] = React.useState<UserToolType[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const [popupPosition, setPopupPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
maxHeight: 0,
|
||||
showAbove: false,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || !anchorRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!anchorRef.current) return;
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const showAbove = spaceAbove > spaceBelow && spaceAbove >= 300;
|
||||
const maxHeight = showAbove ? spaceAbove - 16 : spaceBelow - 16;
|
||||
|
||||
const left = Math.min(
|
||||
rect.left,
|
||||
viewportWidth - Math.min(462, viewportWidth * 0.95) - 10,
|
||||
);
|
||||
|
||||
setPopupPosition({
|
||||
top: showAbove ? rect.top - 8 : rect.bottom + 8,
|
||||
left,
|
||||
maxHeight: Math.min(600, maxHeight),
|
||||
showAbove,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => window.removeEventListener('resize', updatePosition);
|
||||
}, [isOpen, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node) &&
|
||||
anchorRef.current &&
|
||||
!anchorRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose, anchorRef, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
getUserTools();
|
||||
}
|
||||
}, [isOpen, token]);
|
||||
|
||||
const getUserTools = () => {
|
||||
setLoading(true);
|
||||
userService
|
||||
.getUserTools(token)
|
||||
.then((res) => {
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const filtered = (data.tools || []).filter(isChatToolVisible);
|
||||
setUserTools(filtered);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching tools:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const updateToolStatus = (toolId: string, newStatus: boolean) => {
|
||||
userService
|
||||
.updateToolStatus({ id: toolId, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prevTools) =>
|
||||
prevTools.map((tool) =>
|
||||
tool.id === toolId ? { ...tool, status: newStatus } : tool,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filteredTools = userTools.filter((tool) =>
|
||||
tool.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const popupContent = (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="border-border bg-background dark:border-border dark:bg-card fixed z-50 rounded-lg border shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
|
||||
style={{
|
||||
top: popupPosition.showAbove ? popupPosition.top : undefined,
|
||||
bottom: popupPosition.showAbove
|
||||
? undefined
|
||||
: window.innerHeight - popupPosition.top,
|
||||
left: popupPosition.left,
|
||||
maxWidth: Math.min(462, window.innerWidth * 0.95),
|
||||
width: '100%',
|
||||
height: popupPosition.maxHeight,
|
||||
transform: popupPosition.showAbove ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 p-4">
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{t('settings.tools.label')}
|
||||
</h3>
|
||||
|
||||
<Input
|
||||
id="tool-search"
|
||||
name="tool-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('settings.tools.searchPlaceholder')}
|
||||
labelBgClassName="bg-background dark:bg-card"
|
||||
borderVariant="thin"
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex grow justify-center py-4">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dark:border-border mx-4 grow overflow-hidden rounded-md border border-[#D9D9D9]">
|
||||
<div className="scrollbar-overlay h-full overflow-y-auto">
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-8">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt="No tools found"
|
||||
className="mx-auto mb-4 h-24 w-24"
|
||||
/>
|
||||
<p className="text-center text-gray-500 dark:text-gray-400">
|
||||
{t('settings.tools.noToolsFound')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
onClick={() => updateToolStatus(tool.id, !tool.status)}
|
||||
className="dark:border-border dark:hover:bg-accent hover:bg-accent flex items-center justify-between border-b border-[#D9D9D9] p-3"
|
||||
>
|
||||
<div className="mr-3 flex grow items-center">
|
||||
<img
|
||||
src={`/toolIcons/tool_${tool.name}.svg`}
|
||||
alt={`${tool.displayName} icon`}
|
||||
className="mr-4 h-5 w-5 shrink-0"
|
||||
/>
|
||||
<div className="overflow-hidden">
|
||||
<p className="overflow-hidden text-xs font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white">
|
||||
{tool.customName || tool.displayName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div
|
||||
className={`dark:border-border flex h-4 w-4 items-center justify-center rounded-xs border-2 border-[#C6C6C6] p-[0.5px]`}
|
||||
>
|
||||
{tool.status && (
|
||||
<img
|
||||
src={CheckmarkIcon}
|
||||
alt="Tool enabled"
|
||||
width={12}
|
||||
height={12}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 p-4 opacity-75 transition-opacity duration-200 hover:opacity-100">
|
||||
<a
|
||||
href="/settings/tools"
|
||||
className="text-primary inline-flex items-center text-base font-medium"
|
||||
>
|
||||
{t('settings.tools.manageTools')}
|
||||
<img
|
||||
src={RedirectIcon}
|
||||
alt="Go to tools"
|
||||
className="ml-2 h-[11px] w-[11px]"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(popupContent, document.body);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
selectUploadTasks,
|
||||
type UploadTask,
|
||||
} from '../upload/uploadSlice';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const PROGRESS_RADIUS = 10;
|
||||
const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS;
|
||||
@@ -65,7 +67,7 @@ export default function UploadToast() {
|
||||
aria-atomic="false"
|
||||
>
|
||||
<div
|
||||
className={`border-border bg-card w-[271px] overflow-hidden rounded-2xl border shadow-[0px_24px_48px_0px_#00000029] transition-all duration-300`}
|
||||
className={`border-border bg-card shadow-toast w-[271px] overflow-hidden rounded-2xl border transition-all duration-300`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
@@ -74,19 +76,21 @@ export default function UploadToast() {
|
||||
: 'bg-destructive/10 dark:bg-destructive/10'
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-inter dark:text-foreground text-[14px] leading-[16.5px] font-medium text-black">
|
||||
<h3 className="dark:text-foreground text-sm leading-[16.5px] font-medium text-black">
|
||||
{headerLabel}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? t('modals.uploadDoc.progress.expandDetails')
|
||||
: t('modals.uploadDoc.progress.collapseDetails')
|
||||
}
|
||||
className="flex h-8 items-center justify-center p-0 text-black opacity-70 transition-opacity hover:opacity-100 dark:text-white"
|
||||
className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
|
||||
>
|
||||
<img
|
||||
src={ChevronDown}
|
||||
@@ -95,37 +99,17 @@ export default function UploadToast() {
|
||||
collapsed ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={dismissAll}
|
||||
className="flex h-8 items-center justify-center p-0 text-black opacity-70 transition-opacity hover:opacity-100 dark:text-white"
|
||||
className="text-black opacity-70 hover:bg-transparent hover:opacity-100 dark:text-white dark:hover:bg-transparent"
|
||||
aria-label={t('modals.uploadDoc.progress.dismiss')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
d="M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 6L18 18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,13 +151,13 @@ function UploadRow({
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<p
|
||||
className="font-inter dark:text-muted-foreground max-w-[200px] truncate text-[13px] leading-[16.5px] font-normal text-black"
|
||||
className="dark:text-muted-foreground max-w-[200px] truncate text-sm leading-[16.5px] font-normal text-black"
|
||||
title={task.fileName}
|
||||
>
|
||||
{task.fileName}
|
||||
</p>
|
||||
{task.status === 'training' && task.stage && (
|
||||
<span className="font-inter text-muted-foreground mt-0.5 text-[11px] leading-[14px]">
|
||||
<span className="text-muted-foreground mt-0.5 text-xs leading-[14px]">
|
||||
{t(`modals.uploadDoc.progress.${task.stage}`)}
|
||||
</span>
|
||||
)}
|
||||
@@ -185,7 +169,7 @@ function UploadRow({
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-6 w-6 shrink-0 text-[#7D54D1]"
|
||||
className="text-primary h-6 w-6 shrink-0"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
@@ -204,7 +188,7 @@ function UploadRow({
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
className="text-[#7D54D1]"
|
||||
className="text-primary"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ClipIcon from '../../assets/clip.svg';
|
||||
import { FILE_UPLOAD_ACCEPT_ATTR } from '../../constants/fileUpload';
|
||||
|
||||
type AttachFileButtonProps = {
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export default function AttachFileButton({ onChange }: AttachFileButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex cursor-pointer items-center rounded-full border px-2 py-1 transition-colors">
|
||||
<img
|
||||
src={ClipIcon}
|
||||
alt="Attach"
|
||||
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground text-xs font-medium sm:text-sm">
|
||||
{t('conversation.attachments.attach')}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={FILE_UPLOAD_ACCEPT_ATTR}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AlertIcon from '../../assets/alert.svg';
|
||||
import DocumentationDark from '../../assets/documentation-dark.svg';
|
||||
import type { Attachment } from '../../upload/uploadSlice';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type AttachmentChipListProps = {
|
||||
attachments: Attachment[];
|
||||
draggingId: string | null;
|
||||
onRemove: (id: string) => void;
|
||||
onDragStart: (e: React.DragEvent, id: string) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDropOn: (e: React.DragEvent, targetId: string) => void;
|
||||
};
|
||||
|
||||
export default function AttachmentChipList({
|
||||
attachments,
|
||||
draggingId,
|
||||
onRemove,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDropOn,
|
||||
}: AttachmentChipListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
|
||||
{attachments.map((attachment) => {
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
draggable={true}
|
||||
onDragStart={(e) => onDragStart(e, attachment.id)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={(e) => onDropOn(e, attachment.id)}
|
||||
className={`group dark:text-foreground bg-muted text-muted-foreground dark:bg-accent relative flex items-center rounded-xl px-2 py-1 text-xs sm:px-3 sm:py-1.5 sm:text-sm ${
|
||||
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
|
||||
} ${
|
||||
draggingId === attachment.id
|
||||
? 'ring-dashed opacity-60 ring-2 ring-purple-200'
|
||||
: ''
|
||||
}`}
|
||||
title={attachment.fileName}
|
||||
>
|
||||
<div className="bg-primary mr-2 flex h-8 w-8 items-center justify-center rounded-md p-1">
|
||||
{attachment.status === 'completed' && (
|
||||
<img
|
||||
src={DocumentationDark}
|
||||
alt="Attachment"
|
||||
className="h-[15px] w-[15px] object-fill"
|
||||
/>
|
||||
)}
|
||||
|
||||
{attachment.status === 'failed' && (
|
||||
<img
|
||||
src={AlertIcon}
|
||||
alt="Failed"
|
||||
className="h-[15px] w-[15px] object-fill"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(attachment.status === 'uploading' ||
|
||||
attachment.status === 'processing') && (
|
||||
<div className="flex h-[15px] w-[15px] items-center justify-center">
|
||||
<svg className="h-[15px] w-[15px]" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-0"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="transparent"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
className="text-[#ECECF1]"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray="62.83"
|
||||
strokeDashoffset={62.83 * (1 - attachment.progress / 100)}
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
|
||||
{attachment.fileName}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-1.5 h-auto w-auto rounded-full p-1"
|
||||
onClick={() => {
|
||||
onRemove(attachment.id);
|
||||
}}
|
||||
aria-label={t('conversation.attachments.remove')}
|
||||
>
|
||||
<X
|
||||
aria-label={t('conversation.attachments.remove')}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { LoaderCircle, Mic, Square } from 'lucide-react';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export type RecordingState = 'idle' | 'recording' | 'transcribing' | 'error';
|
||||
|
||||
type MicButtonProps = {
|
||||
recordingState: RecordingState;
|
||||
loading: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const getVoiceButtonLabel = (recordingState: RecordingState): string => {
|
||||
if (recordingState === 'recording') return 'Stop recording';
|
||||
if (recordingState === 'transcribing') return 'Transcribing audio';
|
||||
return 'Voice input';
|
||||
};
|
||||
|
||||
const getVoiceButtonText = (recordingState: RecordingState): string => {
|
||||
if (recordingState === 'recording') return 'Stop';
|
||||
if (recordingState === 'transcribing') return 'Transcribing';
|
||||
return 'Voice';
|
||||
};
|
||||
|
||||
export default function MicButton({
|
||||
recordingState,
|
||||
loading,
|
||||
onClick,
|
||||
}: MicButtonProps) {
|
||||
const voiceButtonLabel = getVoiceButtonLabel(recordingState);
|
||||
const voiceButtonText = getVoiceButtonText(recordingState);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
aria-label={voiceButtonLabel}
|
||||
title={voiceButtonLabel}
|
||||
disabled={loading || recordingState === 'transcribing'}
|
||||
className={`xs:px-3 xs:py-1.5 dark:border-border flex h-auto items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors ${
|
||||
recordingState === 'recording'
|
||||
? 'border-[#B42318] bg-[#FEE4E2] text-[#B42318] dark:bg-[#4A2323]'
|
||||
: 'border-border dark:hover:bg-accent hover:bg-gray-100'
|
||||
} ${
|
||||
loading || recordingState === 'transcribing'
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{recordingState === 'transcribing' ? (
|
||||
<LoaderCircle className="mr-1 h-3.5 w-3.5 animate-spin sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Square className="mr-1 h-3.5 w-3.5 fill-current sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Mic className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
<span
|
||||
className={`xs:text-xs dark:text-foreground text-xs font-medium sm:text-sm ${
|
||||
recordingState === 'recording'
|
||||
? 'text-[#B42318]'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{voiceButtonText}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RedirectIcon from '../../assets/redirect.svg';
|
||||
import SourceIcon from '../../assets/source.svg';
|
||||
import type { Doc } from '../../models/misc';
|
||||
import {
|
||||
MultiSelectPopover,
|
||||
type MultiSelectPopoverItem,
|
||||
} from '../MultiSelectPopover';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type SourcesTriggerProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: MultiSelectPopoverItem[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
selectedDocs: Doc[] | null | undefined;
|
||||
onUploadClick: () => void;
|
||||
};
|
||||
|
||||
export default function SourcesTrigger({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
selectedDocs,
|
||||
onUploadClick,
|
||||
}: SourcesTriggerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MultiSelectPopover
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('conversation.sources.text')}
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
searchPlaceholder={t('settings.sources.searchPlaceholder')}
|
||||
emptyMessage={t('conversation.sources.noSourcesAvailable')}
|
||||
footer={
|
||||
<div className="flex flex-col gap-3">
|
||||
<a
|
||||
href="/settings/sources"
|
||||
className="text-primary inline-flex items-center gap-2 text-base font-medium"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('settings.sources.goToSources')}
|
||||
<img
|
||||
src={RedirectIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onUploadClick}
|
||||
className="border-primary text-primary hover:bg-primary/90 h-auto w-auto self-start rounded-full border bg-transparent px-4 py-2 text-sm font-medium shadow-none transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
{t('settings.sources.uploadNew')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="xs:px-3 xs:py-1.5 dark:border-border border-border hover:bg-accent dark:hover:bg-muted flex h-auto max-w-[130px] items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors sm:max-w-[150px]"
|
||||
title={
|
||||
selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.map((doc) => doc.name).join(', ')
|
||||
: t('conversation.sources.title')
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={SourceIcon}
|
||||
alt="Sources"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
|
||||
{selectedDocs && selectedDocs.length > 0
|
||||
? selectedDocs.length === 1
|
||||
? selectedDocs[0].name
|
||||
: `${selectedDocs.length} sources selected`
|
||||
: t('conversation.sources.title')}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RedirectIcon from '../../assets/redirect.svg';
|
||||
import ToolIcon from '../../assets/tool.svg';
|
||||
import {
|
||||
MultiSelectPopover,
|
||||
type MultiSelectPopoverItem,
|
||||
} from '../MultiSelectPopover';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type ToolsTriggerProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: MultiSelectPopoverItem[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export default function ToolsTrigger({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
loading,
|
||||
}: ToolsTriggerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MultiSelectPopover
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('settings.tools.label')}
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
searchPlaceholder={t('settings.tools.searchPlaceholder')}
|
||||
emptyMessage={t('settings.tools.noToolsFound')}
|
||||
loading={loading}
|
||||
footer={
|
||||
<a
|
||||
href="/settings/tools"
|
||||
className="text-primary inline-flex items-center text-base font-medium"
|
||||
>
|
||||
{t('settings.tools.manageTools')}
|
||||
<img
|
||||
src={RedirectIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="ml-2 h-[11px] w-[11px]"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] dark:border-border border-border hover:bg-muted dark:hover:bg-muted flex h-auto max-w-[130px] items-center justify-start rounded-full border bg-transparent px-2 py-1 shadow-none transition-colors"
|
||||
>
|
||||
<img
|
||||
src={ToolIcon}
|
||||
alt="Tools"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
|
||||
{t('settings.tools.label')}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as AttachFileButton } from './AttachFileButton';
|
||||
export { default as AttachmentChipList } from './AttachmentChipList';
|
||||
export { default as MicButton } from './MicButton';
|
||||
export { default as SourcesTrigger } from './SourcesTrigger';
|
||||
export { default as ToolsTrigger } from './ToolsTrigger';
|
||||
export type { RecordingState } from './MicButton';
|
||||
@@ -0,0 +1,801 @@
|
||||
import React, {
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import { formatBytes } from '../../utils/stringUtils';
|
||||
import userService from '../../api/services/userService';
|
||||
import ArrowLeft from '../../assets/arrow-left.svg';
|
||||
import EyeView from '../../assets/eye-view.svg';
|
||||
import FileIcon from '../../assets/file.svg';
|
||||
import FolderIcon from '../../assets/folder.svg';
|
||||
import ThreeDots from '../../assets/three-dots.svg';
|
||||
import { useLoaderState, useOutsideAlerter } from '../../hooks';
|
||||
import Chunks from '../Chunks';
|
||||
import SkeletonLoader from '../SkeletonLoader';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import type {
|
||||
DirectoryStructure,
|
||||
FileNode,
|
||||
RowMenuContext,
|
||||
SearchResult,
|
||||
TreeBrowserController,
|
||||
TreeMenuOption,
|
||||
} from './types';
|
||||
|
||||
/** Column ordering for the size/tokens pair. */
|
||||
export type ColumnOrder = 'size-first' | 'tokens-first';
|
||||
|
||||
export interface TreeBrowserProps {
|
||||
docId: string;
|
||||
sourceName: string;
|
||||
onBackToDocuments: () => void;
|
||||
/**
|
||||
* "size-first" = file name | size | tokens | actions (FileTree).
|
||||
* "tokens-first" = file name | tokens | size | actions (ConnectorTree).
|
||||
*/
|
||||
columnOrder: ColumnOrder;
|
||||
/** When true, directory entries are sorted alphabetically within group. */
|
||||
sortEntries?: boolean;
|
||||
/** Top-right action area (e.g. Add File button, Sync button). */
|
||||
topRightAction: React.ReactNode;
|
||||
/**
|
||||
* Optional status text shown to the left of the search field while a
|
||||
* mutation is in progress (e.g. "Uploading files...").
|
||||
*/
|
||||
statusLabel?: string | null;
|
||||
/**
|
||||
* Builds the row action menu. Defaults to a single "View" option.
|
||||
* Wrappers can extend this with Delete, etc.
|
||||
*/
|
||||
getRowMenuOptions?: (ctx: RowMenuContext) => TreeMenuOption[];
|
||||
/** Modals / overlays the wrapper wants rendered alongside the tree. */
|
||||
extraContent?: React.ReactNode;
|
||||
/**
|
||||
* Called after a directory structure response is parsed. Useful for
|
||||
* connectors that need the provider field exposed on the response.
|
||||
*/
|
||||
onDirectoryDataLoaded?: (data: any) => void;
|
||||
/**
|
||||
* Imperative handle so wrappers can trigger a refresh after a
|
||||
* successful mutation.
|
||||
*/
|
||||
controllerRef?: React.MutableRefObject<TreeBrowserController | null>;
|
||||
/**
|
||||
* Mirrors the current breadcrumb path back to the wrapper so it can
|
||||
* construct paths for upload / delete mutations without owning the
|
||||
* navigation state.
|
||||
*/
|
||||
onCurrentPathChange?: (path: string[]) => void;
|
||||
}
|
||||
|
||||
function calculateDirectoryStats(structure: DirectoryStructure): {
|
||||
totalSize: number;
|
||||
totalTokens: number;
|
||||
} {
|
||||
let totalSize = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
Object.entries(structure).forEach(([, node]) => {
|
||||
if (node.type) {
|
||||
totalSize += node.size_bytes || 0;
|
||||
totalTokens += node.token_count || 0;
|
||||
} else {
|
||||
const stats = calculateDirectoryStats(node);
|
||||
totalSize += stats.totalSize;
|
||||
totalTokens += stats.totalTokens;
|
||||
}
|
||||
});
|
||||
|
||||
return { totalSize, totalTokens };
|
||||
}
|
||||
|
||||
function searchFiles(
|
||||
query: string,
|
||||
structure: DirectoryStructure,
|
||||
currentPath: string[] = [],
|
||||
): SearchResult[] {
|
||||
let results: SearchResult[] = [];
|
||||
|
||||
Object.entries(structure).forEach(([name, node]) => {
|
||||
const fullPath = [...currentPath, name].join('/');
|
||||
const displayName =
|
||||
typeof node.display_name === 'string' && node.display_name.trim()
|
||||
? node.display_name
|
||||
: '';
|
||||
const queryLower = query.toLowerCase();
|
||||
const matchTarget = displayName ? `${name} ${displayName}` : name;
|
||||
|
||||
if (matchTarget.toLowerCase().includes(queryLower)) {
|
||||
results.push({
|
||||
name: displayName || name,
|
||||
path: fullPath,
|
||||
isFile: !!node.type,
|
||||
});
|
||||
}
|
||||
|
||||
if (!node.type) {
|
||||
results = [
|
||||
...results,
|
||||
...searchFiles(query, node as DirectoryStructure, [
|
||||
...currentPath,
|
||||
name,
|
||||
]),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
directoryStructure: DirectoryStructure | null,
|
||||
path: string,
|
||||
): string {
|
||||
if (!directoryStructure) {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
let structure: any = directoryStructure;
|
||||
if (typeof structure === 'string') {
|
||||
try {
|
||||
structure = JSON.parse(structure);
|
||||
} catch (e) {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
}
|
||||
if (typeof structure !== 'object' || structure === null) {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let current: any = structure;
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
typeof current === 'object' &&
|
||||
typeof current.display_name === 'string' &&
|
||||
current.display_name.trim()
|
||||
) {
|
||||
return current.display_name;
|
||||
}
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
docId,
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
columnOrder,
|
||||
sortEntries = false,
|
||||
topRightAction,
|
||||
statusLabel,
|
||||
getRowMenuOptions,
|
||||
extraContent,
|
||||
onDirectoryDataLoaded,
|
||||
controllerRef,
|
||||
onCurrentPathChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useLoaderState(true, 500);
|
||||
const [, setError] = useState<string | null>(null);
|
||||
const [directoryStructure, setDirectoryStructure] =
|
||||
useState<DirectoryStructure | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||
const token = useSelector(selectToken);
|
||||
const [selectedFile, setSelectedFile] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onCurrentPathChangeRef = useRef(onCurrentPathChange);
|
||||
const onDirectoryDataLoadedRef = useRef(onDirectoryDataLoaded);
|
||||
useEffect(() => {
|
||||
onCurrentPathChangeRef.current = onCurrentPathChange;
|
||||
onDirectoryDataLoadedRef.current = onDirectoryDataLoaded;
|
||||
});
|
||||
useEffect(() => {
|
||||
onCurrentPathChangeRef.current?.(currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
useOutsideAlerter(
|
||||
searchDropdownRef,
|
||||
() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
},
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(fileName: string, displayName?: string) => {
|
||||
const fullPath = [...currentPath, fileName].join('/');
|
||||
setSelectedFile({ id: fullPath, name: displayName ?? fileName });
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
const navigateToDirectory = useCallback((dirName: string) => {
|
||||
setCurrentPath((prev) => [...prev, dirName]);
|
||||
}, []);
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
setCurrentPath((prev) => prev.slice(0, -1));
|
||||
}, []);
|
||||
|
||||
const refreshDirectory = useCallback(async () => {
|
||||
try {
|
||||
const response = await userService.getDirectoryStructure(docId, token);
|
||||
const data = await response.json();
|
||||
if (!mountedRef.current) return false;
|
||||
if (data && data.directory_structure) {
|
||||
setDirectoryStructure(data.directory_structure);
|
||||
onDirectoryDataLoadedRef.current?.(data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error('Error refreshing directory structure:', err);
|
||||
return false;
|
||||
}
|
||||
}, [docId, token]);
|
||||
|
||||
const resetPath = useCallback(() => {
|
||||
setCurrentPath([]);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
controllerRef as React.MutableRefObject<TreeBrowserController | null>,
|
||||
() => ({ refreshDirectory, resetPath }),
|
||||
[refreshDirectory, resetPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDirectoryStructure = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await userService.getDirectoryStructure(docId, token);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.directory_structure) {
|
||||
setDirectoryStructure(data.directory_structure);
|
||||
onDirectoryDataLoadedRef.current?.(data);
|
||||
} else {
|
||||
setError('Invalid response format');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load directory structure');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (docId) {
|
||||
fetchDirectoryStructure();
|
||||
}
|
||||
}, [docId, token]);
|
||||
|
||||
const getCurrentDirectory = useCallback((): DirectoryStructure => {
|
||||
if (!directoryStructure) return {};
|
||||
|
||||
let structure: any = directoryStructure;
|
||||
if (typeof structure === 'string') {
|
||||
try {
|
||||
structure = JSON.parse(structure);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error parsing directory structure in getCurrentDirectory:',
|
||||
e,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof structure !== 'object' || structure === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let current: any = structure;
|
||||
for (const dir of currentPath) {
|
||||
if (
|
||||
current[dir] &&
|
||||
typeof current[dir] === 'object' &&
|
||||
!current[dir].type
|
||||
) {
|
||||
current = current[dir];
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}, [directoryStructure, currentPath]);
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null);
|
||||
} else if (currentPath.length === 0) {
|
||||
onBackToDocuments?.();
|
||||
} else {
|
||||
navigateUp();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSelect = (result: SearchResult) => {
|
||||
if (result.isFile) {
|
||||
const pathParts = result.path.split('/');
|
||||
const fileName = pathParts.pop() || '';
|
||||
setCurrentPath(pathParts);
|
||||
|
||||
setSelectedFile({
|
||||
id: result.path,
|
||||
name: result.name || fileName,
|
||||
});
|
||||
} else {
|
||||
setCurrentPath(result.path.split('/'));
|
||||
setSelectedFile(null);
|
||||
}
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const handleFileSearch = (q: string): SearchResult[] => {
|
||||
if (directoryStructure) {
|
||||
return searchFiles(q, directoryStructure);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleFileSelect = (path: string) => {
|
||||
const pathParts = path.split('/');
|
||||
const fileName = pathParts.pop() || '';
|
||||
setCurrentPath(pathParts);
|
||||
setSelectedFile({
|
||||
id: path,
|
||||
name: resolveDisplayName(directoryStructure, path) || fileName,
|
||||
});
|
||||
};
|
||||
|
||||
const buildDefaultViewOption = (
|
||||
name: string,
|
||||
isFile: boolean,
|
||||
displayName?: string,
|
||||
): TreeMenuOption => ({
|
||||
icon: EyeView,
|
||||
label: t('settings.sources.view'),
|
||||
onClick: (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (isFile) {
|
||||
handleFileClick(name, displayName);
|
||||
} else {
|
||||
navigateToDirectory(name);
|
||||
}
|
||||
},
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
const resolveRowMenuOptions = (
|
||||
name: string,
|
||||
isFile: boolean,
|
||||
itemId: string,
|
||||
displayName?: string,
|
||||
): TreeMenuOption[] => {
|
||||
const defaultViewOption = buildDefaultViewOption(name, isFile, displayName);
|
||||
if (getRowMenuOptions) {
|
||||
return getRowMenuOptions({
|
||||
name,
|
||||
isFile,
|
||||
itemId,
|
||||
displayName,
|
||||
defaultViewOption,
|
||||
});
|
||||
}
|
||||
return [defaultViewOption];
|
||||
};
|
||||
|
||||
const renderRowActionsCell = (
|
||||
name: string,
|
||||
isFile: boolean,
|
||||
itemId: string,
|
||||
displayName?: string,
|
||||
) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="dark:hover:bg-muted h-[35px] w-6 shrink-0 p-0 hover:bg-[#EBEBEB]"
|
||||
aria-label={t('settings.sources.menuAlt')}
|
||||
>
|
||||
<img
|
||||
src={ThreeDots}
|
||||
alt={t('settings.sources.menuAlt')}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[144px]">
|
||||
{resolveRowMenuOptions(name, isFile, itemId, displayName).map(
|
||||
(option, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={idx}
|
||||
variant={option.variant}
|
||||
onSelect={(event) => {
|
||||
option.onClick(event as unknown as SyntheticEvent);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={option.icon}
|
||||
alt=""
|
||||
width={option.iconWidth ?? 16}
|
||||
height={option.iconHeight ?? 16}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the size + tokens column pair in the right order for the
|
||||
* configured columnOrder. Sizes/tokens of 0 (or undefined) render as
|
||||
* "-" — matches the pre-refactor behavior of both trees.
|
||||
*/
|
||||
const renderColumnPair = (sizeBytes: number, tokens: number) => {
|
||||
const sizeDisplay = sizeBytes > 0 ? formatBytes(sizeBytes) : '-';
|
||||
const tokensDisplay = tokens > 0 ? tokens.toLocaleString() : '-';
|
||||
|
||||
if (columnOrder === 'size-first') {
|
||||
return (
|
||||
<>
|
||||
<TableCell width="30%" align="left">
|
||||
{sizeDisplay}
|
||||
</TableCell>
|
||||
<TableCell width="20%" align="right">
|
||||
{tokensDisplay}
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TableCell width="30%" align="left">
|
||||
{tokensDisplay}
|
||||
</TableCell>
|
||||
<TableCell width="20%" align="left">
|
||||
{sizeDisplay}
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileTree = (structure: DirectoryStructure): React.ReactNode[] => {
|
||||
const entries = Object.entries(structure);
|
||||
|
||||
const sortedEntries = sortEntries
|
||||
? entries.sort(([nameA, nodeA], [nameB, nodeB]) => {
|
||||
const isFileA = !!nodeA.type;
|
||||
const isFileB = !!nodeB.type;
|
||||
if (isFileA !== isFileB) {
|
||||
return isFileA ? 1 : -1; // Directories first
|
||||
}
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
: entries;
|
||||
|
||||
const directories = sortedEntries.filter(([, node]) => !node.type);
|
||||
const files = sortedEntries.filter(([, node]) => node.type);
|
||||
|
||||
const parentRow =
|
||||
currentPath.length > 0
|
||||
? [
|
||||
<TableRow key="parent-dir" onClick={navigateUp}>
|
||||
<TableCell width="40%" align="left">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={FolderIcon}
|
||||
alt={t('settings.sources.parentFolderAlt')}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">..</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell width="30%" align="left">
|
||||
-
|
||||
</TableCell>
|
||||
<TableCell
|
||||
width="20%"
|
||||
align={columnOrder === 'size-first' ? 'right' : 'left'}
|
||||
>
|
||||
-
|
||||
</TableCell>
|
||||
<TableCell width="10%" align="right"></TableCell>
|
||||
</TableRow>,
|
||||
]
|
||||
: [];
|
||||
|
||||
const directoryRows = directories.map(([name, node]) => {
|
||||
const itemId = `dir-${name}`;
|
||||
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
|
||||
|
||||
return (
|
||||
<TableRow key={itemId} onClick={() => navigateToDirectory(name)}>
|
||||
<TableCell width="40%" align="left">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<img
|
||||
src={FolderIcon}
|
||||
alt={t('settings.sources.folderAlt')}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{renderColumnPair(dirStats.totalSize, dirStats.totalTokens)}
|
||||
<TableCell width="10%" align="right">
|
||||
{renderRowActionsCell(name, false, itemId)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
const fileRows = files.map(([name, node]) => {
|
||||
const itemId = `file-${name}`;
|
||||
const displayName =
|
||||
typeof node.display_name === 'string' && node.display_name.trim()
|
||||
? node.display_name
|
||||
: name;
|
||||
const fileNode = node as FileNode;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={itemId}
|
||||
onClick={() => handleFileClick(name, displayName)}
|
||||
>
|
||||
<TableCell width="40%" align="left">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<img
|
||||
src={FileIcon}
|
||||
alt={t('settings.sources.fileAlt')}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{displayName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{renderColumnPair(
|
||||
fileNode.size_bytes || 0,
|
||||
fileNode.token_count || 0,
|
||||
)}
|
||||
<TableCell width="10%" align="right">
|
||||
{renderRowActionsCell(name, true, itemId, displayName)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return [...parentRow, ...directoryRows, ...fileRows];
|
||||
};
|
||||
|
||||
const renderFileSearch = () => (
|
||||
<div className="relative w-52" ref={searchDropdownRef}>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
if (directoryStructure) {
|
||||
setSearchResults(searchFiles(e.target.value, directoryStructure));
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.sources.searchFiles')}
|
||||
className={`h-[38px] px-4 py-2 ${searchQuery ? 'rounded-t-3xl' : 'rounded-3xl'}`}
|
||||
/>
|
||||
|
||||
{searchQuery && (
|
||||
<div className="border-border bg-card dark:border-border dark:bg-card absolute top-full right-0 left-0 z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-xl border border-t-0 shadow-lg transition-all duration-200">
|
||||
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="text-muted-foreground py-2 text-center text-sm">
|
||||
{t('settings.sources.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleSearchSelect(result)}
|
||||
title={result.path}
|
||||
className={`hover:bg-muted dark:hover:bg-muted flex min-w-0 cursor-pointer items-center px-3 py-2 ${
|
||||
index !== searchResults.length - 1
|
||||
? 'border-border dark:border-border border-b'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={result.isFile ? FileIcon : FolderIcon}
|
||||
alt={
|
||||
result.isFile
|
||||
? t('settings.sources.fileAlt')
|
||||
: t('settings.sources.folderAlt')
|
||||
}
|
||||
className="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="flex-1 truncate text-sm">{result.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPathNavigation = () => (
|
||||
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex w-full items-center sm:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground mr-3 h-[29px] w-[29px] rounded-full p-2 dark:border-0"
|
||||
onClick={handleBackNavigation}
|
||||
>
|
||||
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center">
|
||||
<span className="text-primary font-semibold wrap-break-word">
|
||||
{sourceName}
|
||||
</span>
|
||||
{currentPath.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1 shrink-0">/</span>
|
||||
{currentPath.map((dir, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<span className="dark:text-foreground wrap-break-word text-gray-700">
|
||||
{dir}
|
||||
</span>
|
||||
{index < currentPath.length - 1 && (
|
||||
<span className="text-muted-foreground mx-1 shrink-0">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{selectedFile && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1 shrink-0">/</span>
|
||||
<span className="dark:text-foreground wrap-break-word text-gray-700">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2 flex w-full flex-row flex-nowrap items-center justify-end gap-2 sm:mt-0 sm:w-auto">
|
||||
{statusLabel && (
|
||||
<div className="text-muted-foreground text-sm">{statusLabel}</div>
|
||||
)}
|
||||
{renderFileSearch()}
|
||||
{topRightAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedFile ? (
|
||||
<div className="flex">
|
||||
<div className="flex-1">
|
||||
<Chunks
|
||||
documentId={docId}
|
||||
documentName={sourceName}
|
||||
handleGoBack={() => setSelectedFile(null)}
|
||||
path={selectedFile.id}
|
||||
displayPath={[...currentPath, selectedFile.name].join('/')}
|
||||
onFileSearch={handleFileSearch}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-2">{renderPathNavigation()}</div>
|
||||
|
||||
<div className="w-full">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader width="40%" align="left">
|
||||
{t('settings.sources.fileName')}
|
||||
</TableHeader>
|
||||
{columnOrder === 'size-first' ? (
|
||||
<>
|
||||
<TableHeader width="30%" align="left">
|
||||
{t('settings.sources.size')}
|
||||
</TableHeader>
|
||||
<TableHeader width="20%" align="right">
|
||||
{t('settings.sources.tokens')}
|
||||
</TableHeader>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableHeader width="30%" align="left">
|
||||
{t('settings.sources.tokens')}
|
||||
</TableHeader>
|
||||
<TableHeader width="20%" align="left">
|
||||
{t('settings.sources.size')}
|
||||
</TableHeader>
|
||||
</>
|
||||
)}
|
||||
<TableHeader width="10%" align="right">
|
||||
<span className="sr-only">
|
||||
{t('settings.sources.actions')}
|
||||
</span>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<SkeletonLoader component="fileTable" />
|
||||
) : (
|
||||
renderFileTree(currentDirectory)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{extraContent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeBrowser;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { SyntheticEvent } from 'react';
|
||||
|
||||
export interface FileNode {
|
||||
type?: string;
|
||||
token_count?: number;
|
||||
size_bytes?: number;
|
||||
display_name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DirectoryStructure {
|
||||
[key: string]: FileNode;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
name: string;
|
||||
path: string;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
export type TreeMenuOption = {
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: (event: SyntheticEvent) => void;
|
||||
variant: 'default' | 'destructive';
|
||||
iconWidth?: number;
|
||||
iconHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Row context passed to consumers' getRowMenuOptions callbacks so they
|
||||
* can build per-row menus (e.g. add a Delete option for upload trees).
|
||||
*/
|
||||
export interface RowMenuContext {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
itemId: string;
|
||||
displayName?: string;
|
||||
/** Default "View" option already provided by TreeBrowser. */
|
||||
defaultViewOption: TreeMenuOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller exposed to wrappers via the controllerRef prop so they can
|
||||
* trigger a refresh of the directory structure after a successful
|
||||
* mutation (upload, delete, sync, etc.).
|
||||
*/
|
||||
export interface TreeBrowserController {
|
||||
refreshDirectory: () => Promise<boolean>;
|
||||
/** Resets the breadcrumb path back to the root. */
|
||||
resetPath: () => void;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useStore } from 'react-redux';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const MAX_WAIT_MS = 5 * 60_000;
|
||||
|
||||
export type ReingestTerminal = 'completed' | 'failed' | 'timeout' | 'unmounted';
|
||||
|
||||
/**
|
||||
* Waits for a terminal source.ingest.{completed,failed} event tagged with
|
||||
* sourceId in the bounded notifications.recentEvents ring. Resolves on
|
||||
* the first terminal whose timestamp is >= opStartedAt (so a stale
|
||||
* terminal from a prior op on the same source can't short-circuit the
|
||||
* current wait).
|
||||
*
|
||||
* Returns a function `waitForTerminal(sourceId, opStartedAt)` that callers
|
||||
* invoke after kicking off the backend mutation. Cleans up its subscribe
|
||||
* and timer on unmount.
|
||||
*/
|
||||
export function useReingestSseWaiter() {
|
||||
const store = useStore<RootState>();
|
||||
const mountedRef = useRef(true);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const waitForTerminal = useCallback(
|
||||
(sourceId: string | undefined, opStartedAt: number) =>
|
||||
new Promise<ReingestTerminal>((resolve) => {
|
||||
if (!mountedRef.current) {
|
||||
resolve('unmounted');
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalFromSse = (): 'completed' | 'failed' | null => {
|
||||
if (!sourceId) return null;
|
||||
const events = store.getState().notifications.recentEvents;
|
||||
for (const event of events) {
|
||||
if (event.scope?.id !== sourceId) continue;
|
||||
const ts = event.ts ? Date.parse(event.ts) : NaN;
|
||||
if (!Number.isFinite(ts) || ts < opStartedAt) continue;
|
||||
if (event.type === 'source.ingest.completed') return 'completed';
|
||||
if (event.type === 'source.ingest.failed') return 'failed';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Cover the race where the event landed between the POST
|
||||
// returning and the subscribe call.
|
||||
const initial = terminalFromSse();
|
||||
if (initial) {
|
||||
resolve(initial);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const finish = (value: ReingestTerminal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
timerRef.current = window.setTimeout(
|
||||
() => finish('timeout'),
|
||||
MAX_WAIT_MS,
|
||||
);
|
||||
unsubscribeRef.current = store.subscribe(() => {
|
||||
if (!mountedRef.current) {
|
||||
finish('unmounted');
|
||||
return;
|
||||
}
|
||||
const next = terminalFromSse();
|
||||
if (next) finish(next);
|
||||
});
|
||||
}),
|
||||
[store],
|
||||
);
|
||||
|
||||
return { waitForTerminal, mountedRef };
|
||||
}
|
||||
@@ -1,32 +1,3 @@
|
||||
export type InputProps = {
|
||||
type: 'text' | 'number';
|
||||
value: string | string[] | number;
|
||||
colorVariant?: 'silver' | 'jet' | 'gray';
|
||||
borderVariant?: 'thin' | 'thick';
|
||||
textSize?: 'small' | 'medium';
|
||||
isAutoFocused?: boolean;
|
||||
id?: string;
|
||||
maxLength?: number;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactElement;
|
||||
labelBgClassName?: string;
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => void;
|
||||
onPaste?: (
|
||||
e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => void;
|
||||
onKeyDown?: (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => void;
|
||||
leftIcon?: React.ReactNode;
|
||||
edgeRoundness?: string;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export type MermaidRendererProps = {
|
||||
code: string;
|
||||
isLoading?: boolean;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('overflow-hidden shadow-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:ring-ring/50 flex flex-1 items-center justify-between gap-4 text-left transition-all outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0 transition-transform duration-200 dark:invert"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('px-4 pb-3', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Robot from '../../assets/robot.svg';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AvatarProps = {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
fallbackSrc?: string;
|
||||
className?: string;
|
||||
imgClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Avatar({
|
||||
src,
|
||||
alt = 'agent',
|
||||
fallbackSrc = Robot,
|
||||
className,
|
||||
imgClassName,
|
||||
children,
|
||||
}: AvatarProps) {
|
||||
const resolvedSrc = src && src.trim() !== '' ? src : fallbackSrc;
|
||||
const [currentSrc, setCurrentSrc] = useState(resolvedSrc);
|
||||
|
||||
useEffect(() => {
|
||||
const newSrc = src && src.trim() !== '' ? src : fallbackSrc;
|
||||
if (newSrc !== currentSrc) {
|
||||
setCurrentSrc(newSrc);
|
||||
}
|
||||
}, [src, fallbackSrc]);
|
||||
|
||||
return (
|
||||
<div data-slot="avatar" className={cn('shrink-0', className)}>
|
||||
{children ?? (
|
||||
<img
|
||||
src={currentSrc}
|
||||
alt={alt}
|
||||
className={imgClassName}
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
onError={() => {
|
||||
if (currentSrc !== fallbackSrc) setCurrentSrc(fallbackSrc);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar };
|
||||
@@ -12,6 +12,8 @@ const buttonVariants = cva(
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
'destructive-outline':
|
||||
'border border-destructive text-destructive bg-transparent hover:bg-destructive hover:text-white focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
|
||||
@@ -19,7 +19,7 @@ function Command({
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-xl',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -40,7 +40,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2,22 +2,88 @@ import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
type InputProps = React.ComponentProps<'input'> & {
|
||||
label?: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
labelBgClassName?: string;
|
||||
};
|
||||
|
||||
const baseInputClasses =
|
||||
'text-foreground file:text-foreground placeholder:text-muted-foreground border-border h-[42px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm';
|
||||
|
||||
const themeInputClasses =
|
||||
'dark:border-border dark:text-white dark:placeholder:text-gray-400 selection:bg-primary selection:text-primary-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive';
|
||||
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
leftIcon,
|
||||
labelBgClassName = 'bg-card',
|
||||
id,
|
||||
placeholder,
|
||||
required,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const generatedId = React.useId();
|
||||
const inputId = id ?? (label ? generatedId : undefined);
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
data-slot="input"
|
||||
className={cn(baseInputClasses, themeInputClasses, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'text-foreground file:text-foreground placeholder:text-muted-foreground border-border h-[42px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'dark:border-border dark:text-white dark:placeholder:text-gray-400',
|
||||
'selection:bg-primary selection:text-primary-foreground',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
id={inputId}
|
||||
data-slot="input"
|
||||
// Use a single-space placeholder so :placeholder-shown drives the
|
||||
// floating-label peer-trick even when the caller didn't pass one.
|
||||
placeholder={placeholder ?? ' '}
|
||||
required={required}
|
||||
className={cn(
|
||||
baseInputClasses,
|
||||
themeInputClasses,
|
||||
'peer placeholder:text-transparent dark:placeholder:text-transparent',
|
||||
leftIcon && 'pl-10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{leftIcon ? (
|
||||
<div className="pointer-events-none absolute top-1/2 left-3 flex -translate-y-1/2 items-center justify-center">
|
||||
{leftIcon}
|
||||
</div>
|
||||
) : null}
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn(
|
||||
'text-muted-foreground pointer-events-none absolute -top-2.5 left-3 max-w-[calc(100%-24px)] cursor-none overflow-hidden px-2 text-xs text-ellipsis whitespace-nowrap transition-all select-none',
|
||||
'peer-placeholder-shown:top-2.5 peer-placeholder-shown:text-base',
|
||||
leftIcon
|
||||
? 'peer-placeholder-shown:left-7'
|
||||
: 'peer-placeholder-shown:left-3',
|
||||
'peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs',
|
||||
labelBgClassName,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required ? <span className="text-destructive ml-0.5">*</span> : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
export type { InputProps };
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { VisuallyHidden } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
const SIZE_CLASSES: Record<ModalSize, string> = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-lg',
|
||||
lg: 'sm:max-w-2xl',
|
||||
xl: 'sm:max-w-4xl',
|
||||
full: 'sm:max-w-[calc(100vw-2rem)]',
|
||||
};
|
||||
|
||||
export type ModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
hideTitle?: boolean;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
isPerformingTask?: boolean;
|
||||
size?: ModalSize;
|
||||
};
|
||||
|
||||
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
|
||||
{
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
hideTitle = false,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
showCloseButton = true,
|
||||
isPerformingTask = false,
|
||||
size = 'md',
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const shouldShowCloseButton = showCloseButton && !isPerformingTask;
|
||||
|
||||
// When a task is performing, block click-outside / pointer-outside to
|
||||
// mirror the legacy WrapperModal lock. Esc remains enabled (Radix default).
|
||||
const blockOutsideInteractions = isPerformingTask
|
||||
? (event: Event) => event.preventDefault()
|
||||
: undefined;
|
||||
|
||||
// Radix requires a DialogTitle in the a11y tree. If the consumer wants the
|
||||
// title hidden visually, wrap it in VisuallyHidden so screen readers still
|
||||
// announce it. If no title was supplied at all, provide a sensible default
|
||||
// ("Dialog") behind VisuallyHidden so Radix never warns.
|
||||
const resolvedTitle = title ?? 'Dialog';
|
||||
const titleNode =
|
||||
hideTitle || !title ? (
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>{resolvedTitle}</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
) : (
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
);
|
||||
|
||||
const descriptionNode = description ? (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
) : (
|
||||
<VisuallyHidden.Root>
|
||||
<DialogDescription>{resolvedTitle}</DialogDescription>
|
||||
</VisuallyHidden.Root>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/25 backdrop-blur-xs dark:bg-black/50" />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="modal-content"
|
||||
onPointerDownOutside={blockOutsideInteractions}
|
||||
onInteractOutside={blockOutsideInteractions}
|
||||
// Radix portals this to <body> in the DOM, but React still bubbles
|
||||
// synthetic events through the JSX tree. Stop the bubble at the
|
||||
// modal boundary so consumers mounted inside clickable cards (e.g.
|
||||
// MoveToFolderModal inside AgentCard) don't trigger the card's
|
||||
// onClick when the user interacts inside the modal.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className={cn(
|
||||
'bg-card text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 shadow-modal fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl p-8 duration-200 outline-none',
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{titleNode}
|
||||
{descriptionNode}
|
||||
<div
|
||||
className={cn(
|
||||
'no-scrollbar text-foreground overflow-y-auto',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="flex justify-end gap-2">{footer}</div>
|
||||
) : null}
|
||||
{shouldShowCloseButton && (
|
||||
<DialogClose
|
||||
className="ring-offset-background focus:ring-ring absolute top-3 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
export { Modal };
|
||||
@@ -28,7 +28,7 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -37,7 +37,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=lg]:h-[42px] data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-gray-600 dark:[&_svg:not([class*='text-'])]:text-gray-400",
|
||||
"group aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=lg]:h-[42px] data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-gray-600 dark:[&_svg:not([class*='text-'])]:text-gray-400",
|
||||
variant === 'default' &&
|
||||
'border-border bg-card focus-visible:ring-ring/50 hover:bg-accent data-placeholder:text-muted-foreground',
|
||||
variant === 'ghost' &&
|
||||
@@ -48,7 +48,7 @@ function SelectTrigger({
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
<ChevronDownIcon className="size-4 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
@@ -57,7 +57,7 @@ function SelectTrigger({
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'item-aligned',
|
||||
position = 'popper',
|
||||
align = 'center',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
@@ -66,7 +66,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'border-border bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-200 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border text-gray-900 shadow-md dark:text-white',
|
||||
'border-border bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-200 max-h-(--radix-select-content-available-height) w-(--radix-select-trigger-width) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border text-gray-900 shadow-md dark:text-white',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer focus-visible:ring-ring/50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted dark:data-[state=unchecked]:bg-muted inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||