@anchrd/intel 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
import type { Flow, FlowGraph, FlowNode } from "@contract/flow.ts";
|
|
2
|
+
import { flowNodeLayer } from "@contract/flow.ts";
|
|
3
|
+
import type { Node } from "@contract/node.ts";
|
|
4
|
+
import { serverOf } from "@contract/tool.ts";
|
|
5
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
7
|
+
import {
|
|
8
|
+
addEdge,
|
|
9
|
+
applyEdgeChanges,
|
|
10
|
+
applyNodeChanges,
|
|
11
|
+
Background,
|
|
12
|
+
type Connection,
|
|
13
|
+
Controls,
|
|
14
|
+
type Edge,
|
|
15
|
+
type EdgeChange,
|
|
16
|
+
Handle,
|
|
17
|
+
MiniMap,
|
|
18
|
+
type NodeChange,
|
|
19
|
+
type NodeProps,
|
|
20
|
+
Position,
|
|
21
|
+
ReactFlow,
|
|
22
|
+
type Node as ReactFlowNode,
|
|
23
|
+
ReactFlowProvider,
|
|
24
|
+
useReactFlow,
|
|
25
|
+
} from "@xyflow/react";
|
|
26
|
+
import { FileSearch, Info, Send, Wrench } from "lucide-react";
|
|
27
|
+
import { createContext, useContext, useEffect, useId, useMemo, useState } from "react";
|
|
28
|
+
import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
|
|
29
|
+
import { ViewMenu } from "@/app/view-menu/view-menu.tsx";
|
|
30
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
31
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
32
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
33
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
34
|
+
import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
|
|
35
|
+
import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
|
|
36
|
+
import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
37
|
+
import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
|
|
38
|
+
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
39
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
40
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
41
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
42
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
43
|
+
import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
|
|
44
|
+
import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
|
|
45
|
+
import { useResolvedTheme } from "@/theme/theme-context.tsx";
|
|
46
|
+
import { TitleRow, TitleRowFrame, TitleRowScrollArea } from "@/title-row/title-row.tsx";
|
|
47
|
+
|
|
48
|
+
type CanvasNode = ReactFlowNode<{ node: FlowNode; branches?: readonly string[] }, "intel">;
|
|
49
|
+
type CanvasEdge = Edge;
|
|
50
|
+
|
|
51
|
+
// One handle id at both ends of a context edge. React Flow reports the handles a connection was
|
|
52
|
+
// drawn between, and that is the only place the two meanings can still be told apart — the contract
|
|
53
|
+
// stores the meaning itself, not which dot it was dragged from.
|
|
54
|
+
const ContextHandle = "context";
|
|
55
|
+
|
|
56
|
+
// What a condition drawn on this canvas calls its branches. A DEFAULT for a new one, not a
|
|
57
|
+
// vocabulary: `sourceHandle` is any non-empty string in the contract, and `compileFlow` asks only
|
|
58
|
+
// that a condition's branches are unique and not null. A graph arrives over MCP as readily as from
|
|
59
|
+
// here, and one whose branches are called something else is as valid as this one.
|
|
60
|
+
const DefaultBranches = ["yes", "no"] as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The branch names of one condition, as the loaded graph spells them.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ Read from the edges, never imposed on them. Rendering a fixed `yes`/`no` pair left every edge
|
|
66
|
+
* saved with other names unattached to any handle — the author redrew it, the redrawn edge took
|
|
67
|
+
* this canvas's name, and the old edge stayed in the state until its target had two incoming edges
|
|
68
|
+
* and `compileFlow` refused the save (#627). The names are taken once, when the version is loaded,
|
|
69
|
+
* so deleting an edge does not take its branch off the card: the handle stays, and drawing the edge
|
|
70
|
+
* again lands on the same name it had.
|
|
71
|
+
*
|
|
72
|
+
* Filled up to two from the defaults only where the graph is short of them — a condition needs two
|
|
73
|
+
* branches to be worth drawing, and one with none at all would offer nowhere to start.
|
|
74
|
+
*/
|
|
75
|
+
function branchesOf(graph: FlowGraph, nodeId: string): string[] {
|
|
76
|
+
const named = graph.edges
|
|
77
|
+
.filter((edge) => edge.kind === "flow" && edge.source === nodeId)
|
|
78
|
+
.map((edge) => edge.sourceHandle)
|
|
79
|
+
.filter((handle): handle is string => handle !== null);
|
|
80
|
+
const branches = [...new Set(named)];
|
|
81
|
+
for (const fallback of DefaultBranches) {
|
|
82
|
+
if (branches.length >= DefaultBranches.length) break;
|
|
83
|
+
if (!branches.includes(fallback)) branches.push(fallback);
|
|
84
|
+
}
|
|
85
|
+
return branches;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The order the bar offers them in: roughly the order a flow is built, from the start to the result.
|
|
89
|
+
// ⚠️ The start is in the bar even though every graph opens with one, because it can be deleted —
|
|
90
|
+
// without an entry there would be no way back to a flow that has a beginning.
|
|
91
|
+
const paletteKinds = [
|
|
92
|
+
"trigger",
|
|
93
|
+
"instruction",
|
|
94
|
+
"condition",
|
|
95
|
+
"subflow",
|
|
96
|
+
"folder",
|
|
97
|
+
"document",
|
|
98
|
+
"upload",
|
|
99
|
+
"table",
|
|
100
|
+
"tool",
|
|
101
|
+
"output",
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
// What the tree hands over, read back defensively: the payload crosses a browser API, so it is
|
|
105
|
+
// parsed rather than trusted. A drop that does not carry what we put there is simply ignored.
|
|
106
|
+
function droppedEntry(payload: string): { id: string; kind: string; title: string } | null {
|
|
107
|
+
try {
|
|
108
|
+
const parsed: unknown = JSON.parse(payload);
|
|
109
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
110
|
+
const { id, kind, title } = parsed as Record<string, unknown>;
|
|
111
|
+
if (typeof id !== "string" || typeof kind !== "string" || typeof title !== "string")
|
|
112
|
+
return null;
|
|
113
|
+
return { id, kind, title };
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A row's kind decides what dropping it makes, so nothing has to be asked. A folder becomes a
|
|
120
|
+
// Folder link, a flow becomes the Flow step that calls it (D25).
|
|
121
|
+
const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
|
|
122
|
+
folder: "folder",
|
|
123
|
+
document: "document",
|
|
124
|
+
attachment: "upload",
|
|
125
|
+
table: "table",
|
|
126
|
+
flow: "subflow",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The tree links of this flow that name nothing any more, by resource id (#509).
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ A context rather than a field on the node's `data`, because the canvas nodes are React state
|
|
133
|
+
* that the loaded version owns: writing the answer of a query into them would put a second writer
|
|
134
|
+
* on that state and let a background refetch land in the middle of an edit. The set is read where
|
|
135
|
+
* it is drawn and nowhere else.
|
|
136
|
+
*/
|
|
137
|
+
const InvalidLinks = createContext<ReadonlySet<string>>(new Set());
|
|
138
|
+
|
|
139
|
+
function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
140
|
+
const i18n = useI18n();
|
|
141
|
+
const contract = data.node;
|
|
142
|
+
const invalidLinks = useContext(InvalidLinks);
|
|
143
|
+
// ⚠️ Said in a WORD, not by colour alone. A card that only turned red would say nothing to a
|
|
144
|
+
// colour-blind reader and nothing at all to a screen reader — and this is the one state on the
|
|
145
|
+
// canvas that stops the flow from running (#509).
|
|
146
|
+
const broken = isLinkNode(contract) && invalidLinks.has(contract.configuration.resourceId);
|
|
147
|
+
const Icon = nodeIcon[contract.kind];
|
|
148
|
+
const branching = contract.kind === "condition";
|
|
149
|
+
const branches = data.branches ?? DefaultBranches;
|
|
150
|
+
// ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
|
|
151
|
+
// token. Shape survives zooming out past the point where the label is legible, and it is the only
|
|
152
|
+
// one of the three that also carries into the overview map, where nothing is written at all.
|
|
153
|
+
const start = contract.kind === "trigger";
|
|
154
|
+
// ⚠️ A call that rides along says so on the call itself (ADR-0004 §5). A version choice that only
|
|
155
|
+
// showed up in the inspector would be invisible on exactly the screen people read a flow from,
|
|
156
|
+
// and "why did this flow change" would have no answer on the canvas.
|
|
157
|
+
const riding = contract.kind === "subflow" ? contract.configuration.version.mode : null;
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
className={`min-w-48 border bg-card p-3 text-card-foreground shadow-sm ${start ? "rounded-full border-primary bg-primary/5 px-4" : "rounded-xl"} ${selected ? "ring-2 ring-ring" : ""}`}
|
|
161
|
+
>
|
|
162
|
+
{contract.kind !== "trigger" && <Handle type="target" position={Position.Left} />}
|
|
163
|
+
<div className="flex items-center gap-2">
|
|
164
|
+
<span
|
|
165
|
+
className={`grid size-8 shrink-0 place-items-center bg-primary/10 text-primary ${start ? "rounded-full bg-primary text-primary-foreground" : "rounded-lg"}`}
|
|
166
|
+
>
|
|
167
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
168
|
+
</span>
|
|
169
|
+
<span className="min-w-0">
|
|
170
|
+
<span className="block truncate text-sm font-medium">{contract.label}</span>
|
|
171
|
+
{/* The kind through the catalog, not the raw key: the start node is called Start on the
|
|
172
|
+
canvas as well, and `trigger` would name an event that no longer exists (#39).
|
|
173
|
+
⚠️ A broken link says so INSTEAD of naming its kind, because "Document" beside a
|
|
174
|
+
reference that names nothing is the sentence #509 is about: the card looked like every
|
|
175
|
+
valid link on the canvas. */}
|
|
176
|
+
<span
|
|
177
|
+
className={`block text-xs ${broken ? "text-destructive" : "text-muted-foreground"}`}
|
|
178
|
+
>
|
|
179
|
+
{broken ? i18n.t("flows.nodeInvalid") : i18n.t(`flows.node.${contract.kind}`)}
|
|
180
|
+
</span>
|
|
181
|
+
</span>
|
|
182
|
+
{riding === "follows" || riding === "pinned" ? (
|
|
183
|
+
<span className="ml-auto shrink-0 rounded-full border px-2 py-0.5 text-[0.625rem] uppercase tracking-wide text-muted-foreground">
|
|
184
|
+
{i18n.t(`flows.subflowBadge.${riding}`)}
|
|
185
|
+
</span>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
{contract.kind !== "output" && !branching && (
|
|
189
|
+
<Handle type="source" position={Position.Right} />
|
|
190
|
+
)}
|
|
191
|
+
{branching &&
|
|
192
|
+
branches.map((branch, index) => (
|
|
193
|
+
<Handle
|
|
194
|
+
key={branch}
|
|
195
|
+
id={branch}
|
|
196
|
+
type="source"
|
|
197
|
+
position={Position.Right}
|
|
198
|
+
// Spread over the right edge rather than pinned: a condition may carry more than the two
|
|
199
|
+
// this canvas offers, and three branches stacked on two fixed points would hide one.
|
|
200
|
+
style={{ top: `${(100 * (index + 1)) / (branches.length + 1)}%` }}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
{/* The context point (#37). Sideways is the order of work, downwards is what a step works
|
|
204
|
+
with — including at the output, where the same edge reads as where the result goes. Both
|
|
205
|
+
ends sit on the vertical axis so an attachment hangs under its step and the two meanings
|
|
206
|
+
are told apart by direction as well as by the dashed line. The start has no top point:
|
|
207
|
+
the beginning of a flow is not material another step works with. */}
|
|
208
|
+
<Handle id={ContextHandle} type="source" position={Position.Bottom} />
|
|
209
|
+
{contract.kind !== "trigger" && (
|
|
210
|
+
<Handle id={ContextHandle} type="target" position={Position.Top} />
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const nodeTypes = { intel: FlowCard };
|
|
217
|
+
|
|
218
|
+
function defaultGraph(): FlowGraph {
|
|
219
|
+
return {
|
|
220
|
+
nodes: [
|
|
221
|
+
{
|
|
222
|
+
id: "trigger",
|
|
223
|
+
kind: "trigger",
|
|
224
|
+
label: "Manual start",
|
|
225
|
+
position: { x: 80, y: 180 },
|
|
226
|
+
configuration: { mode: "manual" },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "output",
|
|
230
|
+
kind: "output",
|
|
231
|
+
label: "Result",
|
|
232
|
+
position: { x: 520, y: 180 },
|
|
233
|
+
configuration: {},
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
edges: [
|
|
237
|
+
{
|
|
238
|
+
id: "trigger-output",
|
|
239
|
+
source: "trigger",
|
|
240
|
+
target: "output",
|
|
241
|
+
kind: "flow",
|
|
242
|
+
label: null,
|
|
243
|
+
sourceHandle: null,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The edges left over once `connection` is drawn.
|
|
251
|
+
*
|
|
252
|
+
* ⚠️ A point in the order of work leads to ONE place, and drawing from it again means "there
|
|
253
|
+
* instead". `compileFlow` says so twice — a step has exactly one outgoing edge, and a condition's
|
|
254
|
+
* branches are unique — so a second line from the same point can only ever be a graph that will not
|
|
255
|
+
* save. Dropping the old one here is what #627 cost when nothing did: the author redrew a branch,
|
|
256
|
+
* both lines stayed, and the refusal that followed named a node they had not touched.
|
|
257
|
+
*
|
|
258
|
+
* The context point is the exception it has always been: a step works with as much material as it
|
|
259
|
+
* needs, and those edges leave from the same handle by design.
|
|
260
|
+
*/
|
|
261
|
+
export function clearedFor(edges: CanvasEdge[], connection: Connection): CanvasEdge[] {
|
|
262
|
+
if (connection.sourceHandle === ContextHandle) return edges;
|
|
263
|
+
return edges.filter(
|
|
264
|
+
(edge) =>
|
|
265
|
+
edge.source !== connection.source ||
|
|
266
|
+
(edge.sourceHandle ?? null) !== (connection.sourceHandle ?? null),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function canvas(graph: FlowGraph) {
|
|
271
|
+
return {
|
|
272
|
+
nodes: graph.nodes.map((node) => ({
|
|
273
|
+
id: node.id,
|
|
274
|
+
type: "intel" as const,
|
|
275
|
+
position: node.position,
|
|
276
|
+
data: node.kind === "condition" ? { node, branches: branchesOf(graph, node.id) } : { node },
|
|
277
|
+
})),
|
|
278
|
+
edges: graph.edges.map((edge) => {
|
|
279
|
+
const context = edge.kind === "context";
|
|
280
|
+
return {
|
|
281
|
+
id: edge.id,
|
|
282
|
+
source: edge.source,
|
|
283
|
+
target: edge.target,
|
|
284
|
+
// The handle ids are put back from the stored meaning, so a context edge lands on the
|
|
285
|
+
// vertical pair again and a flow edge keeps the branch handle it was saved with.
|
|
286
|
+
sourceHandle: context ? ContextHandle : edge.sourceHandle,
|
|
287
|
+
targetHandle: context ? ContextHandle : null,
|
|
288
|
+
label: edge.label ?? undefined,
|
|
289
|
+
...contextEdgeStyle(context),
|
|
290
|
+
};
|
|
291
|
+
}),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Dashed and unarrowed, in the muted token rather than the colour of a flow edge: the reader has to
|
|
296
|
+
// be able to separate the procedure from the material without being told which is which.
|
|
297
|
+
function contextEdgeStyle(context: boolean): Partial<CanvasEdge> {
|
|
298
|
+
if (!context) return {};
|
|
299
|
+
return {
|
|
300
|
+
className: "intel-context-edge",
|
|
301
|
+
style: { strokeDasharray: "6 4" },
|
|
302
|
+
animated: false,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function graph(nodes: CanvasNode[], edges: CanvasEdge[]): FlowGraph {
|
|
307
|
+
return {
|
|
308
|
+
nodes: nodes.map((node) => ({
|
|
309
|
+
...node.data.node,
|
|
310
|
+
position: node.position,
|
|
311
|
+
})),
|
|
312
|
+
edges: edges.map((edge) => {
|
|
313
|
+
const context = edge.sourceHandle === ContextHandle;
|
|
314
|
+
return {
|
|
315
|
+
id: edge.id,
|
|
316
|
+
source: edge.source,
|
|
317
|
+
target: edge.target,
|
|
318
|
+
kind: context ? ("context" as const) : ("flow" as const),
|
|
319
|
+
label: typeof edge.label === "string" && edge.label ? edge.label : null,
|
|
320
|
+
// A context edge carries no branch: the handle it was drawn from is a fact about the canvas,
|
|
321
|
+
// and storing it would make `compileFlow` read a branch where there is none.
|
|
322
|
+
sourceHandle: context ? null : (edge.sourceHandle ?? null),
|
|
323
|
+
};
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ⚠️ Not every change React Flow reports is an edit. It also announces selection and the sizes it
|
|
329
|
+
// has just measured, and both arrive unasked on the first paint — a flow would be "unsaved" the
|
|
330
|
+
// moment it was opened, and the one signal that means something would mean nothing.
|
|
331
|
+
function editsNodes(changes: NodeChange<CanvasNode>[]): boolean {
|
|
332
|
+
return changes.some(
|
|
333
|
+
(change) =>
|
|
334
|
+
change.type === "add" ||
|
|
335
|
+
change.type === "remove" ||
|
|
336
|
+
change.type === "replace" ||
|
|
337
|
+
(change.type === "position" && change.dragging === true),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function editsEdges(changes: EdgeChange<CanvasEdge>[]): boolean {
|
|
342
|
+
return changes.some(
|
|
343
|
+
(change) => change.type === "add" || change.type === "remove" || change.type === "replace",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ⚠️ `upload` is what the flow calls an attachment. The two names are deliberately not unified:
|
|
348
|
+
// in the tree it is a file somebody uploaded, in a flow it is material a step reads, and renaming
|
|
349
|
+
// either one to match the other would make the other read wrong.
|
|
350
|
+
//
|
|
351
|
+
// The reverse map is DERIVED rather than written a second time — two hand-kept tables of the same
|
|
352
|
+
// correspondence are two tables that eventually disagree, and the disagreement would show up as a
|
|
353
|
+
// picker offering the wrong kind.
|
|
354
|
+
const nodeKindOfLink = Object.fromEntries(
|
|
355
|
+
Object.entries(flowKindOfEntry)
|
|
356
|
+
.filter(([, kind]) => kind !== undefined && kind !== "subflow")
|
|
357
|
+
.map(([nodeKind, kind]) => [kind, nodeKind]),
|
|
358
|
+
) as Partial<Record<FlowNode["kind"], Node["kind"]>>;
|
|
359
|
+
|
|
360
|
+
type LinkNode = Extract<FlowNode, { configuration: { resourceId: string } }>;
|
|
361
|
+
|
|
362
|
+
// A predicate rather than a boolean, so the update below can build the one configuration a link
|
|
363
|
+
// has without widening every other node's to match.
|
|
364
|
+
function isLinkNode(node: FlowNode): node is LinkNode {
|
|
365
|
+
return flowNodeLayer[node.kind] === "link" && "resourceId" in node.configuration;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function newNode(
|
|
369
|
+
kind: FlowNode["kind"],
|
|
370
|
+
index: number,
|
|
371
|
+
firstNodeId?: string,
|
|
372
|
+
firstFlowId?: string,
|
|
373
|
+
firstServer?: string,
|
|
374
|
+
): FlowNode {
|
|
375
|
+
const common = {
|
|
376
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
377
|
+
label: kind[0]?.toUpperCase() + kind.slice(1),
|
|
378
|
+
position: {
|
|
379
|
+
x: 260 + (index % 3) * 240,
|
|
380
|
+
y: 80 + Math.floor(index / 3) * 180,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
switch (kind) {
|
|
384
|
+
case "trigger":
|
|
385
|
+
return { ...common, kind, label: "Manual start", configuration: { mode: "manual" } };
|
|
386
|
+
case "instruction":
|
|
387
|
+
return {
|
|
388
|
+
...common,
|
|
389
|
+
kind,
|
|
390
|
+
configuration: { prompt: "Describe what the AI should do." },
|
|
391
|
+
};
|
|
392
|
+
// The four link kinds differ only in what the picker offers, so they are one branch. A link
|
|
393
|
+
// starts out naming nothing: it is filled in from the inspector, and `compileFlow` refuses to
|
|
394
|
+
// publish a graph whose reference is empty.
|
|
395
|
+
case "folder":
|
|
396
|
+
case "document":
|
|
397
|
+
case "upload":
|
|
398
|
+
case "table":
|
|
399
|
+
return { ...common, kind, configuration: { resourceId: firstNodeId ?? "" } };
|
|
400
|
+
case "tool":
|
|
401
|
+
return {
|
|
402
|
+
...common,
|
|
403
|
+
kind,
|
|
404
|
+
// ⚠️ A server, not an empty string. `ToolServerHandle` has a minimum length, so a draft
|
|
405
|
+
// with `""` cannot be saved at all — the author would build a step and lose it at the next
|
|
406
|
+
// save with a raw field-path error. The tree link one line up takes the same way out.
|
|
407
|
+
configuration: {
|
|
408
|
+
server: firstServer ?? "",
|
|
409
|
+
allow: null,
|
|
410
|
+
fingerprint: null,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
case "condition":
|
|
414
|
+
return {
|
|
415
|
+
...common,
|
|
416
|
+
kind,
|
|
417
|
+
configuration: {
|
|
418
|
+
mode: "semantic",
|
|
419
|
+
instruction: "Describe how to choose yes or no.",
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
case "subflow":
|
|
423
|
+
// `latest` while drafting: nobody should have to version things while building, and
|
|
424
|
+
// publishing turns it into the concrete version (ADR-0004 §5).
|
|
425
|
+
return {
|
|
426
|
+
...common,
|
|
427
|
+
kind,
|
|
428
|
+
configuration: { flowId: firstFlowId ?? "", version: { mode: "latest" } },
|
|
429
|
+
};
|
|
430
|
+
case "output":
|
|
431
|
+
return { ...common, kind, configuration: {} };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Which sentence a refused SAVE is told with.
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ `400 flow_graph_invalid` and `409` are two different answers, and until #508 they shared one
|
|
439
|
+
* sentence — the conflict one. For a rejected graph that advice is not merely useless but
|
|
440
|
+
* destructive: reloading discards the unsaved canvas, and the next attempt is refused for exactly
|
|
441
|
+
* the same reason, so following it costs the work and changes nothing.
|
|
442
|
+
*
|
|
443
|
+
* The graph rule therefore shows the API's OWN sentence: only the server knows which step broke
|
|
444
|
+
* which rule, and it names that step by its label since #508. A conflict keeps the reload sentence,
|
|
445
|
+
* because there reloading IS the way out. Everything else gets neither — a timeout or a `502` is
|
|
446
|
+
* worth repeating, and nothing about it says the loaded version is stale.
|
|
447
|
+
*
|
|
448
|
+
* ⚠️ Not `resourceErrorKey`: that maps a code onto a catalog key, and this case has no key to map
|
|
449
|
+
* onto — its whole content is the server's sentence.
|
|
450
|
+
*/
|
|
451
|
+
function saveRefusalText(error: unknown, i18n: I18n): string {
|
|
452
|
+
const refusal = error instanceof IntelRequestError ? error : null;
|
|
453
|
+
if (refusal?.status === 400 && refusal.code === "flow_graph_invalid") {
|
|
454
|
+
return i18n.t("flows.saveInvalid", { detail: refusal.message });
|
|
455
|
+
}
|
|
456
|
+
if (refusal?.status === 409) return i18n.t("flows.saveConflict");
|
|
457
|
+
return i18n.t("flows.saveFailed");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Which sentence a refused PUBLISH is told with — #508 one surface further (#551).
|
|
462
|
+
*
|
|
463
|
+
* Publishing answers with at least eight different refusals: the compile rules of the save path,
|
|
464
|
+
* a link whose document is out of reach, a tool server that is gone, a frozen schema that moved,
|
|
465
|
+
* three rules about calling another flow — and all of them read
|
|
466
|
+
* "Publishing failed. Reload the latest version and try again." Reloading is the way out of exactly
|
|
467
|
+
* one of them, and for the rest it is the advice that costs the canvas and changes nothing.
|
|
468
|
+
*
|
|
469
|
+
* The split is therefore by what the answer IS, not by which code it carries:
|
|
470
|
+
*
|
|
471
|
+
* - **A refusal** (`4xx`) always shows the server's own sentence. It is the only party that knows
|
|
472
|
+
* which step broke which rule, and since #508 it names that step by its label rather than by an
|
|
473
|
+
* id — which is why #551 rewrote `flow_node_unavailable`, the one that still named a resource id.
|
|
474
|
+
* - **Two of them add the way out**, because the server's sentence identifies the step and stops
|
|
475
|
+
* there: a document out of reach is answered by asking for access, and a tool server that is
|
|
476
|
+
* gone by looking in the portal. Neither is something the API should prescribe per surface.
|
|
477
|
+
* - **A conflict** keeps the reload sentence, because there reloading IS the way out.
|
|
478
|
+
* - **Everything else** — a timeout, a `502`, a network that came back — says nothing about the
|
|
479
|
+
* loaded version and is worth repeating, so it gets neither.
|
|
480
|
+
*/
|
|
481
|
+
function publishRefusalText(error: unknown, i18n: I18n): string {
|
|
482
|
+
const refusal = error instanceof IntelRequestError ? error : null;
|
|
483
|
+
// ⚠️ A `5xx` carries a sentence too, and showing it would dress a broken deployment up as a
|
|
484
|
+
// decision about this flow. Only the deliberate range speaks for itself.
|
|
485
|
+
if (refusal === null || refusal.status < 400 || refusal.status >= 500) {
|
|
486
|
+
return i18n.t("flows.publishFailed");
|
|
487
|
+
}
|
|
488
|
+
const detail = { detail: refusal.message };
|
|
489
|
+
switch (refusal.code) {
|
|
490
|
+
case "flow_node_unavailable":
|
|
491
|
+
return i18n.t("flows.publishNodeUnavailable", detail);
|
|
492
|
+
case "flow_tool_unavailable":
|
|
493
|
+
return i18n.t("flows.publishToolUnavailable", detail);
|
|
494
|
+
// The two ways the version being published stopped being the one to publish. Both are races
|
|
495
|
+
// against another editor, and the loaded version is genuinely stale afterwards.
|
|
496
|
+
case "flow_version_conflict":
|
|
497
|
+
case "flow_publish_conflict":
|
|
498
|
+
return i18n.t("flows.publishConflict");
|
|
499
|
+
default:
|
|
500
|
+
return i18n.t("flows.publishRefused", detail);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
|
|
505
|
+
// drop turns a screen point into a graph point — has to be called from a component UNDER it, and
|
|
506
|
+
// the hook lives in the same component that renders the canvas.
|
|
507
|
+
export function Flows() {
|
|
508
|
+
return (
|
|
509
|
+
<ReactFlowProvider>
|
|
510
|
+
<FlowsEditor />
|
|
511
|
+
</ReactFlowProvider>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function FlowsEditor() {
|
|
516
|
+
const { data } = useIntelRouterContext();
|
|
517
|
+
const i18n = useI18n();
|
|
518
|
+
const flow = useReactFlow();
|
|
519
|
+
const theme = useResolvedTheme();
|
|
520
|
+
const queryClient = useQueryClient();
|
|
521
|
+
const navigate = useNavigate();
|
|
522
|
+
// The servers a tool step can name (#489). The flat function list the picker used to show is
|
|
523
|
+
// what that ticket set out to remove; which function runs is decided while the flow runs.
|
|
524
|
+
// ⚠️ The flat function list is NOT what a step is chosen from any more (#489) — it is only what
|
|
525
|
+
// an author narrows a chosen server WITH. It is asked for regardless of whether a tool step is
|
|
526
|
+
// selected, because a query that starts when a panel opens shows a spinner inside the panel.
|
|
527
|
+
const tools = useQuery({
|
|
528
|
+
queryKey: ["tools"],
|
|
529
|
+
queryFn: () => data.listTools(),
|
|
530
|
+
// ⚠️ No retry. This query only ENRICHES the panel — the server is already chosen without it —
|
|
531
|
+
// and a retry is paused while the tab is unfocused, so a deterministic failure would leave the
|
|
532
|
+
// function list empty for as long as the reader looks away, with nothing saying why (#212).
|
|
533
|
+
retry: false,
|
|
534
|
+
});
|
|
535
|
+
const toolServers = useQuery({
|
|
536
|
+
queryKey: ["tool-servers"],
|
|
537
|
+
queryFn: () => data.listToolServers(),
|
|
538
|
+
// ⚠️ No retry here either, and for a sharper reason than the list above: the panel shows
|
|
539
|
+
// NOTHING while this is pending. A deterministic 502 with an unfocused tab pauses the retry,
|
|
540
|
+
// and the author then sits in front of an empty server field with no sentence saying why —
|
|
541
|
+
// which is #212 exactly. `error` is a state this panel can speak; `pending` is not.
|
|
542
|
+
retry: false,
|
|
543
|
+
});
|
|
544
|
+
// A tree link points at a node, so the editor needs candidates. The graph is the authorized
|
|
545
|
+
// flat list of them; walking the folder tree for a picker would be the N+1 all over again.
|
|
546
|
+
const nodeGraph = useQuery({
|
|
547
|
+
queryKey: ["node-graph"],
|
|
548
|
+
queryFn: () => data.getNodeGraph(),
|
|
549
|
+
});
|
|
550
|
+
// The candidates for a subflow step. Which of them a flow may actually call is answered when it is
|
|
551
|
+
// published (ADR-0004 §3) — the picker offers what the user may see, and the refusal names the
|
|
552
|
+
// reason rather than hiding the option.
|
|
553
|
+
const callable = useQuery({
|
|
554
|
+
queryKey: ["flows"],
|
|
555
|
+
queryFn: () => data.listFlows(),
|
|
556
|
+
});
|
|
557
|
+
/**
|
|
558
|
+
* The refusal of the top level, read off the query the picker already holds.
|
|
559
|
+
*
|
|
560
|
+
* ⚠️ No second query, and that is the whole difference to #445 next door: `["flows"]` is asked
|
|
561
|
+
* with or without a selection, so the answer for the empty screen is already in the cache. What
|
|
562
|
+
* the screen did was throw it away — `listTreeChildren` fails whole once `flows/read` is missing,
|
|
563
|
+
* the sidebar has said so since #430, and the main area beside it went on inviting the reader to
|
|
564
|
+
* pick a flow they cannot see (#557).
|
|
565
|
+
*
|
|
566
|
+
* ⚠️ Off the ERROR rather than off a missing answer. A sentence about a permission, shown while
|
|
567
|
+
* the request is still travelling, claims a refusal nobody has spoken yet — the mistake from #350,
|
|
568
|
+
* and `packages/intel/ui/CLAUDE.md` has the rule: a missing answer is a reason to say less, never to
|
|
569
|
+
* refuse more. Until a refusal arrives, what stands is the invitation, which claims nothing.
|
|
570
|
+
*/
|
|
571
|
+
const rootRefusal = refusalOf(callable.error);
|
|
572
|
+
// The tree in the sidebar and the header search both name the flow to open through `?select=`;
|
|
573
|
+
// `?view=` says how it is shown — the editor, the relation graph (#19) or the runs (#35).
|
|
574
|
+
const selection = useRouterState({
|
|
575
|
+
select: (state) => ({
|
|
576
|
+
id: selectedFrom(state.location.search),
|
|
577
|
+
view: viewFrom(state.location.search),
|
|
578
|
+
}),
|
|
579
|
+
});
|
|
580
|
+
const selectedFlowId = selection.id;
|
|
581
|
+
// A single flow answers the relation question for itself: what it reads and what it calls.
|
|
582
|
+
const relations = useQuery({
|
|
583
|
+
queryKey: ["relation-graph", "flow", selectedFlowId],
|
|
584
|
+
queryFn: () => data.getRelationGraph({ of: "flow", flowId: selectedFlowId ?? "" }),
|
|
585
|
+
enabled: Boolean(selectedFlowId) && selection.view === "graph",
|
|
586
|
+
});
|
|
587
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
588
|
+
const [publishing, setPublishing] = useState(false);
|
|
589
|
+
// Lifted out of the bar because the canvas dismisses it too — a click on the pane is the third way
|
|
590
|
+
// out, next to the trigger and Escape.
|
|
591
|
+
const [paletteOpen, setPaletteOpen] = usePaletteOpen();
|
|
592
|
+
const document = useQuery({
|
|
593
|
+
queryKey: ["flow", selectedFlowId],
|
|
594
|
+
queryFn: () => data.getFlow(selectedFlowId ?? ""),
|
|
595
|
+
enabled: Boolean(selectedFlowId),
|
|
596
|
+
});
|
|
597
|
+
// ⚠️ A refusal is not a failure. "Reload the latest version and try again." is advice for a
|
|
598
|
+
// network that came back; against a `403` or a `404` it sends somebody to reload a screen that
|
|
599
|
+
// will answer the same thing forever, and it hides that the answer was final (#430).
|
|
600
|
+
const documentRefusal = refusalOf(document.error);
|
|
601
|
+
const initial = useMemo(() => canvas(defaultGraph()), []);
|
|
602
|
+
const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
|
|
603
|
+
const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
|
|
604
|
+
const [dirty, setDirty] = useState(false);
|
|
605
|
+
// The canvas stays visible during a background refetch; only a mutation needs the stricter
|
|
606
|
+
// guarantee that the loaded document is both the selected flow and settled.
|
|
607
|
+
const documentReady = selectedFlowId !== null && document.data?.flow.id === selectedFlowId;
|
|
608
|
+
const canMutate = documentReady && !document.isFetching;
|
|
609
|
+
// ⚠️ A tool step without a server cannot be saved at all — `ToolServerHandle` has a minimum
|
|
610
|
+
// length. Letting the request go out anyway answers with a generic failure that names no step, so
|
|
611
|
+
// the author learns "something is wrong" instead of "this one needs a server". The refusal
|
|
612
|
+
// belongs here, before the write (#517).
|
|
613
|
+
const stepsMissingServer = nodes.filter(
|
|
614
|
+
(node) => node.data.node.kind === "tool" && node.data.node.configuration.server === "",
|
|
615
|
+
);
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (!document.data || document.data.flow.id !== selectedFlowId) return;
|
|
618
|
+
const next = canvas(document.data.version?.graph ?? defaultGraph());
|
|
619
|
+
setNodes(next.nodes);
|
|
620
|
+
setEdges(next.edges);
|
|
621
|
+
setSelectedNodeId(null);
|
|
622
|
+
setDirty(false);
|
|
623
|
+
}, [document.data, selectedFlowId]);
|
|
624
|
+
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
|
|
625
|
+
// ⚠️ The same query `FlowNeeds` asks, under the same key: TanStack answers both from one cache
|
|
626
|
+
// entry, so the canvas and the panel cannot end up disagreeing about which link is dead. A second
|
|
627
|
+
// key would be a second answer to one question, and the one people would trust is the one they
|
|
628
|
+
// happen to be looking at (#509).
|
|
629
|
+
const needs = useQuery({
|
|
630
|
+
queryKey: ["flow-requirements", selectedFlowId],
|
|
631
|
+
queryFn: () => data.getFlowRequirements(selectedFlowId ?? ""),
|
|
632
|
+
enabled: Boolean(selectedFlowId),
|
|
633
|
+
});
|
|
634
|
+
const invalidLinks = useMemo(
|
|
635
|
+
() => new Set(needs.data?.invalidNodes ?? []),
|
|
636
|
+
[needs.data?.invalidNodes],
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const save = useMutation({
|
|
640
|
+
mutationFn: () =>
|
|
641
|
+
data.saveFlow({
|
|
642
|
+
flowId: selectedFlowId ?? "",
|
|
643
|
+
baseVersionId: document.data?.flow.currentVersionId ?? null,
|
|
644
|
+
graph: graph(nodes, edges),
|
|
645
|
+
idempotencyKey: crypto.randomUUID(),
|
|
646
|
+
}),
|
|
647
|
+
onSuccess: async (saved) => {
|
|
648
|
+
setDirty(false);
|
|
649
|
+
queryClient.setQueryData(["flow", saved.flow.id], saved);
|
|
650
|
+
// Saving is the whole of what changes here: the graph is both what this flow reads and calls
|
|
651
|
+
// — so the relation view of it and of its folder move with it — and the whole of what it
|
|
652
|
+
// needs.
|
|
653
|
+
await Promise.all([
|
|
654
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
655
|
+
queryClient.invalidateQueries({ queryKey: ["flow-requirements", saved.flow.id] }),
|
|
656
|
+
]);
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
function updateNode(update: (node: FlowNode) => FlowNode) {
|
|
661
|
+
setDirty(true);
|
|
662
|
+
setNodes((current) =>
|
|
663
|
+
current.map((node) =>
|
|
664
|
+
node.id === selectedNodeId ? { ...node, data: { node: update(node.data.node) } } : node,
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
<TitleRowFrame className="flex h-full min-h-0 flex-col">
|
|
671
|
+
{selectedFlowId && document.data ? (
|
|
672
|
+
<FlowTitle
|
|
673
|
+
flow={document.data.flow}
|
|
674
|
+
dirty={dirty}
|
|
675
|
+
saving={save.isPending}
|
|
676
|
+
canMutate={canMutate}
|
|
677
|
+
incomplete={stepsMissingServer.map((node) => ({ label: node.data.node.label }))}
|
|
678
|
+
scrollingView={selection.view === "runs"}
|
|
679
|
+
onSave={() => save.mutate()}
|
|
680
|
+
onPublish={() => setPublishing(true)}
|
|
681
|
+
/>
|
|
682
|
+
) : null}
|
|
683
|
+
<UnsavedChangesGuard dirty={dirty} />
|
|
684
|
+
<div className="flex min-h-0 flex-1">
|
|
685
|
+
{!selectedFlowId ? (
|
|
686
|
+
rootRefusal ? (
|
|
687
|
+
<RefusalNotice refusal={rootRefusal} />
|
|
688
|
+
) : (
|
|
689
|
+
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
690
|
+
{i18n.t("flows.select")}
|
|
691
|
+
</div>
|
|
692
|
+
)
|
|
693
|
+
) : /* ⚠️ The refusal comes BEFORE the view switch, and that placement is the whole of #568.
|
|
694
|
+
`?view=graph` and `?view=runs` used to be read first, so a flow the reader may not
|
|
695
|
+
open still rendered its two side views — each with a failure sentence and a "Try
|
|
696
|
+
again" button on an answer that will not change (#430). Whether the reader may see
|
|
697
|
+
this flow at all is answered by `getFlow`, once, for every view of it; asking it
|
|
698
|
+
after the switch means asking it three times and getting two of them wrong. */
|
|
699
|
+
documentRefusal ? (
|
|
700
|
+
<RefusalNotice refusal={documentRefusal} />
|
|
701
|
+
) : selection.view === "runs" ? (
|
|
702
|
+
<FlowRuns flowId={selectedFlowId} />
|
|
703
|
+
) : selection.view === "graph" ? (
|
|
704
|
+
<GraphPane
|
|
705
|
+
query={relations}
|
|
706
|
+
select={(node) =>
|
|
707
|
+
void navigate({
|
|
708
|
+
to: node.kind === "flow" ? "/flows" : "/nodes",
|
|
709
|
+
search: { select: node.id },
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
/>
|
|
713
|
+
) : document.isError ? (
|
|
714
|
+
<div
|
|
715
|
+
role="alert"
|
|
716
|
+
className="grid flex-1 place-items-center p-8 text-center text-sm text-destructive"
|
|
717
|
+
>
|
|
718
|
+
{i18n.t("flows.operationFailed")}
|
|
719
|
+
</div>
|
|
720
|
+
) : !documentReady ? (
|
|
721
|
+
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
722
|
+
{i18n.t("common.loading")}
|
|
723
|
+
</div>
|
|
724
|
+
) : (
|
|
725
|
+
<>
|
|
726
|
+
<section className="relative min-w-0 flex-1">
|
|
727
|
+
{/* Adding is not what one does most of the time — reading the graph is. The bar stays
|
|
728
|
+
open once opened, so three steps in a row cost one click, not three. */}
|
|
729
|
+
<NodePalette
|
|
730
|
+
kinds={paletteKinds}
|
|
731
|
+
open={paletteOpen}
|
|
732
|
+
setOpen={setPaletteOpen}
|
|
733
|
+
// ⚠️ Only the bar is closed off here. `compileFlow` keeps insisting on exactly one
|
|
734
|
+
// start, because a graph can also arrive over MCP or an import, where no bar was
|
|
735
|
+
// involved — this is the earlier answer, not the only one.
|
|
736
|
+
disabledReason={(kind) =>
|
|
737
|
+
kind === "trigger" && nodes.some((node) => node.data.node.kind === "trigger")
|
|
738
|
+
? i18n.t("flows.startExists")
|
|
739
|
+
: null
|
|
740
|
+
}
|
|
741
|
+
add={(kind) => {
|
|
742
|
+
setDirty(true);
|
|
743
|
+
const item = newNode(
|
|
744
|
+
kind,
|
|
745
|
+
nodes.length,
|
|
746
|
+
nodeGraph.data?.nodes[0]?.id,
|
|
747
|
+
callable.data?.items.find((entry) => entry.id !== selectedFlowId)?.id,
|
|
748
|
+
toolServers.data?.items[0]?.handle,
|
|
749
|
+
);
|
|
750
|
+
setNodes((current) => [
|
|
751
|
+
...current,
|
|
752
|
+
{
|
|
753
|
+
id: item.id,
|
|
754
|
+
type: "intel",
|
|
755
|
+
position: item.position,
|
|
756
|
+
data: { node: item },
|
|
757
|
+
},
|
|
758
|
+
]);
|
|
759
|
+
}}
|
|
760
|
+
/>
|
|
761
|
+
{/* ⚠️ React Flow knows nothing about the `.dark` class our tokens hang off; without
|
|
762
|
+
`colorMode` it paints its controls and its minimap from its own light palette and
|
|
763
|
+
leaves two glaring white surfaces on a black canvas. The value comes from the same
|
|
764
|
+
media query that sets the class, so both turn together and mid-session. What the
|
|
765
|
+
surfaces are actually coloured with is in `styles.css`, through the library's own
|
|
766
|
+
theming variables. */}
|
|
767
|
+
<InvalidLinks.Provider value={invalidLinks}>
|
|
768
|
+
<ReactFlow<CanvasNode, CanvasEdge>
|
|
769
|
+
colorMode={theme}
|
|
770
|
+
nodes={nodes}
|
|
771
|
+
edges={edges}
|
|
772
|
+
nodeTypes={nodeTypes}
|
|
773
|
+
fitView
|
|
774
|
+
onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
|
|
775
|
+
if (editsNodes(changes)) setDirty(true);
|
|
776
|
+
setNodes((current) => applyNodeChanges(changes, current));
|
|
777
|
+
}}
|
|
778
|
+
onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
|
|
779
|
+
if (editsEdges(changes)) setDirty(true);
|
|
780
|
+
setEdges((current) => applyEdgeChanges(changes, current));
|
|
781
|
+
}}
|
|
782
|
+
// ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
|
|
783
|
+
// a flow input would be stored as context and drawn as an attachment, while the
|
|
784
|
+
// author meant "and then". Refused at the point where it is still visible.
|
|
785
|
+
isValidConnection={(connection) =>
|
|
786
|
+
(connection.sourceHandle === ContextHandle) ===
|
|
787
|
+
(connection.targetHandle === ContextHandle)
|
|
788
|
+
}
|
|
789
|
+
onConnect={(connection: Connection) => {
|
|
790
|
+
setDirty(true);
|
|
791
|
+
setEdges((current) =>
|
|
792
|
+
addEdge(
|
|
793
|
+
{
|
|
794
|
+
...connection,
|
|
795
|
+
id: crypto.randomUUID(),
|
|
796
|
+
...contextEdgeStyle(connection.sourceHandle === ContextHandle),
|
|
797
|
+
},
|
|
798
|
+
clearedFor(current, connection),
|
|
799
|
+
),
|
|
800
|
+
);
|
|
801
|
+
}}
|
|
802
|
+
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
|
|
803
|
+
onPaneClick={() => {
|
|
804
|
+
setSelectedNodeId(null);
|
|
805
|
+
setPaletteOpen(false);
|
|
806
|
+
}}
|
|
807
|
+
// ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
|
|
808
|
+
// have to feel different while the pointer is still travelling: dropping here makes
|
|
809
|
+
// a node that POINTS at the row, it does not take the row out of its folder.
|
|
810
|
+
onDragOver={(event) => {
|
|
811
|
+
if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
|
|
812
|
+
event.preventDefault();
|
|
813
|
+
event.dataTransfer.dropEffect = "copy";
|
|
814
|
+
}}
|
|
815
|
+
onDrop={(event) => {
|
|
816
|
+
const payload = event.dataTransfer.getData(TreeEntryMediaType);
|
|
817
|
+
if (!payload) return;
|
|
818
|
+
event.preventDefault();
|
|
819
|
+
const dropped = droppedEntry(payload);
|
|
820
|
+
if (!dropped) return;
|
|
821
|
+
const kind = flowKindOfEntry[dropped.kind];
|
|
822
|
+
if (!kind) return;
|
|
823
|
+
// Where the pointer let go, in the graph's own coordinates — not the screen's, or
|
|
824
|
+
// the node would land somewhere else at every zoom level.
|
|
825
|
+
const position = flow.screenToFlowPosition({
|
|
826
|
+
x: event.clientX,
|
|
827
|
+
y: event.clientY,
|
|
828
|
+
});
|
|
829
|
+
setDirty(true);
|
|
830
|
+
setNodes((current) => [
|
|
831
|
+
...current,
|
|
832
|
+
{
|
|
833
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
834
|
+
type: "intel" as const,
|
|
835
|
+
position,
|
|
836
|
+
data: {
|
|
837
|
+
node: {
|
|
838
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
839
|
+
kind,
|
|
840
|
+
label: dropped.title,
|
|
841
|
+
position,
|
|
842
|
+
configuration:
|
|
843
|
+
kind === "subflow"
|
|
844
|
+
? { flowId: dropped.id, version: { mode: "latest" as const } }
|
|
845
|
+
: { resourceId: dropped.id },
|
|
846
|
+
} as FlowNode,
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
]);
|
|
850
|
+
}}
|
|
851
|
+
>
|
|
852
|
+
<Background />
|
|
853
|
+
{/* The overview map has no room for a label, so the start is marked there the only
|
|
854
|
+
way that is left: its own colour, from the tokens rather than a fixed value. */}
|
|
855
|
+
<MiniMap
|
|
856
|
+
pannable
|
|
857
|
+
zoomable
|
|
858
|
+
nodeClassName={(node) =>
|
|
859
|
+
(node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
|
|
860
|
+
}
|
|
861
|
+
/>
|
|
862
|
+
<Controls />
|
|
863
|
+
</ReactFlow>
|
|
864
|
+
</InvalidLinks.Provider>
|
|
865
|
+
</section>
|
|
866
|
+
<aside className="flex w-80 shrink-0 flex-col border-l bg-card">
|
|
867
|
+
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
|
|
868
|
+
<NodeInspector
|
|
869
|
+
node={selectedNode}
|
|
870
|
+
update={updateNode}
|
|
871
|
+
toolServers={toolServers.data?.items ?? []}
|
|
872
|
+
toolServersState={
|
|
873
|
+
toolServers.isPending
|
|
874
|
+
? "pending"
|
|
875
|
+
: toolServers.isError
|
|
876
|
+
? "error"
|
|
877
|
+
: toolServers.data?.portalConnected === false
|
|
878
|
+
? "disconnected"
|
|
879
|
+
: "ready"
|
|
880
|
+
}
|
|
881
|
+
toolNames={(tools.data?.items ?? []).map((entry) => entry.name)}
|
|
882
|
+
toolNamesFailed={tools.isError}
|
|
883
|
+
toolNamesPending={tools.isPending}
|
|
884
|
+
flows={(callable.data?.items ?? []).filter(
|
|
885
|
+
(entry) => entry.id !== selectedFlowId,
|
|
886
|
+
)}
|
|
887
|
+
/>
|
|
888
|
+
<FlowNeeds flowId={selectedFlowId} />
|
|
889
|
+
</TitleRowScrollArea>
|
|
890
|
+
</aside>
|
|
891
|
+
</>
|
|
892
|
+
)}
|
|
893
|
+
</div>
|
|
894
|
+
{save.isError && (
|
|
895
|
+
<p
|
|
896
|
+
role="alert"
|
|
897
|
+
className="fixed bottom-5 left-[18rem] z-20 rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
898
|
+
>
|
|
899
|
+
{saveRefusalText(save.error, i18n)}
|
|
900
|
+
</p>
|
|
901
|
+
)}
|
|
902
|
+
{publishing && selectedFlowId && document.data?.flow.currentVersionId ? (
|
|
903
|
+
<PublishPreview
|
|
904
|
+
flowId={selectedFlowId}
|
|
905
|
+
versionId={document.data.flow.currentVersionId}
|
|
906
|
+
close={() => setPublishing(false)}
|
|
907
|
+
onPublished={async () => {
|
|
908
|
+
setPublishing(false);
|
|
909
|
+
// Publishing appends the frozen version, so more than the flow row moves: the canvas
|
|
910
|
+
// reloads from the new current version, the preview cached under the old version id
|
|
911
|
+
// would still claim there is something left to freeze, and the relation view reads the
|
|
912
|
+
// graph that just changed.
|
|
913
|
+
await Promise.all([
|
|
914
|
+
queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
|
|
915
|
+
queryClient.invalidateQueries({
|
|
916
|
+
queryKey: ["flow-publish-preview", selectedFlowId],
|
|
917
|
+
}),
|
|
918
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
919
|
+
]);
|
|
920
|
+
}}
|
|
921
|
+
/>
|
|
922
|
+
) : null}
|
|
923
|
+
</TitleRowFrame>
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* The flow's title line — the same one a document has (#24).
|
|
929
|
+
*
|
|
930
|
+
* Before this, a flow wore four spelled-out buttons in the shell's header while a document wore
|
|
931
|
+
* icons in its own title line: two screens of one application, built two ways, and a reader who has
|
|
932
|
+
* to learn where actions live twice.
|
|
933
|
+
*
|
|
934
|
+
* ⚠️ `Publish` and `Run` stay visible buttons rather than menu entries, and they are deliberately
|
|
935
|
+
* *not* the same shape as each other. Both reach outside the editor — publishing freezes the version
|
|
936
|
+
* every later run hangs on, and a run keeps going after the tab is closed (Cloudflare Workflows,
|
|
937
|
+
* binding `FLOWS`) — so neither belongs behind three dots. But `Run` is the only one that starts
|
|
938
|
+
* work, so it is the only button in the line that carries a word instead of a picture: the
|
|
939
|
+
* difference in form *is* the warning against hitting it by accident.
|
|
940
|
+
*/
|
|
941
|
+
function FlowTitle({
|
|
942
|
+
flow,
|
|
943
|
+
dirty,
|
|
944
|
+
saving,
|
|
945
|
+
canMutate,
|
|
946
|
+
incomplete,
|
|
947
|
+
scrollingView,
|
|
948
|
+
onSave,
|
|
949
|
+
onPublish,
|
|
950
|
+
}: {
|
|
951
|
+
flow: Flow;
|
|
952
|
+
dirty: boolean;
|
|
953
|
+
saving: boolean;
|
|
954
|
+
canMutate: boolean;
|
|
955
|
+
// Whether what stands below this line can scroll under it. The runs list can and earns its edge
|
|
956
|
+
// the usual way; the canvas and the graph pan instead, and would never get one (#634).
|
|
957
|
+
scrollingView: boolean;
|
|
958
|
+
// Tool steps that name no server yet. They cannot be saved, so the refusal is shown here rather
|
|
959
|
+
// than fetched from the server as a message that names no step.
|
|
960
|
+
incomplete: Array<{ label: string }>;
|
|
961
|
+
onSave(): void;
|
|
962
|
+
onPublish(): void;
|
|
963
|
+
}) {
|
|
964
|
+
const i18n = useI18n();
|
|
965
|
+
// A draft that is already the published one freezes nothing, so publishing it would be a no-op
|
|
966
|
+
// with a dialog in front of it.
|
|
967
|
+
const publishable =
|
|
968
|
+
canMutate &&
|
|
969
|
+
Boolean(flow.currentVersionId) &&
|
|
970
|
+
flow.currentVersionId !== flow.publishedVersionId;
|
|
971
|
+
// ⚠️ Nothing published means `Start run` answers 409 `flow_not_published` (`flows.ts:541`), and
|
|
972
|
+
// until now that was only readable by clicking it. The button that fixes it says so instead.
|
|
973
|
+
const unpublished = flow.publishedVersionId === null;
|
|
974
|
+
const publishLabel = i18n.t(
|
|
975
|
+
!publishable ? "flows.publishNothing" : unpublished ? "flows.publishNeeded" : "flows.publish",
|
|
976
|
+
);
|
|
977
|
+
return (
|
|
978
|
+
// ⚠️ The menu stands to the RIGHT of the run button, not before it (#53, deliberate). Run is the
|
|
979
|
+
// loudest thing in this line and used to hold the edge; the menu takes it, because a menu one can
|
|
980
|
+
// hit without looking is worth more than the primary button being outermost. `TitleRow` is what
|
|
981
|
+
// enforces that — nothing passed in here can get past the menu.
|
|
982
|
+
<TitleRow title={flow.title} target={{ type: "flow", flow }} separated={!scrollingView}>
|
|
983
|
+
{/* ⚠️ #454: the view switch used to stand in the global header, beside the search — where what
|
|
984
|
+
applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
|
|
985
|
+
that names this flow. A flow is the only level with runs, and therefore the only one with
|
|
986
|
+
three views (#35) — behind one symbol since #818, like every other surface. */}
|
|
987
|
+
{incomplete.length > 0 && (
|
|
988
|
+
<p className="mr-2 text-xs text-destructive">
|
|
989
|
+
{i18n.t("flows.toolStepNeedsServer", { label: incomplete[0]?.label ?? "" })}
|
|
990
|
+
</p>
|
|
991
|
+
)}
|
|
992
|
+
<SaveButton
|
|
993
|
+
dirty={dirty && canMutate && incomplete.length === 0}
|
|
994
|
+
saving={saving}
|
|
995
|
+
onSave={onSave}
|
|
996
|
+
/>
|
|
997
|
+
<TooltipProvider delayDuration={300}>
|
|
998
|
+
<Tooltip>
|
|
999
|
+
<TooltipTrigger asChild>
|
|
1000
|
+
<span className="inline-flex">
|
|
1001
|
+
{/* Publishing goes through the preview, always. ADR-0004 §5 asks the author to see
|
|
1002
|
+
the freeze before it happens, and a second, quieter path around the dialog would
|
|
1003
|
+
be the one everybody ends up using. */}
|
|
1004
|
+
<button
|
|
1005
|
+
type="button"
|
|
1006
|
+
onClick={onPublish}
|
|
1007
|
+
disabled={!publishable}
|
|
1008
|
+
aria-label={publishLabel}
|
|
1009
|
+
className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
|
|
1010
|
+
publishable && unpublished ? "bg-primary/10 text-primary" : ""
|
|
1011
|
+
}`}
|
|
1012
|
+
>
|
|
1013
|
+
<Send aria-hidden="true" className="size-4" />
|
|
1014
|
+
</button>
|
|
1015
|
+
</span>
|
|
1016
|
+
</TooltipTrigger>
|
|
1017
|
+
<TooltipContent>{publishLabel}</TooltipContent>
|
|
1018
|
+
</Tooltip>
|
|
1019
|
+
</TooltipProvider>
|
|
1020
|
+
<ViewMenu views={["editor", "graph", "runs"]} />
|
|
1021
|
+
</TitleRow>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ⚠️ The list is the server's, not this component's. Publishing computes the same freeze from the
|
|
1026
|
+
// same code, so what the author agrees to here is what happens — a version resolved in the browser
|
|
1027
|
+
// would be a second opinion, and the two would part ways the moment someone else publishes.
|
|
1028
|
+
function PublishPreview({
|
|
1029
|
+
flowId,
|
|
1030
|
+
versionId,
|
|
1031
|
+
close,
|
|
1032
|
+
onPublished,
|
|
1033
|
+
}: {
|
|
1034
|
+
flowId: string;
|
|
1035
|
+
versionId: string;
|
|
1036
|
+
close(): void;
|
|
1037
|
+
onPublished(): Promise<void>;
|
|
1038
|
+
}) {
|
|
1039
|
+
const { data } = useIntelRouterContext();
|
|
1040
|
+
const i18n = useI18n();
|
|
1041
|
+
const preview = useQuery({
|
|
1042
|
+
queryKey: ["flow-publish-preview", flowId, versionId],
|
|
1043
|
+
queryFn: () => data.previewFlowPublish({ flowId, versionId }),
|
|
1044
|
+
});
|
|
1045
|
+
const publish = useMutation({
|
|
1046
|
+
mutationFn: () => data.publishFlow({ flowId, versionId, idempotencyKey: crypto.randomUUID() }),
|
|
1047
|
+
onSuccess: onPublished,
|
|
1048
|
+
});
|
|
1049
|
+
return (
|
|
1050
|
+
<Modal title={i18n.t("flows.publishPreview")} close={close}>
|
|
1051
|
+
{preview.isPending ? (
|
|
1052
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
1053
|
+
) : preview.isError ? (
|
|
1054
|
+
<p role="alert" className="text-sm text-destructive">
|
|
1055
|
+
{i18n.t("flows.operationFailed")}
|
|
1056
|
+
</p>
|
|
1057
|
+
) : (
|
|
1058
|
+
<>
|
|
1059
|
+
<p className="text-sm text-muted-foreground">
|
|
1060
|
+
{preview.data.calls.length === 0 && preview.data.tools.length === 0
|
|
1061
|
+
? i18n.t("flows.publishPreviewEmpty")
|
|
1062
|
+
: i18n.t("flows.publishPreviewIntro")}
|
|
1063
|
+
</p>
|
|
1064
|
+
{/* ⚠️ The tool surface belongs in the preview because publishing FREEZES it: after this
|
|
1065
|
+
confirmation the step is pinned to these functions and inherits nothing the provider
|
|
1066
|
+
adds later. An author who is not shown it is asked to freeze something unseen (#517). */}
|
|
1067
|
+
{preview.data.tools.length > 0 && (
|
|
1068
|
+
<ul className="mt-4 space-y-2">
|
|
1069
|
+
{preview.data.tools.map((tool) => (
|
|
1070
|
+
<li key={tool.nodeId} className="rounded-md border p-3 text-sm">
|
|
1071
|
+
<span className="block font-medium">{tool.nodeLabel}</span>
|
|
1072
|
+
<span className="block text-xs text-muted-foreground">{tool.server}</span>
|
|
1073
|
+
<span
|
|
1074
|
+
className={`mt-1 block text-xs ${tool.available ? "text-muted-foreground" : "text-destructive"}`}
|
|
1075
|
+
>
|
|
1076
|
+
{!tool.available
|
|
1077
|
+
? i18n.t("flows.publishPreviewToolUnavailable")
|
|
1078
|
+
: tool.allow === null
|
|
1079
|
+
? i18n.t("flows.toolAllowAll")
|
|
1080
|
+
: i18n.t("flows.publishPreviewToolAllow", {
|
|
1081
|
+
functions: tool.allow.join(", "),
|
|
1082
|
+
})}
|
|
1083
|
+
</span>
|
|
1084
|
+
</li>
|
|
1085
|
+
))}
|
|
1086
|
+
</ul>
|
|
1087
|
+
)}
|
|
1088
|
+
<ul className="mt-4 space-y-2">
|
|
1089
|
+
{preview.data.calls.map((call) => {
|
|
1090
|
+
// ⚠️ One condition for the sentence and its colour. They were two, so a call that
|
|
1091
|
+
// rides along into a callee with nothing published read "runs along" in the colour of
|
|
1092
|
+
// a failure — the reader would have gone looking for a problem that is not there.
|
|
1093
|
+
const blocked = !call.available && call.mode !== "follows";
|
|
1094
|
+
return (
|
|
1095
|
+
<li key={call.nodeId} className="rounded-md border p-3 text-sm">
|
|
1096
|
+
<span className="block font-medium">{call.nodeLabel}</span>
|
|
1097
|
+
<span className="block text-xs text-muted-foreground">{call.calleeTitle}</span>
|
|
1098
|
+
<span
|
|
1099
|
+
className={`mt-1 block text-xs ${blocked ? "text-destructive" : "text-muted-foreground"}`}
|
|
1100
|
+
>
|
|
1101
|
+
{blocked
|
|
1102
|
+
? i18n.t("flows.publishPreviewUnavailable")
|
|
1103
|
+
: call.freezes
|
|
1104
|
+
? i18n.t("flows.publishPreviewFreeze", {
|
|
1105
|
+
sequence: call.versionSequence ?? 0,
|
|
1106
|
+
})
|
|
1107
|
+
: call.mode === "pinned"
|
|
1108
|
+
? i18n.t("flows.publishPreviewPinned", {
|
|
1109
|
+
sequence: call.versionSequence ?? 0,
|
|
1110
|
+
})
|
|
1111
|
+
: i18n.t("flows.publishPreviewFollows")}
|
|
1112
|
+
</span>
|
|
1113
|
+
</li>
|
|
1114
|
+
);
|
|
1115
|
+
})}
|
|
1116
|
+
</ul>
|
|
1117
|
+
{publish.isError && (
|
|
1118
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
1119
|
+
{publishRefusalText(publish.error, i18n)}
|
|
1120
|
+
</p>
|
|
1121
|
+
)}
|
|
1122
|
+
<button
|
|
1123
|
+
type="button"
|
|
1124
|
+
onClick={() => publish.mutate()}
|
|
1125
|
+
disabled={publish.isPending}
|
|
1126
|
+
className="mt-5 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
1127
|
+
>
|
|
1128
|
+
{publish.isPending ? i18n.t("common.saving") : i18n.t("flows.publishConfirm")}
|
|
1129
|
+
</button>
|
|
1130
|
+
</>
|
|
1131
|
+
)}
|
|
1132
|
+
</Modal>
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function NodeInspector({
|
|
1137
|
+
node,
|
|
1138
|
+
update,
|
|
1139
|
+
toolServers,
|
|
1140
|
+
toolServersState,
|
|
1141
|
+
toolNames,
|
|
1142
|
+
toolNamesFailed,
|
|
1143
|
+
toolNamesPending,
|
|
1144
|
+
flows,
|
|
1145
|
+
}: {
|
|
1146
|
+
node: CanvasNode | null;
|
|
1147
|
+
update(fn: (node: FlowNode) => FlowNode): void;
|
|
1148
|
+
toolServers: Array<{ handle: string; name: string; toolCount: number }>;
|
|
1149
|
+
// ⚠️ FOUR answers, not two. A list that is still loading is not one that came back empty; a
|
|
1150
|
+
// failure is neither; and a portal this user never connected is not a portal that reaches
|
|
1151
|
+
// nothing. Drawing them all as "no servers" is the mistake #350 records for the tools screen —
|
|
1152
|
+
// and the fourth one is the worst of them: it answers a SIGN-IN problem with a sentence about
|
|
1153
|
+
// reach, in a place that offers no way to sign in.
|
|
1154
|
+
toolServersState: "pending" | "error" | "disconnected" | "ready";
|
|
1155
|
+
toolNames: string[];
|
|
1156
|
+
// Told apart from "this server offers nothing": a function list that could not be loaded must not
|
|
1157
|
+
// read as a narrowing that lost its functions.
|
|
1158
|
+
toolNamesFailed: boolean;
|
|
1159
|
+
// ⚠️ A query that has not answered is not one that answered "nothing". Without this a step opened
|
|
1160
|
+
// while the catalog is still loading shows its allowed functions as missing — the panel accusing
|
|
1161
|
+
// the author of a narrowing that broke, moments before the list arrives.
|
|
1162
|
+
toolNamesPending: boolean;
|
|
1163
|
+
flows: Flow[];
|
|
1164
|
+
}) {
|
|
1165
|
+
const i18n = useI18n();
|
|
1166
|
+
if (!node) return <p className="p-5 text-sm text-muted-foreground">{i18n.t("flows.inspect")}</p>;
|
|
1167
|
+
const contract = node.data.node;
|
|
1168
|
+
return (
|
|
1169
|
+
<div className="p-5">
|
|
1170
|
+
<h2 className="font-semibold">{i18n.t(`flows.node.${contract.kind}`)}</h2>
|
|
1171
|
+
{/* ⚠️ A marker has no name to give (D25). Start and End are the same two points in every
|
|
1172
|
+
flow, and a start renamed "Rechnung holen" reads on the canvas as a step that does
|
|
1173
|
+
something — which is exactly the confusion the layer split is meant to end. */}
|
|
1174
|
+
{flowNodeLayer[contract.kind] !== "marker" && (
|
|
1175
|
+
<Field
|
|
1176
|
+
label={i18n.t("common.title")}
|
|
1177
|
+
value={contract.label}
|
|
1178
|
+
setValue={(label) => update((value) => ({ ...value, label }))}
|
|
1179
|
+
/>
|
|
1180
|
+
)}
|
|
1181
|
+
{(contract.kind === "instruction" || contract.kind === "condition") && (
|
|
1182
|
+
<TextArea
|
|
1183
|
+
label={i18n.t("flows.instruction")}
|
|
1184
|
+
hint={i18n.t("flows.instructionHint")}
|
|
1185
|
+
value={
|
|
1186
|
+
contract.kind === "instruction"
|
|
1187
|
+
? contract.configuration.prompt
|
|
1188
|
+
: contract.configuration.instruction
|
|
1189
|
+
}
|
|
1190
|
+
setValue={(text) =>
|
|
1191
|
+
update((value) =>
|
|
1192
|
+
value.kind === "instruction"
|
|
1193
|
+
? { ...value, configuration: { prompt: text } }
|
|
1194
|
+
: value.kind === "condition"
|
|
1195
|
+
? {
|
|
1196
|
+
...value,
|
|
1197
|
+
configuration: { mode: "semantic", instruction: text },
|
|
1198
|
+
}
|
|
1199
|
+
: value,
|
|
1200
|
+
)
|
|
1201
|
+
}
|
|
1202
|
+
/>
|
|
1203
|
+
)}
|
|
1204
|
+
{/* ⚠️ One reference, chosen — not a list, ticked. What replaces this properly is the picker
|
|
1205
|
+
in #75 (search, the tree, dropping from the sidebar); until then a select does the one
|
|
1206
|
+
thing the layer rule needs it to do, which is make "exactly one" the only sayable state.
|
|
1207
|
+
The list is filtered to the kind of the node, so a Table node cannot name a folder. */}
|
|
1208
|
+
{isLinkNode(contract) && (
|
|
1209
|
+
<EntryPicker
|
|
1210
|
+
kind={nodeKindOfLink[contract.kind] ?? null}
|
|
1211
|
+
value={contract.configuration.resourceId}
|
|
1212
|
+
label={i18n.t(`flows.node.${contract.kind}`)}
|
|
1213
|
+
onSelect={(resourceId) =>
|
|
1214
|
+
update((value) =>
|
|
1215
|
+
isLinkNode(value) ? { ...value, configuration: { resourceId } } : value,
|
|
1216
|
+
)
|
|
1217
|
+
}
|
|
1218
|
+
/>
|
|
1219
|
+
)}
|
|
1220
|
+
{contract.kind === "subflow" && (
|
|
1221
|
+
<div className="mt-4">
|
|
1222
|
+
<label className="block text-sm font-medium">
|
|
1223
|
+
{i18n.t("flows.subflowTarget")}
|
|
1224
|
+
<select
|
|
1225
|
+
value={contract.configuration.flowId}
|
|
1226
|
+
onChange={(event) => {
|
|
1227
|
+
const flowId = event.target.value;
|
|
1228
|
+
update((value) =>
|
|
1229
|
+
value.kind === "subflow"
|
|
1230
|
+
? { ...value, configuration: { ...value.configuration, flowId } }
|
|
1231
|
+
: value,
|
|
1232
|
+
);
|
|
1233
|
+
}}
|
|
1234
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1235
|
+
>
|
|
1236
|
+
<option value="">{i18n.t("flows.selectSubflow")}</option>
|
|
1237
|
+
{flows.map((entry) => (
|
|
1238
|
+
<option key={entry.id} value={entry.id}>
|
|
1239
|
+
{entry.title}
|
|
1240
|
+
</option>
|
|
1241
|
+
))}
|
|
1242
|
+
</select>
|
|
1243
|
+
</label>
|
|
1244
|
+
{/* The call rule is a folder question, so it cannot be answered while typing. Publishing
|
|
1245
|
+
answers it and names the reason. */}
|
|
1246
|
+
<p className="mt-2 text-xs text-muted-foreground">{i18n.t("flows.subflowRule")}</p>
|
|
1247
|
+
<SubflowVersionField configuration={contract.configuration} update={update} />
|
|
1248
|
+
</div>
|
|
1249
|
+
)}
|
|
1250
|
+
{contract.kind === "tool" && (
|
|
1251
|
+
<ToolStepFields
|
|
1252
|
+
configuration={contract.configuration}
|
|
1253
|
+
servers={toolServers}
|
|
1254
|
+
state={toolServersState}
|
|
1255
|
+
toolNames={toolNames}
|
|
1256
|
+
toolNamesFailed={toolNamesFailed}
|
|
1257
|
+
toolNamesPending={toolNamesPending}
|
|
1258
|
+
update={update}
|
|
1259
|
+
/>
|
|
1260
|
+
)}
|
|
1261
|
+
</div>
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* What a tool step may reach: one server, and optionally only some of its functions.
|
|
1267
|
+
*
|
|
1268
|
+
* ⚠️ The server comes first and the functions are a NARROWING, not the other way round (#489). The
|
|
1269
|
+
* old field offered every function of every server in one flat list — 85 entries here, portal
|
|
1270
|
+
* management tools among them — and asked the author to pick the one call they wanted before they
|
|
1271
|
+
* knew what the step would say. Which function runs is a run-time answer; which server may be
|
|
1272
|
+
* reached is an authoring decision, and that is the one this asks for.
|
|
1273
|
+
*/
|
|
1274
|
+
export function ToolStepFields({
|
|
1275
|
+
configuration,
|
|
1276
|
+
servers,
|
|
1277
|
+
state,
|
|
1278
|
+
toolNames,
|
|
1279
|
+
toolNamesFailed,
|
|
1280
|
+
toolNamesPending,
|
|
1281
|
+
update,
|
|
1282
|
+
}: {
|
|
1283
|
+
configuration: { server: string; allow: string[] | null; fingerprint: string | null };
|
|
1284
|
+
servers: Array<{ handle: string; name: string; toolCount: number }>;
|
|
1285
|
+
state: "pending" | "error" | "disconnected" | "ready";
|
|
1286
|
+
toolNames: string[];
|
|
1287
|
+
toolNamesFailed: boolean;
|
|
1288
|
+
toolNamesPending: boolean;
|
|
1289
|
+
update(fn: (node: FlowNode) => FlowNode): void;
|
|
1290
|
+
}) {
|
|
1291
|
+
const i18n = useI18n();
|
|
1292
|
+
// ⚠️ The group's identity, not its data. Two steps on one screen with the same server would
|
|
1293
|
+
// otherwise share one radio group and unset each other — and the component is exported now, so
|
|
1294
|
+
// that invariant no longer lives in the file that guarantees it.
|
|
1295
|
+
const groupId = useId();
|
|
1296
|
+
const hintId = `${groupId}-hint`;
|
|
1297
|
+
// The one sentence this field has to say, or none. Derived once so the control and the paragraph
|
|
1298
|
+
// cannot disagree about whether there is something to point at.
|
|
1299
|
+
const hint =
|
|
1300
|
+
state === "error"
|
|
1301
|
+
? ({ key: "flows.toolServersFailed", tone: "bad" } as const)
|
|
1302
|
+
: state === "disconnected"
|
|
1303
|
+
? ({ key: "flows.toolServersDisconnected", tone: "bad" } as const)
|
|
1304
|
+
: state === "ready" && servers.length === 0
|
|
1305
|
+
? ({ key: "flows.toolServersEmpty", tone: "plain" } as const)
|
|
1306
|
+
: null;
|
|
1307
|
+
const ownFunctions = toolNames.filter(
|
|
1308
|
+
(name) => configuration.server !== "" && serverOf(name, [configuration.server]) !== null,
|
|
1309
|
+
);
|
|
1310
|
+
// ⚠️ An allowed function the catalog does not carry is DRAWN, not dropped. Rendering only what
|
|
1311
|
+
// the catalog knows would let the panel and the saved graph disagree in silence: the author reads
|
|
1312
|
+
// "narrowed to nothing" while the step still names something. The API treats the same condition
|
|
1313
|
+
// as serious enough to refuse a publish, so the editor may not swallow it.
|
|
1314
|
+
// Nothing is "missing" until the catalog has actually answered.
|
|
1315
|
+
const missing = toolNamesPending
|
|
1316
|
+
? []
|
|
1317
|
+
: (configuration.allow ?? []).filter((name) => !ownFunctions.includes(name));
|
|
1318
|
+
const functions = [...ownFunctions, ...missing];
|
|
1319
|
+
|
|
1320
|
+
function setTool(
|
|
1321
|
+
change: (current: { server: string; allow: string[] | null; fingerprint: string | null }) => {
|
|
1322
|
+
server: string;
|
|
1323
|
+
allow: string[] | null;
|
|
1324
|
+
fingerprint: string | null;
|
|
1325
|
+
},
|
|
1326
|
+
) {
|
|
1327
|
+
update((value) =>
|
|
1328
|
+
value.kind === "tool" ? { ...value, configuration: change(value.configuration) } : value,
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return (
|
|
1333
|
+
<div className="mt-4 space-y-3">
|
|
1334
|
+
<label className="block text-sm font-medium">
|
|
1335
|
+
{i18n.t("flows.tool")}
|
|
1336
|
+
<select
|
|
1337
|
+
value={configuration.server}
|
|
1338
|
+
// ⚠️ A disabled control drops out of the tab order, so the reason has to be ANNOUNCED
|
|
1339
|
+
// rather than merely printed beside it. It points at the hint only when the hint is
|
|
1340
|
+
// actually rendered — `pending` prints nothing on purpose, and a reference to an id that
|
|
1341
|
+
// does not exist is worse than none: a screen reader reads it as a broken relation.
|
|
1342
|
+
aria-describedby={hint === null ? undefined : hintId}
|
|
1343
|
+
disabled={state !== "ready" || servers.length === 0}
|
|
1344
|
+
onChange={(event) => {
|
|
1345
|
+
// ⚠️ Read out of the event NOW, not inside the updater. The updater runs later, and by
|
|
1346
|
+
// then this controlled select has been set back to the state's value — the change would
|
|
1347
|
+
// apply to itself and nothing would move.
|
|
1348
|
+
const server = event.target.value;
|
|
1349
|
+
// Changing the server drops the narrowing AND the frozen surface. Keeping `allow` would
|
|
1350
|
+
// leave functions of the old server behind — the contract refuses that outright — and
|
|
1351
|
+
// keeping the fingerprint would claim a surface nobody has confirmed.
|
|
1352
|
+
setTool(() => ({ server, allow: null, fingerprint: null }));
|
|
1353
|
+
}}
|
|
1354
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
1355
|
+
>
|
|
1356
|
+
<option value="">{i18n.t("flows.selectTool")}</option>
|
|
1357
|
+
{servers.map((entry) => (
|
|
1358
|
+
<option key={entry.handle} value={entry.handle}>
|
|
1359
|
+
{entry.name}
|
|
1360
|
+
</option>
|
|
1361
|
+
))}
|
|
1362
|
+
</select>
|
|
1363
|
+
</label>
|
|
1364
|
+
{/* The three states of the list, told apart. Silence while loading; a reason when it failed;
|
|
1365
|
+
and "you reach none" only once that is actually known. */}
|
|
1366
|
+
{hint !== null && (
|
|
1367
|
+
<p
|
|
1368
|
+
id={hintId}
|
|
1369
|
+
className={`text-xs ${hint.tone === "bad" ? "text-destructive" : "text-muted-foreground"}`}
|
|
1370
|
+
>
|
|
1371
|
+
{i18n.t(hint.key)}
|
|
1372
|
+
</p>
|
|
1373
|
+
)}
|
|
1374
|
+
{configuration.server !== "" && (
|
|
1375
|
+
<fieldset className="space-y-2">
|
|
1376
|
+
<legend className="text-sm font-medium">{i18n.t("flows.toolFunctions")}</legend>
|
|
1377
|
+
<label className="flex items-center gap-2 text-sm">
|
|
1378
|
+
<input
|
|
1379
|
+
type="radio"
|
|
1380
|
+
name={groupId}
|
|
1381
|
+
checked={configuration.allow === null}
|
|
1382
|
+
onChange={() =>
|
|
1383
|
+
setTool((current) => ({ ...current, allow: null, fingerprint: null }))
|
|
1384
|
+
}
|
|
1385
|
+
className="size-4"
|
|
1386
|
+
/>
|
|
1387
|
+
{i18n.t("flows.toolAllowAll")}
|
|
1388
|
+
</label>
|
|
1389
|
+
<label className="flex items-center gap-2 text-sm">
|
|
1390
|
+
<input
|
|
1391
|
+
type="radio"
|
|
1392
|
+
name={groupId}
|
|
1393
|
+
checked={configuration.allow !== null}
|
|
1394
|
+
onChange={() => setTool((current) => ({ ...current, allow: [], fingerprint: null }))}
|
|
1395
|
+
className="size-4"
|
|
1396
|
+
/>
|
|
1397
|
+
{i18n.t("flows.toolAllowSome")}
|
|
1398
|
+
</label>
|
|
1399
|
+
{configuration.allow !== null && (
|
|
1400
|
+
<ul className="max-h-48 space-y-1 overflow-y-auto pl-6">
|
|
1401
|
+
{functions.map((name) => (
|
|
1402
|
+
<li key={name}>
|
|
1403
|
+
<label className="flex items-center gap-2 text-sm">
|
|
1404
|
+
<input
|
|
1405
|
+
type="checkbox"
|
|
1406
|
+
checked={configuration.allow?.includes(name) ?? false}
|
|
1407
|
+
onChange={(event) => {
|
|
1408
|
+
// Same reason as the select above: the checked flag is read now, the
|
|
1409
|
+
// updater runs later.
|
|
1410
|
+
const add = event.target.checked;
|
|
1411
|
+
setTool((current) => ({
|
|
1412
|
+
...current,
|
|
1413
|
+
allow: add
|
|
1414
|
+
? [...(current.allow ?? []), name]
|
|
1415
|
+
: (current.allow ?? []).filter((entry) => entry !== name),
|
|
1416
|
+
fingerprint: null,
|
|
1417
|
+
}));
|
|
1418
|
+
}}
|
|
1419
|
+
className="size-4"
|
|
1420
|
+
/>
|
|
1421
|
+
<span className="min-w-0 truncate">{name}</span>
|
|
1422
|
+
</label>
|
|
1423
|
+
</li>
|
|
1424
|
+
))}
|
|
1425
|
+
</ul>
|
|
1426
|
+
)}
|
|
1427
|
+
{/* Two sentences that only appear when they are true, and neither is the generic hint:
|
|
1428
|
+
a narrowing to nothing cannot be published, and a name the catalog does not carry is
|
|
1429
|
+
something the author has to see rather than lose. */}
|
|
1430
|
+
{configuration.allow?.length === 0 && (
|
|
1431
|
+
<p className="text-xs text-destructive">{i18n.t("flows.toolAllowNone")}</p>
|
|
1432
|
+
)}
|
|
1433
|
+
{toolNamesFailed && (
|
|
1434
|
+
<p className="text-xs text-destructive">{i18n.t("flows.toolFunctionsFailed")}</p>
|
|
1435
|
+
)}
|
|
1436
|
+
{!toolNamesFailed && missing.length > 0 && (
|
|
1437
|
+
<p className="text-xs text-destructive">
|
|
1438
|
+
{i18n.t("flows.toolAllowUnknown", { count: missing.length })}
|
|
1439
|
+
</p>
|
|
1440
|
+
)}
|
|
1441
|
+
<p className="text-xs text-muted-foreground">{i18n.t("flows.toolAllowHint")}</p>
|
|
1442
|
+
</fieldset>
|
|
1443
|
+
)}
|
|
1444
|
+
</div>
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// "What this flow needs": the documents and tools the graph names, and nothing about who may reach
|
|
1449
|
+
// them. A standing "this flow has conflicts" badge is not offered on purpose — for tools it could
|
|
1450
|
+
// never be true, because the catalog is a live query with each user's own portal token (ADR-0003).
|
|
1451
|
+
function FlowNeeds({ flowId }: { flowId: string }) {
|
|
1452
|
+
const { data } = useIntelRouterContext();
|
|
1453
|
+
const i18n = useI18n();
|
|
1454
|
+
const needs = useQuery({
|
|
1455
|
+
queryKey: ["flow-requirements", flowId],
|
|
1456
|
+
queryFn: () => data.getFlowRequirements(flowId),
|
|
1457
|
+
});
|
|
1458
|
+
// ⚠️ A flow that touches nothing and a flow whose every reference is hidden or dead are three
|
|
1459
|
+
// different answers, so neither the counted nor the broken ones may fall out of this condition —
|
|
1460
|
+
// otherwise the panel claims the first when it means one of the others.
|
|
1461
|
+
const empty =
|
|
1462
|
+
needs.data !== undefined &&
|
|
1463
|
+
needs.data.nodes.length === 0 &&
|
|
1464
|
+
needs.data.invalidNodes.length === 0 &&
|
|
1465
|
+
needs.data.hiddenNodes === 0 &&
|
|
1466
|
+
needs.data.servers.length === 0;
|
|
1467
|
+
return (
|
|
1468
|
+
<section aria-label={i18n.t("flows.needs")} className="mt-auto border-t p-5">
|
|
1469
|
+
<h2 className="font-semibold">{i18n.t("flows.needs")}</h2>
|
|
1470
|
+
{needs.isPending && (
|
|
1471
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
1472
|
+
)}
|
|
1473
|
+
{needs.isError && (
|
|
1474
|
+
<p role="alert" className="mt-2 text-sm text-destructive">
|
|
1475
|
+
{i18n.t("flows.needsFailed")}
|
|
1476
|
+
</p>
|
|
1477
|
+
)}
|
|
1478
|
+
{empty && <p className="mt-2 text-sm text-muted-foreground">{i18n.t("flows.needsEmpty")}</p>}
|
|
1479
|
+
{needs.data && !empty && (
|
|
1480
|
+
<>
|
|
1481
|
+
{(needs.data.nodes.length > 0 ||
|
|
1482
|
+
needs.data.invalidNodes.length > 0 ||
|
|
1483
|
+
needs.data.hiddenNodes > 0) && (
|
|
1484
|
+
<>
|
|
1485
|
+
<h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1486
|
+
{i18n.t("flows.needsNodes")}
|
|
1487
|
+
</h3>
|
|
1488
|
+
<ul className="mt-2 space-y-1 text-sm">
|
|
1489
|
+
{needs.data.nodes.map((reference) => (
|
|
1490
|
+
<li key={reference.id} className="flex items-center gap-2">
|
|
1491
|
+
<FileSearch aria-hidden="true" className="size-3.5 shrink-0" />
|
|
1492
|
+
<span className="min-w-0 truncate">{reference.title}</span>
|
|
1493
|
+
</li>
|
|
1494
|
+
))}
|
|
1495
|
+
{/* ⚠️ A dead reference and an unreachable one are two entries, not one (#509).
|
|
1496
|
+
They used to share the counted line, which said "you cannot see it" about a
|
|
1497
|
+
document that is gone — a sentence about a permission, in front of something no
|
|
1498
|
+
permission can fix. This one says what to do instead, because there is exactly
|
|
1499
|
+
one thing to do: replace the step or take it out. */}
|
|
1500
|
+
{needs.data.invalidNodes.length > 0 && (
|
|
1501
|
+
<li className="text-destructive">
|
|
1502
|
+
{i18n.t("flows.needsInvalid", { count: needs.data.invalidNodes.length })}
|
|
1503
|
+
</li>
|
|
1504
|
+
)}
|
|
1505
|
+
{/* ⚠️ Counted, never named. A title is exactly what someone without access to the
|
|
1506
|
+
document may not learn from a list about it. */}
|
|
1507
|
+
{needs.data.hiddenNodes > 0 && (
|
|
1508
|
+
<li className="text-muted-foreground">
|
|
1509
|
+
{i18n.t("flows.needsHidden", { count: needs.data.hiddenNodes })}
|
|
1510
|
+
</li>
|
|
1511
|
+
)}
|
|
1512
|
+
</ul>
|
|
1513
|
+
</>
|
|
1514
|
+
)}
|
|
1515
|
+
{needs.data.servers.length > 0 && (
|
|
1516
|
+
<>
|
|
1517
|
+
<h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1518
|
+
{i18n.t("flows.needsTools")}
|
|
1519
|
+
</h3>
|
|
1520
|
+
<ul className="mt-2 space-y-1 text-sm">
|
|
1521
|
+
{needs.data.servers.map((name) => (
|
|
1522
|
+
<li key={name} className="flex items-center gap-2">
|
|
1523
|
+
<Wrench aria-hidden="true" className="size-3.5 shrink-0" />
|
|
1524
|
+
<span className="min-w-0 truncate">{name}</span>
|
|
1525
|
+
</li>
|
|
1526
|
+
))}
|
|
1527
|
+
</ul>
|
|
1528
|
+
</>
|
|
1529
|
+
)}
|
|
1530
|
+
</>
|
|
1531
|
+
)}
|
|
1532
|
+
</section>
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// The version choice of one call (ADR-0004 §5). Two of the three modes are chosen here; the third,
|
|
1537
|
+
// `pinned`, is what publishing writes, so it is offered as the current value and can be given up
|
|
1538
|
+
// again — but no arbitrary version is picked by hand. Which version a pin points at is a fact of the
|
|
1539
|
+
// published graph, not something to browse for while drafting.
|
|
1540
|
+
function SubflowVersionField({
|
|
1541
|
+
configuration,
|
|
1542
|
+
update,
|
|
1543
|
+
}: {
|
|
1544
|
+
configuration: Extract<FlowNode, { kind: "subflow" }>["configuration"];
|
|
1545
|
+
update(fn: (node: FlowNode) => FlowNode): void;
|
|
1546
|
+
}) {
|
|
1547
|
+
const i18n = useI18n();
|
|
1548
|
+
const mode = configuration.version.mode;
|
|
1549
|
+
return (
|
|
1550
|
+
<div className="mt-4">
|
|
1551
|
+
<label className="block text-sm font-medium">
|
|
1552
|
+
{i18n.t("flows.subflowVersion")}
|
|
1553
|
+
<select
|
|
1554
|
+
value={mode}
|
|
1555
|
+
onChange={(event) => {
|
|
1556
|
+
const next = event.target.value;
|
|
1557
|
+
if (next !== "latest" && next !== "follows") return;
|
|
1558
|
+
update((value) =>
|
|
1559
|
+
value.kind === "subflow"
|
|
1560
|
+
? { ...value, configuration: { ...value.configuration, version: { mode: next } } }
|
|
1561
|
+
: value,
|
|
1562
|
+
);
|
|
1563
|
+
}}
|
|
1564
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1565
|
+
>
|
|
1566
|
+
<option value="latest">{i18n.t("flows.subflowVersion.latest")}</option>
|
|
1567
|
+
<option value="follows">{i18n.t("flows.subflowVersion.follows")}</option>
|
|
1568
|
+
{mode === "pinned" && (
|
|
1569
|
+
<option value="pinned">{i18n.t("flows.subflowVersion.pinned")}</option>
|
|
1570
|
+
)}
|
|
1571
|
+
</select>
|
|
1572
|
+
</label>
|
|
1573
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
1574
|
+
{i18n.t(`flows.subflowVersionHint.${mode}`)}
|
|
1575
|
+
</p>
|
|
1576
|
+
</div>
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function Field({
|
|
1581
|
+
label,
|
|
1582
|
+
value,
|
|
1583
|
+
setValue,
|
|
1584
|
+
}: {
|
|
1585
|
+
label: string;
|
|
1586
|
+
value: string;
|
|
1587
|
+
setValue(value: string): void;
|
|
1588
|
+
}) {
|
|
1589
|
+
return (
|
|
1590
|
+
<label className="mt-4 block text-sm font-medium">
|
|
1591
|
+
{label}
|
|
1592
|
+
<input
|
|
1593
|
+
value={value}
|
|
1594
|
+
onChange={(event) => setValue(event.target.value)}
|
|
1595
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1596
|
+
/>
|
|
1597
|
+
</label>
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
function TextArea({
|
|
1601
|
+
label,
|
|
1602
|
+
value,
|
|
1603
|
+
setValue,
|
|
1604
|
+
hint,
|
|
1605
|
+
}: {
|
|
1606
|
+
label: string;
|
|
1607
|
+
value: string;
|
|
1608
|
+
setValue(value: string): void;
|
|
1609
|
+
hint?: string;
|
|
1610
|
+
}) {
|
|
1611
|
+
const hintId = useId();
|
|
1612
|
+
return (
|
|
1613
|
+
<label className="mt-4 block text-sm font-medium">
|
|
1614
|
+
<span className="flex items-center gap-1.5">
|
|
1615
|
+
{label}
|
|
1616
|
+
{/* The note that keeps the box of parts small (#39): a condition, a check or a branch can be
|
|
1617
|
+
said in words inside the instruction, so `condition` can stay a node without everyone
|
|
1618
|
+
having to drag one for every small decision. A button, not a bare icon — a hint reachable
|
|
1619
|
+
only by hovering is not reachable at all. */}
|
|
1620
|
+
{hint && (
|
|
1621
|
+
<button
|
|
1622
|
+
type="button"
|
|
1623
|
+
aria-label={hint}
|
|
1624
|
+
aria-describedby={hintId}
|
|
1625
|
+
title={hint}
|
|
1626
|
+
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
1627
|
+
>
|
|
1628
|
+
<Info aria-hidden="true" className="size-3.5" />
|
|
1629
|
+
</button>
|
|
1630
|
+
)}
|
|
1631
|
+
</span>
|
|
1632
|
+
<textarea
|
|
1633
|
+
rows={6}
|
|
1634
|
+
value={value}
|
|
1635
|
+
aria-describedby={hint ? hintId : undefined}
|
|
1636
|
+
onChange={(event) => setValue(event.target.value)}
|
|
1637
|
+
className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1638
|
+
/>
|
|
1639
|
+
{hint && (
|
|
1640
|
+
<span id={hintId} className="mt-1 block text-xs font-normal text-muted-foreground">
|
|
1641
|
+
{hint}
|
|
1642
|
+
</span>
|
|
1643
|
+
)}
|
|
1644
|
+
</label>
|
|
1645
|
+
);
|
|
1646
|
+
}
|