Compare commits

...

47 Commits

Author SHA1 Message Date
Vikhyath Mondreti
1d7ae906bc v0.6.16: bullmq optionality 2026-03-30 00:12:21 -07:00
Vikhyath Mondreti
c4f4e6b48c fix(bullmq): disable temporarily (#3841) 2026-03-30 00:04:59 -07:00
Waleed
560fa75155 v0.6.15: workers, security hardening, sidebar improvements, chat fixes, profound 2026-03-29 23:02:19 -07:00
Waleed
1728c370de improvement(landing): lighthouse performance and accessibility fixes (#3837)
* improvement(landing): lighthouse performance and accessibility fixes

* improvement(landing): extract FeatureToggleItem to deduplicate accessibility logic

* lint

* fix(landing): ensure explicit delay prop takes precedence over transition spread
2026-03-29 22:33:34 -07:00
Waleed
82e58a5082 fix(academy): hide academy pages until content is ready (#3839)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:18:13 -07:00
Waleed
336c065234 fix(viewer): image pan/zoom, sort fixes, sidebar dot fixes (#3836)
* feat(file-viewer): add pan and zoom to image preview

* fix(viewer): fix sort key mapping, disable load-more on sort, hide status dots when menu open

* fix(file-viewer): prevent scroll bleed and zoom button micro-pans

* fix(file-viewer): use exponential zoom formula to prevent zero/negative multiplier
2026-03-29 12:40:29 -07:00
Waleed
b3713642b2 feat(resources): add sort and filter to all resource list pages (#3834)
* improvement(tables): improve table filtering UX

- Replace popover filter with persistent inline panel below toolbar
- Add AND/OR toggle between filter rules (shown in Where label slot)
- Sync filter panel state from applied filter on open
- Show filter button active state when filter is applied or panel is open
- Use readable operator labels matching dropdown options
- Add Clear filters button (shown only when filter is active)
- Close filter panel when last rule is removed via X
- Fix empty gap rows appearing in filtered results by skipping position gap rendering when filter is active
- Add toggle mode to ResourceOptionsBar for inline panel pattern
- Memoize FilterRuleRow for perf, fix filterTags key collision, remove dead filterActiveCount prop

* fix(table-filter): use ref to stabilize handleRemove/handleApply callbacks

Reading rules via ref instead of closure eliminates rules from useCallback
dependency arrays, keeping callbacks stable across rule edits and preserving
the memo() benefit on FilterRuleRow.

* improvement(tables,kb): remove hacky patterns, fix KB filter popover width

- Remove non-TSDoc comment from table-filter (rulesRef pattern is self-evident)
- Simplify SearchSection: remove setState-during-render anti-pattern; controlled
  input binds directly to search.value/onChange (simpler and correct)
- Reduce KB filter popover from w-[320px] to w-[200px]; tag filter uses vertical
  layout so narrow width works; Status-only case is now appropriately compact

* feat(knowledge): add sort and filter to KB list page

Sort dropdown: name, documents, tokens, created, last updated — pre-sorted
externally before passing rows to Resource. Active sort highlights the Sort
button; clear resets to default (created desc).

Filter popover: filter by connector status (All / With connectors /
Without connectors). Active filter shown as a removable tag in the toolbar.

* feat(files): add sort and filter to files list page

* feat(scheduled-tasks): add sort and filter to scheduled tasks page

* fix(table-filter): use explicit close handler instead of toggle

* improvement(files,knowledge): replace manual debounce with useDebounce hook and use type guards for file filtering

* fix(resource): prevent popover from inheriting anchor min-width

* feat(tables): add sort to tables list page

* feat(knowledge): add content and owner filters to KB list

* feat(scheduled-tasks): add status and health filters

* feat(files): add size and uploaded-by filters to files list

* feat(tables): add row count, owner, and column type filters

* improvement(scheduled-tasks): use combobox filter panel matching logs UI style

* improvement(knowledge): use combobox filter panel matching logs UI style

* improvement(files): use combobox filter panel matching logs UI style

Replaces button-list filters with Combobox-based multi-select sections for file type, size, and uploaded-by filters, aligning the panel with the logs page filter UI.

* improvement(tables): use combobox filter panel matching logs UI style

* feat(settings): add sort to recently deleted page

Add a sort dropdown next to the search bar allowing users to sort by deletion date (default, newest first), name (A–Z), or type (A–Z).

* feat(logs): add sort to logs page

* improvement(knowledge): upgrade document list filter to combobox style

* fix(resources): fix missing imports, memoization, and stale refs across resource pages

* improvement(tables): remove column type filter

* fix(resources): fix filter/sort correctness issues from audit

* fix(chunks): add server-side sort to document chunks API

Chunk sort was previously done client-side on a single page of
server-paginated data, which only reordered the current page.
Now sort params (sortBy, sortOrder) flow through the full stack:
types → service → API route → query hook → useDocumentChunks → document.tsx.

* perf(resources): memoize filterContent JSX across all resource pages

Resource is wrapped in React.memo, so an unstable filterContent reference
on every parent re-render defeats the memo. Wrap filterContent in useMemo
with correct deps in all 6 pages (files, tables, scheduled-tasks, knowledge,
base, document).

* fix(resources): add missing sort options for all visible columns

Every column visible in a resource table should be sortable. Three pages
had visible columns with no sort support:
- files.tsx: add 'owner' sort (member name lookup)
- scheduled-tasks.tsx: add 'schedule' sort (localeCompare on description)
- knowledge.tsx: add 'connectors' (count) and 'owner' (member name) sorts

Also add 'members' to processedKBs deps in knowledge.tsx since owner
sort now reads member names inside the memo.

* whitelabeling updates, sidebar fixes, files bug

* increased type safety

* pr fixes
2026-03-28 23:31:54 -07:00
Waleed
b9b930bb63 feat(analytics): add Profound web traffic tracking (#3835)
* feat(analytics): add Profound web traffic tracking

* fix(analytics): address PR review — add endpoint check and document trade-offs

* chore(analytics): remove implementation comments

* fix(analytics): guard sendToProfound with try-catch and align check with isProfoundEnabled

* fix(analytics): strip sensitive query params and remove redundant guard

* chore(analytics): remove unnecessary query param filtering
2026-03-28 22:09:23 -07:00
Vikhyath Mondreti
f1ead2ed55 fix docker image build 2026-03-28 20:58:56 -07:00
Waleed
14089f7dbb v0.6.14: performance improvements, connectors UX, collapsed sidebar actions 2026-03-27 13:07:59 -07:00
Waleed
e615816dce v0.6.13: emcn standardization, granola and ketch integrations, security hardening, connectors improvements 2026-03-27 00:16:37 -07:00
Waleed
ca87d7ce29 v0.6.12: billing, blogs UI 2026-03-26 01:19:23 -07:00
Waleed
6bebbc5e29 v0.6.11: billing fixes, rippling, hubspot, UI improvements, demo modal 2026-03-25 22:54:56 -07:00
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
ed9a71f0af v0.6.9: general ux improvements for tables, mothership 2026-03-24 17:03:24 -07:00
Siddharth Ganesan
c78c870fda v0.6.8: mothership tool loop
v0.6.8: mothership tool loop
2026-03-24 04:06:19 -07:00
Waleed
19442f19e2 v0.6.7: kb improvements, edge z index fix, captcha, new trust center, block classifications 2026-03-21 12:43:33 -07:00
Waleed
1731a4d7f0 v0.6.6: landing improvements, styling consistency, mothership table renaming 2026-03-19 23:58:30 -07:00
Waleed
9fcd02fd3b v0.6.5: email validation, integrations page, mothership and custom tool fixes 2026-03-19 16:08:30 -07:00
Waleed
ff7b5b528c v0.6.4: subflows, docusign, ashby new tools, box, workday, billing bug fixes 2026-03-18 23:12:36 -07:00
Waleed
30f2d1a0fc v0.6.3: hubspot integration, kb block improvements 2026-03-18 11:19:55 -07:00
Waleed
4bd0731871 v0.6.2: mothership stability, chat iframe embedding, KB upserts, new blog post 2026-03-18 03:29:39 -07:00
Waleed
4f3bc37fe4 v0.6.1: added better auth admin plugin 2026-03-17 15:16:16 -07:00
Waleed
84d6fdc423 v0.6: mothership, tables, connectors 2026-03-17 12:21:15 -07:00
Vikhyath Mondreti
4c12914d35 v0.5.113: jira, ashby, google ads, grain updates 2026-03-12 22:54:25 -07:00
Waleed
e9bdc57616 v0.5.112: trace spans improvements, fathom integration, jira fixes, canvas navigation updates 2026-03-12 13:30:20 -07:00
Vikhyath Mondreti
36612ae42a v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530) 2026-03-11 17:47:28 -07:00
Waleed
1c2c2c65d4 v0.5.110: webhook execution speedups, SSRF patches 2026-03-11 15:00:24 -07:00
Waleed
ecd3536a72 v0.5.109: obsidian and evernote integrations, slack fixes, remove memory instrumentation 2026-03-09 10:40:37 -07:00
Vikhyath Mondreti
8c0a2e04b1 v0.5.108: workflow input params in agent tools, bun upgrade, dropdown selectors for 14 blocks 2026-03-06 21:02:25 -08:00
Waleed
6586c5ce40 v0.5.107: new reddit, slack tools 2026-03-05 22:48:20 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
55 changed files with 2447 additions and 908 deletions

View File

@@ -1,3 +1,6 @@
/** Shared className for primary auth form submit buttons across all auth pages. */
export const AUTH_SUBMIT_BTN =
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
export const AUTH_PRIMARY_CTA_BASE =
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Full-width variant used for primary auth form submit buttons. */
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const

View File

@@ -288,7 +288,6 @@ export default function Collaboration() {
width={876}
height={480}
className='h-full w-auto object-left md:min-w-[100vw]'
priority
/>
</div>
<div className='hidden lg:block'>

View File

@@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
)
}
interface FeatureToggleItemProps {
feature: PermissionFeature
enabled: boolean
color: string
isInView: boolean
delay: number
textClassName: string
transition: Record<string, unknown>
onToggle: () => void
}
function FeatureToggleItem({
feature,
enabled,
color,
isInView,
delay,
textClassName,
transition,
onToggle,
}: FeatureToggleItemProps) {
return (
<motion.div
key={feature.key}
role='button'
tabIndex={0}
aria-label={`Toggle ${feature.name}`}
aria-pressed={enabled}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ ...transition, delay }}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
{feature.name}
</span>
</motion.div>
)
}
export function AccessControlPanel() {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-40px' })
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
return (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
return (
<motion.div
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
duration: 0.3,
}}
onClick={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
)
})}
{category.features.map((feature, featIdx) => (
<FeatureToggleItem
key={feature.key}
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.05 + (offsetBefore + featIdx) * 0.04}
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
transition={{ duration: 0.3 }}
onToggle={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
/>
))}
</div>
</div>
)
@@ -140,12 +176,11 @@ export function AccessControlPanel() {
<div className='hidden lg:block'>
{PERMISSION_CATEGORIES.map((category, catIdx) => (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
const currentIndex =
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
(sum, c) => sum + c.features.length,
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
) + featIdx
return (
<motion.div
<FeatureToggleItem
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.1 + currentIndex * 0.04,
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
}}
onClick={() =>
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.1 + currentIndex * 0.04}
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
onToggle={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
/>
)
})}
</div>

View File

@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
</div>
{/* Time */}
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
{timeAgo}
</span>
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
<span className='hidden sm:inline'>
<span className='text-[#F6F6F6]/40'> · </span>
<span className='text-[#F6F6F6]/60'> · </span>
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
</span>
</span>

View File

