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>
This commit is contained in:
AlexandManishMadan2882 authored and GitHub committed 2026-05-25 14:54:15 +01:00
1 parent a11f0e856c
commit bb3bd3da4d
140 files changed
+6283 -7082

No files matched your search

+73
View File
@@ -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
+21
View File
@@ -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:
+3
View File
@@ -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",
+3
View File
@@ -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",
+21 -12
View File
@@ -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={
+115 -5
View File
@@ -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">
+120 -65
View File
@@ -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
+6 -3
View File
@@ -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>
);
+80 -40
View File
@@ -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>
+1 -1
View File
@@ -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}
+10 -5
View File
@@ -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
+19 -7
View File
@@ -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">
+49 -37
View File
@@ -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}
+65 -28
View File
@@ -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')}
+312 -315
View File
@@ -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>
) : (
+3 -3
View File
@@ -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}
+9 -6
View File
@@ -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>
+6 -3
View File
@@ -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>
+35 -29
View File
@@ -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"
+136 -97
View File
@@ -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}
+1 -1
View File
@@ -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}
-3
View File
@@ -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

-3
View File
@@ -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

-3
View File
@@ -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

-10
View File
@@ -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

-3
View File
@@ -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

+3
View File
@@ -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

+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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

-3
View File
@@ -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

-59
View File
@@ -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>
);
}
+13 -6
View File
@@ -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)}
-40
View File
@@ -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);
}}
/>
);
}
+34 -44
View File
@@ -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>
);
}
-13
View File
@@ -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>;
}
+55 -40
View File
@@ -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;
+12 -7
View File
@@ -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>
)}
</>
);
+78 -775
View File
@@ -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);
}
}}
/>
);
};
-177
View File
@@ -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 -4
View File
@@ -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>
);
}
+31 -15
View File
@@ -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>
);
-245
View File
@@ -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;
-117
View File
@@ -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,
);
}
-151
View File
@@ -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>
);
}
+22 -16
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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>
+21 -13
View File
@@ -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>
+6 -3
View File
@@ -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`}
-92
View File
@@ -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;
+33 -20
View File
@@ -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>
+146 -228
View File
@@ -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>
);
}
+7 -4
View File
@@ -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>
</>
);
-18
View File
@@ -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;
-17
View File
@@ -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;
-107
View File
@@ -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;
-54
View File
@@ -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>
);
}
+99 -71
View File
@@ -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;
-238
View File
@@ -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);
}
+17 -27
View File
@@ -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>
);
}
-87
View File
@@ -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;
-258
View File
@@ -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);
}
+19 -35
View File
@@ -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;
+52
View File
@@ -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 };
}
-29
View File
@@ -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;
+67
View File
@@ -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 };
+52
View File
@@ -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 };
+2
View File
@@ -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:
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+80 -14
View File
@@ -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 };
+139
View File
@@ -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 };
+1 -1
View File
@@ -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}
+4 -4
View File
@@ -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,
+29
View File
@@ -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 };
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more