From 11f87352b775f2733e04d1b952718a5394ec45e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 4 Aug 2026 20:15:30 -0700 Subject: [PATCH] refactor(resources): migrate log and knowledge onto the axes; collapse tab chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the resource-view layer. Three kinds now have a canonical view (file, log, knowledge) and only `table` remains — deliberately, because the mothership mounts its editing shell, which is a product decision rather than unfinished work. **log** — `log-details/` splits into `components/resources/log-view/` (the view) and a 198-line shell that keeps resize, keyboard nav and close chrome. Four leaked contexts resolved: `useParams` → `source.workspaceId`, `useRouter` → an `onNavigate` prop, `useQueryState(tab)` → view-owned state gated by `hostOwnsUrl(host)`, `usePermissionConfig` → a required `showExecutionInternals` prop (deliberately not folded into `grants`: it gates *seeing* trace internals, not write or run, and an optional field would default permissive — the wrong failure mode for a permission-group restriction). A fifth leak the plan missed — `file-download.tsx` holding its own `useRouter` — was caught by the gate. **knowledge** — `base.tsx` (1733 lines) splits into a 909-line canonical view and a 974-line shell. The read surface (document list, filters, sort, pagination, unavailable state) is the view; upload, connectors, tag editing, rename, delete and bulk operations stay in the shell, exactly as the tables editing grid kept its write path. All six nuqs keys now route through `useKnowledgeListState(host)`, whose every write sits behind `hostOwnsUrl`. **tab chrome** — the five `Embedded*Actions` collapse into one kind-keyed `ResourceTabActions`. They differed only in icon, copy and destination, which is a config table. `knowledge` and `table` destinations now resolve through `workspaceSource().hrefFor()` instead of hand-built strings. **schedule** is removed as a resource kind: its page was deleted upstream, its `resourceHref` case pointed at a route that no longer exists, and `MothershipResourceType` never carried it, so it was unreachable. Behavior change, intended and limited to embedded surfaces: the mothership panel no longer writes the log `?tab` key or the six knowledge keys into the host page's address bar. That is the bug the `host` axis exists to fix. Both route pages keep their deep-linkable params unchanged. `Resource` and `InlineRenameInput` moved to `components/` — mandatory, since a canonical unit may not import the workspace route tree. The workspace barrel re-exports both, so its consumers are byte-unchanged. Ratchets: shadow-named components 8 → 2 (the 2 left are deliberate — `EmbeddedWorkflow`, because a workflow is a live collaborative session rather than a document with an address, and `EmbeddedFolder`, because a folder is structure inside a resource); cross-tree imports 38 → 37. None raised. Verification: apps/sim + 22 packages typecheck, biome clean, 18626 tests pass, and all 15 repo gates pass including both strict variants. Not visually verified. --- .claude/rules/sim-resource-views.md | 6 +- CLAUDE.md | 4 +- .../_shell/desktop-title-bar-surfaces.test.ts | 2 +- .../components/folders/folder-breadcrumbs.ts | 2 +- .../components/folders/folder-row.tsx | 5 +- .../folders/use-folder-row-drag-drop.ts | 2 +- .../[workspaceId]/components/index.ts | 78 +- .../add-resource-dropdown.tsx | 2 +- .../mothership-view/components/index.ts | 2 +- .../components/resource-content/index.ts | 2 +- .../resource-content/resource-content.tsx | 601 +++--- .../mothership-view/mothership-view.tsx | 4 +- .../knowledge/[id]/[documentId]/document.tsx | 3 +- .../[workspaceId]/knowledge/[id]/base.tsx | 1733 ----------------- .../knowledge/[id]/components/index.ts | 1 - .../[id]/hooks/use-knowledge-list-state.ts | 238 +++ .../knowledge/[id]/knowledge-base.tsx | 988 ++++++++++ .../[workspaceId]/knowledge/[id]/page.tsx | 4 +- .../components/line-chart/line-chart.tsx | 3 +- .../workflows-list/workflows-list.tsx | 2 +- .../[workspaceId]/logs/components/index.ts | 5 +- .../logs/components/log-details/index.ts | 3 +- .../components/log-details/log-details.tsx | 715 +------ .../app/workspace/[workspaceId]/logs/logs.tsx | 10 +- .../app/workspace/[workspaceId]/logs/utils.ts | 94 +- .../enrichment-details/enrichment-details.tsx | 6 +- .../workflow-selector-input.tsx | 2 +- .../panel/components/editor/editor.tsx | 3 +- .../panel/components/toolbar/toolbar.tsx | 3 +- .../components/subflows/loop/index.ts | 2 +- .../components/subflows/loop/loop-config.ts | 13 - .../components/subflows/parallel/index.ts | 2 +- .../subflows/parallel/parallel-config.ts | 13 - .../preview-editor/preview-editor.tsx | 2 +- apps/sim/blocks/subflow-tools.ts | 25 + .../components/inline-rename-input/index.ts | 0 .../inline-rename-input.tsx | 0 .../components/floating-overflow-text.tsx | 0 .../resource/components/owner-cell/index.ts | 0 .../components/owner-cell/owner-cell.tsx | 2 +- .../resource-chrome-fallback/index.ts | 0 .../resource-chrome-fallback.tsx | 7 +- .../components/resource-header/index.ts | 0 .../resource-header/resource-header.tsx | 2 +- .../components/resource-options/index.ts | 0 .../resource-options.test.tsx | 2 +- .../resource-options/resource-options.tsx | 2 +- .../resource/components/time-cell.ts | 2 +- apps/sim/components/resource/index.ts | 45 + .../components/resource/resource.tsx | 8 +- .../resource/use-background-context-menu.ts | 0 .../resources/MIGRATING-RESOURCES.md | 27 +- .../document-tags-cell/document-tags-cell.tsx | 41 + .../components/document-tags-cell/index.ts | 1 + .../components/search-highlight/index.ts | 0 .../search-highlight/search-highlight.tsx | 0 .../components/tag-filter-panel/index.ts | 7 + .../tag-filter-panel/tag-filter-panel.tsx | 297 +++ .../resources/knowledge-view/index.ts | 15 + .../knowledge-view/knowledge-view.tsx | 397 ++++ .../knowledge-view/utils/document-rows.tsx | 100 + .../execution-snapshot/execution-snapshot.tsx | 0 .../components/execution-snapshot/index.ts | 0 .../file-download/file-download.tsx | 23 +- .../components/file-download/index.ts | 0 .../log-view}/components/trace-view/index.ts | 0 .../components/trace-view/trace-view.tsx | 4 +- .../components/resources/log-view/index.ts | 16 + .../resources/log-view/log-view.tsx | 721 +++++++ .../log-view/utils/log-presentation.ts | 93 + .../resources/log-view/utils/trace-utils.ts} | 3 +- apps/sim/lib/workflows/subblocks/display.ts | 2 +- apps/sim/resources/kinds.ts | 3 +- apps/sim/resources/source.test.ts | 13 +- apps/sim/resources/source.ts | 3 - scripts/check-resource-views.ts | 61 +- 76 files changed, 3499 insertions(+), 2978 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-knowledge-list-state.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/knowledge-base.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config.ts create mode 100644 apps/sim/blocks/subflow-tools.ts rename apps/sim/{app/workspace/[workspaceId] => }/components/inline-rename-input/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/inline-rename-input/inline-rename-input.tsx (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/floating-overflow-text.tsx (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/owner-cell/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/owner-cell/owner-cell.tsx (95%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-chrome-fallback/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx (93%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-header/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-header/resource-header.tsx (99%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-options/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-options/resource-options.test.tsx (93%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/resource-options/resource-options.tsx (98%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/components/time-cell.ts (95%) create mode 100644 apps/sim/components/resource/index.ts rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/resource.tsx (98%) rename apps/sim/{app/workspace/[workspaceId] => }/components/resource/use-background-context-menu.ts (100%) create mode 100644 apps/sim/components/resources/knowledge-view/components/document-tags-cell/document-tags-cell.tsx create mode 100644 apps/sim/components/resources/knowledge-view/components/document-tags-cell/index.ts rename apps/sim/{app/workspace/[workspaceId]/knowledge/[id] => components/resources/knowledge-view}/components/search-highlight/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId]/knowledge/[id] => components/resources/knowledge-view}/components/search-highlight/search-highlight.tsx (100%) create mode 100644 apps/sim/components/resources/knowledge-view/components/tag-filter-panel/index.ts create mode 100644 apps/sim/components/resources/knowledge-view/components/tag-filter-panel/tag-filter-panel.tsx create mode 100644 apps/sim/components/resources/knowledge-view/index.ts create mode 100644 apps/sim/components/resources/knowledge-view/knowledge-view.tsx create mode 100644 apps/sim/components/resources/knowledge-view/utils/document-rows.tsx rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details => components/resources/log-view}/components/execution-snapshot/execution-snapshot.tsx (100%) rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details => components/resources/log-view}/components/execution-snapshot/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details => components/resources/log-view}/components/file-download/file-download.tsx (87%) rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details => components/resources/log-view}/components/file-download/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details => components/resources/log-view}/components/trace-view/index.ts (100%) rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details => components/resources/log-view}/components/trace-view/trace-view.tsx (99%) create mode 100644 apps/sim/components/resources/log-view/index.ts create mode 100644 apps/sim/components/resources/log-view/log-view.tsx create mode 100644 apps/sim/components/resources/log-view/utils/log-presentation.ts rename apps/sim/{app/workspace/[workspaceId]/logs/components/log-details/utils.ts => components/resources/log-view/utils/trace-utils.ts} (96%) diff --git a/.claude/rules/sim-resource-views.md b/.claude/rules/sim-resource-views.md index 1eceb7c08b4..c5e5abb8a7d 100644 --- a/.claude/rules/sim-resource-views.md +++ b/.claude/rules/sim-resource-views.md @@ -12,7 +12,7 @@ paths: # Resource Views -A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace route page, the mothership panel, an interface module, and the public share page. +A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace route page, the mothership panel, an interface module, and the public share page. **One view per resource. Consumers construct the axes and mount it. They never wrap it.** @@ -30,14 +30,14 @@ Enforced by `bun run check:resources` (strict CI gate: `bun run check:resources: There is no fourth axis. Agent streaming is **one optional prop on `FileView`** (`streaming?: FileViewStreaming`), because only files stream. -`ShareSource` declares `workspaceId?: never` and `resourceId?: never`, and `WorkspaceSource` declares `token?: never` and `seed?: never`. A share source **cannot** carry a workspace id — that is a compile error, not a convention. A kind whose seed is typed `never` (`knowledge`, `log`, `schedule`) structurally cannot construct a share source at all: "no public surface" is a compile-time fact. +`ShareSource` declares `workspaceId?: never` and `resourceId?: never`, and `WorkspaceSource` declares `token?: never` and `seed?: never`. A share source **cannot** carry a workspace id — that is a compile error, not a convention. A kind whose seed is typed `never` (`table`, `knowledge`, `log`) structurally cannot construct a share source at all: "no public surface" is a compile-time fact. ``` apps/sim/resources/ # kinds.ts · source.ts · grants.ts · host.ts — pure TS apps/sim/components/resources// # 'use client' — THE view, one per resource ``` -A resource kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply **absent** from `CANONICAL_UNITS` in the check. That is the correct state for an unmigrated kind. Do not add a flag, a shim, or a placeholder entry for it. +A resource kind with no canonical view yet (`table`, `knowledge`, `log`) is simply **absent** from `CANONICAL_UNITS` in the check. That is the correct state for an unmigrated kind. Do not add a flag, a shim, or a placeholder entry for it. ## Consume: construct the axes, then mount diff --git a/CLAUDE.md b/CLAUDE.md index 4aea4c8f460..75153d7ccf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -416,7 +416,7 @@ Principles when building or migrating shared UI: ## Resource Views -A **resource** is a thing a workspace holds that can also be shared — a file, a table, a knowledge base, a log, a scheduled task. A resource with a canonical view has exactly ONE, in `apps/sim/components/resources//`, mounted by every consumer: the workspace route page, the mothership panel, the public share page. +A **resource** is a thing a workspace holds that can also be shared — a file, a table, a knowledge base, a log. A resource with a canonical view has exactly ONE, in `apps/sim/components/resources//`, mounted by every consumer: the workspace route page, the mothership panel, the public share page. Views are mounted against exactly **three axes**, defined in `apps/sim/resources/**` (pure TypeScript — no React, no `'use client'`, because a Server Component builds a share source during SSR): @@ -424,7 +424,7 @@ Views are mounted against exactly **three axes**, defined in `apps/sim/resources - `grants` — what this viewer may do: `{ write, run }`. Replaces `canEdit`, `canRun`, `canAdmin`, `disableEdit/Insert/Delete`. - `host` — who owns the URL, the router, the document frame: `'page' | 'panel' | 'public'`. Replaces `embedded`. `hostOwnsUrl(host)` is the one place the "embedded views do not write nuqs keys" rule lives. -There is no fourth axis; agent streaming is one optional prop on `FileView`. Consumers CONSTRUCT the axes and MOUNT the view — never wrap it in a passthrough, never reach past its barrel, never reimplement its UI because it lacks a seam (add the seam), never import `@/app/workspace/[workspaceId]/**` from an anonymous surface (`app/f/**`, `app/(interfaces)/**`), and never read `useRouter`/`useParams`/`useQueryState`/`useUserPermissionsContext` inside a unit. A kind with no canonical view yet (`knowledge`, `log`, `schedule`) is simply absent from the check's `CANONICAL_UNITS` — no flag, shim, or placeholder. +There is no fourth axis; agent streaming is one optional prop on `FileView`. Consumers CONSTRUCT the axes and MOUNT the view — never wrap it in a passthrough, never reach past its barrel, never reimplement its UI because it lacks a seam (add the seam), never import `@/app/workspace/[workspaceId]/**` from an anonymous surface (`app/f/**`, `app/(interfaces)/**`), and never read `useRouter`/`useParams`/`useQueryState`/`useUserPermissionsContext` inside a unit. A kind with no canonical view yet (`knowledge`, `log`) is simply absent from the check's `CANONICAL_UNITS` — no flag, shim, or placeholder. Enforced by `bun run check:resources` (strict: `check:resources:strict`), which ratchets counters for wrappers, imports past a barrel, cross-tree imports, unsanctioned props, token-as-`workspaceId`, and context leaks. Escape hatches — reason mandatory, on the line directly above: `// boundary-resource-wrapper:`, `// boundary-resource-internal:`, `// boundary-resource-tree:`, `// boundary-resource-prop:`. Full rules in `.claude/rules/sim-resource-views.md`. diff --git a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts index 53b0f298bfb..13931d7e19c 100644 --- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts +++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts @@ -40,7 +40,7 @@ const logoShell = read('../(landing)/components/logo-shell/logo-shell.tsx') const pageHeaderBar = read('../../components/page-header-bar.ts') const settingsHeader = read('../../components/settings/settings-header.tsx') const resourceHeader = read( - '../workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx' + '../../components/resource/components/resource-header/resource-header.tsx' ) const mothershipView = read( '../workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 67e333a5c9b..883ae5119ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -3,7 +3,7 @@ import type { BreadcrumbEditing, BreadcrumbItem, DropdownOption, -} from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' +} from '@/components/resource/components/resource-header' import type { WorkflowFolder } from '@/stores/folders/types' export interface FolderBreadcrumbItemsOptions { diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx index 34ab496419e..f6a5504e400 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx @@ -1,9 +1,6 @@ import { Folder } from '@sim/emcn/icons' +import type { ResourceCell, ResourceRow } from '@/components/resource/resource' import { folderRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' -import type { - ResourceCell, - ResourceRow, -} from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkflowFolder } from '@/stores/folders/types' const FOLDER_ICON = diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index b6a746b2606..8531c1d0df9 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -1,8 +1,8 @@ 'use client' import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { RowDragDropConfig } from '@/components/resource/resource' import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' -import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' /** * Private drag payload, namespaced so a drag started on another Sim surface (or an external diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 55864e67797..944dd6b4b6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,47 +1,47 @@ -export { ConversationListItem } from './conversation-list-item' -export type { ErrorBoundaryProps, ErrorStateProps } from './error' -export { ErrorShell, ErrorState } from './error' -export { InlineRenameInput } from './inline-rename-input' -export { IntegrationTabsHeader } from './integration-tabs-header' -export { MessageActions } from './message-actions' -export { FloatingOverflowText } from './resource/components/floating-overflow-text' +/** + * `Resource` and `InlineRenameInput` are re-exported from `@/components/**`: + * they moved out of this tree because the canonical resource views under + * `components/resources/**` mount them too, and a shared unit may not import + * `@/app/workspace/[workspaceId]/**`. The workspace tree keeps reaching them + * through this barrel, which stays its single aggregated entry point. + */ +export { InlineRenameInput } from '@/components/inline-rename-input' export { + type BreadcrumbEditing, + type BreadcrumbItem, + type ChromeActionSpec, + type ColumnOption, + type DropdownOption, + EMPTY_CELL_PLACEHOLDER, + type FilterConfig, + type FilterTag, + FloatingOverflowText, type MemberFilterOption, memberFilterOptions, ownerCell, -} from './resource/components/owner-cell' -export { - type ChromeActionSpec, + type PaginationConfig, + Resource, + type ResourceAction, + type ResourceCell, + type ResourceCellEditing, ResourceChromeFallback, -} from './resource/components/resource-chrome-fallback' -export type { - BreadcrumbEditing, - BreadcrumbItem, - DropdownOption, - ResourceAction, -} from './resource/components/resource-header' -export type { - ColumnOption, - FilterConfig, - FilterTag, - SearchConfig, - SearchTag, - SortConfig, -} from './resource/components/resource-options' -export { SortDropdown } from './resource/components/resource-options' -export { timeCell } from './resource/components/time-cell' -export type { - PaginationConfig, - ResourceCell, - ResourceCellEditing, - ResourceColumn, - ResourceRow, - ResourceTableHandle, - RowDragDropConfig, - SelectableConfig, -} from './resource/resource' -export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' -export { useBackgroundContextMenu } from './resource/use-background-context-menu' + type ResourceColumn, + type ResourceRow, + type ResourceTableHandle, + type RowDragDropConfig, + type SearchConfig, + type SearchTag, + type SelectableConfig, + type SortConfig, + SortDropdown, + timeCell, + useBackgroundContextMenu, +} from '@/components/resource' +export { ConversationListItem } from './conversation-list-item' +export type { ErrorBoundaryProps, ErrorStateProps } from './error' +export { ErrorShell, ErrorState } from './error' +export { IntegrationTabsHeader } from './integration-tabs-header' +export { MessageActions } from './message-actions' export { ResourceTile } from './resource-tile' export { ShareModal, type ShareModalProps } from './share-modal' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 6fe54a3678e..e03b5b753cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -16,6 +16,7 @@ import { Tooltip, } from '@sim/emcn' import { Folder, Plus } from '@sim/emcn/icons' +import { formatDate } from '@/components/resources/log-view' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { BROWSER_SESSION_RESOURCE_ID, @@ -36,7 +37,6 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' import { listIntegrations } from '@/blocks/integration-matcher' import { useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index 47d3ad87474..9f008482ad9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts @@ -1,5 +1,5 @@ export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown' -export { ResourceActions, ResourceContent } from './resource-content' +export { ResourceContent, ResourceTabActions } from './resource-content' export type { ResourceTypeConfig } from './resource-registry' export { getResourceConfig, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts index 9dd2b9e9da3..01d7d62c62e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts @@ -1 +1 @@ -export { ResourceActions, ResourceContent } from './resource-content' +export { ResourceContent, ResourceTabActions } from './resource-content' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index f3717bef026..eefcabbb3a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,16 @@ 'use client' -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type ElementType, + lazy, + memo, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { Download, @@ -15,6 +25,7 @@ import { import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { FileView, type PreviewMode, resolveFileCategory } from '@/components/resources/file-view' +import { LogView } from '@/components/resources/log-view' import { ResourceEmptyState } from '@/components/resources/resource-empty-state' import { isApiClientError } from '@/lib/api/client/errors' import { useSession } from '@/lib/auth/auth-client' @@ -40,9 +51,9 @@ import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/h import type { GenericResourceData, MothershipResource, + MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' -import { LogDetailsContent } from '@/app/workspace/[workspaceId]/logs/components' +import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/knowledge-base' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext, @@ -56,8 +67,14 @@ import { useLogDetail } from '@/hooks/queries/logs' import { downloadTableExport } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { grantsFromPermissions, type ResourceGrants, workspaceSource } from '@/resources' +import { + grantsFromPermissions, + type ResourceGrants, + type ResourceKind, + workspaceSource, +} from '@/resources' import { useExecutionStore } from '@/stores/execution/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -238,6 +255,29 @@ export const ResourceContent = memo(function ResourceContent({ [file, workspaceId] ) + const isLogResource = resource.type === 'log' + const { + data: log, + isLoading: isLogLoading, + error: logError, + } = useLogDetail(resource.id, workspaceId, { enabled: isLogResource }) + + const logSource = useMemo( + () => workspaceSource({ kind: 'log' as const, workspaceId, resourceId: resource.id }), + [workspaceId, resource.id] + ) + const { config: permissionConfig } = usePermissionConfig() + + const onNotFoundRef = useRef(onNotFound) + onNotFoundRef.current = onNotFound + + useEffect(() => { + if (!isLogResource) return + if (isApiClientError(logError) && logError.status === 404) { + onNotFoundRef.current?.(resource.id) + } + }, [isLogResource, logError, resource.id]) + if (isStreamingFile) { return (
@@ -320,21 +360,40 @@ export const ResourceContent = memo(function ResourceContent({ id={resource.id} knowledgeBaseName={resource.title} workspaceId={workspaceId} + host='panel' /> ) case 'folder': return - case 'log': + case 'log': { + if (isLogLoading) return LOADING_SKELETON + + if (!log) { + return ( + + ) + } + return ( - onNotFound(resource.id) : undefined} - /> +
+ +
) + } case 'generic': return ( @@ -359,53 +418,227 @@ export const ResourceContent = memo(function ResourceContent({ } }) -interface ResourceActionsProps { +const actionsLogger = createLogger('ResourceTabActions') + +/** One tab-header affordance: a tooltip-wrapped icon button. */ +interface ResourceTabAction { + key: string + icon: ElementType + /** The button's `aria-label`. */ + label: string + /** Tooltip copy. Shorter than {@link label} where the label spells out the object. */ + tooltip: string + onClick: () => void + disabled?: boolean +} + +/** + * Everything a per-kind builder may need beyond the resource itself. Resolved + * once by {@link ResourceTabActions} so the builders stay plain functions with + * no hooks of their own. + */ +interface ResourceTabActionContext { workspaceId: string resource: MothershipResource + /** Navigates the current tab. The host owns the router; builders only ask. */ + navigate: (href: string) => void + /** The resolved workspace file record, when the tab shows a file. */ + file: WorkspaceFileRecord | undefined + /** The log's execution id, when the tab shows a log and its detail has loaded. */ + logExecutionId: string | undefined } -export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) { - switch (resource.type) { - case 'workflow': - return - case 'file': - return ( - - ) - case 'knowledgebase': - return ( - - ) - case 'table': - return ( - +type ResourceTabActionBuilder = (context: ResourceTabActionContext) => ResourceTabAction[] + +/** The "open this where it lives" button every actionable kind carries. */ +function openAction(label: string, onClick: () => void): ResourceTabAction { + return { key: 'open', icon: SquareArrowUpRight, label, tooltip: label, onClick } +} + +/** + * The same button, addressed through the resource axis rather than a hand-built + * template string, so the route is declared exactly once in + * `resourceHref`. `hrefFor` is typed `string | null` because a share source has + * no in-app route; a workspace source always resolves one. + */ +function openResourceAction( + label: string, + kind: ResourceKind, + { workspaceId, resource, navigate }: ResourceTabActionContext +): ResourceTabAction { + const source = workspaceSource({ kind, workspaceId, resourceId: resource.id }) + return openAction(label, () => { + const href = source.hrefFor({ to: 'self' }) + if (href) navigate(href) + }) +} + +/** + * The tab-header actions for each resource kind. A kind absent from this map + * (folder, generic, browser, terminal, …) has no header actions. + * + * Two destinations deliberately do NOT come from the resource axis, because + * routing them through it would change where the button goes: + * + * - `file` opens `/files/`, the browser with the file selected. The axis + * spells `/files//view`, the separate fullscreen route. Both exist. + * - `log` opens the logs page keyed on the *execution* id read off the fetched + * detail. The axis builds `?executionId=`, but a log resource is + * addressed by its log-row id, which is a different identifier. + */ +const RESOURCE_TAB_ACTIONS: Partial> = { + /** + * Not a resource kind — a workflow is a live collaborative session, not a + * document with an address — so its route is spelled here. It is also the one + * kind that opens a new browser tab instead of navigating in place. + */ + workflow: ({ workspaceId, resource }) => [ + openAction('Open workflow', () => + window.open(`/workspace/${workspaceId}/w/${resource.id}`, '_blank') + ), + ], + + file: (context) => { + const { workspaceId, resource, navigate, file } = context + const download = async () => { + if (!file) return + try { + await triggerFileDownload(file) + } catch (err) { + actionsLogger.error('Failed to download file:', err) + } + } + return [ + openAction('Open in files', () => + navigate(`/workspace/${workspaceId}/files/${encodeURIComponent(file?.id ?? resource.id)}`) + ), + { + key: 'download', + icon: Download, + label: 'Download file', + tooltip: 'Download', + disabled: !file, + onClick: () => void download(), + }, + ] + }, + + knowledgebase: (context) => [openResourceAction('Open knowledge base', 'knowledge', context)], + + table: (context) => { + const { resource } = context + const exportCsv = async () => { + try { + await downloadTableExport(resource.id, resource.title) + } catch (err) { + actionsLogger.error('Failed to export table:', err) + } + } + return [ + openResourceAction('Open table', 'table', context), + { + key: 'export', + icon: Download, + label: 'Export table as CSV', + tooltip: 'Export CSV', + onClick: () => void exportCsv(), + }, + ] + }, + + log: ({ workspaceId, navigate, logExecutionId }) => [ + openAction('Open in logs', () => + navigate( + `/workspace/${workspaceId}/logs${logExecutionId ? `?executionId=${logExecutionId}` : ''}` ) - case 'log': - return - case 'folder': - case 'generic': - case 'browser': - case 'terminal': - return null - default: - return null - } + ), + ], } -interface EmbeddedWorkflowActionsProps { +function ResourceTabActionButton({ + icon: Icon, + label, + tooltip, + onClick, + disabled, +}: Omit) { + return ( + + + + + +

{tooltip}

+
+
+ ) +} + +interface ResourceTabActionsProps { + workspaceId: string + resource: MothershipResource +} + +/** + * The action buttons in the active tab's header, keyed by resource type through + * {@link RESOURCE_TAB_ACTIONS}. + * + * The two lookups a builder cannot do for itself are resolved here and gated on + * the kind that needs them, so a table tab mounts neither the file list nor a + * log detail. Workflow's run control stays a component of its own: it carries + * the execution machinery, the usage gate, and a `setActiveWorkflow` effect that + * must not run for any other kind. + */ +export function ResourceTabActions({ workspaceId, resource }: ResourceTabActionsProps) { + const router = useRouter() + const navigate = useCallback((href: string) => router.push(href), [router]) + + const isFile = resource.type === 'file' + const isLog = resource.type === 'log' + + const { data: files = [] } = useWorkspaceFiles(workspaceId, 'active', { enabled: isFile }) + const file = useMemo( + () => (isFile ? findWorkspaceFile(files, resource.id, resource.path) : undefined), + [isFile, files, resource.id, resource.path] + ) + const { data: log } = useLogDetail(isLog ? resource.id : undefined, workspaceId) + + const actions = + RESOURCE_TAB_ACTIONS[resource.type]?.({ + workspaceId, + resource, + navigate, + file, + logExecutionId: log?.executionId ?? undefined, + }) ?? [] + + return ( + <> + {actions.map(({ key, ...action }) => ( + + ))} + {resource.type === 'workflow' && ( + + )} + + ) +} + +interface WorkflowRunControlProps { workspaceId: string workflowId: string } -function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { +/** Runs or stops the workflow shown in the active tab. */ +function WorkflowRunControl({ workspaceId, workflowId }: WorkflowRunControlProps) { const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -459,204 +692,14 @@ function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowAc await handleRunWorkflow() } - const handleOpenWorkflow = () => { - window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') - } - return ( - <> - - - - - -

Open workflow

-
-
- - - - - -

{isExecuting ? 'Stop' : 'Run workflow'}

-
-
- - ) -} - -interface EmbeddedKnowledgeBaseActionsProps { - workspaceId: string - knowledgeBaseId: string -} - -function EmbeddedKnowledgeBaseActions({ - workspaceId, - knowledgeBaseId, -}: EmbeddedKnowledgeBaseActionsProps) { - const router = useRouter() - - const handleOpenKnowledgeBase = () => { - router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`) - } - - return ( - - - - - -

Open knowledge base

-
-
- ) -} - -const tableLogger = createLogger('EmbeddedTableActions') - -interface EmbeddedTableActionsProps { - workspaceId: string - tableId: string - tableName: string -} - -function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTableActionsProps) { - const router = useRouter() - - const handleOpenTable = () => { - router.push(`/workspace/${workspaceId}/tables/${tableId}`) - } - - const handleExport = async () => { - try { - await downloadTableExport(tableId, tableName) - } catch (err) { - tableLogger.error('Failed to export table:', err) - } - } - - return ( - <> - - - - - -

Open table

-
-
- - - - - -

Export CSV

-
-
- - ) -} - -const fileLogger = createLogger('EmbeddedFileActions') - -interface EmbeddedFileActionsProps { - workspaceId: string - fileId: string - filePath?: string -} - -function EmbeddedFileActions({ workspaceId, fileId, filePath }: EmbeddedFileActionsProps) { - const router = useRouter() - const { data: files = [] } = useWorkspaceFiles(workspaceId) - const file = useMemo(() => findWorkspaceFile(files, fileId, filePath), [files, fileId, filePath]) - - const handleDownload = async () => { - if (!file) return - try { - await triggerFileDownload(file) - } catch (err) { - fileLogger.error('Failed to download file:', err) - } - } - - const handleOpenInFiles = () => { - router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(file?.id ?? fileId)}`) - } - - return ( - <> - - - - - -

Open in files

-
-
- - - - - -

Download

-
-
- + void handleRun()} + /> ) } @@ -738,73 +781,3 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
) } - -interface EmbeddedLogProps { - workspaceId: string - logId: string - onNotFound?: () => void -} - -function EmbeddedLog({ workspaceId, logId, onNotFound }: EmbeddedLogProps) { - const { data: log, isLoading, error } = useLogDetail(logId, workspaceId) - - const onNotFoundRef = useRef(onNotFound) - onNotFoundRef.current = onNotFound - - useEffect(() => { - if (isApiClientError(error) && error.status === 404) { - onNotFoundRef.current?.() - } - }, [error]) - - if (isLoading) return LOADING_SKELETON - - if (!log) { - return ( - - ) - } - - return ( -
- -
- ) -} - -interface EmbeddedLogActionsProps { - workspaceId: string - logId: string -} - -function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsProps) { - const router = useRouter() - const { data: log } = useLogDetail(logId, workspaceId) - - const handleOpenInLogs = () => { - const param = log?.executionId ? `?executionId=${log.executionId}` : '' - router.push(`/workspace/${workspaceId}/logs${param}`) - } - - return ( - - - - - -

Open in logs

-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index 4a5fdfb59ad..5348ef922a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -19,7 +19,7 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' -import { ResourceActions, ResourceContent, ResourceTabs } from './components' +import { ResourceContent, ResourceTabActions, ResourceTabs } from './components' /** * Panels that are kept mounted across resource switches rather than rebuilt. @@ -188,7 +188,7 @@ export const MothershipView = memo( activeId={active?.id ?? null} useFixedResourceToggle={useFixedResourceToggle} actions={ - active ? : null + active ? : null } previewMode={isActivePreviewable ? previewMode : undefined} onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 5966e6049da..29c4279ae4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { SearchHighlight } from '@/components/resources/knowledge-view' import type { ChunkData } from '@/lib/knowledge/types' import { formatTokenCount } from '@/lib/tokenization' import type { @@ -32,7 +33,7 @@ import { documentParsers, documentUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' -import { ActionBar, SearchHighlight } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx deleted file mode 100644 index f4d33dad29f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ /dev/null @@ -1,1733 +0,0 @@ -'use client' - -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - Badge, - Button, - ChipConfirmModal, - type ChipConfirmTextSegment, - ChipDatePicker, - ChipDropdown, - type ChipDropdownOption, - ChipInput, - ChipModal, - ChipModalBody, - ChipModalHeader, - cellIconNodeClass, - chipContentGap, - chipContentLabelClass, - chipVariants, - cn, - FloatingTooltip, - isTextClipped, - Loader, - Tooltip, - Trash, - useFloatingTooltip, -} from '@sim/emcn' -import { CircleAlert, Database, DatabaseX, Pencil, Plus, TagIcon, X } from '@sim/emcn/icons' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { format } from 'date-fns' -import { useParams, useRouter } from 'next/navigation' -import { useQueryState, useQueryStates } from 'nuqs' -import { usePostHog } from 'posthog-js/react' -import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' -import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' -import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' -import type { DocumentData } from '@/lib/knowledge/types' -import { captureEvent } from '@/lib/posthog/client' -import { formatFileSize } from '@/lib/uploads/utils/file-utils' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import type { - BreadcrumbItem, - FilterTag, - ResourceAction, - ResourceCell, - ResourceColumn, - ResourceRow, - SelectableConfig, - SortConfig, -} from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' -import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' -import { - ActionBar, - AddConnectorModal, - AddDocumentsModal, - BaseTagsModal, - ConnectorsSection, - DocumentContextMenu, - RenameDocumentModal, - SearchHighlight, -} from '@/app/workspace/[workspaceId]/knowledge/[id]/components' -import { - addConnectorParam, - documentFiltersParsers, - documentFiltersUrlKeys, - kbDocumentSortParams, - pageParam, - pageUrlKeys, -} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' -import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { - useKnowledgeBase, - useKnowledgeBaseDocuments, - useKnowledgeBasesList, -} from '@/hooks/kb/use-knowledge' -import { - type TagDefinition, - useKnowledgeBaseTagDefinitions, -} from '@/hooks/kb/use-knowledge-base-tag-definitions' -import { isConnectorSyncingOrPending, useConnectorList } from '@/hooks/queries/kb/connectors' -import type { DocumentTagFilter } from '@/hooks/queries/kb/knowledge' -import { - useBulkDocumentOperation, - useDeleteDocument, - useDeleteKnowledgeBase, - useUpdateDocument, - useUpdateKnowledgeBase, -} from '@/hooks/queries/kb/knowledge' -import { useDebounce } from '@/hooks/use-debounce' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' -import { useInlineRename } from '@/hooks/use-inline-rename' -import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return' -import { useUrlSort } from '@/hooks/use-url-sort' - -const logger = createLogger('KnowledgeBase') - -const DOCUMENTS_PER_PAGE = 50 - -const DOCUMENT_COLUMNS: ResourceColumn[] = [ - { id: 'name', header: 'Name', widthMultiplier: 0.8 }, - { id: 'size', header: 'Size', widthMultiplier: 0.75 }, - { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 }, - { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 }, - { id: 'uploaded', header: 'Uploaded' }, - { id: 'status', header: 'Status', widthMultiplier: 0.75 }, - { id: 'tags', header: 'Tags' }, -] - -const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ - { value: 'all', label: 'All' }, - { value: 'enabled', label: 'Enabled' }, - { value: 'disabled', label: 'Disabled' }, -] - -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - -interface KnowledgeBaseProps { - id: string - knowledgeBaseName?: string - workspaceId?: string -} - -const AnimatedLoader = ({ className }: { className?: string }) => ( - -) - -const getStatusBadge = (doc: DocumentData) => { - switch (doc.processingStatus) { - case 'pending': - return ( - - Pending - - ) - case 'processing': - return ( - - Processing - - ) - case 'failed': - return doc.processingError ? ( - - Failed - - ) : ( - - Failed - - ) - case 'completed': - return doc.enabled ? ( - - Enabled - - ) : ( - - Disabled - - ) - default: - return ( - - Unknown - - ) - } -} - -interface TagValue { - slot: AllTagSlot - displayName: string - value: string -} - -/** - * Tags cell for the documents table. Shows the joined tag values inline and - * reveals the full `name: value` breakdown only when the inline text is - * actually clipped — an un-truncated cell already says everything the tooltip - * would. - */ -function DocumentTagsCell({ tags }: { tags: TagValue[] }) { - const { state, handlers } = useFloatingTooltip(isTextClipped) - - return ( - <> - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - {...handlers} - > - {tags.map((tag) => tag.value).join(', ')} - - -
- {tags.map((tag) => ( -
- {tag.displayName}: {tag.value} -
- ))} -
-
- - ) -} - -/** - * Computes tag values for a document - */ -function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] { - const result: TagValue[] = [] - const defsBySlot = new Map(definitions.map((d) => [d.tagSlot, d])) - - for (const slot of ALL_TAG_SLOTS) { - const raw = doc[slot] - if (raw == null) continue - - const def = defsBySlot.get(slot) - const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text' - - let value: string - if (fieldType === 'date') { - try { - value = format(new Date(raw as string), 'MMM d, yyyy') - } catch { - value = String(raw) - } - } else if (fieldType === 'boolean') { - value = raw ? 'Yes' : 'No' - } else if (fieldType === 'number' && typeof raw === 'number') { - value = raw.toLocaleString() - } else { - value = String(raw) - } - - if (value) { - result.push({ slot, displayName: def?.displayName || slot, value }) - } - } - - return result -} - -export function KnowledgeBase({ - id, - knowledgeBaseName: passedKnowledgeBaseName, - workspaceId: propWorkspaceId, -}: KnowledgeBaseProps) { - const params = useParams() - const workspaceId = propWorkspaceId || (params.workspaceId as string) - const router = useRouter() - const [addConnectorType, setAddConnectorType] = useQueryState( - addConnectorParam.key, - addConnectorParam.parser - ) - const posthog = usePostHog() - - useEffect(() => { - captureEvent(posthog, 'knowledge_base_opened', { - knowledge_base_id: id, - knowledge_base_name: passedKnowledgeBaseName ?? 'Unknown', - }) - }, [id, passedKnowledgeBaseName, posthog]) - - useOAuthReturnForKBConnectors(id) - const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false }) - const userPermissions = useUserPermissionsContext() - - const { mutate: updateDocumentMutation, mutateAsync: updateDocumentAsync } = useUpdateDocument() - const { mutate: deleteDocumentMutation } = useDeleteDocument() - const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } = - useDeleteKnowledgeBase(workspaceId) - const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) - - const kbRename = useInlineRename({ - onSave: (kbId, name) => - updateKnowledgeBaseMutation({ knowledgeBaseId: kbId, updates: { name } }), - }) - const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation() - - const [showTagsModal, setShowTagsModal] = useState(false) - const [tagFilterEntries, setTagFilterEntries] = useState< - { - id: string - tagName: string - tagSlot: string - fieldType: FilterFieldType - operator: string - value: string - valueTo: string - }[] - >([]) - - const activeTagFilters: DocumentTagFilter[] = useMemo( - () => - tagFilterEntries.reduce((acc, f) => { - if (!f.tagSlot || !f.value.trim()) return acc - // A `between` filter only applies once both bounds are set. Sending it - // with just the lower bound would be rejected at the API boundary and - // break the whole list while the user is still entering the range. - if (f.operator === 'between' && !f.valueTo.trim()) return acc - acc.push({ - tagSlot: f.tagSlot, - fieldType: f.fieldType, - operator: f.operator, - value: f.value, - ...(f.operator === 'between' ? { valueTo: f.valueTo } : {}), - }) - return acc - }, []), - [tagFilterEntries] - ) - - const [selectedDocuments, setSelectedDocuments] = useState>(() => new Set()) - const [isSelectAllMode, setIsSelectAllMode] = useState(false) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false) - const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false) - const [documentToDelete, setDocumentToDelete] = useState(null) - const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) - const [showConnectorsModal, setShowConnectorsModal] = useState(false) - const [currentPage, setCurrentPage] = useQueryState(pageParam.key, { - ...pageParam.parser, - ...pageUrlKeys, - }) - - const [{ q: searchQuery, enabled: enabledFilter }, setDocumentFilters] = useQueryStates( - documentFiltersParsers, - documentFiltersUrlKeys - ) - - /** - * The input is controlled directly by the instant nuqs value; only the URL - * write is debounced. The document query below reads a debounced value so it - * doesn't refetch on every keystroke. Changing the search resets pagination. - */ - const handleSearchChange = useDebouncedSearchSetter((value, options) => { - setDocumentFilters({ q: value }, options) - setCurrentPage(1) - }) - const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) - /** Raw URL value drives the input; matching/highlighting always sees it trimmed. */ - const highlightQuery = searchQuery.trim() - - const { - sort: sortColumn, - dir: sortDirection, - activeSort, - onSort: onSortColumn, - onClear: onClearSort, - } = useUrlSort(kbDocumentSortParams, documentFiltersUrlKeys) - - const setEnabledFilter = useCallback( - (value: 'all' | 'enabled' | 'disabled') => { - setDocumentFilters({ enabled: value }) - setCurrentPage(1) - }, - [setDocumentFilters, setCurrentPage] - ) - - const [contextMenuDocument, setContextMenuDocument] = useState(null) - const [showRenameModal, setShowRenameModal] = useState(false) - const [documentToRename, setDocumentToRename] = useState(null) - const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) - const [documentForTagsId, setDocumentForTagsId] = useState(null) - const showAddConnectorModal = addConnectorType != null - const updateAddConnectorParam = useCallback( - (value: string | null) => { - void setAddConnectorType(value, { history: 'replace', scroll: false }) - }, - [setAddConnectorType] - ) - const setShowAddConnectorModal = useCallback( - (open: boolean) => updateAddConnectorParam(open ? '' : null), - [updateAddConnectorParam] - ) - - const { - isOpen: isContextMenuOpen, - position: contextMenuPosition, - menuRef, - handleContextMenu: baseHandleContextMenu, - closeMenu: closeContextMenu, - } = useContextMenu() - - const { - knowledgeBase, - error: knowledgeBaseError, - refresh: refreshKnowledgeBase, - } = useKnowledgeBase(id) - - const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id) - const hasSyncingConnectors = connectors.some(isConnectorSyncingOrPending) - const hasSyncingConnectorsRef = useRef(hasSyncingConnectors) - hasSyncingConnectorsRef.current = hasSyncingConnectors - - const { - documents, - pagination, - isPlaceholderData: isPlaceholderDocuments, - error: documentsError, - hasProcessingDocuments, - updateDocument, - refreshDocuments, - } = useKnowledgeBaseDocuments(id, { - search: debouncedSearchQuery.trim() || undefined, - limit: DOCUMENTS_PER_PAGE, - offset: (currentPage - 1) * DOCUMENTS_PER_PAGE, - sortBy: sortColumn as DocumentSortField, - sortOrder: sortDirection as SortOrder, - refetchInterval: (data) => { - if (isDeleting) return false - const hasPending = data?.documents?.some( - (doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing' - ) - if (hasPending) return 3000 - if (hasSyncingConnectorsRef.current) return 5000 - return false - }, - enabledFilter: enabledFilter, - tagFilters: activeTagFilters.length > 0 ? activeTagFilters : undefined, - }) - - const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id) - - const prevHadSyncingRef = useRef(false) - useEffect(() => { - if (prevHadSyncingRef.current && !hasSyncingConnectors) { - refreshKnowledgeBase() - refreshDocuments() - } - prevHadSyncingRef.current = hasSyncingConnectors - }, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments]) - - const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base' - /** - * Breadcrumb leaf label. Falls back to the canonical '…' placeholder while - * the name loads (mirroring loading.tsx) instead of duplicating the root - * "Knowledge Base" crumb. - */ - const knowledgeBaseCrumbLabel = knowledgeBase?.name || passedKnowledgeBaseName || '…' - const error = knowledgeBaseError || documentsError - - const totalPages = Math.ceil(pagination.total / pagination.limit) - - /** - * Checks for documents with stale processing states and marks them as failed - */ - const checkForDeadProcesses = useCallback( - (docsToCheck: DocumentData[]) => { - const now = new Date() - const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes - - const staleDocuments = docsToCheck.filter((doc) => { - if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) { - return false - } - - const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime() - return processingDuration > DEAD_PROCESS_THRESHOLD_MS - }) - - if (staleDocuments.length === 0) return - - logger.warn(`Found ${staleDocuments.length} documents with dead processes`) - - staleDocuments.forEach((doc) => { - updateDocumentMutation( - { - knowledgeBaseId: id, - documentId: doc.id, - updates: { markFailedDueToTimeout: true }, - }, - { - onSuccess: () => { - logger.info( - `Successfully marked dead process as failed for document: ${doc.filename}` - ) - }, - } - ) - }) - }, - [id, updateDocumentMutation] - ) - - useEffect(() => { - if (hasProcessingDocuments) { - checkForDeadProcesses(documents) - } - }, [hasProcessingDocuments, documents, checkForDeadProcesses]) - - const handleToggleEnabled = (docId: string) => { - const document = documents.find((doc) => doc.id === docId) - if (!document) return - - const newEnabled = !document.enabled - - updateDocument(docId, { enabled: newEnabled }) - - updateDocumentMutation( - { - knowledgeBaseId: id, - documentId: docId, - updates: { enabled: newEnabled }, - }, - { - onError: () => { - updateDocument(docId, { enabled: !newEnabled }) - }, - } - ) - } - - /** - * Handles retrying a failed document processing - */ - const handleRetryDocument = (docId: string) => { - updateDocument(docId, { - processingStatus: 'pending', - processingError: null, - processingStartedAt: null, - processingCompletedAt: null, - }) - - updateDocumentMutation( - { - knowledgeBaseId: id, - documentId: docId, - updates: { retryProcessing: true }, - }, - { - onSuccess: () => { - logger.info(`Document retry initiated successfully for: ${docId}`) - }, - onError: (err) => { - logger.error('Error retrying document:', err) - updateDocument(docId, { - processingStatus: 'failed', - processingError: getErrorMessage(err, 'Failed to retry document processing'), - }) - }, - } - ) - } - - /** - * Opens the rename document modal - */ - const handleRenameDocument = (doc: DocumentData) => { - setDocumentToRename(doc) - setShowRenameModal(true) - } - - /** - * Opens the document tags modal - */ - const handleViewDocumentTags = (doc: DocumentData) => { - setDocumentForTagsId(doc.id) - setShowDocumentTagsModal(true) - } - - /** - * Saves the renamed document - */ - const handleSaveRename = async (documentId: string, newName: string) => { - const currentDoc = documents.find((doc) => doc.id === documentId) - const previousName = currentDoc?.filename - - updateDocument(documentId, { filename: newName }) - - try { - await updateDocumentAsync({ knowledgeBaseId: id, documentId, updates: { filename: newName } }) - logger.info(`Document renamed: ${documentId}`) - } catch (err) { - if (previousName !== undefined) { - updateDocument(documentId, { filename: previousName }) - } - logger.error('Error renaming document:', err) - throw err - } - } - - /** - * Opens the delete document confirmation modal - */ - const handleDeleteDocument = (docId: string) => { - setDocumentToDelete(docId) - setShowDeleteDocumentModal(true) - } - - /** - * Confirms and executes the deletion of a single document - */ - const confirmDeleteDocument = () => { - if (!documentToDelete) return - - deleteDocumentMutation( - { knowledgeBaseId: id, documentId: documentToDelete }, - { - onSuccess: () => { - setSelectedDocuments((prev) => { - const newSet = new Set(prev) - newSet.delete(documentToDelete) - return newSet - }) - }, - onSettled: () => { - setShowDeleteDocumentModal(false) - setDocumentToDelete(null) - }, - } - ) - } - - /** - * Handles selecting/deselecting a document - */ - const handleSelectDocument = (docId: string, checked: boolean) => { - setSelectedDocuments((prev) => { - const newSet = new Set(prev) - if (checked) { - newSet.add(docId) - } else { - newSet.delete(docId) - } - return newSet - }) - } - - /** - * Handles selecting/deselecting all documents - */ - const handleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedDocuments(new Set(documents.map((doc) => doc.id))) - } else { - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - } - } - - const isAllSelected = documents.length > 0 && selectedDocuments.size === documents.length - - /** - * Handles clicking on a document row to navigate to detail view - */ - const handleDocumentClick = (docId: string) => { - const document = documents.find((doc) => doc.id === docId) - if (document?.processingStatus !== 'completed') return - const urlParams = new URLSearchParams({ - kbName: knowledgeBaseName, - docName: document?.filename || 'Document', - }) - router.push(`/workspace/${workspaceId}/knowledge/${id}/${docId}?${urlParams.toString()}`) - } - - /** - * Handles deleting the entire knowledge base - */ - const handleDeleteKnowledgeBase = () => { - if (!knowledgeBase) return - - deleteKnowledgeBaseMutation( - { knowledgeBaseId: id }, - { - onSuccess: () => { - removeKnowledgeBase(id) - router.push(`/workspace/${workspaceId}/knowledge`) - }, - } - ) - } - - const handleAddDocuments = () => { - setShowAddDocumentsModal(true) - } - - /** - * Handles bulk enabling of selected documents - */ - const handleBulkEnable = () => { - if (isSelectAllMode) { - bulkDocumentMutation( - { - knowledgeBaseId: id, - operation: 'enable', - selectAll: true, - enabledFilter: enabledFilter, - }, - { - onSuccess: (result) => { - logger.info(`Successfully enabled ${result.successCount} documents`) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }, - } - ) - return - } - - const documentsToEnable = documents.filter( - (doc) => selectedDocuments.has(doc.id) && !doc.enabled - ) - - if (documentsToEnable.length === 0) return - - bulkDocumentMutation( - { - knowledgeBaseId: id, - operation: 'enable', - documentIds: documentsToEnable.map((doc) => doc.id), - }, - { - onSuccess: (result) => { - result.updatedDocuments?.forEach((updatedDoc) => { - updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled }) - }) - logger.info(`Successfully enabled ${result.successCount} documents`) - setSelectedDocuments(new Set()) - }, - } - ) - } - - /** - * Handles bulk disabling of selected documents - */ - const handleBulkDisable = () => { - if (isSelectAllMode) { - bulkDocumentMutation( - { - knowledgeBaseId: id, - operation: 'disable', - selectAll: true, - enabledFilter: enabledFilter, - }, - { - onSuccess: (result) => { - logger.info(`Successfully disabled ${result.successCount} documents`) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }, - } - ) - return - } - - const documentsToDisable = documents.filter( - (doc) => selectedDocuments.has(doc.id) && doc.enabled - ) - - if (documentsToDisable.length === 0) return - - bulkDocumentMutation( - { - knowledgeBaseId: id, - operation: 'disable', - documentIds: documentsToDisable.map((doc) => doc.id), - }, - { - onSuccess: (result) => { - result.updatedDocuments?.forEach((updatedDoc) => { - updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled }) - }) - logger.info(`Successfully disabled ${result.successCount} documents`) - setSelectedDocuments(new Set()) - }, - } - ) - } - - const handleBulkDelete = () => { - if (selectedDocuments.size === 0) return - setShowBulkDeleteModal(true) - } - - const confirmBulkDelete = () => { - if (isSelectAllMode) { - bulkDocumentMutation( - { - knowledgeBaseId: id, - operation: 'delete', - selectAll: true, - enabledFilter: enabledFilter, - }, - { - onSuccess: (result) => { - logger.info(`Successfully deleted ${result.successCount} documents`) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }, - onSettled: () => { - setShowBulkDeleteModal(false) - }, - } - ) - return - } - - const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id)) - - if (documentsToDelete.length === 0) return - - bulkDocumentMutation( - { - knowledgeBaseId: id, - operation: 'delete', - documentIds: documentsToDelete.map((doc) => doc.id), - }, - { - onSuccess: (result) => { - logger.info(`Successfully deleted ${result.successCount} documents`) - setSelectedDocuments(new Set()) - }, - onSettled: () => { - setShowBulkDeleteModal(false) - }, - } - ) - } - - const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id)) - const enabledCount = isSelectAllMode - ? enabledFilter === 'disabled' - ? 0 - : pagination.total - : selectedDocumentsList.filter((doc) => doc.enabled).length - const disabledCount = isSelectAllMode - ? enabledFilter === 'enabled' - ? 0 - : pagination.total - : selectedDocumentsList.filter((doc) => !doc.enabled).length - - const handleDocumentContextMenu = useCallback( - (e: React.MouseEvent, docId: string) => { - const doc = documents.find((d) => d.id === docId) - if (!doc) return - - const isCurrentlySelected = selectedDocuments.has(doc.id) - - if (!isCurrentlySelected) { - setSelectedDocuments(new Set([doc.id])) - } - - setContextMenuDocument(doc) - baseHandleContextMenu(e) - }, - [documents, selectedDocuments, baseHandleContextMenu] - ) - - const handleEmptyContextMenu = useCallback( - (e: React.MouseEvent) => { - setContextMenuDocument(null) - baseHandleContextMenu(e) - }, - [baseHandleContextMenu] - ) - - const handleContextMenuClose = useCallback(() => { - closeContextMenu() - setContextMenuDocument(null) - }, [closeContextMenu]) - - const breadcrumbs: BreadcrumbItem[] = [ - { - label: 'Knowledge Base', - icon: Database, - onClick: () => router.push(`/workspace/${workspaceId}/knowledge`), - }, - { - label: knowledgeBaseCrumbLabel, - icon: Database, - editing: kbRename.editingId - ? { - isEditing: true, - value: kbRename.editValue, - onChange: kbRename.setEditValue, - onSubmit: kbRename.submitRename, - onCancel: kbRename.cancelRename, - disabled: kbRename.isSaving, - } - : undefined, - dropdownItems: [ - ...(userPermissions.canEdit || userPermissions.isLoading - ? [ - { - label: 'Rename', - icon: Pencil, - disabled: !userPermissions.canEdit, - onClick: () => kbRename.startRename(id, knowledgeBaseName), - }, - { - label: 'Tags', - icon: TagIcon, - disabled: !userPermissions.canEdit, - onClick: () => setShowTagsModal(true), - }, - { - label: 'Delete', - icon: Trash, - disabled: !userPermissions.canEdit, - onClick: () => setShowDeleteDialog(true), - }, - ] - : []), - ], - }, - ] - - const headerActions: ResourceAction[] = [ - ...(userPermissions.canEdit || userPermissions.isLoading - ? [ - { - text: 'New connector', - icon: Plus, - disabled: !userPermissions.canEdit, - onSelect: () => setShowAddConnectorModal(true), - }, - ] - : []), - ] - - 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, - /** Sorting (or clearing the sort) resets pagination to the first page. */ - onSort: (column, direction) => { - onSortColumn(column, direction) - setCurrentPage(1) - }, - onClear: () => { - onClearSort() - setCurrentPage(1) - }, - }), - [activeSort, onSortColumn, onClearSort, setCurrentPage] - ) - - const filterContent = useMemo( - () => ( - -
-
- Status - {enabledFilter !== 'all' && ( - - )} -
- { - if (value !== 'all' && value !== 'enabled' && value !== 'disabled') return - setEnabledFilter(value) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }} - align='start' - fullWidth - /> -
- { - setTagFilterEntries(entries) - setCurrentPage(1) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }} - /> -
- ), - [enabledFilter, tagDefinitions, tagFilterEntries] - ) - - const connectorBadges = - connectors.length > 0 ? ( - <> - {connectors.map((connector) => { - const def = CONNECTOR_META_REGISTRY[connector.connectorType] - const ConnectorIcon = def?.icon - return ( - - ) - })} - - ) : null - - const filterTags: FilterTag[] = useMemo( - () => [ - ...(enabledFilter !== 'all' - ? [ - { - label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`, - onRemove: () => { - setEnabledFilter('all') - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }, - }, - ] - : []), - ...tagFilterEntries.reduce<{ label: string; onRemove: () => void }[]>((acc, f) => { - if (!f.tagSlot || !f.value.trim()) return acc - acc.push({ - label: `${f.tagName}: ${f.value}`, - onRemove: () => { - const updated = tagFilterEntries.filter((e) => e.id !== f.id) - setTagFilterEntries(updated) - setCurrentPage(1) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }, - }) - return acc - }, []), - ], - [enabledFilter, tagFilterEntries] - ) - - const selectableConfig: SelectableConfig = { - selectedIds: selectedDocuments, - onSelectRow: handleSelectDocument, - onSelectAll: handleSelectAll, - isAllSelected, - disabled: !userPermissions.canEdit, - } - - const documentRows: ResourceRow[] = useMemo( - () => - documents.map((doc) => { - const ConnectorIcon = doc.connectorType - ? CONNECTOR_META_REGISTRY[doc.connectorType]?.icon - : null - const DocIcon = ConnectorIcon || getDocumentIcon(doc.mimeType, doc.filename) - - const tags = getDocumentTags(doc, tagDefinitions) - - const statusCell: ResourceCell = - doc.processingStatus === 'failed' && doc.processingError - ? { - content: ( - - -
{getStatusBadge(doc)}
-
- - {doc.processingError} - -
- ), - } - : { content: getStatusBadge(doc) } - - const tagsCell: ResourceCell = - tags.length === 0 ? { label: null } : { content: } - - return { - id: doc.id, - cells: { - name: { - content: ( - - - - - - - - - ), - }, - size: { label: formatFileSize(doc.fileSize) }, - tokens: { - label: - doc.processingStatus === 'completed' - ? doc.tokenCount > 1000 - ? `${(doc.tokenCount / 1000).toFixed(1)}k` - : doc.tokenCount.toLocaleString() - : null, - }, - chunks: { - label: doc.processingStatus === 'completed' ? doc.chunkCount.toLocaleString() : null, - }, - uploaded: { - content: ( - - - - {format(new Date(doc.uploadedAt), 'MMM d')} - - - - {format(new Date(doc.uploadedAt), 'MMM d, yyyy h:mm a')} - - - ), - }, - status: statusCell, - tags: tagsCell, - }, - } - }), - [documents, tagDefinitions, highlightQuery] - ) - - if (error && !knowledgeBase) { - return ( -
- -
-

- Knowledge base not found -

-

- This knowledge base may have been deleted or moved -

-
-
- ) - } - - return ( - <> - - - - setCurrentPage(page), - }} - overlay={ - 1 ? 'bottom-[72px]' : undefined} - selectedCount={selectedDocuments.size} - onEnable={disabledCount > 0 ? handleBulkEnable : undefined} - onDisable={enabledCount > 0 ? handleBulkDisable : undefined} - onDelete={handleBulkDelete} - enabledCount={enabledCount} - disabledCount={disabledCount} - isLoading={isBulkOperating} - totalCount={pagination.total} - isAllPageSelected={isAllSelected} - isAllSelected={isSelectAllMode} - onSelectAll={() => setIsSelectAllMode(true)} - onClearSelectAll={() => { - setIsSelectAllMode(false) - setSelectedDocuments(new Set()) - }} - /> - } - /> - - - - - - - { - setShowDeleteDocumentModal(open) - if (!open) setDocumentToDelete(null) - }} - srTitle='Delete Document' - title='Delete Document' - text={(() => { - const docToDelete = documents.find((doc) => doc.id === documentToDelete) - const base: ChipConfirmTextSegment[] = [ - 'Are you sure you want to delete ', - { text: docToDelete?.filename ?? 'this document', bold: true }, - '? ', - ] - return docToDelete?.connectorId - ? [ - ...base, - { - text: 'This document is synced from a connector. Deleting it will permanently exclude it from future syncs. To temporarily hide it from search, disable it instead.', - error: true, - }, - ] - : [ - ...base, - { text: 'This will permanently delete the document.', error: true }, - ' This action cannot be undone.', - ] - })()} - confirm={{ - label: 'Delete Document', - onClick: confirmDeleteDocument, - }} - /> - - - - - - {showAddConnectorModal && ( - - )} - - {documentToRename && ( - - )} - - {documentForTagsId && ( - doc.id === documentForTagsId) ?? null} - onDocumentUpdate={(updates) => updateDocument(documentForTagsId, updates)} - /> - )} - - - setShowConnectorsModal(false)}> - Connected Sources - - - - - - - { - const urlParams = new URLSearchParams({ - kbName: knowledgeBaseName, - docName: contextMenuDocument.filename || 'Document', - }) - window.open( - `/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`, - '_blank' - ) - } - : undefined - } - onOpenSource={ - contextMenuDocument?.sourceUrl && selectedDocuments.size === 1 - ? () => window.open(contextMenuDocument.sourceUrl!, '_blank', 'noopener,noreferrer') - : undefined - } - onRename={contextMenuDocument ? () => handleRenameDocument(contextMenuDocument) : undefined} - onToggleEnabled={ - contextMenuDocument - ? selectedDocuments.size > 1 - ? () => { - if (disabledCount > 0) { - handleBulkEnable() - } else { - handleBulkDisable() - } - } - : () => handleToggleEnabled(contextMenuDocument.id) - : undefined - } - onViewTags={ - contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit - ? () => handleViewDocumentTags(contextMenuDocument) - : undefined - } - onDelete={ - contextMenuDocument - ? selectedDocuments.size > 1 - ? handleBulkDelete - : () => handleDeleteDocument(contextMenuDocument.id) - : undefined - } - onAddDocument={handleAddDocuments} - disableRename={!userPermissions.canEdit} - disableToggleEnabled={ - !userPermissions.canEdit || - contextMenuDocument?.processingStatus === 'processing' || - contextMenuDocument?.processingStatus === 'pending' - } - disableDelete={ - !userPermissions.canEdit || contextMenuDocument?.processingStatus === 'processing' - } - disableAddDocument={!userPermissions.canEdit} - /> - - ) -} - -/** - * Sizes the filter popover to its content with pure CSS `max-content` (clamped to - * `[280, 420]`). Because the padding box is part of `max-content`, the `p-3` - * inset is preserved on every edge — there is no separate measured/animated outer - * layer that can disagree by a few pixels and clip the right padding. The width - * still adapts to the active filters; it just resizes instantly rather than - * animating. - */ -function AutoWidthPanel({ children }: { children: ReactNode }) { - return
{children}
-} - -interface TagFilterEntry { - id: string - tagName: string - tagSlot: string - fieldType: FilterFieldType - operator: string - value: string - valueTo: string -} - -const createEmptyEntry = (): TagFilterEntry => ({ - id: generateId(), - tagName: '', - tagSlot: '', - fieldType: 'text', - operator: 'contains', - value: '', - valueTo: '', -}) - -/** - * Default operator when a tag is selected. Text filters default to `contains` - * so typing part of a value finds matches (exact `equals` stays one click away - * in the operator dropdown); other field types keep their first, equality - * operator. - */ -function getDefaultOperatorForFieldType( - fieldType: FilterFieldType, - operators: ReturnType -): string { - if (fieldType === 'text') return 'contains' - return operators[0]?.value ?? 'eq' -} - -interface TagFilterSectionProps { - tagDefinitions: TagDefinition[] - entries: TagFilterEntry[] - onChange: (entries: TagFilterEntry[]) => void -} - -interface TagFilterValueControlProps { - entry: TagFilterEntry - onChange: (patch: Partial) => void -} - -/** - * Renders the value input for a knowledge base tag filter row. - */ -function TagFilterValueControl({ entry, onChange }: TagFilterValueControlProps) { - const isBetween = entry.operator === 'between' - - if (entry.fieldType === 'date') { - if (isBetween) { - return ( -
- onChange({ value })} - placeholder='From' - fullWidth - /> - to - onChange({ valueTo: value })} - placeholder='To' - fullWidth - /> -
- ) - } - - return ( - onChange({ value })} - placeholder='Select date' - fullWidth - /> - ) - } - - if (isBetween) { - return ( -
- onChange({ value: event.target.value })} - placeholder='From' - /> - to - onChange({ valueTo: event.target.value })} - placeholder='To' - /> -
- ) - } - - return ( - onChange({ value: event.target.value })} - placeholder={ - entry.fieldType === 'boolean' - ? 'true or false' - : entry.fieldType === 'number' - ? 'Enter number' - : 'Enter value' - } - /> - ) -} - -/** - * Tag filter section rendered inside the combined filter popover. - */ -function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectionProps) { - const activeCount = entries.filter((f) => f.tagSlot && f.value.trim()).length - - const tagOptions: ChipDropdownOption[] = tagDefinitions.map((t) => ({ - value: t.displayName, - label: t.displayName, - })) - - const filtersToShow = useMemo( - () => (entries.length > 0 ? entries : [createEmptyEntry()]), - [entries] - ) - - const scrollRef = useRef(null) - const prevCountRef = useRef(filtersToShow.length) - - useEffect(() => { - if (filtersToShow.length > prevCountRef.current) { - const el = scrollRef.current - if (el) { - requestAnimationFrame(() => { - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) - }) - } - } - prevCountRef.current = filtersToShow.length - }, [filtersToShow.length]) - - const updateEntry = (id: string, patch: Partial) => { - const existing = filtersToShow.find((e) => e.id === id) - if (!existing) return - const updated = filtersToShow.map((e) => (e.id === id ? { ...e, ...patch } : e)) - onChange(updated) - } - - const handleTagChange = (id: string, tagName: string) => { - const def = tagDefinitions.find((t) => t.displayName === tagName) - const fieldType = (def?.fieldType || 'text') as FilterFieldType - const operators = getOperatorsForFieldType(fieldType) - updateEntry(id, { - tagName, - tagSlot: def?.tagSlot || '', - fieldType, - operator: getDefaultOperatorForFieldType(fieldType, operators), - value: '', - valueTo: '', - }) - } - - const addFilter = () => { - onChange([...filtersToShow, createEmptyEntry()]) - } - - const removeFilter = (id: string) => { - const remaining = filtersToShow.filter((e) => e.id !== id) - onChange(remaining.length > 0 ? remaining : []) - } - - if (tagDefinitions.length === 0) return null - - return ( -
-
- Filter by tags - {activeCount > 0 && ( - - )} -
- -
- {filtersToShow.map((entry, index) => { - const operators = getOperatorsForFieldType(entry.fieldType) - const operatorOptions: ChipDropdownOption[] = operators.map((op) => ({ - value: op.value, - label: op.label, - })) - - return ( -
- {index > 0 && ( -
- - and - -
-
- )} -
-
- handleTagChange(entry.id, value)} - placeholder='Select tag' - align='start' - matchTriggerWidth={false} - contentClassName='max-h-[240px] overflow-y-auto' - className='max-w-[150px]' - /> - {entry.tagSlot && ( - updateEntry(entry.id, { operator: value, valueTo: '' })} - placeholder='Operator' - align='start' - matchTriggerWidth={false} - /> - )} -
- -
- {entry.tagSlot && ( - updateEntry(entry.id, patch)} - /> - )} -
- ) - })} -
- - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts index 12e32ebf736..d26e85dc9e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts @@ -6,4 +6,3 @@ export { ConnectorsSection } from './connectors-section' export { DocumentContextMenu } from './document-context-menu' export { EditConnectorModal } from './edit-connector-modal' export { RenameDocumentModal } from './rename-document-modal' -export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-knowledge-list-state.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-knowledge-list-state.ts new file mode 100644 index 00000000000..6817b71625e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-knowledge-list-state.ts @@ -0,0 +1,238 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useQueryState, useQueryStates } from 'nuqs' +import type { SortConfig } from '@/components/resource' +import type { KnowledgeEnabledFilter, TagFilterEntry } from '@/components/resources/knowledge-view' +import { SEARCH_DEBOUNCE_MS, type SortDirection } from '@/lib/url-state' +import { + addConnectorParam, + documentFiltersParsers, + documentFiltersUrlKeys, + KB_SORT_COLUMNS, + kbDocumentSortParams, + pageParam, + pageUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' +import type { DocumentTagFilter } from '@/hooks/queries/kb/knowledge' +import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useUrlSort } from '@/hooks/use-url-sort' +import { hostOwnsUrl, type ResourceHost } from '@/resources' + +const DEFAULT_SORT = kbDocumentSortParams.default + +export interface KnowledgeListState { + searchQuery: string + setSearchQuery: (value: string) => void + /** Debounced copy that feeds the document query, so typing does not refetch per keystroke. */ + debouncedSearchQuery: string + /** Raw value drives the input; matching/highlighting always sees it trimmed. */ + highlightQuery: string + enabledFilter: KnowledgeEnabledFilter + setEnabledFilter: (value: KnowledgeEnabledFilter) => void + tagFilterEntries: TagFilterEntry[] + setTagFilterEntries: (entries: TagFilterEntry[]) => void + /** The subset of `tagFilterEntries` complete enough to send to the API. */ + activeTagFilters: DocumentTagFilter[] + currentPage: number + setCurrentPage: (page: number) => void + sortColumn: string + sortDirection: SortDirection + sort: Omit + /** Non-null while the add-connector modal is open; the value seeds its connector type. */ + addConnectorType: string | null + setAddConnectorType: (value: string | null) => void +} + +/** + * Document-list view-state for one knowledge base, stored where the host allows. + * + * A host that owns the URL (`hostOwnsUrl`) keeps search, status, sort and page + * in query params, so the list is shareable and survives reload. An embedded + * host does not: it holds the identical state locally, because writing + * unnamespaced `?q` / `?enabled` / `?sort` / `?dir` / `?page` / `?addConnector` + * keys would pollute the address bar of whatever page is hosting the panel. + * + * Both branches are wired unconditionally — hooks may not be called + * conditionally — and only the returned pair differs. In an embedded host the + * URL values are read but never written, so a key that happens to already be on + * the host's URL cannot steer the panel either. + * + * `tagFilterEntries` is never URL-backed in any host: it is an array of rich + * filter-rule objects, too large and too structured for a query param. + */ +export function useKnowledgeListState({ host }: { host: ResourceHost }): KnowledgeListState { + const ownsUrl = hostOwnsUrl(host) + + const [urlPage, setUrlPage] = useQueryState(pageParam.key, { + ...pageParam.parser, + ...pageUrlKeys, + }) + const [{ q: urlSearch, enabled: urlEnabled }, setUrlFilters] = useQueryStates( + documentFiltersParsers, + documentFiltersUrlKeys + ) + const urlSort = useUrlSort(kbDocumentSortParams, documentFiltersUrlKeys) + const [urlAddConnectorType, setUrlAddConnectorType] = useQueryState( + addConnectorParam.key, + addConnectorParam.parser + ) + + const [localPage, setLocalPage] = useState(1) + const [localSearch, setLocalSearch] = useState('') + const [localEnabled, setLocalEnabled] = useState('all') + const [localSort, setLocalSort] = useState<{ column: string; direction: SortDirection }>( + DEFAULT_SORT + ) + const [localAddConnectorType, setLocalAddConnectorType] = useState(null) + const [tagFilterEntries, setTagFilterEntriesState] = useState([]) + + const writeUrlSearch = useDebouncedSearchSetter((value, options) => { + void setUrlFilters({ q: value }, options) + void setUrlPage(1) + }) + + const setSearchQuery = useCallback( + (value: string) => { + if (ownsUrl) { + writeUrlSearch(value) + return + } + setLocalSearch(value) + setLocalPage(1) + }, + [ownsUrl, writeUrlSearch] + ) + + const setEnabledFilter = useCallback( + (value: KnowledgeEnabledFilter) => { + if (ownsUrl) { + void setUrlFilters({ enabled: value }) + void setUrlPage(1) + return + } + setLocalEnabled(value) + setLocalPage(1) + }, + [ownsUrl, setUrlFilters, setUrlPage] + ) + + const setCurrentPage = useCallback( + (page: number) => { + if (ownsUrl) { + void setUrlPage(page) + return + } + setLocalPage(page) + }, + [ownsUrl, setUrlPage] + ) + + const setTagFilterEntries = useCallback( + (entries: TagFilterEntry[]) => { + setTagFilterEntriesState(entries) + setCurrentPage(1) + }, + [setCurrentPage] + ) + + const setAddConnectorType = useCallback( + (value: string | null) => { + if (ownsUrl) { + void setUrlAddConnectorType(value, { history: 'replace', scroll: false }) + return + } + setLocalAddConnectorType(value) + }, + [ownsUrl, setUrlAddConnectorType] + ) + + const localOnSort = useCallback((column: string, direction: SortDirection) => { + if (!(KB_SORT_COLUMNS as readonly string[]).includes(column)) return + setLocalSort({ column, direction }) + setLocalPage(1) + }, []) + + const localOnClear = useCallback(() => { + setLocalSort(DEFAULT_SORT) + setLocalPage(1) + }, []) + + const searchQuery = ownsUrl ? urlSearch : localSearch + const enabledFilter = ownsUrl ? urlEnabled : localEnabled + const currentPage = ownsUrl ? urlPage : localPage + const sortColumn = ownsUrl ? urlSort.sort : localSort.column + const sortDirection = ownsUrl ? urlSort.dir : localSort.direction + const addConnectorType = ownsUrl ? urlAddConnectorType : localAddConnectorType + + const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS) + + /** + * Sorting (or clearing the sort) resets pagination to the first page. In the + * URL branch the reset is a second write; nuqs batches both into one update. + */ + const sort = useMemo>(() => { + if (!ownsUrl) { + const isDefault = + localSort.column === DEFAULT_SORT.column && localSort.direction === DEFAULT_SORT.direction + return { + active: isDefault ? null : localSort, + onSort: localOnSort, + onClear: localOnClear, + } + } + return { + active: urlSort.activeSort, + onSort: (column, direction) => { + urlSort.onSort(column, direction) + void setUrlPage(1) + }, + onClear: () => { + urlSort.onClear() + void setUrlPage(1) + }, + } + }, [ownsUrl, localSort, localOnSort, localOnClear, urlSort, setUrlPage]) + + const activeTagFilters = useMemo( + () => + tagFilterEntries.reduce((acc, f) => { + if (!f.tagSlot || !f.value.trim()) return acc + /** + * A `between` filter only applies once both bounds are set. Sending it + * with just the lower bound would be rejected at the API boundary and + * break the whole list while the user is still entering the range. + */ + if (f.operator === 'between' && !f.valueTo.trim()) return acc + acc.push({ + tagSlot: f.tagSlot, + fieldType: f.fieldType, + operator: f.operator, + value: f.value, + ...(f.operator === 'between' ? { valueTo: f.valueTo } : {}), + }) + return acc + }, []), + [tagFilterEntries] + ) + + return { + searchQuery, + setSearchQuery, + debouncedSearchQuery, + highlightQuery: searchQuery.trim(), + enabledFilter, + setEnabledFilter, + tagFilterEntries, + setTagFilterEntries, + activeTagFilters, + currentPage, + setCurrentPage, + sortColumn, + sortDirection, + sort, + addConnectorType, + setAddConnectorType, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/knowledge-base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/knowledge-base.tsx new file mode 100644 index 00000000000..0651ff9f5f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/knowledge-base.tsx @@ -0,0 +1,988 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ChipConfirmModal, + type ChipConfirmTextSegment, + ChipModal, + ChipModalBody, + ChipModalHeader, + Trash, +} from '@sim/emcn' +import { Database, Pencil, Plus, TagIcon } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { Resource } from '@/components/resource' +import { + type KnowledgeDocumentList, + type KnowledgeEnabledFilter, + KnowledgeView, + type TagFilterEntry, +} from '@/components/resources/knowledge-view' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import type { DocumentData } from '@/lib/knowledge/types' +import { captureEvent } from '@/lib/posthog/client' +import type { BreadcrumbItem, ResourceAction } from '@/app/workspace/[workspaceId]/components' +import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' +import { + ActionBar, + AddConnectorModal, + AddDocumentsModal, + BaseTagsModal, + ConnectorsSection, + DocumentContextMenu, + RenameDocumentModal, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { useKnowledgeListState } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-knowledge-list-state' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { + useKnowledgeBase, + useKnowledgeBaseDocuments, + useKnowledgeBasesList, +} from '@/hooks/kb/use-knowledge' +import { useKnowledgeBaseTagDefinitions } from '@/hooks/kb/use-knowledge-base-tag-definitions' +import { isConnectorSyncingOrPending, useConnectorList } from '@/hooks/queries/kb/connectors' +import { + useBulkDocumentOperation, + useDeleteDocument, + useDeleteKnowledgeBase, + useUpdateDocument, + useUpdateKnowledgeBase, +} from '@/hooks/queries/kb/knowledge' +import { useInlineRename } from '@/hooks/use-inline-rename' +import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return' +import { grantsFromPermissions, type ResourceHost, workspaceSource } from '@/resources' + +const logger = createLogger('KnowledgeBase') + +const DOCUMENTS_PER_PAGE = 50 + +interface KnowledgeBaseProps { + id: string + knowledgeBaseName?: string + workspaceId?: string + /** + * Who owns the URL around this surface. The knowledge page owns it; the + * mothership panel does not, and its document-list view-state stays local so + * it never writes `?q` / `?enabled` / `?sort` / `?dir` / `?page` into the + * host's address bar. + */ + host: ResourceHost +} + +/** + * The knowledge base editing shell: the header and every mutation a workspace + * member can perform on a base (upload, connectors, tags, rename, delete, bulk + * enable/disable/delete), wrapped around the canonical {@link KnowledgeView} + * that renders the document list itself. + */ +export function KnowledgeBase({ + id, + knowledgeBaseName: passedKnowledgeBaseName, + workspaceId: propWorkspaceId, + host, +}: KnowledgeBaseProps) { + const params = useParams() + const workspaceId = propWorkspaceId || (params.workspaceId as string) + const router = useRouter() + const posthog = usePostHog() + + useEffect(() => { + captureEvent(posthog, 'knowledge_base_opened', { + knowledge_base_id: id, + knowledge_base_name: passedKnowledgeBaseName ?? 'Unknown', + }) + }, [id, passedKnowledgeBaseName, posthog]) + + useOAuthReturnForKBConnectors(id) + const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false }) + const userPermissions = useUserPermissionsContext() + + const { mutate: updateDocumentMutation, mutateAsync: updateDocumentAsync } = useUpdateDocument() + const { mutate: deleteDocumentMutation } = useDeleteDocument() + const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } = + useDeleteKnowledgeBase(workspaceId) + const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) + + const kbRename = useInlineRename({ + onSave: (kbId, name) => + updateKnowledgeBaseMutation({ knowledgeBaseId: kbId, updates: { name } }), + }) + const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation() + + const [showTagsModal, setShowTagsModal] = useState(false) + const [selectedDocuments, setSelectedDocuments] = useState>(() => new Set()) + const [isSelectAllMode, setIsSelectAllMode] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false) + const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false) + const [documentToDelete, setDocumentToDelete] = useState(null) + const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) + const [showConnectorsModal, setShowConnectorsModal] = useState(false) + + /** Clearing the list selection is what every filter change does before refetching. */ + const clearSelection = useCallback(() => { + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }, []) + + const { + searchQuery, + setSearchQuery, + highlightQuery, + debouncedSearchQuery, + enabledFilter, + setEnabledFilter, + tagFilterEntries, + setTagFilterEntries, + activeTagFilters, + currentPage, + setCurrentPage, + sortColumn, + sortDirection, + sort, + addConnectorType, + setAddConnectorType, + } = useKnowledgeListState({ host }) + + const showAddConnectorModal = addConnectorType != null + const setShowAddConnectorModal = useCallback( + (open: boolean) => setAddConnectorType(open ? '' : null), + [setAddConnectorType] + ) + + const [contextMenuDocument, setContextMenuDocument] = useState(null) + const [showRenameModal, setShowRenameModal] = useState(false) + const [documentToRename, setDocumentToRename] = useState(null) + const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) + const [documentForTagsId, setDocumentForTagsId] = useState(null) + + const { + isOpen: isContextMenuOpen, + position: contextMenuPosition, + handleContextMenu: baseHandleContextMenu, + closeMenu: closeContextMenu, + } = useContextMenu() + + const { + knowledgeBase, + error: knowledgeBaseError, + refresh: refreshKnowledgeBase, + } = useKnowledgeBase(id) + + const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id) + const hasSyncingConnectors = connectors.some(isConnectorSyncingOrPending) + const hasSyncingConnectorsRef = useRef(hasSyncingConnectors) + hasSyncingConnectorsRef.current = hasSyncingConnectors + + const { + documents, + pagination, + error: documentsError, + hasProcessingDocuments, + updateDocument, + refreshDocuments, + } = useKnowledgeBaseDocuments(id, { + search: debouncedSearchQuery.trim() || undefined, + limit: DOCUMENTS_PER_PAGE, + offset: (currentPage - 1) * DOCUMENTS_PER_PAGE, + sortBy: sortColumn as DocumentSortField, + sortOrder: sortDirection as SortOrder, + refetchInterval: (data) => { + if (isDeleting) return false + const hasPending = data?.documents?.some( + (doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing' + ) + if (hasPending) return 3000 + if (hasSyncingConnectorsRef.current) return 5000 + return false + }, + enabledFilter: enabledFilter, + tagFilters: activeTagFilters.length > 0 ? activeTagFilters : undefined, + }) + + const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id) + + const prevHadSyncingRef = useRef(false) + useEffect(() => { + if (prevHadSyncingRef.current && !hasSyncingConnectors) { + refreshKnowledgeBase() + refreshDocuments() + } + prevHadSyncingRef.current = hasSyncingConnectors + }, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments]) + + const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base' + /** + * Breadcrumb leaf label. Falls back to the canonical '…' placeholder while + * the name loads (mirroring loading.tsx) instead of duplicating the root + * "Knowledge Base" crumb. + */ + const knowledgeBaseCrumbLabel = knowledgeBase?.name || passedKnowledgeBaseName || '…' + const error = knowledgeBaseError || documentsError + + const totalPages = Math.ceil(pagination.total / pagination.limit) + + const source = useMemo( + () => workspaceSource({ kind: 'knowledge', workspaceId, resourceId: id }), + [workspaceId, id] + ) + const grants = useMemo(() => grantsFromPermissions(userPermissions), [userPermissions]) + + /** + * Checks for documents with stale processing states and marks them as failed + */ + const checkForDeadProcesses = useCallback( + (docsToCheck: DocumentData[]) => { + const now = new Date() + const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes + + const staleDocuments = docsToCheck.filter((doc) => { + if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) { + return false + } + + const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime() + return processingDuration > DEAD_PROCESS_THRESHOLD_MS + }) + + if (staleDocuments.length === 0) return + + logger.warn(`Found ${staleDocuments.length} documents with dead processes`) + + staleDocuments.forEach((doc) => { + updateDocumentMutation( + { + knowledgeBaseId: id, + documentId: doc.id, + updates: { markFailedDueToTimeout: true }, + }, + { + onSuccess: () => { + logger.info( + `Successfully marked dead process as failed for document: ${doc.filename}` + ) + }, + } + ) + }) + }, + [id, updateDocumentMutation] + ) + + useEffect(() => { + if (hasProcessingDocuments) { + checkForDeadProcesses(documents) + } + }, [hasProcessingDocuments, documents, checkForDeadProcesses]) + + const handleToggleEnabled = (docId: string) => { + const document = documents.find((doc) => doc.id === docId) + if (!document) return + + const newEnabled = !document.enabled + + updateDocument(docId, { enabled: newEnabled }) + + updateDocumentMutation( + { + knowledgeBaseId: id, + documentId: docId, + updates: { enabled: newEnabled }, + }, + { + onError: () => { + updateDocument(docId, { enabled: !newEnabled }) + }, + } + ) + } + + /** + * Opens the rename document modal + */ + const handleRenameDocument = (doc: DocumentData) => { + setDocumentToRename(doc) + setShowRenameModal(true) + } + + /** + * Opens the document tags modal + */ + const handleViewDocumentTags = (doc: DocumentData) => { + setDocumentForTagsId(doc.id) + setShowDocumentTagsModal(true) + } + + /** + * Saves the renamed document + */ + const handleSaveRename = async (documentId: string, newName: string) => { + const currentDoc = documents.find((doc) => doc.id === documentId) + const previousName = currentDoc?.filename + + updateDocument(documentId, { filename: newName }) + + try { + await updateDocumentAsync({ knowledgeBaseId: id, documentId, updates: { filename: newName } }) + logger.info(`Document renamed: ${documentId}`) + } catch (err) { + if (previousName !== undefined) { + updateDocument(documentId, { filename: previousName }) + } + logger.error('Error renaming document:', err) + throw err + } + } + + /** + * Opens the delete document confirmation modal + */ + const handleDeleteDocument = (docId: string) => { + setDocumentToDelete(docId) + setShowDeleteDocumentModal(true) + } + + /** + * Confirms and executes the deletion of a single document + */ + const confirmDeleteDocument = () => { + if (!documentToDelete) return + + deleteDocumentMutation( + { knowledgeBaseId: id, documentId: documentToDelete }, + { + onSuccess: () => { + setSelectedDocuments((prev) => { + const newSet = new Set(prev) + newSet.delete(documentToDelete) + return newSet + }) + }, + onSettled: () => { + setShowDeleteDocumentModal(false) + setDocumentToDelete(null) + }, + } + ) + } + + /** + * Handles selecting/deselecting a document + */ + const handleSelectDocument = (docId: string, checked: boolean) => { + setSelectedDocuments((prev) => { + const newSet = new Set(prev) + if (checked) { + newSet.add(docId) + } else { + newSet.delete(docId) + } + return newSet + }) + } + + /** + * Handles selecting/deselecting all documents + */ + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedDocuments(new Set(documents.map((doc) => doc.id))) + } else { + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + } + } + + const isAllSelected = documents.length > 0 && selectedDocuments.size === documents.length + + /** + * Handles clicking on a document row to navigate to detail view + */ + const handleDocumentClick = (docId: string) => { + const document = documents.find((doc) => doc.id === docId) + if (document?.processingStatus !== 'completed') return + const urlParams = new URLSearchParams({ + kbName: knowledgeBaseName, + docName: document?.filename || 'Document', + }) + router.push(`/workspace/${workspaceId}/knowledge/${id}/${docId}?${urlParams.toString()}`) + } + + /** + * Handles deleting the entire knowledge base + */ + const handleDeleteKnowledgeBase = () => { + if (!knowledgeBase) return + + deleteKnowledgeBaseMutation( + { knowledgeBaseId: id }, + { + onSuccess: () => { + removeKnowledgeBase(id) + router.push(`/workspace/${workspaceId}/knowledge`) + }, + } + ) + } + + const handleAddDocuments = () => { + setShowAddDocumentsModal(true) + } + + /** + * Handles bulk enabling of selected documents + */ + const handleBulkEnable = () => { + if (isSelectAllMode) { + bulkDocumentMutation( + { + knowledgeBaseId: id, + operation: 'enable', + selectAll: true, + enabledFilter: enabledFilter, + }, + { + onSuccess: (result) => { + logger.info(`Successfully enabled ${result.successCount} documents`) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }, + } + ) + return + } + + const documentsToEnable = documents.filter( + (doc) => selectedDocuments.has(doc.id) && !doc.enabled + ) + + if (documentsToEnable.length === 0) return + + bulkDocumentMutation( + { + knowledgeBaseId: id, + operation: 'enable', + documentIds: documentsToEnable.map((doc) => doc.id), + }, + { + onSuccess: (result) => { + result.updatedDocuments?.forEach((updatedDoc) => { + updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled }) + }) + logger.info(`Successfully enabled ${result.successCount} documents`) + setSelectedDocuments(new Set()) + }, + } + ) + } + + /** + * Handles bulk disabling of selected documents + */ + const handleBulkDisable = () => { + if (isSelectAllMode) { + bulkDocumentMutation( + { + knowledgeBaseId: id, + operation: 'disable', + selectAll: true, + enabledFilter: enabledFilter, + }, + { + onSuccess: (result) => { + logger.info(`Successfully disabled ${result.successCount} documents`) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }, + } + ) + return + } + + const documentsToDisable = documents.filter( + (doc) => selectedDocuments.has(doc.id) && doc.enabled + ) + + if (documentsToDisable.length === 0) return + + bulkDocumentMutation( + { + knowledgeBaseId: id, + operation: 'disable', + documentIds: documentsToDisable.map((doc) => doc.id), + }, + { + onSuccess: (result) => { + result.updatedDocuments?.forEach((updatedDoc) => { + updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled }) + }) + logger.info(`Successfully disabled ${result.successCount} documents`) + setSelectedDocuments(new Set()) + }, + } + ) + } + + const handleBulkDelete = () => { + if (selectedDocuments.size === 0) return + setShowBulkDeleteModal(true) + } + + const confirmBulkDelete = () => { + if (isSelectAllMode) { + bulkDocumentMutation( + { + knowledgeBaseId: id, + operation: 'delete', + selectAll: true, + enabledFilter: enabledFilter, + }, + { + onSuccess: (result) => { + logger.info(`Successfully deleted ${result.successCount} documents`) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }, + onSettled: () => { + setShowBulkDeleteModal(false) + }, + } + ) + return + } + + const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id)) + + if (documentsToDelete.length === 0) return + + bulkDocumentMutation( + { + knowledgeBaseId: id, + operation: 'delete', + documentIds: documentsToDelete.map((doc) => doc.id), + }, + { + onSuccess: (result) => { + logger.info(`Successfully deleted ${result.successCount} documents`) + setSelectedDocuments(new Set()) + }, + onSettled: () => { + setShowBulkDeleteModal(false) + }, + } + ) + } + + const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id)) + const enabledCount = isSelectAllMode + ? enabledFilter === 'disabled' + ? 0 + : pagination.total + : selectedDocumentsList.filter((doc) => doc.enabled).length + const disabledCount = isSelectAllMode + ? enabledFilter === 'enabled' + ? 0 + : pagination.total + : selectedDocumentsList.filter((doc) => !doc.enabled).length + + const handleDocumentContextMenu = useCallback( + (e: React.MouseEvent, docId: string) => { + const doc = documents.find((d) => d.id === docId) + if (!doc) return + + const isCurrentlySelected = selectedDocuments.has(doc.id) + + if (!isCurrentlySelected) { + setSelectedDocuments(new Set([doc.id])) + } + + setContextMenuDocument(doc) + baseHandleContextMenu(e) + }, + [documents, selectedDocuments, baseHandleContextMenu] + ) + + const handleEmptyContextMenu = useCallback( + (e: React.MouseEvent) => { + setContextMenuDocument(null) + baseHandleContextMenu(e) + }, + [baseHandleContextMenu] + ) + + const handleContextMenuClose = useCallback(() => { + closeContextMenu() + setContextMenuDocument(null) + }, [closeContextMenu]) + + const breadcrumbs: BreadcrumbItem[] = [ + { + label: 'Knowledge Base', + icon: Database, + onClick: () => router.push(`/workspace/${workspaceId}/knowledge`), + }, + { + label: knowledgeBaseCrumbLabel, + icon: Database, + editing: kbRename.editingId + ? { + isEditing: true, + value: kbRename.editValue, + onChange: kbRename.setEditValue, + onSubmit: kbRename.submitRename, + onCancel: kbRename.cancelRename, + disabled: kbRename.isSaving, + } + : undefined, + dropdownItems: [ + ...(userPermissions.canEdit || userPermissions.isLoading + ? [ + { + label: 'Rename', + icon: Pencil, + disabled: !userPermissions.canEdit, + onClick: () => kbRename.startRename(id, knowledgeBaseName), + }, + { + label: 'Tags', + icon: TagIcon, + disabled: !userPermissions.canEdit, + onClick: () => setShowTagsModal(true), + }, + { + label: 'Delete', + icon: Trash, + disabled: !userPermissions.canEdit, + onClick: () => setShowDeleteDialog(true), + }, + ] + : []), + ], + }, + ] + + const headerActions: ResourceAction[] = [ + ...(userPermissions.canEdit || userPermissions.isLoading + ? [ + { + text: 'New connector', + icon: Plus, + disabled: !userPermissions.canEdit, + onSelect: () => setShowAddConnectorModal(true), + }, + ] + : []), + ] + + const handleEnabledFilterChange = useCallback( + (value: KnowledgeEnabledFilter) => { + setEnabledFilter(value) + clearSelection() + }, + [setEnabledFilter, clearSelection] + ) + + const handleTagFilterEntriesChange = useCallback( + (entries: TagFilterEntry[]) => { + setTagFilterEntries(entries) + clearSelection() + }, + [setTagFilterEntries, clearSelection] + ) + + const unavailable = Boolean(error) && !knowledgeBase + + const list: KnowledgeDocumentList = { + documents, + tagDefinitions, + connectors, + unavailable, + search: searchQuery, + onSearchChange: setSearchQuery, + highlightQuery, + enabledFilter, + onEnabledFilterChange: handleEnabledFilterChange, + tagFilterEntries, + onTagFilterEntriesChange: handleTagFilterEntriesChange, + sort, + pagination: { + currentPage, + totalPages, + onPageChange: (page) => setCurrentPage(page), + }, + } + + /** + * A knowledge base that could not be resolved replaces the whole surface — + * no header, no toolbar, no modals. There is nothing to act on, and the + * breadcrumb would name a base that is gone. + */ + if (unavailable) { + return + } + + return ( + <> + + + setShowConnectorsModal(true), + overlay: ( + 1 ? 'bottom-[72px]' : undefined} + selectedCount={selectedDocuments.size} + onEnable={disabledCount > 0 ? handleBulkEnable : undefined} + onDisable={enabledCount > 0 ? handleBulkDisable : undefined} + onDelete={handleBulkDelete} + enabledCount={enabledCount} + disabledCount={disabledCount} + isLoading={isBulkOperating} + totalCount={pagination.total} + isAllPageSelected={isAllSelected} + isAllSelected={isSelectAllMode} + onSelectAll={() => setIsSelectAllMode(true)} + onClearSelectAll={clearSelection} + /> + ), + }} + /> + + + + + + + { + setShowDeleteDocumentModal(open) + if (!open) setDocumentToDelete(null) + }} + srTitle='Delete Document' + title='Delete Document' + text={(() => { + const docToDelete = documents.find((doc) => doc.id === documentToDelete) + const base: ChipConfirmTextSegment[] = [ + 'Are you sure you want to delete ', + { text: docToDelete?.filename ?? 'this document', bold: true }, + '? ', + ] + return docToDelete?.connectorId + ? [ + ...base, + { + text: 'This document is synced from a connector. Deleting it will permanently exclude it from future syncs. To temporarily hide it from search, disable it instead.', + error: true, + }, + ] + : [ + ...base, + { text: 'This will permanently delete the document.', error: true }, + ' This action cannot be undone.', + ] + })()} + confirm={{ + label: 'Delete Document', + onClick: confirmDeleteDocument, + }} + /> + + + + + + {showAddConnectorModal && ( + + )} + + {documentToRename && ( + + )} + + {documentForTagsId && ( + doc.id === documentForTagsId) ?? null} + onDocumentUpdate={(updates) => updateDocument(documentForTagsId, updates)} + /> + )} + + + setShowConnectorsModal(false)}> + Connected Sources + + + + + + + { + const urlParams = new URLSearchParams({ + kbName: knowledgeBaseName, + docName: contextMenuDocument.filename || 'Document', + }) + window.open( + `/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`, + '_blank' + ) + } + : undefined + } + onOpenSource={ + contextMenuDocument?.sourceUrl && selectedDocuments.size === 1 + ? () => window.open(contextMenuDocument.sourceUrl!, '_blank', 'noopener,noreferrer') + : undefined + } + onRename={contextMenuDocument ? () => handleRenameDocument(contextMenuDocument) : undefined} + onToggleEnabled={ + contextMenuDocument + ? selectedDocuments.size > 1 + ? () => { + if (disabledCount > 0) { + handleBulkEnable() + } else { + handleBulkDisable() + } + } + : () => handleToggleEnabled(contextMenuDocument.id) + : undefined + } + onViewTags={ + contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit + ? () => handleViewDocumentTags(contextMenuDocument) + : undefined + } + onDelete={ + contextMenuDocument + ? selectedDocuments.size > 1 + ? handleBulkDelete + : () => handleDeleteDocument(contextMenuDocument.id) + : undefined + } + onAddDocument={handleAddDocuments} + disableRename={!userPermissions.canEdit} + disableToggleEnabled={ + !userPermissions.canEdit || + contextMenuDocument?.processingStatus === 'processing' || + contextMenuDocument?.processingStatus === 'pending' + } + disableDelete={ + !userPermissions.canEdit || contextMenuDocument?.processingStatus === 'processing' + } + disableAddDocument={!userPermissions.canEdit} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx index 18a90b7cce0..5f0b8420dfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' -import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' +import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/knowledge-base' interface PageProps { params: Promise<{ @@ -21,7 +21,7 @@ export default async function KnowledgeBasePage({ params, searchParams }: PagePr return ( - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx index 5067b579835..fa405270ae1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx @@ -1,7 +1,8 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn } from '@sim/emcn' import { generateShortId } from '@sim/utils/id' -import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' +import { formatDate } from '@/components/resources/log-view' +import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' export interface LineChartPoint { timestamp: string diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index 0fb57df945d..3fdd7746e78 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -1,8 +1,8 @@ import { memo } from 'react' import { cn, handleKeyboardActivation } from '@sim/emcn' import { Workflow } from '@sim/emcn/icons' +import { DELETED_WORKFLOW_LABEL } from '@/components/resources/log-view' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' -import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { StatusBar, type StatusBarSegment } from '..' export interface WorkflowExecutionItem { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index c8b8e357e15..ed050272933 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -1,6 +1,3 @@ export { Dashboard } from './dashboard' -export { LogDetails, LogDetailsContent } from './log-details' -export { ExecutionSnapshot } from './log-details/components/execution-snapshot' -export { FileCards } from './log-details/components/file-download' -export { TraceView } from './log-details/components/trace-view' +export { LogDetails } from './log-details' export { LogRowContextMenu } from './log-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts index cc8d670e1e5..5d9685fdbd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/index.ts @@ -1,2 +1 @@ -export type { LogDetailsTab } from './log-details' -export { LogDetails, LogDetailsContent, WorkflowOutputSection } from './log-details' +export { LogDetails } from './log-details' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index b04518ef1f0..0e67aff4d46 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,683 +1,23 @@ 'use client' -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { - Badge, - Button, - Chip, - ChipInput, - ChipModalTabs, - Code, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Duplicate, - Eye, - handleKeyboardActivation, - Redo, - Search as SearchIcon, - Tooltip, - useCopyToClipboard, -} from '@sim/emcn' -import { - ArrowDown, - ArrowUp, - Check, - ChevronUp, - Clipboard, - Search, - Workflow, - Wrench, - X, -} from '@sim/emcn/icons' -import { formatDuration } from '@sim/utils/formatting' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { Button, cn, Tooltip } from '@sim/emcn' +import { ChevronUp, Redo, X } from '@sim/emcn/icons' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { createPortal } from 'react-dom' -import { getDisplayStatus, StatusBadge } from '@/components/execution-status' +import type { LogViewTab } from '@/components/resources/log-view' +import { LogView } from '@/components/resources/log-view' import type { WorkflowLogRow } from '@/lib/api/contracts/logs' -import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' -import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' -import { isChatEnabled } from '@/lib/core/config/env-flags' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' -import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' -import type { TraceSpan } from '@/lib/logs/types' -import { sendMothershipMessage } from '@/lib/mothership/events' -import { - ExecutionSnapshot, - FileCards, - TraceView, -} from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { logDetailsTabParam, logDetailsTabUrlKeys, } from '@/app/workspace/[workspaceId]/logs/search-params' -import { - DELETED_WORKFLOW_LABEL, - formatDate, - TriggerBadge, -} from '@/app/workspace/[workspaceId]/logs/utils' -import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { usePermissionConfig } from '@/hooks/use-permission-config' -import { formatCost } from '@/providers/utils' +import { grantsFromPermissions, workspaceSource } from '@/resources' import { useLogDetailsUIStore } from '@/stores/logs/store' import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils' -import type { ChatContext } from '@/stores/panel' - -/** - * Renders an already-apportioned integer credit value. `dollars` is only used - * to distinguish a genuine zero ("0 credits") from a sub-credit charge that - * rounded down to zero ("<1 credit"); the credit figure itself is authoritative. - */ -function creditLabel(credits: number, dollars: number): string { - if (credits <= 0) return dollars > 0 ? '<1 credit' : '0 credits' - return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}` -} - -export const WorkflowOutputSection = memo( - function WorkflowOutputSection({ output }: { output: Record }) { - const contentRef = useRef(null) - const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) - - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) - const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) - - const { - isSearchActive, - searchQuery, - setSearchQuery, - matchCount, - currentMatchIndex, - activateSearch, - closeSearch, - goToNextMatch, - goToPreviousMatch, - handleMatchCountChange, - searchInputRef, - } = useCodeViewerFeatures({ contentRef }) - - const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) - - function handleContextMenu(e: React.MouseEvent) { - e.preventDefault() - e.stopPropagation() - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setIsContextMenuOpen(true) - } - - function handleCopy() { - copy(jsonString) - setIsContextMenuOpen(false) - } - - function handleSearch() { - activateSearch() - setIsContextMenuOpen(false) - } - - return ( -
-
- - {/* Glass action buttons overlay */} - {!isSearchActive && ( -
- - - - - {copied ? 'Copied' : 'Copy'} - - - - - - Search - -
- )} -
- - {/* Search Overlay */} - {isSearchActive && ( -
e.stopPropagation()} - > - setSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-0.5 w-[94px]' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} - - - - -
- )} - - {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} - {typeof document !== 'undefined' && - createPortal( - setIsContextMenuOpen(false)} - modal={false} - > - -
- - e.preventDefault()} - > - - - Copy - - - - - Search - - - , - document.body - )} -
- ) - }, - (prev, next) => prev.output === next.output -) - -export type LogDetailsTab = 'overview' | 'trace' - -interface LogDetailsContentProps { - log: WorkflowLogRow - onActiveTabChange?: (tab: LogDetailsTab) => void -} - -export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentProps) { - const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) - const [activeTab, setActiveTab] = useQueryState(logDetailsTabParam.key, { - ...logDetailsTabParam.parser, - ...logDetailsTabUrlKeys, - }) - const { copied: copiedRunId, copy: copyRunId } = useCopyToClipboard({ resetMs: 1500 }) - - const scrollAreaRef = useRef(null) - - const router = useRouter() - const { workspaceId } = useParams<{ workspaceId: string }>() - - const { config: permissionConfig } = usePermissionConfig() - - const isInitialTabMountRef = useRef(true) - /** - * Honors a deep-linked tab on first mount; resets to overview only when - * switching to a different log. - */ - useEffect(() => { - if (isInitialTabMountRef.current) { - isInitialTabMountRef.current = false - } else { - setActiveTab('overview') - } - if (scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = 0 - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- stable nuqs setter; reset tab when switching logs - }, [log.id]) - - const isLikelyExecution = !!log.executionId && log.trigger !== 'mothership' - const isWorkflowExecutionLog = - (log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans - - const hasCostInfo = !!(isWorkflowExecutionLog && log.cost) - const showWorkflowState = - isWorkflowExecutionLog && - !!log.executionId && - log.trigger !== 'mothership' && - !permissionConfig.hideTraceSpans - - const showTraceTab = !permissionConfig.hideTraceSpans && isLikelyExecution - // double-cast-allowed: contract schema makes duration/startTime optional for legacy persisted JSON; runtime data always supplies them. - const traceSpans = log.executionData?.traceSpans as unknown as TraceSpan[] | undefined - - const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab - - useLayoutEffect(() => { - onActiveTabChange?.(resolvedTab) - }, [resolvedTab, onActiveTabChange]) - - const workflowOutput = useMemo(() => { - const executionData = log.executionData as { finalOutput?: Record } | undefined - if (!executionData?.finalOutput) return null - return filterHiddenOutputKeys(executionData.finalOutput) as Record - }, [log.executionData]) - - const workflowInput = useMemo(() => { - const executionData = log.executionData as { workflowInput?: unknown } | undefined - const raw = executionData?.workflowInput - if (raw === undefined || raw === null) return null - if (typeof raw === 'object' && !Array.isArray(raw)) { - return raw as Record - } - return { input: raw } as Record - }, [log.executionData]) - - // Cost breakdown, sourced solely from the usage_log ledger (single source of - // truth). Line items (Base Run / per-model / per-integration) get integer - // credits apportioned with a single round at the total so rows always - // reconcile (never round-then-sum, which drifts). Pre-ledger runs that only - // have the cost_total projection show the total alone — no itemization, no - // parallel jsonb reconstruction. - const costBreakdown = useMemo((): { - rows: Array<{ key: string; label: string; credits: number; dollars: number }> - totalCredits: number - totalDollars: number - tokens: { input: number; output: number } - } | null => { - const ledger = log.costLedger - if (ledger && ledger.items.length > 0) { - const credits = apportionCredits( - ledger.items.map((item, i) => ({ key: String(i), dollars: item.cost })) - ) - const rows = ledger.items.map((item, i) => ({ - key: String(i), - label: - item.category === 'fixed' && item.description === 'execution_fee' - ? 'Base Run' - : item.description, - credits: credits[String(i)] ?? 0, - dollars: item.cost, - })) - return { - rows, - totalCredits: dollarsToCredits(ledger.total), - totalDollars: ledger.total, - tokens: { - input: ledger.items.reduce((s, it) => s + (it.inputTokens ?? 0), 0), - output: ledger.items.reduce((s, it) => s + (it.outputTokens ?? 0), 0), - }, - } - } - - // Total-only (pre-ledger runs with just the cost_total projection). - const total = log.cost?.total - if (total == null) return null - return { - rows: [], - totalCredits: dollarsToCredits(total), - totalDollars: total, - tokens: { input: 0, output: 0 }, - } - }, [log.costLedger, log.cost]) - - const formattedTimestamp = formatDate(log.createdAt) - const logStatus = getDisplayStatus(log.status) - - /** - * Troubleshooting hands the failed run off to Chat, tagging it by - * `executionId`. A real Chat run can't be debugged from inside itself, so - * mothership-triggered logs are excluded — `isLikelyExecution` already encodes - * "has an executionId and isn't a mothership run". - */ - const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution - - /** - * Hands the failed run to Chat. When a chat is already mounted (e.g. the run - * is being viewed inside Chat's resource panel) it consumes the tagged - * message directly; otherwise a one-shot handoff is persisted and we navigate - * to a fresh chat that picks it up on mount. Navigation is gated on a - * successful store, so a failed write never strands the user on an empty chat. - */ - const handleTroubleshoot = useCallback(() => { - if (!log.executionId) return - const workflowName = log.workflow?.name?.trim() || null - const context: ChatContext = { - kind: 'logs', - executionId: log.executionId, - label: workflowName ?? 'this run', - } - const message = workflowName - ? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.` - : 'This workflow run failed. Investigate the error in this run and help me fix it.' - if (sendMothershipMessage(message, [context])) return - if (MothershipHandoffStorage.store({ message, contexts: [context] }, workspaceId)) { - router.push(`/workspace/${workspaceId}/home`) - } - }, [log.executionId, log.workflow?.name, workspaceId, router]) - - return ( - <> -
- setActiveTab(v as LogDetailsTab)} - /> - - {/* Overview Tab */} - {resolvedTab === 'overview' && ( -
-
- {/* Timestamp + Workflow header */} -
-
- - Timestamp - - - {formattedTimestamp - ? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}` - : '—'} - -
-
- - {log.trigger === 'mothership' ? 'Job' : 'Workflow'} - -
- - - {log.trigger === 'mothership' - ? log.jobTitle || 'Untitled Job' - : log.workflow?.name || - (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} - -
-
-
- - {/* Details Section */} -
- {/* Run ID — click to copy */} - {log.executionId && ( -
copyRunId(log.executionId!)} - onKeyDown={(event) => - handleKeyboardActivation(event, () => copyRunId(log.executionId!)) - } - > - - Run ID - - - {copiedRunId ? 'Copied!' : log.executionId} - -
- )} - - {/* Level */} -
- - Level - - -
- - {/* Trigger */} -
- - Trigger - - {log.trigger ? ( - - ) : ( - - None - - )} -
- - {/* Duration */} -
- - Duration - - - {formatDuration(log.duration, { precision: 2 }) || '—'} - -
- - {/* Version */} - {log.deploymentVersion && ( -
- - Version - -
- - {log.deploymentVersionName || `v${log.deploymentVersion}`} - -
-
- )} - - {/* Snapshot */} - {showWorkflowState && ( -
- - Snapshot - - setIsExecutionSnapshotOpen(true)}> - View Snapshot - -
- )} - - {/* Troubleshoot */} - {canTroubleshoot && ( -
- - Troubleshoot - - - Troubleshoot in Chat - -
- )} -
- - {/* Workflow Input */} - {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Input - - -
- )} - - {/* Workflow Output */} - {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Output - - -
- )} - - {/* Files */} - {log.files && log.files.length > 0 && } - - {/* Cost Breakdown */} - {hasCostInfo && costBreakdown && ( -
- {costBreakdown.rows.map((row) => ( -
- - {row.label} - - - {creditLabel(row.credits, row.dollars)} - -
- ))} -
- - Total - - - {creditLabel(costBreakdown.totalCredits, costBreakdown.totalDollars)} - -
- {(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && ( -
- - Tokens - - - {costBreakdown.tokens.input} in · {costBreakdown.tokens.output} out - -
- )} -
-

- Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus model - and tool usage. -

-
-
- )} -
-
- )} - - {/* Trace Tab */} - {showTraceTab && resolvedTab === 'trace' && ( -
- {traceSpans?.length ? ( - - ) : log.executionData ? ( -
- - No trace data available for this run - -
- ) : ( -
- - Loading trace… - -
- )} -
- )} -
- - {/* Frozen Canvas Modal */} - {log.executionId && ( - setIsExecutionSnapshotOpen(false)} - /> - )} - - ) -} interface LogDetailsProps { log: WorkflowLogRow | null @@ -689,7 +29,7 @@ interface LogDetailsProps { hasPrev?: boolean onRetryExecution?: () => void isRetryPending?: boolean - onActiveTabChange?: (tab: LogDetailsTab) => void + onActiveTabChange?: (tab: LogViewTab) => void } export const LogDetails = memo(function LogDetails({ @@ -704,16 +44,39 @@ export const LogDetails = memo(function LogDetails({ isRetryPending = false, onActiveTabChange, }: LogDetailsProps) { - const activeTabRef = useRef('overview') + const activeTabRef = useRef('overview') const handleActiveTabChange = useCallback( - (tab: LogDetailsTab) => { + (tab: LogViewTab) => { activeTabRef.current = tab onActiveTabChange?.(tab) }, [onActiveTabChange] ) + const router = useRouter() + const { workspaceId } = useParams<{ workspaceId: string }>() + const permissions = useUserPermissionsContext() + const { config: permissionConfig } = usePermissionConfig() + + /** + * The logs page owns its URL, so the tab stays deep-linkable here rather than + * inside the view. + */ + const [activeTab, setActiveTab] = useQueryState(logDetailsTabParam.key, { + ...logDetailsTabParam.parser, + ...logDetailsTabUrlKeys, + }) + + const source = useMemo( + () => workspaceSource({ kind: 'log' as const, workspaceId, resourceId: log?.id ?? '' }), + [workspaceId, log?.id] + ) + const grants = useMemo(() => grantsFromPermissions(permissions), [permissions]) + const showExecutionInternals = !permissionConfig.hideTraceSpans + + const handleNavigate = useCallback((path: string) => router.push(path), [router]) + const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() @@ -816,7 +179,17 @@ export const LogDetails = memo(function LogDetails({
- + )} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 04696adcf92..5b6636d737d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -33,6 +33,12 @@ import { STATUS_CONFIG, StatusBadge, } from '@/components/execution-status' +import { + DELETED_WORKFLOW_LABEL, + ExecutionSnapshot, + formatDate, + TriggerBadge, +} from '@/components/resources/log-view' import type { WorkflowLogDetail, WorkflowLogRow, @@ -91,8 +97,8 @@ import { useDebounce } from '@/hooks/use-debounce' import { useUrlSort } from '@/hooks/use-url-sort' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' -import { DELETED_WORKFLOW_LABEL, formatDate, parseDuration, TriggerBadge } from './utils' +import { Dashboard, LogDetails, LogRowContextMenu } from './components' +import { parseDuration } from './utils' const LOGS_PER_PAGE = 50 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index be877ef6620..9bb6283ed78 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -1,10 +1,4 @@ -import React from 'react' -import { Badge } from '@sim/emcn' -import { formatDuration, formatRelativeTime } from '@sim/utils/formatting' -import { format } from 'date-fns' -import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' -import { getBlock } from '@/blocks/registry' -import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' +import { formatDuration } from '@sim/utils/formatting' export const LOG_COLUMNS = { workflow: { width: 'w-[22%]', minWidth: 'min-w-[140px]', label: 'Workflow' }, @@ -15,66 +9,6 @@ export const LOG_COLUMNS = { duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' }, } as const -export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow' - -const TRIGGER_VARIANT_MAP: Record['variant']> = { - manual: 'gray-secondary', - api: 'blue', - schedule: 'green', - chat: 'purple', - webhook: 'orange', - mcp: 'cyan', - copilot: 'pink', - mothership: 'pink', - workflow: 'blue-secondary', - custom_block: 'blue-secondary', -} - -interface TriggerBadgeProps { - trigger: string -} - -/** - * Renders a colored badge indicating the workflow trigger type. - * Core triggers display with their designated colors; integrations show with icons. - * @param props - Component props containing the trigger type - * @returns A Badge with appropriate styling for the trigger type - */ -export function TriggerBadge({ trigger }: TriggerBadgeProps) { - const metadata = getIntegrationMetadata(trigger) - const isIntegration = !(CORE_TRIGGER_TYPES as readonly string[]).includes(trigger) - const block = isIntegration ? getBlock(trigger) : null - const IconComponent = block?.icon - - const coreVariant = TRIGGER_VARIANT_MAP[trigger] - if (coreVariant) { - return React.createElement( - Badge, - { variant: coreVariant, size: 'sm', className: 'whitespace-nowrap' }, - metadata.label - ) - } - - if (IconComponent) { - return React.createElement( - Badge, - { - variant: 'gray-secondary', - size: 'sm', - icon: IconComponent, - className: 'whitespace-nowrap', - }, - metadata.label - ) - } - - return React.createElement( - Badge, - { variant: 'gray-secondary', size: 'sm', className: 'whitespace-nowrap' }, - metadata.label - ) -} - interface LogWithDuration { totalDurationMs?: number | string duration?: number | string @@ -111,29 +45,3 @@ export function formatLatency(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return '—' return formatDuration(ms, { precision: 2 }) ?? '—' } - -export const formatDate = (dateString: string) => { - const date = new Date(dateString) - return { - full: date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }), - time: date.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }), - formatted: format(date, 'HH:mm:ss'), - compact: format(date, 'MMM d HH:mm:ss'), - compactDate: format(date, 'MMM d').toUpperCase(), - compactTime: format(date, 'h:mm a'), - relative: formatRelativeTime(dateString), - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx index e964a276987..4ef9bdf606a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx @@ -3,14 +3,14 @@ import { useEffect, useState } from 'react' import { Badge, Button, ChipModalTabs, cn, X } from '@sim/emcn' import { formatDuration } from '@sim/utils/formatting' -import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table' import { adjustBgForContrast, + formatDate, getBlockIconAndColor, iconColorClass, -} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils' +} from '@/components/resources/log-view' +import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' -import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' import { useEnrichmentDetail } from '@/hooks/queries/tables' import { formatCost } from '@/providers/utils' import { useLogDetailsUIStore } from '@/stores/logs/store' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx index 4d1266b273f..89a241f9956 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' -import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' +import { DELETED_WORKFLOW_LABEL } from '@/components/resources/log-view' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import type { SubBlockConfig } from '@/blocks/types' import type { SelectorContext } from '@/hooks/selectors/types' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 79a09e5d348..6a23cc1ed08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -41,8 +41,6 @@ import { useEditorSubblockLayout, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks' import { ActiveSearchTargetProvider } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' -import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' -import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { @@ -52,6 +50,7 @@ import { import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { getTileIconColorClass } from '@/blocks/icon-color' import { getBlock } from '@/blocks/registry' +import { LoopTool, ParallelTool } from '@/blocks/subflow-tools' import { useFolderMap } from '@/hooks/queries/folders' import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' import { useWorkflowMap, useWorkflowState } from '@/hooks/queries/workflows' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index ff6b7e76f75..825a9671f2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -28,8 +28,6 @@ import { captureEvent } from '@/lib/posthog/client' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components' import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks' -import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' -import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { buildCustomBlockConfig, CUSTOM_BLOCK_TILE_COLOR, @@ -39,6 +37,7 @@ import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' import { getTileIconColorClass } from '@/blocks/icon-color' import { getCanonicalBlocksByCategory } from '@/blocks/registry' +import { LoopTool, ParallelTool } from '@/blocks/subflow-tools' import type { BlockConfig } from '@/blocks/types' import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider' import { useCustomBlocks } from '@/hooks/queries/custom-blocks' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/index.ts index 140f23328ae..9f3e2b3f45c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/index.ts @@ -1 +1 @@ -export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' +export { LoopTool } from '@/blocks/subflow-tools' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config.ts deleted file mode 100644 index 5eb91b7cd55..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Repeat } from '@sim/emcn/icons' - -/** - * Loop tool configuration for the toolbar. - * Defines the visual appearance of the Loop subflow container in the toolbar. - */ -export const LoopTool = { - type: 'loop', - name: 'Loop', - icon: Repeat, - bgColor: '#2FB3FF', - docsLink: 'https://docs.sim.ai/workflows/blocks/loop', -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/index.ts index 2d621f6f3f2..2fc685bbc93 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/index.ts @@ -1 +1 @@ -export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' +export { ParallelTool } from '@/blocks/subflow-tools' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config.ts deleted file mode 100644 index 71600a4b024..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Split } from '@sim/emcn/icons' - -/** - * Parallel tool configuration for the toolbar. - * Defines the visual appearance of the Parallel subflow container in the toolbar. - */ -export const ParallelTool = { - type: 'parallel', - name: 'Parallel', - icon: Split, - bgColor: '#FEE12B', - docsLink: 'https://docs.sim.ai/workflows/blocks/parallel', -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 610365f9bac..8ce1bcaf182 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -30,6 +30,7 @@ import { import { formatDuration } from '@sim/utils/formatting' import { useParams } from 'next/navigation' import { ReactFlowProvider } from 'reactflow' +import { DELETED_WORKFLOW_LABEL } from '@/components/resources/log-view' import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references' import { buildCanonicalIndex, @@ -39,7 +40,6 @@ import { isSubBlockVisibleForMode, isToolInputOnlySubBlock, } from '@/lib/workflows/subblocks/visibility' -import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow' diff --git a/apps/sim/blocks/subflow-tools.ts b/apps/sim/blocks/subflow-tools.ts new file mode 100644 index 00000000000..4e4ff236d78 --- /dev/null +++ b/apps/sim/blocks/subflow-tools.ts @@ -0,0 +1,25 @@ +import { Repeat, Split } from '@sim/emcn/icons' + +/** + * Visual identity of the two subflow containers. + * + * Lives with the block registry rather than inside the workflow editor because + * both the editor's toolbar and the log view's trace renderer need it, and a + * shared leaf under `app/workspace/[workspaceId]/**` is exactly the shape that + * makes a workspace-nested module read as importable from anywhere. + */ +export const LoopTool = { + type: 'loop', + name: 'Loop', + icon: Repeat, + bgColor: '#2FB3FF', + docsLink: 'https://docs.sim.ai/workflows/blocks/loop', +} as const + +export const ParallelTool = { + type: 'parallel', + name: 'Parallel', + icon: Split, + bgColor: '#FEE12B', + docsLink: 'https://docs.sim.ai/workflows/blocks/parallel', +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/index.ts b/apps/sim/components/inline-rename-input/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/index.ts rename to apps/sim/components/inline-rename-input/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx b/apps/sim/components/inline-rename-input/inline-rename-input.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx rename to apps/sim/components/inline-rename-input/inline-rename-input.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx b/apps/sim/components/resource/components/floating-overflow-text.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx rename to apps/sim/components/resource/components/floating-overflow-text.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/components/resource/components/owner-cell/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts rename to apps/sim/components/resource/components/owner-cell/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/components/resource/components/owner-cell/owner-cell.tsx similarity index 95% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx rename to apps/sim/components/resource/components/owner-cell/owner-cell.tsx index bb33d549ed2..3de2d2a96b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/components/resource/components/owner-cell/owner-cell.tsx @@ -1,5 +1,5 @@ import { memo, type ReactNode } from 'react' -import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' +import type { ResourceCell } from '@/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' interface OwnerAvatarProps { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/index.ts b/apps/sim/components/resource/components/resource-chrome-fallback/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/index.ts rename to apps/sim/components/resource/components/resource-chrome-fallback/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx similarity index 93% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx rename to apps/sim/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx index 48fca17aa52..01093472c07 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx +++ b/apps/sim/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx @@ -2,11 +2,8 @@ import type { ComponentType } from 'react' import { noop } from '@sim/utils/helpers' -import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' -import { - Resource, - type ResourceColumn, -} from '@/app/workspace/[workspaceId]/components/resource/resource' +import type { BreadcrumbItem } from '@/components/resource/components/resource-header' +import { Resource, type ResourceColumn } from '@/components/resource/resource' /** * The static visual shape of a header action chip. The loading fallback only diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts b/apps/sim/components/resource/components/resource-header/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts rename to apps/sim/components/resource/components/resource-header/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/components/resource/components/resource-header/resource-header.tsx similarity index 99% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx rename to apps/sim/components/resource/components/resource-header/resource-header.tsx index d70a12049b8..88d8627e3d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/components/resource/components/resource-header/resource-header.tsx @@ -33,9 +33,9 @@ import { } from '@sim/emcn' import { ArrowUpLeft } from '@sim/emcn/icons' import { createPortal } from 'react-dom' +import { InlineRenameInput } from '@/components/inline-rename-input' import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar' import { orderHeaderActions } from '@/components/settings/settings-header' -import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' export interface DropdownOption { label: string diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/index.ts b/apps/sim/components/resource/components/resource-options/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/index.ts rename to apps/sim/components/resource/components/resource-options/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx b/apps/sim/components/resource/components/resource-options/resource-options.test.tsx similarity index 93% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx rename to apps/sim/components/resource/components/resource-options/resource-options.test.tsx index 7a8e6682153..9cc239bb592 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx +++ b/apps/sim/components/resource/components/resource-options/resource-options.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SortDropdown } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options' +import { SortDropdown } from '@/components/resource/components/resource-options/resource-options' const LONG_COLUMN_LABEL = 'highest_current_champion_role_across_the_entire_company' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx b/apps/sim/components/resource/components/resource-options/resource-options.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx rename to apps/sim/components/resource/components/resource-options/resource-options.tsx index 101acc67ee8..384f4dc0cfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx +++ b/apps/sim/components/resource/components/resource-options/resource-options.tsx @@ -18,7 +18,7 @@ import { Search, X, } from '@sim/emcn' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import { FloatingOverflowText } from '@/components/resource/components/floating-overflow-text' const SEARCH_ICON = ( diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell.ts b/apps/sim/components/resource/components/time-cell.ts similarity index 95% rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell.ts rename to apps/sim/components/resource/components/time-cell.ts index fb970451599..fc59b3ff1f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell.ts +++ b/apps/sim/components/resource/components/time-cell.ts @@ -1,4 +1,4 @@ -import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' +import type { ResourceCell } from '@/components/resource/resource' const SECOND = 1000 const MINUTE = 60 * SECOND diff --git a/apps/sim/components/resource/index.ts b/apps/sim/components/resource/index.ts new file mode 100644 index 00000000000..0cb0261427f --- /dev/null +++ b/apps/sim/components/resource/index.ts @@ -0,0 +1,45 @@ +/** + * The shared resource-page chrome: the `Resource` compound shell (header, + * options toolbar, data table) and the cell/column primitives its consumers + * build rows from. + * + * It lives under `components/` rather than the workspace route tree because it + * has consumers on both sides of that tree — the workspace pages (files, + * knowledge, tables, logs) and the canonical resource views under + * `components/resources/**`, which may not import + * `@/app/workspace/[workspaceId]/**`. + */ +export { FloatingOverflowText } from './components/floating-overflow-text' +export { type MemberFilterOption, memberFilterOptions, ownerCell } from './components/owner-cell' +export { + type ChromeActionSpec, + ResourceChromeFallback, +} from './components/resource-chrome-fallback' +export type { + BreadcrumbEditing, + BreadcrumbItem, + DropdownOption, + ResourceAction, +} from './components/resource-header' +export type { + ColumnOption, + FilterConfig, + FilterTag, + SearchConfig, + SearchTag, + SortConfig, +} from './components/resource-options' +export { SortDropdown } from './components/resource-options' +export { timeCell } from './components/time-cell' +export type { + PaginationConfig, + ResourceCell, + ResourceCellEditing, + ResourceColumn, + ResourceRow, + ResourceTableHandle, + RowDragDropConfig, + SelectableConfig, +} from './resource' +export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource' +export { useBackgroundContextMenu } from './use-background-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/components/resource/resource.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx rename to apps/sim/components/resource/resource.tsx index 721d3bc0c5a..bed3f2b6a97 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/components/resource/resource.tsx @@ -23,10 +23,10 @@ import { } from '@sim/emcn' import { ChevronLeft, ChevronRight, Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' -import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' -import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' -import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' +import { InlineRenameInput } from '@/components/inline-rename-input' +import { FloatingOverflowText } from '@/components/resource/components/floating-overflow-text' +import { ResourceHeader } from '@/components/resource/components/resource-header' +import { ResourceOptions } from '@/components/resource/components/resource-options' export interface ResourceColumn { id: string diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-background-context-menu.ts b/apps/sim/components/resource/use-background-context-menu.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/components/resource/use-background-context-menu.ts rename to apps/sim/components/resource/use-background-context-menu.ts diff --git a/apps/sim/components/resources/MIGRATING-RESOURCES.md b/apps/sim/components/resources/MIGRATING-RESOURCES.md index 7bfbd0fea2c..e4a14ce020d 100644 --- a/apps/sim/components/resources/MIGRATING-RESOURCES.md +++ b/apps/sim/components/resources/MIGRATING-RESOURCES.md @@ -3,7 +3,7 @@ Follow-up work after the file resource was moved onto the three axes, and the table's view layer was moved out of its route. -This is the playbook for making a resource — a table, a knowledge base, a log, a scheduled task — +This is the playbook for making a resource — a table, a knowledge base, a log — render the same way everywhere: on its own page, in a chat/mothership tab, and on a public share. Read `.claude/rules/sim-resource-views.md` first; that is the rule. This is the migration plan. @@ -16,7 +16,6 @@ This is the migration plan. | **table** | *view layer only* (`components/resources/table-view`) | tables page + mothership, via the editing shell that mounts its parts | Moved out of the route tree; no standalone read-only view has a consumer yet. | | **knowledge** | — | knowledge page, mothership panel | No public consumer yet. | | **log** | — | logs page, mothership panel, tables page | `LogDetailsContent` is already shared; it just leaks context. | -| **schedule** | — | scheduled-tasks page, mothership panel | Smallest surface. | | **workflow** | *deliberately excluded* | — | Not a document. See "Why workflow is not a resource". | | **folder** | *not a resource* | — | Organisational structure inside files/knowledge, not a thing you render. | @@ -87,6 +86,10 @@ resource from the stored share on every request, so there is nothing for a calle Add the unit to `CANONICAL_UNITS`. Migrate every consumer in the same PR. Lower the `R1b` (`shadowNamedComponents`) baseline as each `Embedded*` component for that kind disappears. +Tab chrome is not a view and never blocked a migration: the mothership's five `Embedded*Actions` +differed only in icon, copy and destination, so they are now one kind-keyed `ResourceTabActions` +driven by a `RESOURCE_TAB_ACTIONS` config map. + --- ## Table — the next one, and the one that pays @@ -168,7 +171,7 @@ a chat panel fetch 1000 rows to show ten, or make every bulk operation ten times editing-shell, or mutation hooks end up shipped to anonymous surfaces. - **`[...arr].sort()`**, never `toSorted` — SWC does not polyfill it and it throws on iOS 15. -## Knowledge, log, schedule +## Knowledge and log Lower value, and none has a public consumer today. @@ -178,7 +181,6 @@ Lower value, and none has a public consumer today. - **knowledge** — `KnowledgeBaseProps` is `{ id, knowledgeBaseName?, workspaceId? }`, so it structurally cannot leave the workspace. It also writes unnamespaced nuqs keys (`?addConnector/?page/?q/?enabled`) that pollute the host URL when embedded — `host` fixes that. -- **schedule** — smallest surface; do it last or fold it into whichever PR is already open. ## Why workflow is not a resource @@ -208,5 +210,18 @@ Carry these into whichever PR touches the area: byte-serves correctly (both are media) but the wrong player element is chosen. - `components/rich-markdown-editor/` moved as a unit and keeps its own flat internal layout, which is inconsistent with the rest of `file-view/`. -- The mothership keeps 8 `Embedded*` components for kinds with no canonical view. Each disappears - with its kind's migration; lower the `R1b` baseline as they go. +- The mothership keeps 3 `Embedded*` components — `EmbeddedWorkflow`, `EmbeddedFolder`, + `EmbeddedLog` — for kinds with no canonical view. Each disappears with its kind's migration; + lower the `R1b` baseline as they go. The tab-chrome half is done: the five `Embedded*Actions` + collapsed into one kind-keyed `ResourceTabActions`. +- **Two mothership tab destinations do not match `resourceHref`, and were left alone.** Both are + kept verbatim in `RESOURCE_TAB_ACTIONS` rather than silently moved onto the axis: + - **file** — the tab opens `/files/` (the browser with the file selected); the axis spells + `/files//view` (the separate fullscreen route). Both routes exist, so this is a product + question — which one should "Open in files" mean? — not a bug. + - **log** — the tab opens `?executionId=`, read off the fetched detail; the axis + builds `?executionId=`. A log resource is addressed by its **log-row id**, and the + logs page resolves `?executionId` through `/api/logs/by-execution/[executionId]`. So + `resourceHref('log', …)` is latently wrong for every caller that passes a log id — today only + `simLinkPath`, which no surface emits a `sim:log/` mention into. Fix it by deciding which id + the axis addresses a log by, then making both sides agree. diff --git a/apps/sim/components/resources/knowledge-view/components/document-tags-cell/document-tags-cell.tsx b/apps/sim/components/resources/knowledge-view/components/document-tags-cell/document-tags-cell.tsx new file mode 100644 index 00000000000..6eb5fb7155c --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/components/document-tags-cell/document-tags-cell.tsx @@ -0,0 +1,41 @@ +'use client' + +import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn' +import type { TagValue } from '@/components/resources/knowledge-view/utils/document-rows' + +interface DocumentTagsCellProps { + tags: TagValue[] +} + +/** + * Tags cell for the documents table. Shows the joined tag values inline and + * reveals the full `name: value` breakdown only when the inline text is + * actually clipped — an un-truncated cell already says everything the tooltip + * would. + */ +export function DocumentTagsCell({ tags }: DocumentTagsCellProps) { + const { state, handlers } = useFloatingTooltip(isTextClipped) + + return ( + <> + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + {...handlers} + > + {tags.map((tag) => tag.value).join(', ')} + + +
+ {tags.map((tag) => ( +
+ {tag.displayName}: {tag.value} +
+ ))} +
+
+ + ) +} diff --git a/apps/sim/components/resources/knowledge-view/components/document-tags-cell/index.ts b/apps/sim/components/resources/knowledge-view/components/document-tags-cell/index.ts new file mode 100644 index 00000000000..db34df88e00 --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/components/document-tags-cell/index.ts @@ -0,0 +1 @@ +export { DocumentTagsCell } from './document-tags-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts b/apps/sim/components/resources/knowledge-view/components/search-highlight/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts rename to apps/sim/components/resources/knowledge-view/components/search-highlight/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx b/apps/sim/components/resources/knowledge-view/components/search-highlight/search-highlight.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx rename to apps/sim/components/resources/knowledge-view/components/search-highlight/search-highlight.tsx diff --git a/apps/sim/components/resources/knowledge-view/components/tag-filter-panel/index.ts b/apps/sim/components/resources/knowledge-view/components/tag-filter-panel/index.ts new file mode 100644 index 00000000000..b360ff832c2 --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/components/tag-filter-panel/index.ts @@ -0,0 +1,7 @@ +export { + AutoWidthPanel, + createEmptyEntry, + FILTER_SECTION_LABEL_CLASS, + type TagFilterEntry, + TagFilterSection, +} from './tag-filter-panel' diff --git a/apps/sim/components/resources/knowledge-view/components/tag-filter-panel/tag-filter-panel.tsx b/apps/sim/components/resources/knowledge-view/components/tag-filter-panel/tag-filter-panel.tsx new file mode 100644 index 00000000000..d49cde5033b --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/components/tag-filter-panel/tag-filter-panel.tsx @@ -0,0 +1,297 @@ +'use client' + +import { type ReactNode, useEffect, useMemo, useRef } from 'react' +import { Button, ChipDatePicker, ChipDropdown, type ChipDropdownOption, ChipInput } from '@sim/emcn' +import { Plus, X } from '@sim/emcn/icons' +import { generateId } from '@sim/utils/id' +import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' +import type { TagDefinition } from '@/hooks/kb/use-knowledge-base-tag-definitions' + +/** Shared label styling for every section heading inside the filter popover. */ +export const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' + +/** + * One tag-filter rule in the popover. Kept out of the URL deliberately: it is a + * rich rule object (slot, field type, operator, value, upper bound), too large + * and too structured for a query param. + */ +export interface TagFilterEntry { + id: string + tagName: string + tagSlot: string + fieldType: FilterFieldType + operator: string + value: string + valueTo: string +} + +export const createEmptyEntry = (): TagFilterEntry => ({ + id: generateId(), + tagName: '', + tagSlot: '', + fieldType: 'text', + operator: 'contains', + value: '', + valueTo: '', +}) + +/** + * Sizes the filter popover to its content with pure CSS `max-content` (clamped to + * `[280, 420]`). Because the padding box is part of `max-content`, the `p-3` + * inset is preserved on every edge — there is no separate measured/animated outer + * layer that can disagree by a few pixels and clip the right padding. The width + * still adapts to the active filters; it just resizes instantly rather than + * animating. + */ +export function AutoWidthPanel({ children }: { children: ReactNode }) { + return
{children}
+} + +/** + * Default operator when a tag is selected. Text filters default to `contains` + * so typing part of a value finds matches (exact `equals` stays one click away + * in the operator dropdown); other field types keep their first, equality + * operator. + */ +function getDefaultOperatorForFieldType( + fieldType: FilterFieldType, + operators: ReturnType +): string { + if (fieldType === 'text') return 'contains' + return operators[0]?.value ?? 'eq' +} + +interface TagFilterValueControlProps { + entry: TagFilterEntry + onChange: (patch: Partial) => void +} + +/** + * Renders the value input for a knowledge base tag filter row. + */ +function TagFilterValueControl({ entry, onChange }: TagFilterValueControlProps) { + const isBetween = entry.operator === 'between' + + if (entry.fieldType === 'date') { + if (isBetween) { + return ( +
+ onChange({ value })} + placeholder='From' + fullWidth + /> + to + onChange({ valueTo: value })} + placeholder='To' + fullWidth + /> +
+ ) + } + + return ( + onChange({ value })} + placeholder='Select date' + fullWidth + /> + ) + } + + if (isBetween) { + return ( +
+ onChange({ value: event.target.value })} + placeholder='From' + /> + to + onChange({ valueTo: event.target.value })} + placeholder='To' + /> +
+ ) + } + + return ( + onChange({ value: event.target.value })} + placeholder={ + entry.fieldType === 'boolean' + ? 'true or false' + : entry.fieldType === 'number' + ? 'Enter number' + : 'Enter value' + } + /> + ) +} + +interface TagFilterSectionProps { + tagDefinitions: TagDefinition[] + entries: TagFilterEntry[] + onChange: (entries: TagFilterEntry[]) => void +} + +/** + * Tag filter section rendered inside the combined filter popover. + */ +export function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectionProps) { + const activeCount = entries.filter((f) => f.tagSlot && f.value.trim()).length + + const tagOptions: ChipDropdownOption[] = tagDefinitions.map((t) => ({ + value: t.displayName, + label: t.displayName, + })) + + const filtersToShow = useMemo( + () => (entries.length > 0 ? entries : [createEmptyEntry()]), + [entries] + ) + + const scrollRef = useRef(null) + const prevCountRef = useRef(filtersToShow.length) + + useEffect(() => { + if (filtersToShow.length > prevCountRef.current) { + const el = scrollRef.current + if (el) { + requestAnimationFrame(() => { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + }) + } + } + prevCountRef.current = filtersToShow.length + }, [filtersToShow.length]) + + const updateEntry = (id: string, patch: Partial) => { + const existing = filtersToShow.find((e) => e.id === id) + if (!existing) return + const updated = filtersToShow.map((e) => (e.id === id ? { ...e, ...patch } : e)) + onChange(updated) + } + + const handleTagChange = (id: string, tagName: string) => { + const def = tagDefinitions.find((t) => t.displayName === tagName) + const fieldType = (def?.fieldType || 'text') as FilterFieldType + const operators = getOperatorsForFieldType(fieldType) + updateEntry(id, { + tagName, + tagSlot: def?.tagSlot || '', + fieldType, + operator: getDefaultOperatorForFieldType(fieldType, operators), + value: '', + valueTo: '', + }) + } + + const addFilter = () => { + onChange([...filtersToShow, createEmptyEntry()]) + } + + const removeFilter = (id: string) => { + const remaining = filtersToShow.filter((e) => e.id !== id) + onChange(remaining.length > 0 ? remaining : []) + } + + if (tagDefinitions.length === 0) return null + + return ( +
+
+ Filter by tags + {activeCount > 0 && ( + + )} +
+ +
+ {filtersToShow.map((entry, index) => { + const operators = getOperatorsForFieldType(entry.fieldType) + const operatorOptions: ChipDropdownOption[] = operators.map((op) => ({ + value: op.value, + label: op.label, + })) + + return ( +
+ {index > 0 && ( +
+ + and + +
+
+ )} +
+
+ handleTagChange(entry.id, value)} + placeholder='Select tag' + align='start' + matchTriggerWidth={false} + contentClassName='max-h-[240px] overflow-y-auto' + className='max-w-[150px]' + /> + {entry.tagSlot && ( + updateEntry(entry.id, { operator: value, valueTo: '' })} + placeholder='Operator' + align='start' + matchTriggerWidth={false} + /> + )} +
+ +
+ {entry.tagSlot && ( + updateEntry(entry.id, patch)} + /> + )} +
+ ) + })} +
+ + +
+ ) +} diff --git a/apps/sim/components/resources/knowledge-view/index.ts b/apps/sim/components/resources/knowledge-view/index.ts new file mode 100644 index 00000000000..8c2dcc0bdd0 --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/index.ts @@ -0,0 +1,15 @@ +/** + * The knowledge base resource view. Consumers mount {@link KnowledgeView} + * against a source, grants, and a host, and supply the document list state they + * own; everything else here is what a surface needs to describe a knowledge + * base document without opening it. + */ +export { SearchHighlight } from './components/search-highlight' +export type { TagFilterEntry } from './components/tag-filter-panel' +export type { + KnowledgeDocumentList, + KnowledgeEnabledFilter, + KnowledgeViewInteraction, + KnowledgeViewProps, +} from './knowledge-view' +export { KnowledgeView } from './knowledge-view' diff --git a/apps/sim/components/resources/knowledge-view/knowledge-view.tsx b/apps/sim/components/resources/knowledge-view/knowledge-view.tsx new file mode 100644 index 00000000000..31b48f899b6 --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/knowledge-view.tsx @@ -0,0 +1,397 @@ +'use client' + +import { type ReactNode, useMemo } from 'react' +import { + Button, + ChipDropdown, + type ChipDropdownOption, + cellIconNodeClass, + chipContentGap, + chipContentLabelClass, + chipVariants, + cn, + Loader, + Tooltip, +} from '@sim/emcn' +import { DatabaseX } from '@sim/emcn/icons' +import { format } from 'date-fns' +import { getDocumentIcon } from '@/components/icons/document-icons' +import { + type FilterTag, + FloatingOverflowText, + type PaginationConfig, + Resource, + type ResourceCell, + type ResourceColumn, + type ResourceRow, + type SelectableConfig, + type SortConfig, +} from '@/components/resource' +import { DocumentTagsCell } from '@/components/resources/knowledge-view/components/document-tags-cell' +import { SearchHighlight } from '@/components/resources/knowledge-view/components/search-highlight' +import { + AutoWidthPanel, + FILTER_SECTION_LABEL_CLASS, + type TagFilterEntry, + TagFilterSection, +} from '@/components/resources/knowledge-view/components/tag-filter-panel' +import { + getDocumentTags, + getStatusBadge, +} from '@/components/resources/knowledge-view/utils/document-rows' +import type { DocumentData } from '@/lib/knowledge/types' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import type { TagDefinition } from '@/hooks/kb/use-knowledge-base-tag-definitions' +import type { ConnectorData } from '@/hooks/queries/kb/connectors' +import type { ResourceGrants, ResourceHost, ResourceSource } from '@/resources' + +const DOCUMENT_COLUMNS: ResourceColumn[] = [ + { id: 'name', header: 'Name', widthMultiplier: 0.8 }, + { id: 'size', header: 'Size', widthMultiplier: 0.75 }, + { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 }, + { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 }, + { id: 'uploaded', header: 'Uploaded' }, + { id: 'status', header: 'Status', widthMultiplier: 0.75 }, + { id: 'tags', header: 'Tags' }, +] + +const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ + { value: 'all', label: 'All' }, + { value: 'enabled', label: 'Enabled' }, + { value: 'disabled', label: 'Disabled' }, +] + +/** Sortable document columns, labelled as the sort menu shows them. */ +const SORT_OPTIONS: 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' }, +] + +/** The three buckets the status filter offers. */ +export type KnowledgeEnabledFilter = 'all' | 'enabled' | 'disabled' + +/** + * The document list as the host resolved it: the rows themselves, the + * definitions the tag column and tag filters read, and the view-state driving + * the query behind them. + * + * The state lives on the host rather than in the view because it decides where + * it is stored: a host that owns the URL keeps `q` / `enabled` / `sort` / `dir` + * / `page` as query params, while an embedded one keeps the same values locally + * so they never reach its address bar. That is exactly the decision + * `hostOwnsUrl(host)` expresses. + */ +export interface KnowledgeDocumentList { + documents: DocumentData[] + tagDefinitions: TagDefinition[] + connectors: ConnectorData[] + /** The knowledge base itself could not be resolved — the view shows the unavailable state. */ + unavailable: boolean + search: string + onSearchChange: (value: string) => void + /** The search text used for match highlighting: always the trimmed query. */ + highlightQuery: string + enabledFilter: KnowledgeEnabledFilter + onEnabledFilterChange: (value: KnowledgeEnabledFilter) => void + tagFilterEntries: TagFilterEntry[] + onTagFilterEntriesChange: (entries: TagFilterEntry[]) => void + /** Sort state and handlers; the column list itself is the view's own. */ + sort: Omit + pagination: PaginationConfig +} + +/** + * Row-level affordances owned by the host's write path — selecting documents for + * a bulk operation, the row context menu, and the action bar those drive. A host + * with no write path (a future share surface) simply omits them and the list + * renders as a plain reading surface. + */ +export interface KnowledgeViewInteraction { + selection?: Omit + overlay?: ReactNode + onRowClick?: (documentId: string) => void + onRowContextMenu?: (e: React.MouseEvent, documentId: string) => void + /** Opens the host's connected-sources surface from a connector badge. */ + onConnectorSelect?: () => void +} + +export interface KnowledgeViewProps { + source: ResourceSource<'knowledge'> + grants: ResourceGrants + /** + * Declared because every view is mounted against all three axes, and inert in + * the read surface today: the document list draws the same chrome on a page + * and in a panel. Its URL half is what matters here — who may write the + * document-list query params — and that is settled by the host when it builds + * {@link KnowledgeDocumentList}, via `hostOwnsUrl`. + */ + host: ResourceHost + list: KnowledgeDocumentList + interaction?: KnowledgeViewInteraction +} + +/** + * The knowledge base read surface: the document list with its search, status and + * tag filters, sort, pagination, and the state shown when the base itself is + * gone. + * + * It renders the body of a {@link Resource} shell — the options toolbar and the + * table — so the host keeps ownership of the header (breadcrumbs, rename, the + * new-document and new-connector actions) alongside every mutation those drive. + */ +export function KnowledgeView({ source, grants, list, interaction }: KnowledgeViewProps) { + const { + documents, + tagDefinitions, + connectors, + unavailable, + search, + onSearchChange, + highlightQuery, + enabledFilter, + onEnabledFilterChange, + tagFilterEntries, + onTagFilterEntriesChange, + sort, + pagination, + } = list + + const filterContent = useMemo( + () => ( + +
+
+ Status + {enabledFilter !== 'all' && ( + + )} +
+ { + if (value !== 'all' && value !== 'enabled' && value !== 'disabled') return + onEnabledFilterChange(value) + }} + align='start' + fullWidth + /> +
+ +
+ ), + [ + enabledFilter, + onEnabledFilterChange, + tagDefinitions, + tagFilterEntries, + onTagFilterEntriesChange, + ] + ) + + const connectorBadges = + connectors.length > 0 ? ( + <> + {connectors.map((connector) => { + const def = CONNECTOR_META_REGISTRY[connector.connectorType] + const ConnectorIcon = def?.icon + return ( + + ) + })} + + ) : null + + const filterTags: FilterTag[] = useMemo( + () => [ + ...(enabledFilter !== 'all' + ? [ + { + label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`, + onRemove: () => onEnabledFilterChange('all'), + }, + ] + : []), + ...tagFilterEntries.reduce((acc, f) => { + if (!f.tagSlot || !f.value.trim()) return acc + acc.push({ + label: `${f.tagName}: ${f.value}`, + onRemove: () => onTagFilterEntriesChange(tagFilterEntries.filter((e) => e.id !== f.id)), + }) + return acc + }, []), + ], + [enabledFilter, onEnabledFilterChange, tagFilterEntries, onTagFilterEntriesChange] + ) + + const sortConfig: SortConfig = useMemo(() => ({ options: SORT_OPTIONS, ...sort }), [sort]) + + const selectableConfig: SelectableConfig | undefined = useMemo( + () => + interaction?.selection ? { ...interaction.selection, disabled: !grants.write } : undefined, + [interaction?.selection, grants.write] + ) + + const documentRows: ResourceRow[] = useMemo( + () => + documents.map((doc) => { + const ConnectorIcon = doc.connectorType + ? CONNECTOR_META_REGISTRY[doc.connectorType]?.icon + : null + const DocIcon = ConnectorIcon || getDocumentIcon(doc.mimeType, doc.filename) + + const tags = getDocumentTags(doc, tagDefinitions) + + const statusCell: ResourceCell = + doc.processingStatus === 'failed' && doc.processingError + ? { + content: ( + + +
{getStatusBadge(doc)}
+
+ + {doc.processingError} + +
+ ), + } + : { content: getStatusBadge(doc) } + + const tagsCell: ResourceCell = + tags.length === 0 ? { label: null } : { content: } + + return { + id: doc.id, + cells: { + name: { + content: ( + + + + + + + + + ), + }, + size: { label: formatFileSize(doc.fileSize) }, + tokens: { + label: + doc.processingStatus === 'completed' + ? doc.tokenCount > 1000 + ? `${(doc.tokenCount / 1000).toFixed(1)}k` + : doc.tokenCount.toLocaleString() + : null, + }, + chunks: { + label: doc.processingStatus === 'completed' ? doc.chunkCount.toLocaleString() : null, + }, + uploaded: { + content: ( + + + + {format(new Date(doc.uploadedAt), 'MMM d')} + + + + {format(new Date(doc.uploadedAt), 'MMM d, yyyy h:mm a')} + + + ), + }, + status: statusCell, + tags: tagsCell, + }, + } + }), + [documents, tagDefinitions, highlightQuery] + ) + + if (unavailable) { + return ( +
+ +
+

+ Knowledge base not found +

+

{source.unavailableCopy('missing')}

+
+
+ ) + } + + return ( + <> + + + + ) +} diff --git a/apps/sim/components/resources/knowledge-view/utils/document-rows.tsx b/apps/sim/components/resources/knowledge-view/utils/document-rows.tsx new file mode 100644 index 00000000000..1d589b871de --- /dev/null +++ b/apps/sim/components/resources/knowledge-view/utils/document-rows.tsx @@ -0,0 +1,100 @@ +import { Badge, Loader } from '@sim/emcn' +import { CircleAlert } from '@sim/emcn/icons' +import { format } from 'date-fns' +import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' +import type { DocumentData } from '@/lib/knowledge/types' +import type { TagDefinition } from '@/hooks/kb/use-knowledge-base-tag-definitions' + +const AnimatedLoader = ({ className }: { className?: string }) => ( + +) + +/** One resolved tag on a document: which slot it came from, and how it reads. */ +export interface TagValue { + slot: AllTagSlot + displayName: string + value: string +} + +/** Processing/enabled state of a document, as the status column renders it. */ +export function getStatusBadge(doc: DocumentData) { + switch (doc.processingStatus) { + case 'pending': + return ( + + Pending + + ) + case 'processing': + return ( + + Processing + + ) + case 'failed': + return doc.processingError ? ( + + Failed + + ) : ( + + Failed + + ) + case 'completed': + return doc.enabled ? ( + + Enabled + + ) : ( + + Disabled + + ) + default: + return ( + + Unknown + + ) + } +} + +/** + * Resolves a document's populated tag slots into display values, formatting each + * by the field type its definition declares (falling back to the slot's own + * type, then to text). + */ +export function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] { + const result: TagValue[] = [] + const defsBySlot = new Map(definitions.map((d) => [d.tagSlot, d])) + + for (const slot of ALL_TAG_SLOTS) { + const raw = doc[slot] + if (raw == null) continue + + const def = defsBySlot.get(slot) + const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text' + + let value: string + if (fieldType === 'date') { + try { + value = format(new Date(raw as string), 'MMM d, yyyy') + } catch { + value = String(raw) + } + } else if (fieldType === 'boolean') { + value = raw ? 'Yes' : 'No' + } else if (fieldType === 'number' && typeof raw === 'number') { + value = raw.toLocaleString() + } else { + value = String(raw) + } + + if (value) { + result.push({ slot, displayName: def?.displayName || slot, value }) + } + } + + return result +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/components/resources/log-view/components/execution-snapshot/execution-snapshot.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx rename to apps/sim/components/resources/log-view/components/execution-snapshot/execution-snapshot.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts b/apps/sim/components/resources/log-view/components/execution-snapshot/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts rename to apps/sim/components/resources/log-view/components/execution-snapshot/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/components/resources/log-view/components/file-download/file-download.tsx similarity index 87% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx rename to apps/sim/components/resources/log-view/components/file-download/file-download.tsx index 205ad59a408..fff5bddea4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/components/resources/log-view/components/file-download/file-download.tsx @@ -3,7 +3,6 @@ import { Button } from '@sim/emcn' import { Download } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { useRouter } from 'next/navigation' import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FileCards') @@ -23,12 +22,18 @@ interface FileCardsProps { files: FileData[] isExecutionFile?: boolean workspaceId?: string + /** + * How the host moves the viewer to a file's in-app viewer. Omitted by a host + * with no router (a share), where the navigation is inert by construction. + */ + onNavigate?: (path: string) => void } interface FileCardProps { file: FileData isExecutionFile?: boolean workspaceId?: string + onNavigate?: (path: string) => void } function formatFileSize(bytes: number): string { @@ -39,9 +44,7 @@ function formatFileSize(bytes: number): string { return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}` } -function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) { - const router = useRouter() - +function FileCard({ file, isExecutionFile = false, workspaceId, onNavigate }: FileCardProps) { const handleDownload = () => { try { logger.info(`Initiating download for file: ${file.name}`) @@ -72,8 +75,8 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) } else { const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null - if (viewerUrl) { - router.push(viewerUrl) + if (viewerUrl && onNavigate) { + onNavigate(viewerUrl) logger.info(`Navigated to viewer URL: ${viewerUrl}`) } else { logger.warn( @@ -114,7 +117,12 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) ) } -export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) { +export function FileCards({ + files, + isExecutionFile = false, + workspaceId, + onNavigate, +}: FileCardsProps) { if (!files || files.length === 0) { return null } @@ -130,6 +138,7 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC file={file} isExecutionFile={isExecutionFile} workspaceId={workspaceId} + onNavigate={onNavigate} /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/index.ts b/apps/sim/components/resources/log-view/components/file-download/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/index.ts rename to apps/sim/components/resources/log-view/components/file-download/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/index.ts b/apps/sim/components/resources/log-view/components/trace-view/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/index.ts rename to apps/sim/components/resources/log-view/components/trace-view/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/components/resources/log-view/components/trace-view/trace-view.tsx similarity index 99% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx rename to apps/sim/components/resources/log-view/components/trace-view/trace-view.tsx index 01d725a9719..b8c62603972 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/components/resources/log-view/components/trace-view/trace-view.tsx @@ -31,7 +31,6 @@ import { } from '@sim/emcn/icons' import { formatDuration } from '@sim/utils/formatting' import { createPortal } from 'react-dom' -import type { TraceSpan } from '@/lib/logs/types' import { adjustBgForContrast, formatCostAmount, @@ -45,7 +44,8 @@ import { iconColorClass, isIterationType, parseTime, -} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils' +} from '@/components/resources/log-view/utils/trace-utils' +import type { TraceSpan } from '@/lib/logs/types' import { isCustomBlockType } from '@/blocks/custom/build-config' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' diff --git a/apps/sim/components/resources/log-view/index.ts b/apps/sim/components/resources/log-view/index.ts new file mode 100644 index 00000000000..3189f7b41a4 --- /dev/null +++ b/apps/sim/components/resources/log-view/index.ts @@ -0,0 +1,16 @@ +/** + * The log resource view. Consumers mount {@link LogView} against a source, + * grants, and a host; everything else here is what the surrounding surfaces + * (the logs table, the logs page's snapshot modal) need to describe a run + * without opening its details panel. + */ + +export { ExecutionSnapshot } from './components/execution-snapshot' +export type { LogViewProps, LogViewTab } from './log-view' +export { LogView, WorkflowOutputSection } from './log-view' +export { DELETED_WORKFLOW_LABEL, formatDate, TriggerBadge } from './utils/log-presentation' +export { + adjustBgForContrast, + getBlockIconAndColor, + iconColorClass, +} from './utils/trace-utils' diff --git a/apps/sim/components/resources/log-view/log-view.tsx b/apps/sim/components/resources/log-view/log-view.tsx new file mode 100644 index 00000000000..0ea47bea35a --- /dev/null +++ b/apps/sim/components/resources/log-view/log-view.tsx @@ -0,0 +1,721 @@ +'use client' + +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + Badge, + Button, + Chip, + ChipInput, + ChipModalTabs, + Code, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Duplicate, + Eye, + handleKeyboardActivation, + Search as SearchIcon, + Tooltip, + useCopyToClipboard, +} from '@sim/emcn' +import { ArrowDown, ArrowUp, Check, Clipboard, Search, Workflow, Wrench, X } from '@sim/emcn/icons' +import { formatDuration } from '@sim/utils/formatting' +import { createPortal } from 'react-dom' +import { getDisplayStatus, StatusBadge } from '@/components/execution-status' +import { ExecutionSnapshot } from '@/components/resources/log-view/components/execution-snapshot' +import { FileCards } from '@/components/resources/log-view/components/file-download' +import { TraceView } from '@/components/resources/log-view/components/trace-view' +import { + DELETED_WORKFLOW_LABEL, + formatDate, + TriggerBadge, +} from '@/components/resources/log-view/utils/log-presentation' +import type { WorkflowLogRow } from '@/lib/api/contracts/logs' +import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' +import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { isChatEnabled } from '@/lib/core/config/env-flags' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' +import type { TraceSpan } from '@/lib/logs/types' +import { sendMothershipMessage } from '@/lib/mothership/events' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' +import { formatCost } from '@/providers/utils' +import type { ResourceGrants, ResourceHost, ResourceSource } from '@/resources' +import { hostOwnsUrl } from '@/resources' +import type { ChatContext } from '@/stores/panel' + +/** + * Renders an already-apportioned integer credit value. `dollars` is only used + * to distinguish a genuine zero ("0 credits") from a sub-credit charge that + * rounded down to zero ("<1 credit"); the credit figure itself is authoritative. + */ +function creditLabel(credits: number, dollars: number): string { + if (credits <= 0) return dollars > 0 ? '<1 credit' : '0 credits' + return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}` +} + +export const WorkflowOutputSection = memo( + function WorkflowOutputSection({ output }: { output: Record }) { + const contentRef = useRef(null) + const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) + + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + + const { + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) + + const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) + + function handleContextMenu(e: React.MouseEvent) { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + } + + function handleCopy() { + copy(jsonString) + setIsContextMenuOpen(false) + } + + function handleSearch() { + activateSearch() + setIsContextMenuOpen(false) + } + + return ( +
+
+ + {/* Glass action buttons overlay */} + {!isSearchActive && ( +
+ + + + + {copied ? 'Copied' : 'Copy'} + + + + + + Search + +
+ )} +
+ + {/* Search Overlay */} + {isSearchActive && ( +
e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-0.5 w-[94px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
+ )} + + {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} + {typeof document !== 'undefined' && + createPortal( + setIsContextMenuOpen(false)} + modal={false} + > + +
+ + e.preventDefault()} + > + + + Copy + + + + + Search + + + , + document.body + )} +
+ ) + }, + (prev, next) => prev.output === next.output +) + +export type LogViewTab = 'overview' | 'trace' + +export interface LogViewProps { + source: ResourceSource<'log'> + grants: ResourceGrants + host: ResourceHost + /** + * The run to render. Supplied rather than resolved from `source` because both + * consumers already hold it — the logs page from its list query, the panel + * from `useLogDetail` — and refetching by id would double every fetch. + */ + log: WorkflowLogRow + /** + * Whether this viewer may see a run's execution internals: trace spans, the + * frozen-canvas snapshot, and workflow input/output payloads. An enterprise + * permission group can withhold these from someone who may otherwise read the + * log, so it is not derivable from `grants.write` or `grants.run`. + * + * Required, not optional-defaulting-to-true: a consumer that forgets it must + * fail to compile rather than silently reveal payloads the group hid. It stays + * a prop instead of a `ResourceGrants` field because it is the only per-kind + * capability in the tree and no other view has a use for it. + */ + showExecutionInternals: boolean + /** + * Active tab, when the host owns the URL and deep-links it. Ignored unless + * `hostOwnsUrl(host)` — an embedded host cannot drive (or pollute) this + * through the address bar even if a consumer passes it. + */ + tab?: LogViewTab + /** Tab the viewer selected. Only called for a host that owns the URL. */ + onTabChange?: (tab: LogViewTab) => void + /** The resolved tab, after a hidden Trace tab folds back to Overview. */ + onActiveTabChange?: (tab: LogViewTab) => void + /** + * How this host moves the viewer — the router half of `host`. Supplied by a + * host that owns a router (`router.push`); omitted by a `'public'` one, where + * the handoff has nowhere to land and stays inert. + */ + onNavigate?: (path: string) => void +} + +/** + * Renders one workflow run — status, cost ledger, input/output payloads, files, + * and the execution trace — from whichever address its {@link ResourceSource} + * carries. Mounted by the logs page's details panel and by the chat resource + * panel; neither wraps it. + */ +export function LogView({ + source, + host, + log, + showExecutionInternals, + tab, + onTabChange, + onActiveTabChange, + onNavigate, +}: LogViewProps) { + const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) + /** + * Tab state lives here for every host. A host that owns the URL deep-links it + * through `tab`/`onTabChange`; an embedded one keeps it local, so the panel + * stops writing an unnamespaced key into its host's address bar. + */ + const urlOwned = hostOwnsUrl(host) + const [localTab, setLocalTab] = useState('overview') + const activeTab = urlOwned ? (tab ?? 'overview') : localTab + const setActiveTab = useCallback( + (next: LogViewTab) => { + if (urlOwned) onTabChange?.(next) + else setLocalTab(next) + }, + [urlOwned, onTabChange] + ) + + const { copied: copiedRunId, copy: copyRunId } = useCopyToClipboard({ resetMs: 1500 }) + + const scrollAreaRef = useRef(null) + + const workspaceId = source.via === 'workspace' ? source.workspaceId : null + + const isInitialTabMountRef = useRef(true) + /** + * Honors a deep-linked tab on first mount; resets to overview only when + * switching to a different log. + */ + useEffect(() => { + if (isInitialTabMountRef.current) { + isInitialTabMountRef.current = false + } else { + setActiveTab('overview') + } + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = 0 + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset the tab only when switching logs, not when the setter identity changes + }, [log.id]) + + const isLikelyExecution = !!log.executionId && log.trigger !== 'mothership' + const isWorkflowExecutionLog = + (log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans + + const hasCostInfo = !!(isWorkflowExecutionLog && log.cost) + const showWorkflowState = + isWorkflowExecutionLog && + !!log.executionId && + log.trigger !== 'mothership' && + showExecutionInternals + + const showTraceTab = showExecutionInternals && isLikelyExecution + // double-cast-allowed: contract schema makes duration/startTime optional for legacy persisted JSON; runtime data always supplies them. + const traceSpans = log.executionData?.traceSpans as unknown as TraceSpan[] | undefined + + const resolvedTab: LogViewTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab + + useLayoutEffect(() => { + onActiveTabChange?.(resolvedTab) + }, [resolvedTab, onActiveTabChange]) + + const workflowOutput = useMemo(() => { + const executionData = log.executionData as { finalOutput?: Record } | undefined + if (!executionData?.finalOutput) return null + return filterHiddenOutputKeys(executionData.finalOutput) as Record + }, [log.executionData]) + + const workflowInput = useMemo(() => { + const executionData = log.executionData as { workflowInput?: unknown } | undefined + const raw = executionData?.workflowInput + if (raw === undefined || raw === null) return null + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw as Record + } + return { input: raw } as Record + }, [log.executionData]) + + // Cost breakdown, sourced solely from the usage_log ledger (single source of + // truth). Line items (Base Run / per-model / per-integration) get integer + // credits apportioned with a single round at the total so rows always + // reconcile (never round-then-sum, which drifts). Pre-ledger runs that only + // have the cost_total projection show the total alone — no itemization, no + // parallel jsonb reconstruction. + const costBreakdown = useMemo((): { + rows: Array<{ key: string; label: string; credits: number; dollars: number }> + totalCredits: number + totalDollars: number + tokens: { input: number; output: number } + } | null => { + const ledger = log.costLedger + if (ledger && ledger.items.length > 0) { + const credits = apportionCredits( + ledger.items.map((item, i) => ({ key: String(i), dollars: item.cost })) + ) + const rows = ledger.items.map((item, i) => ({ + key: String(i), + label: + item.category === 'fixed' && item.description === 'execution_fee' + ? 'Base Run' + : item.description, + credits: credits[String(i)] ?? 0, + dollars: item.cost, + })) + return { + rows, + totalCredits: dollarsToCredits(ledger.total), + totalDollars: ledger.total, + tokens: { + input: ledger.items.reduce((s, it) => s + (it.inputTokens ?? 0), 0), + output: ledger.items.reduce((s, it) => s + (it.outputTokens ?? 0), 0), + }, + } + } + + // Total-only (pre-ledger runs with just the cost_total projection). + const total = log.cost?.total + if (total == null) return null + return { + rows: [], + totalCredits: dollarsToCredits(total), + totalDollars: total, + tokens: { input: 0, output: 0 }, + } + }, [log.costLedger, log.cost]) + + const formattedTimestamp = formatDate(log.createdAt) + const logStatus = getDisplayStatus(log.status) + + /** + * Troubleshooting hands the failed run off to Chat, tagging it by + * `executionId`. A real Chat run can't be debugged from inside itself, so + * mothership-triggered logs are excluded — `isLikelyExecution` already encodes + * "has an executionId and isn't a mothership run". + */ + const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution + + /** + * Hands the failed run to Chat. When a chat is already mounted (e.g. the run + * is being viewed inside Chat's resource panel) it consumes the tagged + * message directly; otherwise a one-shot handoff is persisted and we navigate + * to a fresh chat that picks it up on mount. Navigation is gated on a + * successful store, so a failed write never strands the user on an empty chat. + */ + const handleTroubleshoot = useCallback(() => { + if (!log.executionId) return + const workflowName = log.workflow?.name?.trim() || null + const context: ChatContext = { + kind: 'logs', + executionId: log.executionId, + label: workflowName ?? 'this run', + } + const message = workflowName + ? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.` + : 'This workflow run failed. Investigate the error in this run and help me fix it.' + if (sendMothershipMessage(message, [context])) return + if (!workspaceId || !onNavigate) return + if (MothershipHandoffStorage.store({ message, contexts: [context] }, workspaceId)) { + onNavigate(`/workspace/${workspaceId}/home`) + } + }, [log.executionId, log.workflow?.name, workspaceId, onNavigate]) + + return ( + <> +
+ setActiveTab(v as LogViewTab)} + /> + + {/* Overview Tab */} + {resolvedTab === 'overview' && ( +
+
+ {/* Timestamp + Workflow header */} +
+
+ + Timestamp + + + {formattedTimestamp + ? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}` + : '—'} + +
+
+ + {log.trigger === 'mothership' ? 'Job' : 'Workflow'} + +
+ + + {log.trigger === 'mothership' + ? log.jobTitle || 'Untitled Job' + : log.workflow?.name || + (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} + +
+
+
+ + {/* Details Section */} +
+ {/* Run ID — click to copy */} + {log.executionId && ( +
copyRunId(log.executionId!)} + onKeyDown={(event) => + handleKeyboardActivation(event, () => copyRunId(log.executionId!)) + } + > + + Run ID + + + {copiedRunId ? 'Copied!' : log.executionId} + +
+ )} + + {/* Level */} +
+ + Level + + +
+ + {/* Trigger */} +
+ + Trigger + + {log.trigger ? ( + + ) : ( + + None + + )} +
+ + {/* Duration */} +
+ + Duration + + + {formatDuration(log.duration, { precision: 2 }) || '—'} + +
+ + {/* Version */} + {log.deploymentVersion && ( +
+ + Version + +
+ + {log.deploymentVersionName || `v${log.deploymentVersion}`} + +
+
+ )} + + {/* Snapshot */} + {showWorkflowState && ( +
+ + Snapshot + + setIsExecutionSnapshotOpen(true)}> + View Snapshot + +
+ )} + + {/* Troubleshoot */} + {canTroubleshoot && ( +
+ + Troubleshoot + + + Troubleshoot in Chat + +
+ )} +
+ + {/* Workflow Input */} + {isWorkflowExecutionLog && workflowInput && showExecutionInternals && ( +
+ + Workflow Input + + +
+ )} + + {/* Workflow Output */} + {isWorkflowExecutionLog && workflowOutput && showExecutionInternals && ( +
+ + Workflow Output + + +
+ )} + + {/* Files */} + {log.files && log.files.length > 0 && ( + + )} + + {/* Cost Breakdown */} + {hasCostInfo && costBreakdown && ( +
+ {costBreakdown.rows.map((row) => ( +
+ + {row.label} + + + {creditLabel(row.credits, row.dollars)} + +
+ ))} +
+ + Total + + + {creditLabel(costBreakdown.totalCredits, costBreakdown.totalDollars)} + +
+ {(costBreakdown.tokens.input > 0 || costBreakdown.tokens.output > 0) && ( +
+ + Tokens + + + {costBreakdown.tokens.input} in · {costBreakdown.tokens.output} out + +
+ )} +
+

+ Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus model + and tool usage. +

+
+
+ )} +
+
+ )} + + {/* Trace Tab */} + {showTraceTab && resolvedTab === 'trace' && ( +
+ {traceSpans?.length ? ( + + ) : log.executionData ? ( +
+ + No trace data available for this run + +
+ ) : ( +
+ + Loading trace… + +
+ )} +
+ )} +
+ + {/* Frozen Canvas Modal */} + {log.executionId && ( + setIsExecutionSnapshotOpen(false)} + /> + )} + + ) +} diff --git a/apps/sim/components/resources/log-view/utils/log-presentation.ts b/apps/sim/components/resources/log-view/utils/log-presentation.ts new file mode 100644 index 00000000000..3616e432257 --- /dev/null +++ b/apps/sim/components/resources/log-view/utils/log-presentation.ts @@ -0,0 +1,93 @@ +import React from 'react' +import { Badge } from '@sim/emcn' +import { formatRelativeTime } from '@sim/utils/formatting' +import { format } from 'date-fns' +import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' +import { getBlock } from '@/blocks/registry' +import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' + +export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow' + +const TRIGGER_VARIANT_MAP: Record['variant']> = { + manual: 'gray-secondary', + api: 'blue', + schedule: 'green', + chat: 'purple', + webhook: 'orange', + mcp: 'cyan', + copilot: 'pink', + mothership: 'pink', + workflow: 'blue-secondary', + custom_block: 'blue-secondary', +} + +interface TriggerBadgeProps { + trigger: string +} + +/** + * Renders a colored badge indicating the workflow trigger type. + * Core triggers display with their designated colors; integrations show with icons. + * @param props - Component props containing the trigger type + * @returns A Badge with appropriate styling for the trigger type + */ +export function TriggerBadge({ trigger }: TriggerBadgeProps) { + const metadata = getIntegrationMetadata(trigger) + const isIntegration = !(CORE_TRIGGER_TYPES as readonly string[]).includes(trigger) + const block = isIntegration ? getBlock(trigger) : null + const IconComponent = block?.icon + + const coreVariant = TRIGGER_VARIANT_MAP[trigger] + if (coreVariant) { + return React.createElement( + Badge, + { variant: coreVariant, size: 'sm', className: 'whitespace-nowrap' }, + metadata.label + ) + } + + if (IconComponent) { + return React.createElement( + Badge, + { + variant: 'gray-secondary', + size: 'sm', + icon: IconComponent, + className: 'whitespace-nowrap', + }, + metadata.label + ) + } + + return React.createElement( + Badge, + { variant: 'gray-secondary', size: 'sm', className: 'whitespace-nowrap' }, + metadata.label + ) +} + +export const formatDate = (dateString: string) => { + const date = new Date(dateString) + return { + full: date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }), + time: date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }), + formatted: format(date, 'HH:mm:ss'), + compact: format(date, 'MMM d HH:mm:ss'), + compactDate: format(date, 'MMM d').toUpperCase(), + compactTime: format(date, 'h:mm a'), + relative: formatRelativeTime(dateString), + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/components/resources/log-view/utils/trace-utils.ts similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts rename to apps/sim/components/resources/log-view/utils/trace-utils.ts index d41182aea7b..af9544d6cf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/components/resources/log-view/utils/trace-utils.ts @@ -4,9 +4,8 @@ import { formatCreditCost } from '@/lib/billing/credits/conversion' import { perceivedBrightness } from '@/lib/colors' import { hasUnhandledError } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' -import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' -import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getBlock, getBlockByToolName } from '@/blocks' +import { LoopTool, ParallelTool } from '@/blocks/subflow-tools' import { PROVIDER_DEFINITIONS } from '@/providers/models' import { normalizeToolId } from '@/tools/normalize' diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index 17689f68818..490a227e8ed 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -8,8 +8,8 @@ */ import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' +import { DELETED_WORKFLOW_LABEL } from '@/components/resources/log-view' import type { FilterRule, SortRule } from '@/lib/table/types' -import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' diff --git a/apps/sim/resources/kinds.ts b/apps/sim/resources/kinds.ts index 2a0ac4f249f..6627ab44bad 100644 --- a/apps/sim/resources/kinds.ts +++ b/apps/sim/resources/kinds.ts @@ -7,7 +7,7 @@ * collaborative socket session (it joins and leaves a room), so embedding one * ADDS lifecycle rather than re-hosting an existing view. */ -export const RESOURCE_KINDS = ['file', 'table', 'knowledge', 'log', 'schedule'] as const +export const RESOURCE_KINDS = ['file', 'table', 'knowledge', 'log'] as const export type ResourceKind = (typeof RESOURCE_KINDS)[number] @@ -39,7 +39,6 @@ export interface ResourceSeedMap { table: never knowledge: never log: never - schedule: never } export type ResourceSeed = ResourceSeedMap[K] diff --git a/apps/sim/resources/source.test.ts b/apps/sim/resources/source.test.ts index 80438ef33b2..2ff31844a18 100644 --- a/apps/sim/resources/source.test.ts +++ b/apps/sim/resources/source.test.ts @@ -62,10 +62,10 @@ describe('workspaceSource', () => { workspaceId: 'ws_1', resourceId: 'kb_1', }) - const schedule = workspaceSource({ kind: 'schedule', workspaceId: 'ws_1', resourceId: 'sch_1' }) + const log = workspaceSource({ kind: 'log', workspaceId: 'ws_1', resourceId: 'exec_1' }) expect(knowledge.unavailableCopy('missing')).toContain('knowledge base') - expect(schedule.unavailableCopy('missing')).toContain('scheduled task') + expect(log.unavailableCopy('missing')).toContain('log') }) it('resolves a self link to its own workspace route', () => { @@ -89,9 +89,6 @@ describe('workspaceSource', () => { expect(source.hrefFor({ to: 'resource', kind: 'log', id: 'exec_1' })).toBe( '/workspace/ws_1/logs?executionId=exec_1' ) - expect(source.hrefFor({ to: 'resource', kind: 'schedule', id: 'sch_1' })).toBe( - '/workspace/ws_1/scheduled-tasks?taskId=sch_1' - ) }) it('escapes ids so a hostile id cannot graft extra path or query onto the route', () => { @@ -202,10 +199,10 @@ describe('shareSource', () => { const knowledge = shareSource({ kind: 'knowledge', token: 't', grantId: 'g', seed: {} }) // @ts-expect-error — 'log' seeds `never`. const log = shareSource({ kind: 'log', token: 't', grantId: 'g', seed: {} }) - // @ts-expect-error — 'schedule' seeds `never`. - const schedule = shareSource({ kind: 'schedule', token: 't', grantId: 'g', seed: {} }) + // @ts-expect-error — 'table' seeds `never`. + const table = shareSource({ kind: 'table', token: 't', grantId: 'g', seed: {} }) - expect([knowledge.via, log.via, schedule.via]).toEqual(['share', 'share', 'share']) + expect([knowledge.via, log.via, table.via]).toEqual(['share', 'share', 'share']) }) }) diff --git a/apps/sim/resources/source.ts b/apps/sim/resources/source.ts index bc2d6a170ab..a9582b7bf50 100644 --- a/apps/sim/resources/source.ts +++ b/apps/sim/resources/source.ts @@ -12,7 +12,6 @@ const RESOURCE_NOUN: Record = { table: 'table', knowledge: 'knowledge base', log: 'log', - schedule: 'scheduled task', } interface ResourceSourceBase { @@ -95,8 +94,6 @@ function resourceHref(workspaceId: string, kind: ResourceKind, id: string): stri return `${workspace}/knowledge/${resource}` case 'log': return `${workspace}/logs?executionId=${resource}` - case 'schedule': - return `${workspace}/scheduled-tasks?taskId=${resource}` } } diff --git a/scripts/check-resource-views.ts b/scripts/check-resource-views.ts index 27724c85b1e..1b27d280c04 100644 --- a/scripts/check-resource-views.ts +++ b/scripts/check-resource-views.ts @@ -78,21 +78,23 @@ const RESOURCE_POLICY_BASELINE = { */ wrapperMounts: 0, /** - * R1b — components whose NAME announces a per-consumer fork. All 8 are the - * mothership panel's `Embedded*` tab chrome. - * - * These do NOT all drop off as kinds get canonical views, which an earlier - * version of this note claimed: the `Embedded*Actions` members are tab chrome - * (open / export buttons), not views, and both `EmbeddedFileActions` and - * `EmbeddedTableActions` outlived their kinds' migrations. Only the content - * components (`EmbeddedWorkflow`, `EmbeddedFolder`, `EmbeddedLog`) go. - * Collapsing the `*Actions` into one kind-keyed component is the real fix and - * is its own change. + * R1b — components whose NAME announces a per-consumer fork. The 3 left are + * the mothership panel's `Embedded*` tab CONTENT, and each drops off with its + * kind's migration onto a canonical view. * * Lowered 11 → 8 when the scheduled-tasks retirement took - * `EmbeddedScheduledTask` and `EmbeddedScheduledTaskActions` with it. + * `EmbeddedScheduledTask` and `EmbeddedScheduledTaskActions` with it, then + * 8 → 3 when the five `Embedded*Actions` collapsed into the one kind-keyed + * `ResourceTabActions` — tab chrome was never per-kind work: the buttons + * differ only in icon, copy and destination, which is a config table — and + * 3 → 2 when the log kind gained `LogView` and the panel began constructing + * the axes and mounting it directly, retiring `EmbeddedLog`. + * + * The 2 that remain are deliberate: `EmbeddedWorkflow` (a workflow is a live + * collaborative session, not a document with an address) and `EmbeddedFolder` + * (a folder is structure inside a resource, not a resource). */ - shadowNamedComponents: 8, + shadowNamedComponents: 2, /** * R2 — imports that reach past a unit barrel. At its floor: the three * legitimate deep imports are all `lazy()` code-split points, listed in @@ -121,7 +123,7 @@ const RESOURCE_POLICY_BASELINE = { * tree grew its own `ee/` and billing surfaces and was never refreshed, so it * described a tree that no longer existed rather than a budget anyone spent. */ - crossTreeWorkspaceImports: 38, + crossTreeWorkspaceImports: 37, /** R4a — unsanctioned capability/chrome attributes at a canonical-view mount. */ viewPropVocabularyViolations: 0, /** R4b — unsanctioned capability/chrome props declared on a canonical view's props type. */ @@ -174,6 +176,25 @@ const CANONICAL_UNITS: readonly CanonicalUnit[] = [ root: 'apps/sim/components/resources/table-view', views: [], }, + { + barrel: '@/components/resources/log-view', + root: 'apps/sim/components/resources/log-view', + views: ['LogView'], + kind: 'log', + }, + { + /** + * The knowledge base READ surface: the document list with its search, + * status and tag filters, sort, pagination, and unavailable state. The + * editing shell (upload, connectors, tags, rename, delete, bulk operations) + * stays in the route page and mounts this, exactly as the tables editing + * grid kept its write path. + */ + barrel: '@/components/resources/knowledge-view', + root: 'apps/sim/components/resources/knowledge-view', + views: ['KnowledgeView'], + kind: 'knowledge', + }, { barrel: '@/components/resources/resource-provider', root: 'apps/sim/components/resources/resource-provider', @@ -187,7 +208,7 @@ const CANONICAL_UNITS: readonly CanonicalUnit[] = [ ] /** Every resource kind, mirroring `apps/sim/resources/kinds.ts`. */ -const RESOURCE_KINDS = ['file', 'table', 'knowledge', 'log', 'schedule'] as const +const RESOURCE_KINDS = ['file', 'table', 'knowledge', 'log'] as const /** The three-axis layer. Pure TypeScript, server-importable, no JSX. */ const RESOURCE_AXIS_ROOT = 'apps/sim/resources' @@ -366,7 +387,17 @@ const INTERNAL_IMPORT_ALLOWLIST: ReadonlyMap([ - // (empty) — every anonymous-surface import of the workspace tree is a finding. + /** + * The log view's snapshot modal renders the frozen execution canvas, which IS + * the workflow editor's `Preview` — a ReactFlow subtree that cannot leave + * `w/` without dragging the editor with it. + * + * Safe here in a way it would not be for `file-view`: `ResourceSeedMap['log']` + * is `never`, so a log structurally cannot be addressed by a share token and + * no anonymous surface can mount this unit. R3a — the rule that actually + * guards anonymous surfaces — still covers it and stays at 0. + */ + 'apps/sim/components/resources/log-view/components/execution-snapshot/execution-snapshot.tsx', ]) const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx'])