@@ -85,7 +85,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SOC 2 & HIPAA
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
Type II · PHI protected
</span>
</div>
@@ -105,7 +105,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
Open Source
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
View on GitHub
</span>
</div>
@@ -120,7 +120,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SSO & SCIM
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
Okta, Azure AD, Google
</span>
</div>
@@ -165,7 +165,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Audit Trail
</h3>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
Every action is captured with full actor attribution.
</p>
</div>
@@ -179,7 +179,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Access Control
</h3>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
Restrict providers, surfaces, and tools per group.
</p>
</div>
@@ -211,7 +211,7 @@ export default function Enterprise() {
(tag, i) => (
<span
key={i}
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
>
{tag}
</span>
@@ -221,7 +221,7 @@ export default function Enterprise() {
</div>
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<DemoRequestModal>

View File

@@ -190,7 +190,6 @@ export default function Features() {
width={1440}
height={366}
className='h-auto w-full'
priority
/>
</div>

View File

@@ -67,6 +67,7 @@ export function FooterCTA() {
type='button'
onClick={handleSubmit}
disabled={isEmpty}
aria-label='Submit message'
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#C0C0C0' : '#1C1C1C',

View File

@@ -26,7 +26,7 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
// { label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Academy', href: '/academy' },
// { label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },

View File

@@ -152,12 +152,13 @@ export default async function PartnersPage() {
recognition in the growing ecosystem of AI workflow builders.
</p>
<div className='flex items-center gap-4'>
<Link
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy →
</Link>
</Link> */}
<a
href='#how-it-works'
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
@@ -275,12 +276,13 @@ export default async function PartnersPage() {
Complete Sim Academy to earn your first certification and unlock partner benefits.
It's free to start — no credit card required.
</p>
<Link
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy
</Link>
</Link> */}
</div>
</section>
</main>

View File

@@ -15,6 +15,12 @@
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
--auth-primary-btn-bg: #ffffff;
--auth-primary-btn-border: #ffffff;
--auth-primary-btn-text: #000000;
--auth-primary-btn-hover-bg: #e0e0e0;
--auth-primary-btn-hover-border: #e0e0e0;
--auth-primary-btn-hover-text: #000000;
/* z-index scale for layered UI
Popover must be above modal so dropdowns inside modals render correctly */

View File

@@ -1,5 +1,9 @@
import type React from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
// TODO: Remove notFound() call to make academy pages public once content is ready
const ACADEMY_ENABLED = false
export const metadata: Metadata = {
title: {
@@ -17,6 +21,10 @@ export const metadata: Metadata = {
}
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
if (!ACADEMY_ENABLED) {
notFound()
}
return (
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
{children}

View File

@@ -15,6 +15,8 @@ const GetChunksQuerySchema = z.object({
enabled: z.enum(['true', 'false', 'all']).optional().default('all'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
offset: z.coerce.number().min(0).optional().default(0),
sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'),
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
})
const CreateChunkSchema = z.object({
@@ -88,6 +90,8 @@ export async function GET(
enabled: searchParams.get('enabled') || undefined,
limit: searchParams.get('limit') || undefined,
offset: searchParams.get('offset') || undefined,
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: searchParams.get('sortOrder') || undefined,
})
const result = await queryChunks(documentId, queryParams, requestId)

View File

@@ -293,7 +293,7 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
<button
onClick={() => handleVerifyOtp()}
disabled={otpValue.length !== 6 || isVerifyingOtp}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
className={AUTH_SUBMIT_BTN}
>
{isVerifyingOtp ? (
<span className='flex items-center gap-2'>

View File

@@ -1,6 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
interface FormErrorStateProps {
@@ -12,10 +13,7 @@ export function FormErrorState({ error }: FormErrorStateProps) {
return (
<StatusPageLayout title='Form Unavailable' description={error}>
<button
onClick={() => router.push('/workspace')}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button onClick={() => router.push('/workspace')} className={AUTH_SUBMIT_BTN}>
Return to Workspace
</button>
</StatusPageLayout>

View File

@@ -5,6 +5,7 @@ import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
@@ -75,7 +76,7 @@ export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
<button
type='submit'
disabled={!password.trim() || isSubmitting}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
className={AUTH_SUBMIT_BTN}
>
{isSubmitting ? (
<span className='flex items-center gap-2'>

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
const logger = createLogger('FormError')
@@ -21,10 +22,7 @@ export default function FormError({ error, reset }: FormErrorProps) {
title='Something went wrong'
description='We encountered an error loading this form. Please try again.'
>
<button
onClick={reset}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button onClick={reset} className={AUTH_SUBMIT_BTN}>
Try again
</button>
</StatusPageLayout>

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { Loader2 } from 'lucide-react'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import {
@@ -322,11 +323,7 @@ export default function Form({ identifier }: { identifier: string }) {
)}
{fields.length > 0 && (
<button
type='submit'
disabled={isSubmitting}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button type='submit' disabled={isSubmitting} className={AUTH_SUBMIT_BTN}>
{isSubmitting ? (
<span className='flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />

View File

@@ -3,6 +3,7 @@
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
interface InviteStatusCardProps {
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
@@ -55,10 +56,7 @@ export function InviteStatusCard({
<div className='mt-8 w-full max-w-[410px] space-y-3'>
{isExpiredError && (
<button
onClick={() => router.push('/')}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button onClick={() => router.push('/')} className={`${AUTH_PRIMARY_CTA_BASE} w-full`}>
Request New Invitation
</button>
)}
@@ -69,9 +67,9 @@ export function InviteStatusCard({
onClick={action.onClick}
disabled={action.disabled || action.loading}
className={cn(
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50',
`${AUTH_PRIMARY_CTA_BASE} w-full`,
index !== 0 &&
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
)}
>
{action.loading ? (

View File

@@ -218,6 +218,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
{/* OneDollarStats Analytics */}
<link rel='dns-prefetch' href='https://assets.onedollarstats.com' />
<script defer src='https://assets.onedollarstats.com/stonks.js' />
<PublicEnvScript />

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import Link from 'next/link'
import { getNavBlogPosts } from '@/lib/blog/registry'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
import Navbar from '@/app/(home)/components/navbar/navbar'
export const metadata: Metadata = {
@@ -9,9 +10,6 @@ export const metadata: Metadata = {
robots: { index: false, follow: true },
}
const CTA_BASE =
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
export default async function NotFound() {
const blogPosts = await getNavBlogPosts()
return (
@@ -29,10 +27,7 @@ export default async function NotFound() {
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className='mt-3 flex items-center gap-2'>
<Link
href='/'
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)]`}
>
<Link href='/' className={AUTH_PRIMARY_CTA_BASE}>
Return to Home
</Link>
</div>

View File

@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Landing from '@/app/(home)/landing'
export const dynamic = 'force-dynamic'
export const revalidate = 3600
const baseUrl = getBaseUrl()

View File

@@ -3,6 +3,7 @@
import { Suspense, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { InviteLayout } from '@/app/invite/components'
interface UnsubscribeData {
@@ -143,10 +144,7 @@ function UnsubscribeContent() {
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button
onClick={() => window.history.back()}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button onClick={() => window.history.back()} className={AUTH_SUBMIT_BTN}>
Go Back
</button>
</div>
@@ -168,10 +166,7 @@ function UnsubscribeContent() {
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button
onClick={() => window.close()}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
Close
</button>
</div>
@@ -193,10 +188,7 @@ function UnsubscribeContent() {
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button
onClick={() => window.close()}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
Close
</button>
</div>
@@ -222,7 +214,7 @@ function UnsubscribeContent() {
<button
onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
className={AUTH_SUBMIT_BTN}
>
{processing ? (
<span className='flex items-center gap-2'>
@@ -249,7 +241,7 @@ function UnsubscribeContent() {
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeMarketing
}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
className={AUTH_SUBMIT_BTN}
>
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
@@ -263,7 +255,7 @@ function UnsubscribeContent() {
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates
}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
className={AUTH_SUBMIT_BTN}
>
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
@@ -277,7 +269,7 @@ function UnsubscribeContent() {
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications
}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
className={AUTH_SUBMIT_BTN}
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'

View File

@@ -1,4 +1,4 @@
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
import { memo, type ReactNode } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
@@ -19,8 +19,6 @@ import { cn } from '@/lib/core/utils/cn'
const SEARCH_ICON = (
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
)
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
type SortDirection = 'asc' | 'desc'
@@ -67,7 +65,12 @@ export interface SearchConfig {
interface ResourceOptionsBarProps {
search?: SearchConfig
sort?: SortConfig
/** Popover content — renders inside a Popover (used by logs, etc.) */
filter?: ReactNode
/** When provided, Filter button acts as a toggle instead of opening a Popover */
onFilterToggle?: () => void
/** Whether the filter is currently active (highlights the toggle button) */
filterActive?: boolean
filterTags?: FilterTag[]
extras?: ReactNode
}
@@ -76,10 +79,13 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
search,
sort,
filter,
onFilterToggle,
filterActive,
filterTags,
extras,
}: ResourceOptionsBarProps) {
const hasContent = search || sort || filter || extras || (filterTags && filterTags.length > 0)
const hasContent =
search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0)
if (!hasContent) return null
return (
@@ -88,22 +94,39 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
{search && <SearchSection search={search} />}
<div className='flex items-center gap-1.5'>
{extras}
{filterTags?.map((tag) => (
{filterTags?.map((tag, i) => (
<Button
key={tag.label}
key={`${tag.label}-${i}`}
variant='subtle'
className='px-2 py-1 text-caption'
className='max-w-[200px] px-2 py-1 text-caption'
onClick={tag.onRemove}
>
{tag.label}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
<span className='truncate'>{tag.label}</span>
<span className='ml-1 shrink-0 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
{filter && (
{onFilterToggle ? (
<Button
variant='subtle'
className={cn(
'px-2 py-1 text-caption',
filterActive && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
)}
onClick={onFilterToggle}
>
<ListFilter
className={cn(
'mr-1.5 h-[14px] w-[14px]',
filterActive ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
)}
/>
Filter
</Button>
) : filter ? (
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
{FILTER_ICON}
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</Button>
</PopoverPrimitive.Trigger>
@@ -111,15 +134,13 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
<PopoverPrimitive.Content
align='start'
sideOffset={6}
className={cn(
'z-50 rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
)}
className='z-50 w-fit rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{filter}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)}
) : null}
{sort && <SortDropdown config={sort} />}
</div>
</div>
@@ -128,34 +149,6 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
})
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
const [localValue, setLocalValue] = useState(search.value)
const lastReportedRef = useRef(search.value)
if (search.value !== lastReportedRef.current) {
setLocalValue(search.value)
lastReportedRef.current = search.value
}
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value
setLocalValue(next)
search.onChange(next)
},
[search.onChange]
)
const handleClearAll = useCallback(() => {
setLocalValue('')
lastReportedRef.current = ''
if (search.onClearAll) {
search.onClearAll()
} else {
search.onChange('')
}
}, [search.onClearAll, search.onChange])
return (
<div className='relative flex flex-1 items-center'>
{SEARCH_ICON}
@@ -177,8 +170,8 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
<input
ref={search.inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
@@ -186,11 +179,11 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || localValue ? (
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={handleClearAll}
onClick={search.onClearAll ?? (() => search.onChange(''))}
>
<span className='text-caption'></span>
</button>
@@ -213,8 +206,19 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
{SORT_ICON}
<Button
variant='subtle'
className={cn(
'px-2 py-1 text-caption',
active && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
)}
>
<ArrowUpDown
className={cn(
'mr-1.5 h-[14px] w-[14px]',
active ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
)}
/>
Sort
</Button>
</DropdownMenuTrigger>

View File

@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -432,17 +433,120 @@ const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFil
)
})
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
const ZOOM_WHEEL_SENSITIVITY = 0.005
const ZOOM_BUTTON_FACTOR = 1.2
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const offsetAtDragStart = useRef({ x: 0, y: 0 })
const offsetRef = useRef(offset)
offsetRef.current = offset
const containerRef = useRef<HTMLDivElement>(null)
const zoomIn = useCallback(() => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR)), [])
const zoomOut = useCallback(() => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR)), [])
useEffect(() => {
const el = containerRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
setZoom((z) => clampZoom(z * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
} else {
setOffset((o) => ({ x: o.x - e.deltaX, y: o.y - e.deltaY }))
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
offsetAtDragStart.current = offsetRef.current
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
e.preventDefault()
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging.current) return
setOffset({
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
})
}, [])
const handleMouseUp = useCallback(() => {
isDragging.current = false
if (containerRef.current) containerRef.current.style.cursor = 'grab'
}, [])
useEffect(() => {
setZoom(1)
setOffset({ x: 0, y: 0 })
}, [file.key])
return (
<div className='flex flex-1 items-center justify-center overflow-auto bg-[var(--surface-1)] p-6'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full rounded-md object-contain'
loading='eager'
/>
<div
ref={containerRef}
className='relative flex flex-1 cursor-grab overflow-hidden bg-[var(--surface-1)]'
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className='pointer-events-none absolute inset-0 flex items-center justify-center'
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
/>
</div>
<div
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-sm'
onMouseDown={(e) => e.stopPropagation()}
>
<button
type='button'
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
aria-label='Zoom out'
>
<ZoomOut className='h-3.5 w-3.5' />
</button>
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
{Math.round(zoom * 100)}%
</span>
<button
type='button'
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
aria-label='Zoom in'
>
<ZoomIn className='h-3.5 w-3.5' />
</button>
</div>
</div>
)
})

View File

@@ -1,6 +1,7 @@
'use client'
import { memo, useMemo, useRef } from 'react'
import { createContext, memo, useContext, useMemo, useRef } from 'react'
import type { Components, ExtraProps } from 'react-markdown'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
@@ -70,34 +71,51 @@ export const PreviewPanel = memo(function PreviewPanel({
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
/**
* Carries the contentRef and toggle handler from MarkdownPreview down to the
* task-list renderers. Only present when the preview is interactive.
*/
const MarkdownCheckboxCtx = createContext<{
contentRef: React.MutableRefObject<string>
onToggle: (index: number, checked: boolean) => void
} | null>(null)
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
const CheckboxIndexCtx = createContext(-1)
const STATIC_MARKDOWN_COMPONENTS = {
p: ({ children }: any) => (
p: ({ children }: { children?: React.ReactNode }) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
</p>
),
h1: ({ children }: any) => (
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
{children}
</h1>
),
h2: ({ children }: any) => (
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
{children}
</h2>
),
h3: ({ children }: any) => (
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
{children}
</h3>
),
h4: ({ children }: any) => (
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
{children}
</h4>
),
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
code: ({
className,
children,
node: _node,
...props
}: React.HTMLAttributes<HTMLElement> & ExtraProps) => {
const isInline = !className?.includes('language-')
if (isInline) {
return (
@@ -119,8 +137,8 @@ const STATIC_MARKDOWN_COMPONENTS = {
</code>
)
},
pre: ({ children }: any) => <>{children}</>,
a: ({ href, children }: any) => (
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
target='_blank'
@@ -130,102 +148,133 @@ const STATIC_MARKDOWN_COMPONENTS = {
{children}
</a>
),
strong: ({ children }: any) => (
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
),
em: ({ children }: any) => (
em: ({ children }: { children?: React.ReactNode }) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: any) => (
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
),
hr: () => <hr className='my-6 border-[var(--border)]' />,
img: ({ src, alt }: any) => (
img: ({ src, alt, node: _node }: React.ComponentPropsWithoutRef<'img'> & ExtraProps) => (
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
),
table: ({ children }: any) => (
table: ({ children }: { children?: React.ReactNode }) => (
<div className='my-4 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-[13px]'>{children}</table>
</div>
),
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className='bg-[var(--surface-2)]'>{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: React.ReactNode }) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: any) => (
th: ({ children }: { children?: React.ReactNode }) => (
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
td: ({ children }: { children?: React.ReactNode }) => (
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
),
}
function buildMarkdownComponents(
checkboxCounterRef: React.MutableRefObject<number>,
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
) {
const isInteractive = Boolean(onCheckboxToggle)
function UlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ul'> & ExtraProps) {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ul
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
)}
>
{children}
</ul>
)
}
return {
...STATIC_MARKDOWN_COMPONENTS,
ul: ({ className, children }: any) => {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ul
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
)}
>
{children}
</ul>
)
},
ol: ({ className, children }: any) => {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ol
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
)}
>
{children}
</ol>
)
},
li: ({ className, children }: any) => {
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
if (isTaskItem) {
function OlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ol'> & ExtraProps) {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ol
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
)}
>
{children}
</ol>
)
}
function LiRenderer({
className,
children,
node,
}: React.ComponentPropsWithoutRef<'li'> & ExtraProps) {
const ctx = useContext(MarkdownCheckboxCtx)
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
if (isTaskItem) {
if (ctx) {
const offset = node?.position?.start?.offset
if (offset === undefined) {
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
}
return <li className='break-words leading-[1.6]'>{children}</li>
},
input: ({ type, checked, ...props }: any) => {
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
const index = checkboxCounterRef.current++
const before = ctx.contentRef.current.slice(0, offset)
const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm)
return (
<Checkbox
checked={checked ?? false}
onCheckedChange={
isInteractive
? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked))
: undefined
}
disabled={!isInteractive}
size='sm'
className='mt-1 shrink-0'
/>
<CheckboxIndexCtx.Provider value={prior ? prior.length : 0}>
<li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
</CheckboxIndexCtx.Provider>
)
},
}
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
}
return <li className='break-words leading-[1.6]'>{children}</li>
}
function InputRenderer({
type,
checked,
node: _node,
...props
}: React.ComponentPropsWithoutRef<'input'> & ExtraProps) {
const ctx = useContext(MarkdownCheckboxCtx)
const index = useContext(CheckboxIndexCtx)
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
const isInteractive = ctx !== null && index >= 0
return (
<Checkbox
checked={checked ?? false}
onCheckedChange={
isInteractive ? (newChecked) => ctx.onToggle(index, Boolean(newChecked)) : undefined
}
disabled={!isInteractive}
size='sm'
className='mt-1 shrink-0'
/>
)
}
const MARKDOWN_COMPONENTS = {
...STATIC_MARKDOWN_COMPONENTS,
ul: UlRenderer,
ol: OlRenderer,
li: LiRenderer,
input: InputRenderer,
} satisfies Components
const MarkdownPreview = memo(function MarkdownPreview({
content,
isStreaming = false,
@@ -238,32 +287,33 @@ const MarkdownPreview = memo(function MarkdownPreview({
const { ref: scrollRef } = useAutoScroll(isStreaming)
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
const checkboxCounterRef = useRef(0)
const contentRef = useRef(content)
contentRef.current = content
const components = useMemo(
() => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle),
const ctxValue = useMemo(
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
[onCheckboxToggle]
)
checkboxCounterRef.current = 0
const committedMarkdown = useMemo(
() =>
committed ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{committed}
</ReactMarkdown>
) : null,
[committed, components]
[committed]
)
if (onCheckboxToggle) {
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{content}
</ReactMarkdown>
</div>
<MarkdownCheckboxCtx.Provider value={ctxValue}>
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
</MarkdownCheckboxCtx.Provider>
)
}
@@ -275,7 +325,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{incoming}
</ReactMarkdown>
</div>

View File

@@ -6,6 +6,8 @@ import { useParams, useRouter } from 'next/navigation'
import {
Button,
Columns2,
Combobox,
type ComboboxOption,
Download,
DropdownMenu,
DropdownMenuContent,
@@ -31,17 +33,22 @@ import {
formatFileSize,
getFileExtension,
getMimeTypeFromExtension,
isAudioFileType,
isVideoFileType,
} from '@/lib/uploads/utils/file-utils'
import {
isSupportedExtension,
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
import type {
FilterTag,
HeaderAction,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
@@ -66,6 +73,7 @@ import {
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
@@ -86,7 +94,6 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const MIME_TYPE_LABELS: Record<string, string> = {
@@ -161,16 +168,14 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [inputValue, setInputValue] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchTerm(value)
}, 200)
}, [])
const debouncedSearchTerm = useDebounce(inputValue, 200)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [sizeFilter, setSizeFilter] = useState<string[]>([])
const [uploadedByFilter, setUploadedByFilter] = useState<string[]>([])
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
@@ -206,10 +211,60 @@ export function Files() {
selectedFileRef.current = selectedFile
const filteredFiles = useMemo(() => {
if (!debouncedSearchTerm) return files
const q = debouncedSearchTerm.toLowerCase()
return files.filter((f) => f.name.toLowerCase().includes(q))
}, [files, debouncedSearchTerm])
let result = debouncedSearchTerm
? files.filter((f) => f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
: files
if (typeFilter.length > 0) {
result = result.filter((f) => {
const ext = getFileExtension(f.name)
if (typeFilter.includes('document') && isSupportedExtension(ext)) return true
if (typeFilter.includes('audio') && isAudioFileType(f.type)) return true
if (typeFilter.includes('video') && isVideoFileType(f.type)) return true
return false
})
}
if (sizeFilter.length > 0) {
result = result.filter((f) => {
if (sizeFilter.includes('small') && f.size < 1_048_576) return true
if (sizeFilter.includes('medium') && f.size >= 1_048_576 && f.size <= 10_485_760)
return true
if (sizeFilter.includes('large') && f.size > 10_485_760) return true
return false
})
}
if (uploadedByFilter.length > 0) {
result = result.filter((f) => uploadedByFilter.includes(f.uploadedBy))
}
const col = activeSort?.column ?? 'created'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'size':
cmp = a.size - b.size
break
case 'type':
cmp = formatFileType(a.type, a.name).localeCompare(formatFileType(b.type, b.name))
break
case 'created':
cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()
break
case 'owner':
cmp = (members?.find((m) => m.userId === a.uploadedBy)?.name ?? '').localeCompare(
members?.find((m) => m.userId === b.uploadedBy)?.name ?? ''
)
break
}
return dir === 'asc' ? cmp : -cmp
})
}, [files, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter, activeSort, members])
const rowCacheRef = useRef(
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
@@ -245,12 +300,6 @@ export function Files() {
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
nextCache.set(file.id, { row, file, members })
@@ -342,7 +391,7 @@ export function Files() {
}
}
},
[workspaceId]
[workspaceId, uploadFile]
)
const handleDownload = useCallback(async (file: WorkspaceFileRecord) => {
@@ -690,7 +739,6 @@ export function Files() {
handleDeleteSelected,
])
/** Stable refs for values used in callbacks to avoid dependency churn */
const listRenameRef = useRef(listRename)
listRenameRef.current = listRename
const headerRenameRef = useRef(headerRename)
@@ -711,18 +759,14 @@ export function Files() {
const canEdit = userPermissions.canEdit === true
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const searchConfig: SearchConfig = useMemo(
() => ({
value: inputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
onChange: setInputValue,
onClearAll: () => setInputValue(''),
placeholder: 'Search files...',
}),
[inputValue, handleSearchChange, handleSearchClearAll]
[inputValue]
)
const createConfig = useMemo(
@@ -764,6 +808,205 @@ export function Files() {
[handleNavigateToFiles]
)
const typeDisplayLabel = useMemo(() => {
if (typeFilter.length === 0) return 'All'
if (typeFilter.length === 1) {
const labels: Record<string, string> = {
document: 'Documents',
audio: 'Audio',
video: 'Video',
}
return labels[typeFilter[0]] ?? typeFilter[0]
}
return `${typeFilter.length} selected`
}, [typeFilter])
const sizeDisplayLabel = useMemo(() => {
if (sizeFilter.length === 0) return 'All'
if (sizeFilter.length === 1) {
const labels: Record<string, string> = { small: 'Small', medium: 'Medium', large: 'Large' }
return labels[sizeFilter[0]] ?? sizeFilter[0]
}
return `${sizeFilter.length} selected`
}, [sizeFilter])
const uploadedByDisplayLabel = useMemo(() => {
if (uploadedByFilter.length === 0) return 'All'
if (uploadedByFilter.length === 1)
return members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'
return `${uploadedByFilter.length} members`
}, [uploadedByFilter, members])
const memberOptions: ComboboxOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'size', label: 'Size' },
{ id: 'type', label: 'Type' },
{ id: 'created', label: 'Created' },
{ id: 'owner', label: 'Owner' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const hasActiveFilters =
typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>File Type</span>
<Combobox
options={[
{ value: 'document', label: 'Documents' },
{ value: 'audio', label: 'Audio' },
{ value: 'video', label: 'Video' },
]}
multiSelect
multiSelectValues={typeFilter}
onMultiSelectChange={setTypeFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{typeDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Size</span>
<Combobox
options={[
{ value: 'small', label: 'Small (< 1 MB)' },
{ value: 'medium', label: 'Medium (110 MB)' },
{ value: 'large', label: 'Large (> 10 MB)' },
]}
multiSelect
multiSelectValues={sizeFilter}
onMultiSelectChange={setSizeFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{sizeDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Uploaded By
</span>
<Combobox
options={memberOptions}
multiSelect
multiSelectValues={uploadedByFilter}
onMultiSelectChange={setUploadedByFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{uploadedByDisplayLabel}
</span>
}
searchable
searchPlaceholder='Search members...'
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
)}
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setTypeFilter([])
setSizeFilter([])
setUploadedByFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
typeFilter,
sizeFilter,
uploadedByFilter,
memberOptions,
typeDisplayLabel,
sizeDisplayLabel,
uploadedByDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (typeFilter.length > 0) {
const typeLabels: Record<string, string> = {
document: 'Documents',
audio: 'Audio',
video: 'Video',
}
const label =
typeFilter.length === 1
? `Type: ${typeLabels[typeFilter[0]]}`
: `Type: ${typeFilter.length} selected`
tags.push({ label, onRemove: () => setTypeFilter([]) })
}
if (sizeFilter.length > 0) {
const sizeLabels: Record<string, string> = {
small: 'Small',
medium: 'Medium',
large: 'Large',
}
const label =
sizeFilter.length === 1
? `Size: ${sizeLabels[sizeFilter[0]]}`
: `Size: ${sizeFilter.length} selected`
tags.push({ label, onRemove: () => setSizeFilter([]) })
}
if (uploadedByFilter.length > 0) {
const label =
uploadedByFilter.length === 1
? `Uploaded by: ${members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'}`
: `Uploaded by: ${uploadedByFilter.length} members`
tags.push({ label, onRemove: () => setUploadedByFilter([]) })
}
return tags
}, [typeFilter, sizeFilter, uploadedByFilter, members])
if (fileIdFromRoute && !selectedFile) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
@@ -834,7 +1077,9 @@ export function Files() {
title='Files'
create={createConfig}
search={searchConfig}
defaultSort='created'
sort={sortConfig}
filter={filterContent}
filterTags={filterTags}
headerActions={headerActionsConfig}
columns={COLUMNS}
rows={rows}

View File

@@ -91,7 +91,9 @@ export function MothershipChat({
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const isStreamActive = isSending || isReconnecting
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive)
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
scrollOnMount: true,
})
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)

View File

@@ -571,19 +571,19 @@ export function UserInput({
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
const pastedFiles: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) imageFiles.push(file)
if (file) pastedFiles.push(file)
}
}
if (imageFiles.length === 0) return
if (pastedFiles.length === 0) return
e.preventDefault()
const dt = new DataTransfer()
for (const file of imageFiles) {
for (const file of pastedFiles) {
dt.items.add(file)
}
filesRef.current.processFiles(dt.files)

View File

@@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
Badge,
Button,
Combobox,
Modal,
ModalBody,
ModalContent,
@@ -15,7 +16,6 @@ import {
Trash,
} from '@/components/emcn'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { cn } from '@/lib/core/utils/cn'
import type { ChunkData } from '@/lib/knowledge/types'
import { formatTokenCount } from '@/lib/tokenization'
import type {
@@ -27,6 +27,7 @@ import type {
ResourceRow,
SearchConfig,
SelectableConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { Resource, ResourceHeader } from '@/app/workspace/[workspaceId]/components'
import {
@@ -152,7 +153,16 @@ export function Document({
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const enabledFilterParam = useMemo(
() => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'),
[enabledFilter]
)
const {
chunks: initialChunks,
@@ -165,7 +175,21 @@ export function Document({
refreshChunks: initialRefreshChunks,
updateChunk: initialUpdateChunk,
isFetching: isFetchingChunks,
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL, '', enabledFilter)
} = useDocumentChunks(
knowledgeBaseId,
documentId,
currentPageFromURL,
'',
enabledFilterParam,
activeSort?.column === 'tokens'
? 'tokenCount'
: activeSort?.column === 'status'
? 'enabled'
: activeSort?.column === 'index'
? 'chunkIndex'
: undefined,
activeSort?.direction
)
const { data: searchResults = [], error: searchQueryError } = useDocumentChunkSearchQuery(
{
@@ -229,7 +253,10 @@ export function Document({
searchStartIndex + SEARCH_PAGE_SIZE
)
const displayChunks = showingSearch ? paginatedSearchResults : initialChunks
const rawDisplayChunks = showingSearch ? paginatedSearchResults : initialChunks
const displayChunks = rawDisplayChunks ?? []
const currentPage = showingSearch ? searchCurrentPage : initialPage
const totalPages = showingSearch ? searchTotalPages : initialTotalPages
const hasNextPage = showingSearch ? searchCurrentPage < searchTotalPages : initialHasNextPage
@@ -562,47 +589,68 @@ export function Document({
}
: undefined
const filterContent = (
<div className='w-[200px]'>
<div className='border-[var(--border-1)] border-b px-3 py-2'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
</div>
<div className='flex flex-col gap-0.5 px-3 py-2'>
{(['all', 'enabled', 'disabled'] as const).map((value) => (
<button
key={value}
type='button'
className={cn(
'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]',
enabledFilter === value && 'bg-[var(--surface-active)]'
)}
onClick={() => {
setEnabledFilter(value)
const enabledDisplayLabel = useMemo(() => {
if (enabledFilter.length === 0) return 'All'
if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
return `${enabledFilter.length} selected`
}, [enabledFilter])
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
<Combobox
options={[
{ value: 'enabled', label: 'Enabled' },
{ value: 'disabled', label: 'Disabled' },
]}
multiSelect
multiSelectValues={enabledFilter}
onMultiSelectChange={(values) => {
setEnabledFilter(values)
setSelectedChunks(new Set())
void goToPage(1)
}}
>
{value.charAt(0).toUpperCase() + value.slice(1)}
</button>
))}
</div>
</div>
)
const filterTags: FilterTag[] = [
...(enabledFilter !== 'all'
? [
{
label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`,
onRemove: () => {
setEnabledFilter('all')
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{enabledFilter.length > 0 && (
<button
type='button'
onClick={() => {
setEnabledFilter([])
setSelectedChunks(new Set())
void goToPage(1)
},
},
]
: []),
]
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[enabledFilter, enabledDisplayLabel, goToPage]
)
const filterTags: FilterTag[] = useMemo(
() =>
enabledFilter.map((value) => ({
label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`,
onRemove: () => {
setEnabledFilter((prev) => prev.filter((v) => v !== value))
setSelectedChunks(new Set())
void goToPage(1)
},
})),
[enabledFilter, goToPage]
)
const handleChunkClick = useCallback((rowId: string) => {
setSelectedChunkId(rowId)
@@ -814,6 +862,26 @@ export function Document({
}
: undefined
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'index', label: 'Index' },
{ id: 'tokens', label: 'Tokens' },
{ id: 'status', label: 'Status' },
],
active: activeSort,
onSort: (column, direction) => {
setActiveSort({ column, direction })
void goToPage(1)
},
onClear: () => {
setActiveSort(null)
void goToPage(1)
},
}),
[activeSort, goToPage]
)
const chunkRows: ResourceRow[] = useMemo(() => {
if (!isCompleted) {
return [
@@ -1100,6 +1168,7 @@ export function Document({
emptyMessage={emptyMessage}
filter={combinedError ? undefined : filterContent}
filterTags={combinedError ? undefined : filterTags}
sort={combinedError ? undefined : sortConfig}
/>
<DocumentTagsModal

View File

@@ -208,7 +208,7 @@ export function KnowledgeBase({
const [searchQuery, setSearchQuery] = useState('')
const [showTagsModal, setShowTagsModal] = useState(false)
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
const [tagFilterEntries, setTagFilterEntries] = useState<
{
id: string
@@ -235,6 +235,17 @@ export function KnowledgeBase({
[tagFilterEntries]
)
const enabledFilterParam = useMemo<'all' | 'enabled' | 'disabled'>(() => {
if (enabledFilter.length === 1) return enabledFilter[0] as 'enabled' | 'disabled'
return 'all'
}, [enabledFilter])
const enabledDisplayLabel = useMemo(() => {
if (enabledFilter.length === 0) return 'All'
if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
return '2 selected'
}, [enabledFilter])
const handleSearchChange = useCallback((newQuery: string) => {
setSearchQuery(newQuery)
setCurrentPage(1)
@@ -249,8 +260,10 @@ export function KnowledgeBase({
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
const [showConnectorsModal, setShowConnectorsModal] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
@@ -290,8 +303,8 @@ export function KnowledgeBase({
search: searchQuery || undefined,
limit: DOCUMENTS_PER_PAGE,
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
sortBy,
sortOrder,
sortBy: (activeSort?.column ?? 'uploadedAt') as DocumentSortField,
sortOrder: (activeSort?.direction ?? 'desc') as SortOrder,
refetchInterval: (data) => {
if (isDeleting) return false
const hasPending = data?.documents?.some(
@@ -301,7 +314,7 @@ export function KnowledgeBase({
if (hasSyncingConnectorsRef.current) return 5000
return false
},
enabledFilter,
enabledFilter: enabledFilterParam,
tagFilters: activeTagFilters.length > 0 ? activeTagFilters : undefined,
})
@@ -571,7 +584,7 @@ export function KnowledgeBase({
knowledgeBaseId: id,
operation: 'enable',
selectAll: true,
enabledFilter,
enabledFilter: enabledFilterParam,
},
{
onSuccess: (result) => {
@@ -618,7 +631,7 @@ export function KnowledgeBase({
knowledgeBaseId: id,
operation: 'disable',
selectAll: true,
enabledFilter,
enabledFilter: enabledFilterParam,
},
{
onSuccess: (result) => {
@@ -667,7 +680,7 @@ export function KnowledgeBase({
knowledgeBaseId: id,
operation: 'delete',
selectAll: true,
enabledFilter,
enabledFilter: enabledFilterParam,
},
{
onSuccess: (result) => {
@@ -707,12 +720,12 @@ export function KnowledgeBase({
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
const enabledCount = isSelectAllMode
? enabledFilter === 'disabled'
? enabledFilterParam === 'disabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = isSelectAllMode
? enabledFilter === 'enabled'
? enabledFilterParam === 'enabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => !doc.enabled).length
@@ -795,59 +808,83 @@ export function KnowledgeBase({
: []),
]
const sortConfig: SortConfig = {
options: [
{ id: 'filename', label: 'Name' },
{ id: 'fileSize', label: 'Size' },
{ id: 'tokenCount', label: 'Tokens' },
{ id: 'chunkCount', label: 'Chunks' },
{ id: 'uploadedAt', label: 'Uploaded' },
{ id: 'enabled', label: 'Status' },
],
active: { column: sortBy, direction: sortOrder },
onSort: (column, direction) => {
setSortBy(column as DocumentSortField)
setSortOrder(direction)
setCurrentPage(1)
},
}
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'filename', label: 'Name' },
{ id: 'fileSize', label: 'Size' },
{ id: 'tokenCount', label: 'Tokens' },
{ id: 'chunkCount', label: 'Chunks' },
{ id: 'uploadedAt', label: 'Uploaded' },
{ id: 'enabled', label: 'Status' },
],
active: activeSort,
onSort: (column, direction) => {
setActiveSort({ column, direction })
setCurrentPage(1)
},
onClear: () => {
setActiveSort(null)
setCurrentPage(1)
},
}),
[activeSort]
)
const filterContent = (
<div className='w-[320px]'>
<div className='border-[var(--border-1)] border-b px-3 py-2'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
</div>
<div className='flex flex-col gap-0.5 px-3 py-2'>
{(['all', 'enabled', 'disabled'] as const).map((value) => (
<button
key={value}
type='button'
className={cn(
'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]',
enabledFilter === value && 'bg-[var(--surface-active)]'
)}
onClick={() => {
setEnabledFilter(value)
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
<Combobox
options={[
{ value: 'enabled', label: 'Enabled' },
{ value: 'disabled', label: 'Disabled' },
]}
multiSelect
multiSelectValues={enabledFilter}
onMultiSelectChange={(values) => {
setEnabledFilter(values)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{enabledFilter.length > 0 && (
<button
type='button'
onClick={() => {
setEnabledFilter([])
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
{value.charAt(0).toUpperCase() + value.slice(1)}
Clear status filter
</button>
))}
)}
<TagFilterSection
tagDefinitions={tagDefinitions}
entries={tagFilterEntries}
onChange={(entries) => {
setTagFilterEntries(entries)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
/>
</div>
<TagFilterSection
tagDefinitions={tagDefinitions}
entries={tagFilterEntries}
onChange={(entries) => {
setTagFilterEntries(entries)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
/>
</div>
),
[enabledFilter, enabledDisplayLabel, tagDefinitions, tagFilterEntries]
)
const connectorBadges =
@@ -871,33 +908,39 @@ export function KnowledgeBase({
</>
) : null
const filterTags: FilterTag[] = [
...(enabledFilter !== 'all'
? [
{
label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`,
onRemove: () => {
setEnabledFilter('all')
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
const filterTags: FilterTag[] = useMemo(
() => [
...(enabledFilter.length > 0
? [
{
label:
enabledFilter.length === 1
? `Status: ${enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'}`
: 'Status: 2 selected',
onRemove: () => {
setEnabledFilter([])
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
},
},
]
: []),
...tagFilterEntries
.filter((f) => f.tagSlot && f.value.trim())
.map((f) => ({
label: `${f.tagName}: ${f.value}`,
onRemove: () => {
const updated = tagFilterEntries.filter((e) => e.id !== f.id)
setTagFilterEntries(updated)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
},
]
: []),
...tagFilterEntries
.filter((f) => f.tagSlot && f.value.trim())
.map((f) => ({
label: `${f.tagName}: ${f.value}`,
onRemove: () => {
const updated = tagFilterEntries.filter((_, idx) => idx !== tagFilterEntries.indexOf(f))
setTagFilterEntries(updated)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
},
})),
]
})),
],
[enabledFilter, tagFilterEntries]
)
const selectableConfig: SelectableConfig = {
selectedIds: selectedDocuments,
@@ -922,7 +965,7 @@ export function KnowledgeBase({
content: (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div style={{ cursor: 'help' }}>{getStatusBadge(doc)}</div>
<div className='cursor-help'>{getStatusBadge(doc)}</div>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs'>
{doc.processingError}
@@ -1019,7 +1062,7 @@ export function KnowledgeBase({
const emptyMessage = searchQuery
? 'No documents found'
: enabledFilter !== 'all' || activeTagFilters.length > 0
: enabledFilter.length > 0 || activeTagFilters.length > 0
? 'Nothing matches your filter'
: undefined

View File

@@ -3,15 +3,18 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import type { ComboboxOption } from '@/components/emcn'
import { Combobox, Tooltip } from '@/components/emcn'
import { Database } from '@/components/emcn/icons'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type {
CreateAction,
FilterTag,
ResourceCell,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
@@ -29,6 +32,7 @@ import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -98,21 +102,16 @@ export function Knowledge() {
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [connectorFilter, setConnectorFilter] = useState<string[]>([])
const [contentFilter, setContentFilter] = useState<string[]>([])
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [searchInputValue, setSearchInputValue] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setSearchInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value)
}, 300)
}, [])
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const debouncedSearchQuery = useDebounce(searchInputValue, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
@@ -184,14 +183,77 @@ export function Knowledge() {
[deleteKnowledgeBaseMutation]
)
const filteredKnowledgeBases = useMemo(
() => filterKnowledgeBases(knowledgeBases, debouncedSearchQuery),
[knowledgeBases, debouncedSearchQuery]
)
const processedKBs = useMemo(() => {
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
if (connectorFilter.length > 0) {
result = result.filter((kb) => {
const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0
if (connectorFilter.includes('connected') && hasConnectors) return true
if (connectorFilter.includes('unconnected') && !hasConnectors) return true
return false
})
}
if (contentFilter.length > 0) {
const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0
result = result.filter((kb) => {
if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true
if (contentFilter.includes('empty') && docCount(kb) === 0) return true
return false
})
}
if (ownerFilter.length > 0) {
result = result.filter((kb) => ownerFilter.includes(kb.userId))
}
const col = activeSort?.column ?? 'created'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'documents':
cmp =
((a as KnowledgeBaseWithDocCount).docCount || 0) -
((b as KnowledgeBaseWithDocCount).docCount || 0)
break
case 'tokens':
cmp = (a.tokenCount || 0) - (b.tokenCount || 0)
break
case 'created':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updated':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'connectors':
cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0)
break
case 'owner':
cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare(
members?.find((m) => m.userId === b.userId)?.name ?? ''
)
break
}
return dir === 'asc' ? cmp : -cmp
})
}, [
knowledgeBases,
debouncedSearchQuery,
connectorFilter,
contentFilter,
ownerFilter,
activeSort,
members,
])
const rows: ResourceRow[] = useMemo(
() =>
filteredKnowledgeBases.map((kb) => {
processedKBs.map((kb) => {
const kbWithCount = kb as KnowledgeBaseWithDocCount
return {
id: kb.id,
@@ -211,16 +273,9 @@ export function Knowledge() {
owner: ownerCell(kb.userId, members),
updated: timeCell(kb.updatedAt),
},
sortValues: {
documents: kbWithCount.docCount || 0,
tokens: kb.tokenCount || 0,
connectors: kb.connectorTypes?.length || 0,
created: -new Date(kb.createdAt).getTime(),
updated: -new Date(kb.updatedAt).getTime(),
},
}
}),
[filteredKnowledgeBases, members]
[processedKBs, members]
)
const handleRowClick = useCallback(
@@ -303,13 +358,190 @@ export function Knowledge() {
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchInputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
onChange: setSearchInputValue,
onClearAll: () => setSearchInputValue(''),
placeholder: 'Search knowledge bases...',
}),
[searchInputValue, handleSearchChange, handleSearchClearAll]
[searchInputValue]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'documents', label: 'Documents' },
{ id: 'tokens', label: 'Tokens' },
{ id: 'connectors', label: 'Connectors' },
{ id: 'created', label: 'Created' },
{ id: 'updated', label: 'Last Updated' },
{ id: 'owner', label: 'Owner' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const connectorDisplayLabel = useMemo(() => {
if (connectorFilter.length === 0) return 'All'
if (connectorFilter.length === 1)
return connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'
return `${connectorFilter.length} selected`
}, [connectorFilter])
const contentDisplayLabel = useMemo(() => {
if (contentFilter.length === 0) return 'All'
if (contentFilter.length === 1)
return contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'
return `${contentFilter.length} selected`
}, [contentFilter])
const ownerDisplayLabel = useMemo(() => {
if (ownerFilter.length === 0) return 'All'
if (ownerFilter.length === 1)
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
return `${ownerFilter.length} members`
}, [ownerFilter, members])
const memberOptions: ComboboxOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const hasActiveFilters =
connectorFilter.length > 0 || contentFilter.length > 0 || ownerFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Connectors</span>
<Combobox
options={[
{ value: 'connected', label: 'With connectors' },
{ value: 'unconnected', label: 'Without connectors' },
]}
multiSelect
multiSelectValues={connectorFilter}
onMultiSelectChange={setConnectorFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{connectorDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Content</span>
<Combobox
options={[
{ value: 'has-docs', label: 'Has documents' },
{ value: 'empty', label: 'Empty' },
]}
multiSelect
multiSelectValues={contentFilter}
onMultiSelectChange={setContentFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{contentDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span>
<Combobox
options={memberOptions}
multiSelect
multiSelectValues={ownerFilter}
onMultiSelectChange={setOwnerFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search members...'
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
)}
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setConnectorFilter([])
setContentFilter([])
setOwnerFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
connectorFilter,
contentFilter,
ownerFilter,
memberOptions,
connectorDisplayLabel,
contentDisplayLabel,
ownerDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (connectorFilter.length > 0) {
const label =
connectorFilter.length === 1
? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}`
: `Connectors: ${connectorFilter.length} types`
tags.push({ label, onRemove: () => setConnectorFilter([]) })
}
if (contentFilter.length > 0) {
const label =
contentFilter.length === 1
? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}`
: `Content: ${contentFilter.length} types`
tags.push({ label, onRemove: () => setContentFilter([]) })
}
if (ownerFilter.length > 0) {
const label =
ownerFilter.length === 1
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
: `Owner: ${ownerFilter.length} members`
tags.push({ label, onRemove: () => setOwnerFilter([]) })
}
return tags
}, [connectorFilter, contentFilter, ownerFilter, members])
return (
<>
<Resource
@@ -317,7 +549,9 @@ export function Knowledge() {
title='Knowledge Base'
create={createAction}
search={searchConfig}
defaultSort='created'
sort={sortConfig}
filter={filterContent}
filterTags={filterTags}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}

View File

@@ -39,6 +39,7 @@ import type {
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
ResourceHeader,
@@ -288,6 +289,10 @@ export default function Logs() {
const activeLogRefetchRef = useRef<() => void>(() => {})
const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} })
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const userPermissions = useUserPermissionsContext()
const [contextMenuOpen, setContextMenuOpen] = useState(false)
@@ -358,11 +363,43 @@ export default function Logs() {
return logsQuery.data.pages.flatMap((page) => page.logs)
}, [logsQuery.data?.pages])
const sortedLogs = useMemo(() => {
if (!activeSort) return logs
const { column, direction } = activeSort
return [...logs].sort((a, b) => {
let cmp = 0
switch (column) {
case 'date':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'duration': {
const aDuration = parseDuration({ duration: a.duration ?? undefined }) ?? -1
const bDuration = parseDuration({ duration: b.duration ?? undefined }) ?? -1
cmp = aDuration - bDuration
break
}
case 'cost': {
const aCost = typeof a.cost?.total === 'number' ? a.cost.total : -1
const bCost = typeof b.cost?.total === 'number' ? b.cost.total : -1
cmp = aCost - bCost
break
}
case 'status':
cmp = (a.status ?? '').localeCompare(b.status ?? '')
break
default:
break
}
return direction === 'asc' ? cmp : -cmp
})
}, [logs, activeSort])
const selectedLogIndex = useMemo(
() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1),
[logs, selectedLogId]
() => (selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1),
[sortedLogs, selectedLogId]
)
const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null
const selectedLogFromList = selectedLogIndex >= 0 ? sortedLogs[selectedLogIndex] : null
const selectedLog = useMemo(() => {
if (!selectedLogFromList) return null
@@ -381,8 +418,8 @@ export default function Logs() {
useFolders(workspaceId)
useEffect(() => {
logsRef.current = logs
}, [logs])
logsRef.current = sortedLogs
}, [sortedLogs])
useEffect(() => {
selectedLogIndexRef.current = selectedLogIndex
}, [selectedLogIndex])
@@ -443,12 +480,12 @@ export default function Logs() {
const handleLogContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
e.preventDefault()
const log = logs.find((l) => l.id === rowId) ?? null
const log = sortedLogs.find((l) => l.id === rowId) ?? null
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuLog(log)
setContextMenuOpen(true)
},
[logs]
[sortedLogs]
)
const handleCopyExecutionId = useCallback(() => {
@@ -603,11 +640,12 @@ export default function Logs() {
}, [initializeFromURL])
const loadMoreLogs = useCallback(() => {
if (activeSort) return
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
if (!isFetching && hasNextPage) {
fetchNextPage()
}
}, [])
}, [activeSort])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -659,7 +697,7 @@ export default function Logs() {
const rows: ResourceRow[] = useMemo(
() =>
logs.map((log) => {
sortedLogs.map((log) => {
const formattedDate = formatDate(log.createdAt)
const displayStatus = getDisplayStatus(log.status)
const isMothershipJob = log.trigger === 'mothership'
@@ -710,7 +748,7 @@ export default function Logs() {
},
}
}),
[logs]
[sortedLogs]
)
const sidebarOverlay = useMemo(
@@ -721,7 +759,7 @@ export default function Logs() {
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
hasNext={selectedLogIndex < logs.length - 1}
hasNext={selectedLogIndex < sortedLogs.length - 1}
hasPrev={selectedLogIndex > 0}
/>
),
@@ -732,7 +770,7 @@ export default function Logs() {
handleNavigateNext,
handleNavigatePrev,
selectedLogIndex,
logs.length,
sortedLogs.length,
]
)
@@ -978,6 +1016,21 @@ export default function Logs() {
[appliedFilters, textSearch, removeBadge, handleFiltersChange]
)
const sortConfig = useMemo<SortConfig>(
() => ({
options: [
{ id: 'date', label: 'Date' },
{ id: 'duration', label: 'Duration' },
{ id: 'cost', label: 'Cost' },
{ id: 'status', label: 'Status' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const searchConfig = useMemo<SearchConfig>(
() => ({
value: currentInput,
@@ -1021,7 +1074,7 @@ export default function Logs() {
label: 'Export',
icon: Download,
onClick: handleExport,
disabled: !userPermissions.canEdit || isExporting || logs.length === 0,
disabled: !userPermissions.canEdit || isExporting || sortedLogs.length === 0,
},
{
label: 'Notifications',
@@ -1054,7 +1107,7 @@ export default function Logs() {
handleExport,
userPermissions.canEdit,
isExporting,
logs.length,
sortedLogs.length,
handleOpenNotificationSettings,
]
)
@@ -1065,6 +1118,7 @@ export default function Logs() {
<ResourceHeader icon={Library} title='Logs' actions={headerActions} />
<ResourceOptionsBar
search={searchConfig}
sort={sortConfig}
filter={
<LogsFilterPanel searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} />
}
@@ -1091,7 +1145,7 @@ export default function Logs() {
onRowContextMenu={handleLogContextMenu}
isLoading={!logsQuery.data}
onLoadMore={loadMoreLogs}
hasMore={logsQuery.hasNextPage ?? false}
hasMore={!activeSort && (logsQuery.hasNextPage ?? false)}
isLoadingMore={logsQuery.isFetchingNextPage}
emptyMessage='No logs found'
overlay={sidebarOverlay}
@@ -1335,7 +1389,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
}, [resetFilters, onSearchQueryChange])
return (
<div className='flex flex-col gap-3 p-3'>
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
<Combobox

View File

@@ -3,11 +3,24 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import {
Button,
Combobox,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Calendar } from '@/components/emcn/icons'
import { formatAbsoluteDate } from '@/lib/core/utils/formatting'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import type {
FilterTag,
ResourceColumn,
ResourceRow,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { ScheduleModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal'
import { ScheduleContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu'
@@ -74,6 +87,13 @@ export function ScheduledTasks() {
const [activeTask, setActiveTask] = useState<WorkspaceScheduleData | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [scheduleTypeFilter, setScheduleTypeFilter] = useState<string[]>([])
const [statusFilter, setStatusFilter] = useState<string[]>([])
const [healthFilter, setHealthFilter] = useState<string[]>([])
const visibleItems = useMemo(
() => allItems.filter((item) => item.sourceType === 'job' && item.status !== 'completed'),
@@ -81,15 +101,68 @@ export function ScheduledTasks() {
)
const filteredItems = useMemo(() => {
if (!debouncedSearchQuery) return visibleItems
const q = debouncedSearchQuery.toLowerCase()
return visibleItems.filter((item) => {
const task = item.prompt || ''
return (
task.toLowerCase().includes(q) || getScheduleDescription(item).toLowerCase().includes(q)
)
let result = debouncedSearchQuery
? visibleItems.filter((item) => {
const task = item.prompt || ''
return (
task.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
getScheduleDescription(item).toLowerCase().includes(debouncedSearchQuery.toLowerCase())
)
})
: visibleItems
if (scheduleTypeFilter.length > 0) {
result = result.filter((item) => {
if (scheduleTypeFilter.includes('recurring') && Boolean(item.cronExpression)) return true
if (scheduleTypeFilter.includes('once') && !item.cronExpression) return true
return false
})
}
if (statusFilter.length > 0) {
result = result.filter((item) => {
if (statusFilter.includes('active') && item.status === 'active') return true
if (statusFilter.includes('paused') && item.status === 'disabled') return true
return false
})
}
if (healthFilter.includes('has-failures')) {
result = result.filter((item) => (item.failedCount ?? 0) > 0)
}
const col = activeSort?.column ?? 'nextRun'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'task':
cmp = (a.prompt || '').localeCompare(b.prompt || '')
break
case 'nextRun':
cmp =
(a.nextRunAt ? new Date(a.nextRunAt).getTime() : 0) -
(b.nextRunAt ? new Date(b.nextRunAt).getTime() : 0)
break
case 'lastRun':
cmp =
(a.lastRanAt ? new Date(a.lastRanAt).getTime() : 0) -
(b.lastRanAt ? new Date(b.lastRanAt).getTime() : 0)
break
case 'schedule':
cmp = getScheduleDescription(a).localeCompare(getScheduleDescription(b))
break
}
return dir === 'asc' ? cmp : -cmp
})
}, [visibleItems, debouncedSearchQuery])
}, [
visibleItems,
debouncedSearchQuery,
scheduleTypeFilter,
statusFilter,
healthFilter,
activeSort,
])
const rows: ResourceRow[] = useMemo(
() =>
@@ -104,10 +177,6 @@ export function ScheduledTasks() {
nextRun: timeCell(item.nextRunAt),
lastRun: timeCell(item.lastRanAt),
},
sortValues: {
nextRun: item.nextRunAt ? -new Date(item.nextRunAt).getTime() : 0,
lastRun: item.lastRanAt ? -new Date(item.lastRanAt).getTime() : 0,
},
})),
[filteredItems]
)
@@ -170,6 +239,151 @@ export function ScheduledTasks() {
}
}
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'task', label: 'Task' },
{ id: 'schedule', label: 'Schedule' },
{ id: 'nextRun', label: 'Next Run' },
{ id: 'lastRun', label: 'Last Run' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const scheduleTypeDisplayLabel = useMemo(() => {
if (scheduleTypeFilter.length === 0) return 'All'
if (scheduleTypeFilter.length === 1)
return scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time'
return `${scheduleTypeFilter.length} selected`
}, [scheduleTypeFilter])
const statusDisplayLabel = useMemo(() => {
if (statusFilter.length === 0) return 'All'
if (statusFilter.length === 1) return statusFilter[0] === 'active' ? 'Active' : 'Paused'
return `${statusFilter.length} selected`
}, [statusFilter])
const healthDisplayLabel = useMemo(() => {
if (healthFilter.length === 0) return 'All'
return 'Has failures'
}, [healthFilter])
const hasActiveFilters =
scheduleTypeFilter.length > 0 || statusFilter.length > 0 || healthFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Schedule Type
</span>
<Combobox
options={[
{ value: 'recurring', label: 'Recurring' },
{ value: 'once', label: 'One-time' },
]}
multiSelect
multiSelectValues={scheduleTypeFilter}
onMultiSelectChange={setScheduleTypeFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{scheduleTypeDisplayLabel}
</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Status</span>
<Combobox
options={[
{ value: 'active', label: 'Active' },
{ value: 'paused', label: 'Paused' },
]}
multiSelect
multiSelectValues={statusFilter}
onMultiSelectChange={setStatusFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{statusDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Health</span>
<Combobox
options={[{ value: 'has-failures', label: 'Has failures' }]}
multiSelect
multiSelectValues={healthFilter}
onMultiSelectChange={setHealthFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{healthDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setScheduleTypeFilter([])
setStatusFilter([])
setHealthFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
scheduleTypeFilter,
statusFilter,
healthFilter,
scheduleTypeDisplayLabel,
statusDisplayLabel,
healthDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (scheduleTypeFilter.length > 0) {
const label =
scheduleTypeFilter.length === 1
? `Type: ${scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time'}`
: `Type: ${scheduleTypeFilter.length} selected`
tags.push({ label, onRemove: () => setScheduleTypeFilter([]) })
}
if (statusFilter.length > 0) {
const label =
statusFilter.length === 1
? `Status: ${statusFilter[0] === 'active' ? 'Active' : 'Paused'}`
: `Status: ${statusFilter.length} selected`
tags.push({ label, onRemove: () => setStatusFilter([]) })
}
if (healthFilter.length > 0) {
tags.push({ label: 'Health: Has failures', onRemove: () => setHealthFilter([]) })
}
return tags
}, [scheduleTypeFilter, statusFilter, healthFilter])
return (
<>
<Resource
@@ -184,7 +398,9 @@ export function ScheduledTasks() {
onChange: setSearchQuery,
placeholder: 'Search scheduled tasks...',
}}
defaultSort='nextRun'
sort={sortConfig}
filter={filterContent}
filterTags={filterTags}
columns={COLUMNS}
rows={rows}
onRowContextMenu={handleRowContextMenu}

View File

@@ -3,7 +3,7 @@
import { useMemo, useState } from 'react'
import { Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
import { Button, Combobox, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
import { Input } from '@/components/ui'
import { formatDate } from '@/lib/core/utils/formatting'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
@@ -34,6 +34,21 @@ function getResourceHref(
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file'
type SortColumn = 'deleted' | 'name' | 'type'
interface SortConfig {
column: SortColumn
direction: 'asc' | 'desc'
}
const DEFAULT_SORT: SortConfig = { column: 'deleted', direction: 'desc' }
const SORT_OPTIONS: { column: SortColumn; direction: 'asc' | 'desc'; label: string }[] = [
{ column: 'deleted', direction: 'desc', label: 'Deleted (newest first)' },
{ column: 'name', direction: 'asc', label: 'Name (AZ)' },
{ column: 'type', direction: 'asc', label: 'Type (AZ)' },
]
const ICON_CLASS = 'h-[14px] w-[14px]'
const RESOURCE_TYPE_TO_MOTHERSHIP: Record<Exclude<ResourceType, 'all'>, MothershipResourceType> = {
@@ -100,6 +115,7 @@ export function RecentlyDeleted() {
const workspaceId = params?.workspaceId as string
const [activeTab, setActiveTab] = useState<ResourceType>('all')
const [searchTerm, setSearchTerm] = useState('')
const [activeSort, setActiveSort] = useState<SortConfig | null>(null)
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
@@ -174,7 +190,6 @@ export function RecentlyDeleted() {
}
}
items.sort((a, b) => b.deletedAt.getTime() - a.deletedAt.getTime())
return items
}, [
workflowsQuery.data,
@@ -191,10 +206,27 @@ export function RecentlyDeleted() {
const normalized = searchTerm.toLowerCase()
items = items.filter((r) => r.name.toLowerCase().includes(normalized))
}
return items
}, [resources, activeTab, searchTerm])
const col = (activeSort ?? DEFAULT_SORT).column
const dir = (activeSort ?? DEFAULT_SORT).direction
return [...items].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'type':
cmp = a.type.localeCompare(b.type)
break
case 'deleted':
cmp = a.deletedAt.getTime() - b.deletedAt.getTime()
break
}
return dir === 'asc' ? cmp : -cmp
})
}, [resources, activeTab, searchTerm, activeSort])
const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0
const selectedSort = activeSort ?? DEFAULT_SORT
function handleRestore(resource: DeletedResource) {
setRestoringIds((prev) => new Set(prev).add(resource.id))
@@ -232,18 +264,41 @@ export function RecentlyDeleted() {
return (
<div className='flex h-full flex-col gap-4.5'>
<div className='flex items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search deleted items...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search deleted items...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='w-[190px] shrink-0'>
<Combobox
size='sm'
align='end'
disabled={isLoading}
value={`${selectedSort.column}:${selectedSort.direction}`}
onChange={(value) => {
const option = SORT_OPTIONS.find(
(sortOption) => `${sortOption.column}:${sortOption.direction}` === value
)
if (option) {
setActiveSort({ column: option.column, direction: option.direction })
}
}}
options={SORT_OPTIONS.map((option) => ({
label: option.label,
value: `${option.column}:${option.direction}`,
}))}
className='h-[30px] rounded-lg border-[var(--border)] bg-transparent px-2.5 text-small dark:bg-[var(--surface-4)]'
/>
</div>
</div>
<SModalTabs value={activeTab} onValueChange={(v) => setActiveTab(v as ResourceType)}>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { nanoid } from 'nanoid'
import {
@@ -11,29 +11,29 @@ import {
DropdownMenuTrigger,
} from '@/components/emcn'
import { ChevronDown, Plus } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { Filter, FilterRule } from '@/lib/table'
import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
const OPERATOR_LABELS: Record<string, string> = {
eq: '=',
ne: '≠',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
contains: '∋',
in: '∈',
} as const
const OPERATOR_LABELS = Object.fromEntries(
COMPARISON_OPERATORS.map((op) => [op.value, op.label])
) as Record<string, string>
interface TableFilterProps {
columns: Array<{ name: string; type: string }>
filter: Filter | null
onApply: (filter: Filter | null) => void
onClose: () => void
}
export function TableFilter({ columns, onApply }: TableFilterProps) {
const [rules, setRules] = useState<FilterRule[]>(() => [createRule(columns)])
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
const [rules, setRules] = useState<FilterRule[]>(() => {
const fromFilter = filterToRules(filter)
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
})
const rulesRef = useRef(rules)
rulesRef.current = rules
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
@@ -46,52 +46,82 @@ export function TableFilter({ columns, onApply }: TableFilterProps) {
const handleRemove = useCallback(
(id: string) => {
setRules((prev) => {
const next = prev.filter((r) => r.id !== id)
return next.length === 0 ? [createRule(columns)] : next
})
const next = rulesRef.current.filter((r) => r.id !== id)
if (next.length === 0) {
onApply(null)
onClose()
setRules([createRule(columns)])
} else {
setRules(next)
}
},
[columns]
[columns, onApply, onClose]
)
const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => {
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
}, [])
const handleToggleLogical = useCallback((id: string) => {
setRules((prev) =>
prev.map((r) =>
r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r
)
)
}, [])
const handleApply = useCallback(() => {
const validRules = rules.filter((r) => r.column && r.value)
const validRules = rulesRef.current.filter((r) => r.column && r.value)
onApply(filterRulesToFilter(validRules))
}, [rules, onApply])
}, [onApply])
const handleClear = useCallback(() => {
setRules([createRule(columns)])
onApply(null)
}, [columns, onApply])
return (
<div className='flex flex-col gap-1.5 p-2'>
{rules.map((rule) => (
<FilterRuleRow
key={rule.id}
rule={rule}
columns={columnOptions}
onUpdate={handleUpdate}
onRemove={handleRemove}
onApply={handleApply}
/>
))}
<div className='border-[var(--border)] border-b bg-[var(--bg)] px-4 py-2'>
<div className='flex flex-col gap-1'>
{rules.map((rule, index) => (
<FilterRuleRow
key={rule.id}
rule={rule}
isFirst={index === 0}
columns={columnOptions}
onUpdate={handleUpdate}
onRemove={handleRemove}
onApply={handleApply}
onToggleLogical={handleToggleLogical}
/>
))}
<div className='flex items-center justify-between gap-3'>
<Button
variant='ghost'
size='sm'
onClick={handleAdd}
className={cn(
'border border-[var(--border)] border-dashed px-2 py-[3px] text-[var(--text-secondary)] text-xs'
)}
>
<Plus className='mr-1 h-[10px] w-[10px]' />
Add filter
</Button>
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
Apply filter
</Button>
<div className='mt-1 flex items-center justify-between'>
<Button
variant='ghost'
size='sm'
onClick={handleAdd}
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
>
<Plus className='mr-1 h-[10px] w-[10px]' />
Add filter
</Button>
<div className='flex items-center gap-1.5'>
{filter !== null && (
<Button
variant='ghost'
size='sm'
onClick={handleClear}
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
>
Clear filters
</Button>
)}
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
Apply filter
</Button>
</div>
</div>
</div>
</div>
)
@@ -99,18 +129,39 @@ export function TableFilter({ columns, onApply }: TableFilterProps) {
interface FilterRuleRowProps {
rule: FilterRule
isFirst: boolean
columns: Array<{ value: string; label: string }>
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
onRemove: (id: string) => void
onApply: () => void
onToggleLogical: (id: string) => void
}
function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRuleRowProps) {
const FilterRuleRow = memo(function FilterRuleRow({
rule,
isFirst,
columns,
onUpdate,
onRemove,
onApply,
onToggleLogical,
}: FilterRuleRowProps) {
return (
<div className='flex items-center gap-1'>
<div className='flex items-center gap-1.5'>
{isFirst ? (
<span className='w-[42px] shrink-0 text-right text-[var(--text-muted)] text-xs'>Where</span>
) : (
<button
onClick={() => onToggleLogical(rule.id)}
className='w-[42px] shrink-0 rounded-full py-0.5 text-right font-medium text-[10px] text-[var(--text-muted)] uppercase tracking-wide transition-colors hover:text-[var(--text-secondary)]'
>
{rule.logicalOperator}
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className='flex h-[30px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
<button className='flex h-[28px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
<span className='truncate'>{rule.column || 'Column'}</span>
<ChevronDown className='ml-1 h-[10px] w-[10px] shrink-0 text-[var(--text-icon)]' />
</button>
@@ -129,8 +180,8 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className='flex h-[30px] min-w-[50px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
<span>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
<button className='flex h-[28px] min-w-[90px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
<span className='truncate'>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
<ChevronDown className='ml-1 h-[10px] w-[10px] shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
@@ -151,25 +202,21 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul
value={rule.value}
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleApply()
if (e.key === 'Enter') onApply()
}}
placeholder='Enter a value'
className='h-[30px] min-w-[160px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]'
/>
<button
onClick={() => onRemove(rule.id)}
className='flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
className='flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</button>
</div>
)
function handleApply() {
onApply()
}
}
})
function createRule(columns: Array<{ name: string }>): FilterRule {
return {

View File

@@ -1,6 +1,7 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { GripVertical } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Button,
@@ -305,6 +306,17 @@ export function Table({
return 0
}, [resizingColumn, displayColumns, columnWidths])
const dropIndicatorLeft = useMemo(() => {
if (!dropTargetColumnName) return null
let left = CHECKBOX_COL_WIDTH
for (const col of displayColumns) {
if (dropSide === 'left' && col.name === dropTargetColumnName) return left
left += columnWidths[col.name] ?? COL_WIDTH
if (dropSide === 'right' && col.name === dropTargetColumnName) return left
}
return null
}, [dropTargetColumnName, dropSide, displayColumns, columnWidths])
const isAllRowsSelected = useMemo(() => {
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
for (const row of rows) {
@@ -367,13 +379,11 @@ export function Table({
setColumnWidths(updatedWidths)
}
const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n))
if (updatedOrder) {
setColumnOrder(updatedOrder)
updateMetadataRef.current({
columnWidths: updatedWidths,
columnOrder: updatedOrder,
})
}
if (updatedOrder) setColumnOrder(updatedOrder)
updateMetadataRef.current({
columnWidths: updatedWidths,
columnOrder: updatedOrder,
})
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
},
})
@@ -682,6 +692,7 @@ export function Table({
}
setDragColumnName(null)
setDropTargetColumnName(null)
setDropSide('left')
}, [])
const handleColumnDragLeave = useCallback(() => {
@@ -1340,8 +1351,7 @@ export function Table({
const insertColumnInOrder = useCallback(
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
const order = columnOrderRef.current
if (!order) return
const order = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name)
const newOrder = [...order]
let anchorIdx = newOrder.indexOf(anchorColumn)
if (anchorIdx === -1) {
@@ -1422,12 +1432,12 @@ export function Table({
const handleDeleteColumnConfirm = useCallback(() => {
if (!deletingColumn) return
const columnToDelete = deletingColumn
const orderAtDelete = columnOrderRef.current
setDeletingColumn(null)
deleteColumnMutation.mutate(columnToDelete, {
onSuccess: () => {
const order = columnOrderRef.current
if (!order) return
const newOrder = order.filter((n) => n !== columnToDelete)
if (!orderAtDelete) return
const newOrder = orderAtDelete.filter((n) => n !== columnToDelete)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
@@ -1448,6 +1458,17 @@ export function Table({
const handleFilterApply = useCallback((filter: Filter | null) => {
setQueryOptions((prev) => ({ ...prev, filter }))
}, [])
const [filterOpen, setFilterOpen] = useState(false)
const handleFilterToggle = useCallback(() => {
setFilterOpen((prev) => !prev)
}, [])
const handleFilterClose = useCallback(() => {
setFilterOpen(false)
}, [])
const columnOptions = useMemo<ColumnOption[]>(
() =>
displayColumns.map((col) => ({
@@ -1526,11 +1547,6 @@ export function Table({
[handleAddColumn, addColumnMutation.isPending]
)
const filterElement = useMemo(
() => <TableFilter columns={displayColumns} onApply={handleFilterApply} />,
[displayColumns, handleFilterApply]
)
const activeSortState = useMemo(() => {
if (!queryOptions.sort) return null
const entries = Object.entries(queryOptions.sort)
@@ -1597,7 +1613,19 @@ export function Table({
<>
<ResourceHeader icon={TableIcon} breadcrumbs={breadcrumbs} create={createAction} />
<ResourceOptionsBar sort={sortConfig} filter={filterElement} />
<ResourceOptionsBar
sort={sortConfig}
onFilterToggle={handleFilterToggle}
filterActive={filterOpen || !!queryOptions.filter}
/>
{filterOpen && (
<TableFilter
columns={displayColumns}
filter={queryOptions.filter}
onApply={handleFilterApply}
onClose={handleFilterClose}
/>
)}
</>
)}
@@ -1677,8 +1705,6 @@ export function Table({
onResize={handleColumnResize}
onResizeEnd={handleColumnResizeEnd}
isDragging={dragColumnName === column.name}
isDropTarget={dropTargetColumnName === column.name}
dropSide={dropTargetColumnName === column.name ? dropSide : undefined}
onDragStart={handleColumnDragStart}
onDragOver={handleColumnDragOver}
onDragEnd={handleColumnDragEnd}
@@ -1701,7 +1727,7 @@ export function Table({
<>
{rows.map((row, index) => {
const prevPosition = index > 0 ? rows[index - 1].position : -1
const gapCount = row.position - prevPosition - 1
const gapCount = queryOptions.filter ? 0 : row.position - prevPosition - 1
return (
<React.Fragment key={row.id}>
{gapCount > 0 && (
@@ -1755,6 +1781,12 @@ export function Table({
style={{ left: resizeIndicatorLeft }}
/>
)}
{dropIndicatorLeft !== null && (
<div
className='-translate-x-[1px] pointer-events-none absolute top-0 z-20 h-full w-[2px] bg-[var(--selection)]'
style={{ left: dropIndicatorLeft }}
/>
)}
</div>
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && (
<AddRowButton onClick={handleAppendRow} />
@@ -2581,8 +2613,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResize,
onResizeEnd,
isDragging,
isDropTarget,
dropSide,
onDragStart,
onDragOver,
onDragEnd,
@@ -2605,8 +2635,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResize: (columnName: string, width: number) => void
onResizeEnd: () => void
isDragging?: boolean
isDropTarget?: boolean
dropSide?: 'left' | 'right'
onDragStart?: (columnName: string) => void
onDragOver?: (columnName: string, side: 'left' | 'right') => void
onDragEnd?: () => void
@@ -2698,22 +2726,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
return (
<th
className={cn(
'relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
isDragging && 'opacity-40'
)}
draggable={!readOnly && !isRenaming}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
>
{isDropTarget && dropSide === 'left' && (
<div className='pointer-events-none absolute top-0 bottom-0 left-[-1px] z-10 w-[2px] bg-[var(--selection)]' />
)}
{isDropTarget && dropSide === 'right' && (
<div className='pointer-events-none absolute top-0 right-[-1px] bottom-0 z-10 w-[2px] bg-[var(--selection)]' />
)}
{isRenaming ? (
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<ColumnTypeIcon type={column.type} />
@@ -2738,63 +2757,73 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
</span>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex h-full w-full min-w-0 cursor-pointer items-center px-2 py-[7px] outline-none active:cursor-grabbing'
>
<ColumnTypeIcon type={column.type} />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{column.name}
</span>
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onSelect={() => onRenameColumn(column.name)}>
<Pencil />
Rename column
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{React.createElement(COLUMN_TYPE_ICONS[column.type] ?? TypeText)}
Change type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{COLUMN_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.type}
disabled={column.type === option.type}
onSelect={() => onChangeType(column.name, option.type)}
>
<option.icon />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onInsertLeft(column.name)}>
<ArrowLeft />
Insert column left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onInsertRight(column.name)}>
<ArrowRight />
Insert column right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onToggleUnique(column.name)}>
<Fingerprint />
{column.unique ? 'Remove unique' : 'Set unique'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onDeleteColumn(column.name)}>
<Trash />
Delete column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className='flex h-full w-full min-w-0 items-center'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none'
>
<ColumnTypeIcon type={column.type} />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{column.name}
</span>
<ChevronDown className='ml-1.5 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onSelect={() => onRenameColumn(column.name)}>
<Pencil />
Rename column
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{React.createElement(COLUMN_TYPE_ICONS[column.type] ?? TypeText)}
Change type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{COLUMN_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.type}
disabled={column.type === option.type}
onSelect={() => onChangeType(column.name, option.type)}
>
<option.icon />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onInsertLeft(column.name)}>
<ArrowLeft />
Insert column left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onInsertRight(column.name)}>
<ArrowRight />
Insert column right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onToggleUnique(column.name)}>
<Fingerprint />
{column.unique ? 'Remove unique' : 'Set unique'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onDeleteColumn(column.name)}>
<Trash />
Delete column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
className='flex h-full cursor-grab items-center pr-1.5 pl-0.5 opacity-0 transition-opacity active:cursor-grabbing group-hover:opacity-100'
>
<GripVertical className='h-3 w-3 shrink-0 text-[var(--text-muted)]' />
</div>
</div>
)}
<div
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'

View File

@@ -3,8 +3,10 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import type { ComboboxOption } from '@/components/emcn'
import {
Button,
Combobox,
Modal,
ModalBody,
ModalContent,
@@ -16,7 +18,13 @@ import {
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import type { TableDefinition } from '@/lib/table'
import { generateUniqueTableName } from '@/lib/table/constants'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import type {
FilterTag,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
@@ -29,6 +37,7 @@ import {
useUploadCsvToTable,
} from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Tables')
@@ -60,6 +69,13 @@ export function Tables() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 300)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [rowCountFilter, setRowCountFilter] = useState<string[]>([])
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const csvInputRef = useRef<HTMLInputElement>(null)
@@ -78,15 +94,56 @@ export function Tables() {
closeMenu: closeRowContextMenu,
} = useContextMenu()
const filteredTables = useMemo(() => {
if (!searchTerm) return tables
const term = searchTerm.toLowerCase()
return tables.filter((table) => table.name.toLowerCase().includes(term))
}, [tables, searchTerm])
const processedTables = useMemo(() => {
let result = debouncedSearchTerm
? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
: tables
if (rowCountFilter.length > 0) {
result = result.filter((t) => {
if (rowCountFilter.includes('empty') && t.rowCount === 0) return true
if (rowCountFilter.includes('small') && t.rowCount >= 1 && t.rowCount <= 100) return true
if (rowCountFilter.includes('large') && t.rowCount > 100) return true
return false
})
}
if (ownerFilter.length > 0) {
result = result.filter((t) => ownerFilter.includes(t.createdBy))
}
const col = activeSort?.column ?? 'created'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'columns':
cmp = a.schema.columns.length - b.schema.columns.length
break
case 'rows':
cmp = a.rowCount - b.rowCount
break
case 'created':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updated':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'owner': {
const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? ''
const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? ''
cmp = aName.localeCompare(bName)
break
}
}
return dir === 'asc' ? cmp : -cmp
})
}, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members])
const rows: ResourceRow[] = useMemo(
() =>
filteredTables.map((table) => ({
processedTables.map((table) => ({
id: table.id,
cells: {
name: {
@@ -105,16 +162,167 @@ export function Tables() {
owner: ownerCell(table.createdBy, members),
updated: timeCell(table.updatedAt),
},
sortValues: {
columns: table.schema.columns.length,
rows: table.rowCount,
created: -new Date(table.createdAt).getTime(),
updated: -new Date(table.updatedAt).getTime(),
},
})),
[filteredTables, members]
[processedTables, members]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchTerm,
onChange: setSearchTerm,
onClearAll: () => setSearchTerm(''),
placeholder: 'Search tables...',
}),
[searchTerm]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'columns', label: 'Columns' },
{ id: 'rows', label: 'Rows' },
{ id: 'created', label: 'Created' },
{ id: 'owner', label: 'Owner' },
{ id: 'updated', label: 'Last Updated' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const rowCountDisplayLabel = useMemo(() => {
if (rowCountFilter.length === 0) return 'All'
if (rowCountFilter.length === 1) {
const labels: Record<string, string> = {
empty: 'Empty',
small: 'Small (1100)',
large: 'Large (101+)',
}
return labels[rowCountFilter[0]] ?? rowCountFilter[0]
}
return `${rowCountFilter.length} selected`
}, [rowCountFilter])
const ownerDisplayLabel = useMemo(() => {
if (ownerFilter.length === 0) return 'All'
if (ownerFilter.length === 1)
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
return `${ownerFilter.length} members`
}, [ownerFilter, members])
const memberOptions: ComboboxOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const hasActiveFilters = rowCountFilter.length > 0 || ownerFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Row Count</span>
<Combobox
options={[
{ value: 'empty', label: 'Empty' },
{ value: 'small', label: 'Small (1100 rows)' },
{ value: 'large', label: 'Large (101+ rows)' },
]}
multiSelect
multiSelectValues={rowCountFilter}
onMultiSelectChange={setRowCountFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{rowCountDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span>
<Combobox
options={memberOptions}
multiSelect
multiSelectValues={ownerFilter}
onMultiSelectChange={setOwnerFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search members...'
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
)}
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setRowCountFilter([])
setOwnerFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
rowCountFilter,
ownerFilter,
memberOptions,
rowCountDisplayLabel,
ownerDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (rowCountFilter.length > 0) {
const rowLabels: Record<string, string> = { empty: 'Empty', small: 'Small', large: 'Large' }
const label =
rowCountFilter.length === 1
? `Rows: ${rowLabels[rowCountFilter[0]]}`
: `Rows: ${rowCountFilter.length} selected`
tags.push({ label, onRemove: () => setRowCountFilter([]) })
}
if (ownerFilter.length > 0) {
const label =
ownerFilter.length === 1
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
: `Owner: ${ownerFilter.length} members`
tags.push({ label, onRemove: () => setOwnerFilter([]) })
}
return tags
}, [rowCountFilter, ownerFilter, members])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -215,7 +423,7 @@ export function Tables() {
}
}
},
[workspaceId, router]
[workspaceId, router, uploadCsv]
)
const handleListUploadCsv = useCallback(() => {
@@ -260,12 +468,10 @@ export function Tables() {
onClick: handleCreateTable,
disabled: uploading || userPermissions.canEdit !== true || createTable.isPending,
}}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search tables...',
}}
defaultSort='created'
search={searchConfig}
sort={sortConfig}
filter={filterContent}
filterTags={filterTags}
headerActions={[
{
label: uploadButtonLabel,

View File

@@ -245,9 +245,6 @@ export function CollapsedTaskFlyoutItem({
title={task.name}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
statusIndicatorClassName={
!(isCurrentRoute || isMenuOpen) ? 'group-hover:hidden' : undefined
}
/>
</Link>
{showActions && (

View File

@@ -3,7 +3,7 @@
import { useCallback, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { ChevronDown, Skeleton, Tooltip } from '@/components/emcn'
import { ChevronDown, Skeleton } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -15,6 +15,7 @@ import {
isBillingEnabled,
sectionConfig,
} from '@/app/workspace/[workspaceId]/settings/navigation'
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useSSOProviders } from '@/ee/sso/hooks/sso'
import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
@@ -186,25 +187,18 @@ export function SettingsSidebar({
<>
{/* Back button */}
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
<Tooltip.Root key={`back-${isCollapsed}`}>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={handleBack}
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
</div>
<span className='truncate font-base text-[var(--text-body)]'>Back</span>
</button>
</Tooltip.Trigger>
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>Back</p>
</Tooltip.Content>
)}
</Tooltip.Root>
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
<button
type='button'
onClick={handleBack}
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
</div>
<span className='truncate font-base text-[var(--text-body)]'>Back</span>
</button>
</SidebarTooltip>
</div>
{/* Settings sections */}
@@ -303,14 +297,13 @@ export function SettingsSidebar({
)
return (
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
<SidebarTooltip
key={`${item.id}-${isCollapsed}`}
label={item.label}
enabled={showCollapsedTooltips}
>
{element}
</SidebarTooltip>
)
})}
</div>

View File

@@ -100,6 +100,28 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Sidebar')
export function SidebarTooltip({
children,
label,
enabled,
side = 'right',
}: {
children: React.ReactElement
label: string
enabled: boolean
side?: 'right' | 'bottom'
}) {
if (!enabled) return children
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Content side={side}>
<p>{label}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
function SidebarItemSkeleton() {
return (
<div className='sidebar-collapse-hide mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2'>
@@ -135,71 +157,61 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, taskId: string) => void
}) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Link
href={task.href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
!(isCurrentRoute || isSelected || isMenuOpen) &&
'hover-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
if (task.id === 'new') return
if (e.shiftKey || e.metaKey || e.ctrlKey) {
e.preventDefault()
onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey)
} else {
useFolderStore.setState({
selectedTasks: new Set<string>(),
lastSelectedTaskId: task.id,
})
}
}}
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>
{task.name}
<SidebarTooltip label={task.name} enabled={showCollapsedTooltips}>
<Link
href={task.href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
!(isCurrentRoute || isSelected || isMenuOpen) && 'hover-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
if (task.id === 'new') return
if (e.shiftKey || e.metaKey || e.ctrlKey) {
e.preventDefault()
onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey)
} else {
useFolderStore.setState({
selectedTasks: new Set<string>(),
lastSelectedTaskId: task.id,
})
}
}}
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>{task.name}</div>
{task.id !== 'new' && (
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
{isActive && !isCurrentRoute && !isMenuOpen && (
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-amber-400 opacity-30 group-hover:hidden' />
)}
{isActive && !isCurrentRoute && !isMenuOpen && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-amber-400 group-hover:hidden' />
)}
{!isActive && isUnread && !isCurrentRoute && !isMenuOpen && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-[var(--brand-accent)] group-hover:hidden' />
)}
<button
type='button'
aria-label='Task options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick(e, task.id)
}}
className={cn(
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
isMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</div>
{task.id !== 'new' && (
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
{isActive && !isCurrentRoute && (
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-amber-400 opacity-30 group-hover:hidden' />
)}
{isActive && !isCurrentRoute && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-amber-400 group-hover:hidden' />
)}
{!isActive && isUnread && !isCurrentRoute && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-[var(--brand-accent)] group-hover:hidden' />
)}
<button
type='button'
aria-label='Task options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick(e, task.id)
}}
className={cn(
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
isMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</div>
)}
</Link>
</Tooltip.Trigger>
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>{task.name}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)}
</Link>
</SidebarTooltip>
)
})
@@ -265,15 +277,12 @@ const SidebarNavItem = memo(function SidebarNavItem({
</button>
) : null
if (!element) return null
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
<SidebarTooltip label={item.label} enabled={showCollapsedTooltips}>
{element}
</SidebarTooltip>
)
})
@@ -317,6 +326,7 @@ export const Sidebar = memo(function Sidebar() {
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const _hasHydrated = useSidebarStore((state) => state._hasHydrated)
const isOnWorkflowPage = !!workflowId
const isCollapsedRef = useRef(isCollapsed)
@@ -326,14 +336,12 @@ export const Sidebar = memo(function Sidebar() {
const isMac = useMemo(() => isMacPlatform(), [])
// Delay collapsed tooltips until the width transition finishes.
const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
useLayoutEffect(() => {
if (!isCollapsed) {
document.documentElement.removeAttribute('data-sidebar-collapsed')
}
}, [isCollapsed])
if (!_hasHydrated) return
document.documentElement.removeAttribute('data-sidebar-collapsed')
}, [_hasHydrated])
useEffect(() => {
if (isCollapsed) {
@@ -1010,10 +1018,6 @@ export const Sidebar = memo(function Sidebar() {
[importWorkspace]
)
// ── Memoised elements & objects for collapsed menus ──
// Prevents new JSX/object references on every render, which would defeat
// React.memo on CollapsedSidebarMenu and its children.
const tasksCollapsedIcon = useMemo(
() => <Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />,
[]
@@ -1054,7 +1058,6 @@ export const Sidebar = memo(function Sidebar() {
[handleCreateWorkflow]
)
// Stable no-op for collapsed workflow context menu delete (never changes)
const noop = useCallback(() => {}, [])
const handleExpandSidebar = useCallback(
@@ -1065,16 +1068,13 @@ export const Sidebar = memo(function Sidebar() {
[toggleCollapsed]
)
// Stable callback for the "New task" button in expanded mode
const handleNewTask = useCallback(
() => navigateToPage(`/workspace/${workspaceId}/home`),
[navigateToPage, workspaceId]
)
// Stable callback for "See more" tasks
const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), [])
// Stable callback for DeleteModal close
const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), [])
const handleEdgeKeyDown = useCallback(
@@ -1087,16 +1087,13 @@ export const Sidebar = memo(function Sidebar() {
[isCollapsed, toggleCollapsed]
)
// Stable handler for help modal open from dropdown
const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), [])
// Stable handler for opening docs
const handleOpenDocs = useCallback(
() => window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer'),
[]
)
// Stable blur handlers for inline rename inputs
const handleTaskRenameBlur = useCallback(
() => void taskFlyoutRename.saveRename(),
[taskFlyoutRename.saveRename]
@@ -1107,7 +1104,6 @@ export const Sidebar = memo(function Sidebar() {
[workflowFlyoutRename.saveRename]
)
// Stable style for hidden file inputs
const hiddenStyle = useMemo(() => ({ display: 'none' }) as const, [])
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
@@ -1205,69 +1201,68 @@ export const Sidebar = memo(function Sidebar() {
onClick={handleSidebarClick}
>
<div className='flex h-full flex-col pt-3'>
{/* Top bar: Logo + Collapse toggle */}
<div className='flex flex-shrink-0 items-center pr-2 pb-2 pl-2.5'>
<div className='flex h-[30px] items-center'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='relative h-[30px]'>
<Link
href={`/workspace/${workspaceId}/home`}
className='sidebar-collapse-hide !transition-none group flex h-[30px] items-center rounded-[8px] px-[7px] hover-hover:bg-[var(--surface-hover)]'
tabIndex={isCollapsed ? -1 : undefined}
aria-label={brand.name}
>
{brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={brand.name}
width={16}
height={16}
className='h-[16px] w-[16px] flex-shrink-0 object-contain'
unoptimized
/>
) : (
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
)}
</Link>
<SidebarTooltip label='Expand sidebar' enabled={showCollapsedTooltips}>
<Link
href={`/workspace/${workspaceId}/home`}
onClick={isCollapsed ? handleExpandSidebar : undefined}
className='group flex h-[30px] items-center rounded-[8px] px-1.5 hover-hover:bg-[var(--surface-hover)]'
aria-label={isCollapsed ? 'Expand sidebar' : brand.name}
onClick={handleExpandSidebar}
className='sidebar-collapse-show !transition-none group absolute top-0 left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover-hover:bg-[var(--surface-hover)]'
tabIndex={isCollapsed ? undefined : -1}
aria-label='Expand sidebar'
>
{brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={brand.name}
alt=''
width={16}
height={16}
className={cn(
'h-[16px] w-[16px] flex-shrink-0 object-contain',
isCollapsed && 'group-hover:hidden'
)}
className='h-[16px] w-[16px] flex-shrink-0 object-contain group-hover:hidden'
unoptimized
/>
) : isCollapsed ? (
<Sim className='h-[16px] w-[16px] flex-shrink-0 group-hover:hidden' />
) : (
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
)}
{isCollapsed && (
<PanelLeft className='hidden h-[16px] w-[16px] flex-shrink-0 rotate-180 text-[var(--text-icon)] group-hover:block' />
<Sim className='h-[16px] w-[16px] flex-shrink-0 group-hover:hidden' />
)}
<PanelLeft className='hidden h-[16px] w-[16px] rotate-180 text-[var(--text-icon)] group-hover:block' />
</Link>
</Tooltip.Trigger>
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>Expand sidebar</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</SidebarTooltip>
</div>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={toggleCollapsed}
className={cn(
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-hover)]',
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
)}
aria-label='Collapse sidebar'
>
<PanelLeft className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</Tooltip.Trigger>
{!isCollapsed && (
<Tooltip.Content side='bottom'>
<p>Collapse sidebar</p>
</Tooltip.Content>
)}
</Tooltip.Root>
<SidebarTooltip label='Collapse sidebar' enabled={!isCollapsed} side='bottom'>
<button
type='button'
onClick={toggleCollapsed}
className={cn(
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-hover)]',
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
)}
aria-label='Collapse sidebar'
>
<PanelLeft className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</SidebarTooltip>
</div>
{/* Workspace Header */}
<div className='flex-shrink-0 pr-2.5 pl-[9px]'>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
@@ -1299,7 +1294,6 @@ export const Sidebar = memo(function Sidebar() {
/>
) : (
<>
{/* Top Navigation: Home, Search */}
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
{topNavItems.map((item) => (
<SidebarNavItem
@@ -1312,7 +1306,6 @@ export const Sidebar = memo(function Sidebar() {
))}
</div>
{/* Workspace */}
<div className='mt-3.5 flex flex-shrink-0 flex-col pb-2'>
<div className='px-4 pb-1.5'>
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
@@ -1330,7 +1323,6 @@ export const Sidebar = memo(function Sidebar() {
</div>
</div>
{/* Scrollable Tasks + Workflows */}
<div
ref={isCollapsed ? undefined : scrollContainerRef}
className={cn(
@@ -1338,7 +1330,6 @@ export const Sidebar = memo(function Sidebar() {
!hasOverflowTop && 'border-transparent'
)}
>
{/* Tasks */}
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
@@ -1460,7 +1451,6 @@ export const Sidebar = memo(function Sidebar() {
)}
</div>
{/* Workflows */}
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
@@ -1612,36 +1602,27 @@ export const Sidebar = memo(function Sidebar() {
</div>
</div>
{/* Footer */}
<div
className={cn(
'flex flex-shrink-0 flex-col gap-0.5 border-t px-2 pt-[9px] pb-2 transition-colors duration-150',
!hasOverflowBottom && 'border-transparent'
)}
>
{/* Help dropdown */}
<DropdownMenu>
<Tooltip.Root>
<SidebarTooltip label='Help' enabled={showCollapsedTooltips}>
<DropdownMenuTrigger asChild>
<Tooltip.Trigger asChild>
<button
type='button'
data-item-id='help'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover-hover:bg-[var(--surface-hover)]'
>
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
Help
</span>
</button>
</Tooltip.Trigger>
<button
type='button'
data-item-id='help'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover-hover:bg-[var(--surface-hover)]'
>
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
Help
</span>
</button>
</DropdownMenuTrigger>
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>Help</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</SidebarTooltip>
<DropdownMenuContent align='start' side='top' sideOffset={4}>
<DropdownMenuItem onSelect={handleOpenDocs}>
<BookOpen className='h-[14px] w-[14px]' />
@@ -1669,7 +1650,6 @@ export const Sidebar = memo(function Sidebar() {
))}
</div>
{/* Nav Item Context Menu */}
<NavItemContextMenu
isOpen={isNavContextMenuOpen}
position={navContextMenuPosition}
@@ -1679,7 +1659,6 @@ export const Sidebar = memo(function Sidebar() {
onCopyLink={handleNavCopyLink}
/>
{/* Task Context Menu */}
<ContextMenu
isOpen={isTaskContextMenuOpen}
position={taskContextMenuPosition}
@@ -1704,7 +1683,6 @@ export const Sidebar = memo(function Sidebar() {
disableDelete={!canEdit}
/>
{/* Task Delete Confirmation Modal */}
<DeleteModal
isOpen={isTaskDeleteModalOpen}
onClose={handleCloseTaskDeleteModal}
@@ -1735,7 +1713,6 @@ export const Sidebar = memo(function Sidebar() {
)}
</div>
{/* Universal Search Modal */}
<SearchModal
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
@@ -1748,14 +1725,12 @@ export const Sidebar = memo(function Sidebar() {
isOnWorkflowPage={!!workflowId}
/>
{/* Footer Navigation Modals */}
<HelpModal
open={isHelpModalOpen}
onOpenChange={setIsHelpModalOpen}
workflowId={workflowId}
workspaceId={workspaceId}
/>
{/* Hidden file input for workspace import */}
<input
ref={workspaceFileInputRef}
type='file'

View File

@@ -10,17 +10,36 @@ function isDarkBackground(hexColor: string): boolean {
return luminance < 0.5
}
function getContrastTextColor(hexColor: string): string {
return isDarkBackground(hexColor) ? '#ffffff' : '#000000'
}
export function generateThemeCSS(): string {
const cssVars: string[] = []
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR) {
cssVars.push(`--brand: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
// Override brand-accent so Run/Deploy buttons and other accent-styled elements use the brand color
cssVars.push(`--brand-accent: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
cssVars.push(`--auth-primary-btn-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
cssVars.push(`--auth-primary-btn-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
cssVars.push(`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
cssVars.push(`--auth-primary-btn-hover-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
const primaryTextColor = getContrastTextColor(process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR)
cssVars.push(`--auth-primary-btn-text: ${primaryTextColor};`)
cssVars.push(`--auth-primary-btn-hover-text: ${primaryTextColor};`)
}
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR) {
cssVars.push(`--brand-hover: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
cssVars.push(
`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
)
cssVars.push(
`--auth-primary-btn-hover-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
)
cssVars.push(
`--auth-primary-btn-hover-text: ${getContrastTextColor(process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR)};`
)
}
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR) {
@@ -32,7 +51,6 @@ export function generateThemeCSS(): string {
}
if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) {
// Add dark theme class when background is dark
const isDark = isDarkBackground(process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR)
if (isDark) {
cssVars.push(`--brand-is-dark: 1;`)

View File

@@ -233,7 +233,9 @@ export function useDocumentChunks(
documentId: string,
page = 1,
search = '',
enabledFilter: 'all' | 'enabled' | 'disabled' = 'all'
enabledFilter: 'all' | 'enabled' | 'disabled' = 'all',
sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled',
sortOrder?: 'asc' | 'desc'
) {
const queryClient = useQueryClient()
@@ -248,6 +250,8 @@ export function useDocumentChunks(
offset,
search: search || undefined,
enabledFilter,
sortBy,
sortOrder,
},
{
enabled: Boolean(knowledgeBaseId && documentId),
@@ -280,11 +284,13 @@ export function useDocumentChunks(
offset,
search: search || undefined,
enabledFilter,
sortBy,
sortOrder,
})
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey),
})
}, [knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient])
}, [knowledgeBaseId, documentId, offset, search, enabledFilter, sortBy, sortOrder, queryClient])
const updateChunk = useCallback(
(chunkId: string, updates: Partial<ChunkData>) => {
@@ -295,6 +301,8 @@ export function useDocumentChunks(
offset,
search: search || undefined,
enabledFilter,
sortBy,
sortOrder,
})
queryClient.setQueryData<KnowledgeChunksResponse>(
knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey),
@@ -309,7 +317,7 @@ export function useDocumentChunks(
}
)
},
[knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient]
[knowledgeBaseId, documentId, offset, search, enabledFilter, sortBy, sortOrder, queryClient]
)
return {

View File

@@ -181,6 +181,8 @@ export interface KnowledgeChunksParams {
enabledFilter?: 'all' | 'enabled' | 'disabled'
limit?: number
offset?: number
sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled'
sortOrder?: 'asc' | 'desc'
}
export interface KnowledgeChunksResponse {
@@ -196,6 +198,8 @@ export async function fetchKnowledgeChunks(
enabledFilter,
limit = 50,
offset = 0,
sortBy,
sortOrder,
}: KnowledgeChunksParams,
signal?: AbortSignal
): Promise<KnowledgeChunksResponse> {
@@ -206,6 +210,8 @@ export async function fetchKnowledgeChunks(
}
if (limit) params.set('limit', limit.toString())
if (offset) params.set('offset', offset.toString())
if (sortBy && sortBy !== 'chunkIndex') params.set('sortBy', sortBy)
if (sortOrder && sortOrder !== 'asc') params.set('sortOrder', sortOrder)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks${params.toString() ? `?${params.toString()}` : ''}`,
@@ -306,6 +312,8 @@ export const serializeChunkParams = (params: KnowledgeChunksParams) =>
enabledFilter: params.enabledFilter ?? 'all',
limit: params.limit ?? 50,
offset: params.offset ?? 0,
sortBy: params.sortBy ?? 'chunkIndex',
sortOrder: params.sortOrder ?? 'asc',
})
export function useKnowledgeChunksQuery(

View File

@@ -5,6 +5,10 @@ const STICK_THRESHOLD = 30
/** User must scroll back to within this distance to re-engage auto-scroll. */
const REATTACH_THRESHOLD = 5
interface UseAutoScrollOptions {
scrollOnMount?: boolean
}
/**
* Manages sticky auto-scroll for a streaming chat container.
*
@@ -16,7 +20,10 @@ const REATTACH_THRESHOLD = 5
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
* for imperative use after layout-changing events like panel expansion.
*/
export function useAutoScroll(isStreaming: boolean) {
export function useAutoScroll(
isStreaming: boolean,
{ scrollOnMount = false }: UseAutoScrollOptions = {}
) {
const containerRef = useRef<HTMLDivElement>(null)
const stickyRef = useRef(true)
const userDetachedRef = useRef(false)
@@ -24,6 +31,7 @@ export function useAutoScroll(isStreaming: boolean) {
const prevScrollHeightRef = useRef(0)
const touchStartYRef = useRef(0)
const rafIdRef = useRef(0)
const scrollOnMountRef = useRef(scrollOnMount)
const scrollToBottom = useCallback(() => {
const el = containerRef.current
@@ -33,7 +41,7 @@ export function useAutoScroll(isStreaming: boolean) {
const callbackRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el
if (el) el.scrollTop = el.scrollHeight
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
}, [])
useEffect(() => {

View File

@@ -0,0 +1,120 @@
/**
* Profound Analytics - Custom log integration
*
* Buffers HTTP request logs in memory and flushes them in batches to Profound's API.
* Runs in Node.js (proxy.ts on ECS), so module-level state persists across requests.
* @see https://docs.tryprofound.com/agent-analytics/custom
*/
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
const logger = createLogger('ProfoundAnalytics')
const FLUSH_INTERVAL_MS = 10_000
const MAX_BATCH_SIZE = 500
interface ProfoundLogEntry {
timestamp: string
method: string
host: string
path: string
status_code: number
ip: string
user_agent: string
query_params?: Record<string, string>
referer?: string
}
let buffer: ProfoundLogEntry[] = []
let flushTimer: NodeJS.Timeout | null = null
/**
* Returns true if Profound analytics is configured.
*/
export function isProfoundEnabled(): boolean {
return isHosted && Boolean(env.PROFOUND_API_KEY) && Boolean(env.PROFOUND_ENDPOINT)
}
/**
* Flushes buffered log entries to Profound's API.
*/
async function flush(): Promise<void> {
if (buffer.length === 0) return
const apiKey = env.PROFOUND_API_KEY
if (!apiKey) {
buffer = []
return
}
const endpoint = env.PROFOUND_ENDPOINT
if (!endpoint) {
buffer = []
return
}
const entries = buffer.splice(0, MAX_BATCH_SIZE)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(entries),
})
if (!response.ok) {
logger.error(`Profound API returned ${response.status}`)
}
} catch (error) {
logger.error('Failed to flush logs to Profound', error)
}
}
function ensureFlushTimer(): void {
if (flushTimer) return
flushTimer = setInterval(() => {
flush().catch(() => {})
}, FLUSH_INTERVAL_MS)
flushTimer.unref()
}
/**
* Queues a request log entry for the next batch flush to Profound.
*/
export function sendToProfound(request: Request, statusCode: number): void {
if (!isProfoundEnabled()) return
try {
const url = new URL(request.url)
const queryParams: Record<string, string> = {}
url.searchParams.forEach((value, key) => {
queryParams[key] = value
})
buffer.push({
timestamp: new Date().toISOString(),
method: request.method,
host: url.hostname,
path: url.pathname,
status_code: statusCode,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'0.0.0.0',
user_agent: request.headers.get('user-agent') || '',
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
})
ensureFlushTimer()
if (buffer.length >= MAX_BATCH_SIZE) {
flush().catch(() => {})
}
} catch (error) {
logger.error('Failed to enqueue log entry', error)
}
}

View File

@@ -1,8 +1,8 @@
import type { ConnectionOptions } from 'bullmq'
import { env } from '@/lib/core/config/env'
import { env, isTruthy } from '@/lib/core/config/env'
export function isBullMQEnabled(): boolean {
return Boolean(env.REDIS_URL)
return isTruthy(env.CONCURRENCY_CONTROL_ENABLED) && Boolean(env.REDIS_URL)
}
export function getBullMQConnectionOptions(): ConnectionOptions {

View File

@@ -135,6 +135,8 @@ export const env = createEnv({
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
DRIZZLE_ODS_API_KEY: z.string().min(1).optional(), // OneDollarStats API key for analytics tracking
PROFOUND_API_KEY: z.string().min(1).optional(), // Profound analytics API key
PROFOUND_ENDPOINT: z.string().url().optional(), // Profound analytics endpoint
// External Services
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
@@ -184,6 +186,7 @@ export const env = createEnv({
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users
// Admission & Burst Protection
CONCURRENCY_CONTROL_ENABLED: z.string().optional().default('false'), // Set to 'true' to enable BullMQ-based concurrency control (default: inline execution)
ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod
DISPATCH_MAX_QUEUE_PER_WORKSPACE: z.string().optional().default('1000'), // Max queued dispatch jobs per workspace
DISPATCH_MAX_QUEUE_GLOBAL: z.string().optional().default('50000'), // Max queued dispatch jobs globally

View File

@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'crypto'
import { db } from '@sim/db'
import { document, embedding, knowledgeBase } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'
import type {
BatchOperationResult,
ChunkData,
@@ -23,24 +23,27 @@ export async function queryChunks(
filters: ChunkFilters,
requestId: string
): Promise<ChunkQueryResult> {
const { search, enabled = 'all', limit = 50, offset = 0 } = filters
const {
search,
enabled = 'all',
limit = 50,
offset = 0,
sortBy = 'chunkIndex',
sortOrder = 'asc',
} = filters
// Build query conditions
const conditions = [eq(embedding.documentId, documentId)]
// Add enabled filter
if (enabled === 'true') {
conditions.push(eq(embedding.enabled, true))
} else if (enabled === 'false') {
conditions.push(eq(embedding.enabled, false))
}
// Add search filter
if (search) {
conditions.push(ilike(embedding.content, `%${search}%`))
}
// Fetch chunks
const chunks = await db
.select({
id: embedding.id,
@@ -63,11 +66,20 @@ export async function queryChunks(
})
.from(embedding)
.where(and(...conditions))
.orderBy(asc(embedding.chunkIndex))
.orderBy(
(() => {
const col =
sortBy === 'tokenCount'
? embedding.tokenCount
: sortBy === 'enabled'
? embedding.enabled
: embedding.chunkIndex
return sortOrder === 'desc' ? desc(col) : asc(col)
})()
)
.limit(limit)
.offset(offset)
// Get total count for pagination
const totalCount = await db
.select({ count: sql`count(*)` })
.from(embedding)

View File

@@ -3,6 +3,8 @@ export interface ChunkFilters {
enabled?: 'true' | 'false' | 'all'
limit?: number
offset?: number
sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled'
sortOrder?: 'asc' | 'desc'
}
export interface ChunkData {

View File

@@ -16,9 +16,8 @@
"load:workflow:baseline": "BASE_URL=${BASE_URL:-http://localhost:3000} WARMUP_DURATION=${WARMUP_DURATION:-10} WARMUP_RATE=${WARMUP_RATE:-2} PEAK_RATE=${PEAK_RATE:-8} HOLD_DURATION=${HOLD_DURATION:-20} bunx artillery run scripts/load/workflow-concurrency.yml",
"load:workflow:waves": "BASE_URL=${BASE_URL:-http://localhost:3000} WAVE_ONE_DURATION=${WAVE_ONE_DURATION:-10} WAVE_ONE_RATE=${WAVE_ONE_RATE:-6} QUIET_DURATION=${QUIET_DURATION:-5} WAVE_TWO_DURATION=${WAVE_TWO_DURATION:-15} WAVE_TWO_RATE=${WAVE_TWO_RATE:-8} WAVE_THREE_DURATION=${WAVE_THREE_DURATION:-20} WAVE_THREE_RATE=${WAVE_THREE_RATE:-10} bunx artillery run scripts/load/workflow-waves.yml",
"load:workflow:isolation": "BASE_URL=${BASE_URL:-http://localhost:3000} ISOLATION_DURATION=${ISOLATION_DURATION:-30} TOTAL_RATE=${TOTAL_RATE:-9} WORKSPACE_A_WEIGHT=${WORKSPACE_A_WEIGHT:-8} WORKSPACE_B_WEIGHT=${WORKSPACE_B_WEIGHT:-1} bunx artillery run scripts/load/workflow-isolation.yml",
"build": "bun run build:pptx-worker && bun run build:worker && next build",
"build": "bun run build:pptx-worker && next build",
"build:pptx-worker": "bun build ./lib/execution/pptx-worker.cjs --target=node --format=cjs --outfile ./dist/pptx-worker.cjs",
"build:worker": "bun build ./worker/index.ts --target=node --format=esm --splitting --outdir ./dist/worker --external isolated-vm",
"start": "next start",
"worker": "NODE_ENV=production bun run worker/index.ts",
"prepare": "cd ../.. && bun husky",

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getSessionCookie } from 'better-auth/cookies'
import { type NextRequest, NextResponse } from 'next/server'
import { sendToProfound } from './lib/analytics/profound'
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
import { generateRuntimeCSP } from './lib/core/security/csp'
@@ -144,47 +145,47 @@ export async function proxy(request: NextRequest) {
const hasActiveSession = isAuthDisabled || !!sessionCookie
const redirect = handleRootPathRedirects(request, hasActiveSession)
if (redirect) return redirect
if (redirect) return track(request, redirect)
if (url.pathname === '/login' || url.pathname === '/signup') {
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
return track(request, NextResponse.redirect(new URL('/workspace', request.url)))
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
return response
return track(request, response)
}
// Chat pages are publicly accessible embeds — CSP is set in next.config.ts headers
if (url.pathname.startsWith('/chat/')) {
return NextResponse.next()
return track(request, NextResponse.next())
}
// Allow public access to template pages for SEO
if (url.pathname.startsWith('/templates')) {
return NextResponse.next()
return track(request, NextResponse.next())
}
if (url.pathname.startsWith('/workspace')) {
// Allow public access to workspace template pages - they handle their own redirects
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
return NextResponse.next()
return track(request, NextResponse.next())
}
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
return track(request, NextResponse.redirect(new URL('/login', request.url)))
}
return NextResponse.next()
return track(request, NextResponse.next())
}
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
if (invitationRedirect) return invitationRedirect
if (invitationRedirect) return track(request, invitationRedirect)
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
if (workspaceInvitationRedirect) return track(request, workspaceInvitationRedirect)
const securityBlock = handleSecurityFiltering(request)
if (securityBlock) return securityBlock
if (securityBlock) return track(request, securityBlock)
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')
@@ -193,6 +194,14 @@ export async function proxy(request: NextRequest) {
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
}
return track(request, response)
}
/**
* Sends request data to Profound analytics (fire-and-forget) and returns the response.
*/
function track(request: NextRequest, response: NextResponse): NextResponse {
sendToProfound(request, response.status)
return response
}

View File

@@ -71,7 +71,7 @@ services:
build:
context: .
dockerfile: docker/app.Dockerfile
command: ['bun', 'apps/sim/dist/worker/index.js']
command: ['bun', 'apps/sim/worker/index.ts']
restart: unless-stopped
deploy:
resources:

View File

@@ -42,7 +42,7 @@ services:
sim-worker:
image: ghcr.io/simstudioai/simstudio:latest
command: ['bun', 'apps/sim/dist/worker/index.js']
command: ['bun', 'apps/sim/worker/index.ts']
restart: unless-stopped
deploy:
resources:

View File

@@ -105,6 +105,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/public ./apps/sim/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/static ./apps/sim/.next/static
# Copy the full dependency tree and app source so the BullMQ worker can run from source.
# The standalone server continues to use server.js; the worker uses bun on worker/index.ts.
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim ./apps/sim
COPY --from=builder --chown=nextjs:nodejs /app/packages ./packages
# Copy isolated-vm native module (compiled for Node.js in deps stage)
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/isolated-vm ./node_modules/isolated-vm
@@ -114,9 +120,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-v
# Copy the bundled PPTX worker artifact
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/dist/pptx-worker.cjs ./apps/sim/dist/pptx-worker.cjs
# Copy the bundled BullMQ worker (self-contained ESM bundle, only isolated-vm is external)
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/dist/worker ./apps/sim/dist/worker
# Guardrails setup with pip caching
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/requirements.txt ./apps/sim/lib/guardrails/requirements.txt
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/validate_pii.py ./apps/sim/lib/guardrails/validate_pii.py

View File

@@ -37,7 +37,7 @@ spec:
- name: worker
image: {{ include "sim.image" (dict "context" . "image" .Values.worker.image) }}
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
command: ["bun", "apps/sim/dist/worker/index.js"]
command: ["bun", "apps/sim/worker/index.ts"]
ports:
- name: health
containerPort: {{ .Values.worker.healthPort }}