@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,1156 @@
|
|
|
1
|
+
import type { Flow, FlowValidation } from "@contract/flow.ts";
|
|
2
|
+
import type { Node } from "@contract/node.ts";
|
|
3
|
+
import type { ResourceVerb, UnreadableNodes } from "@contract/share.ts";
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
6
|
+
import {
|
|
7
|
+
Archive,
|
|
8
|
+
Columns3,
|
|
9
|
+
CornerLeftUp,
|
|
10
|
+
Ellipsis,
|
|
11
|
+
FolderDown,
|
|
12
|
+
FolderUp,
|
|
13
|
+
History,
|
|
14
|
+
Link2,
|
|
15
|
+
Pencil,
|
|
16
|
+
Share2,
|
|
17
|
+
ShieldCheck,
|
|
18
|
+
SlashSquare,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import type * as React from "react";
|
|
21
|
+
import { useState } from "react";
|
|
22
|
+
import { AccessSummary } from "@/access-summary/access-summary.tsx";
|
|
23
|
+
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
24
|
+
import {
|
|
25
|
+
DropdownMenu,
|
|
26
|
+
DropdownMenuContent,
|
|
27
|
+
DropdownMenuItem,
|
|
28
|
+
DropdownMenuTrigger,
|
|
29
|
+
} from "@/components/ui/dropdown-menu";
|
|
30
|
+
import { flowEntry, IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
31
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
32
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
33
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
34
|
+
import { useNodeImport } from "@/node-import/node-import.tsx";
|
|
35
|
+
import { useResourceRefresh, useResourceRename } from "@/rename-resource/rename-resource.ts";
|
|
36
|
+
import { resourceErrorKey } from "@/resource-error.ts";
|
|
37
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
38
|
+
import { selectedFrom } from "@/router/selection-search.ts";
|
|
39
|
+
import { TableColumnsDialog } from "@/table-columns/table-columns.tsx";
|
|
40
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
41
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a resource's own actions are, at the place the resource stands (#24).
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
|
|
47
|
+
* attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
|
|
48
|
+
* 240px column and made the sidebar something to read buttons in rather than titles. Everything the
|
|
49
|
+
* row menu could do is reached here — including moving, which is why this component owns the folder
|
|
50
|
+
* picker rather than being handed one.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode and no
|
|
53
|
+
* links, a flow has no version history of the node kind. A greyed-out row still promises something
|
|
54
|
+
* is there. Sharing applies to all of them since #530.
|
|
55
|
+
*/
|
|
56
|
+
export type ResourceTarget = { type: "node"; node: Node } | { type: "flow"; flow: Flow };
|
|
57
|
+
|
|
58
|
+
// The same thing as a tree row, because that is what a move works on: a row with a place in the
|
|
59
|
+
// shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
|
|
60
|
+
// and never a second source of truth.
|
|
61
|
+
function entryOf(target: ResourceTarget): TreeEntry {
|
|
62
|
+
return target.type === "flow"
|
|
63
|
+
? flowEntry(target.flow)
|
|
64
|
+
: {
|
|
65
|
+
type: "node",
|
|
66
|
+
id: target.node.id,
|
|
67
|
+
title: target.node.title,
|
|
68
|
+
kind: target.node.kind,
|
|
69
|
+
// A menu moves a row; whether it can be expanded plays no part in that.
|
|
70
|
+
openable: false,
|
|
71
|
+
node: target.node,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function idOf(target: ResourceTarget): string {
|
|
76
|
+
return target.type === "node" ? target.node.id : target.flow.id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function titleOf(target: ResourceTarget): string {
|
|
80
|
+
return target.type === "node" ? target.node.title : target.flow.title;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which sentence explains a verb.
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ The hint is about the SUBJECT as much as the verb, and the branch came back with #530. "See
|
|
87
|
+
* everything in here" is right for a folder and wrong for one flow, and a hint describing the wrong
|
|
88
|
+
* reach is worse than none — it is the sentence somebody reads INSTEAD of thinking about what they
|
|
89
|
+
* are granting. The wording that used to fork here was the agent's (#143), parked with it (#388);
|
|
90
|
+
* this is a different fork, on the subject rather than on a second meaning of one word.
|
|
91
|
+
*/
|
|
92
|
+
export function verbHintKey(verb: ResourceVerb, subject: ResourceTarget["type"] = "node"): string {
|
|
93
|
+
return `${subject}.verbHint.${verb}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The three-dot menu.
|
|
98
|
+
*
|
|
99
|
+
* `variant` is only how the trigger is painted, never which entries it holds. `title` is a plain
|
|
100
|
+
* icon button at the end of a title line, put there by `TitleRow` and by nobody else, so it is
|
|
101
|
+
* always the last thing in that line (#53). `row` is the form for a row in a list — quiet until the
|
|
102
|
+
* row is hovered or something in it takes focus — and since #101 that list is the folder table.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ `row` needs a `group/row` on the row it sits in, or it stays invisible to the mouse: the
|
|
105
|
+
* reveal is `group-hover/row`, and a group that is not there never hovers. Below `md` and under
|
|
106
|
+
* focus it is shown regardless, because hover is not a gesture every input has.
|
|
107
|
+
*/
|
|
108
|
+
export function ResourceMenu({
|
|
109
|
+
target,
|
|
110
|
+
variant,
|
|
111
|
+
}: {
|
|
112
|
+
target: ResourceTarget;
|
|
113
|
+
variant: "row" | "title";
|
|
114
|
+
}) {
|
|
115
|
+
const { data } = useIntelRouterContext();
|
|
116
|
+
const i18n = useI18n();
|
|
117
|
+
const dateTime = useDateTime();
|
|
118
|
+
const navigate = useNavigate();
|
|
119
|
+
const [renaming, setRenaming] = useState(false);
|
|
120
|
+
const [sharing, setSharing] = useState(false);
|
|
121
|
+
const [linksOpen, setLinksOpen] = useState(false);
|
|
122
|
+
const [promptOpen, setPromptOpen] = useState(false);
|
|
123
|
+
const [versionsOpen, setVersionsOpen] = useState(false);
|
|
124
|
+
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
125
|
+
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
126
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
127
|
+
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
128
|
+
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
129
|
+
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
130
|
+
const move = useTreeMove();
|
|
131
|
+
// ⚠️ Not a query. A validation is a snapshot taken when somebody asks (#72, ADR-0003): cached
|
|
132
|
+
// under a key it would be re-served later as if it still held, and the one thing it must never
|
|
133
|
+
// claim is that a moment which has passed is still true.
|
|
134
|
+
const [validation, setValidation] = useState<FlowValidation | null>(null);
|
|
135
|
+
const validate = useMutation({
|
|
136
|
+
mutationFn: () => data.validateFlow(id),
|
|
137
|
+
onSuccess: setValidation,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const id = idOf(target);
|
|
141
|
+
const title = titleOf(target);
|
|
142
|
+
const node = target.type === "node" ? target.node : null;
|
|
143
|
+
// A folder holds things; it is not itself retrieved, and nothing is written into it that could
|
|
144
|
+
// link anywhere. Both entries are therefore absent on one rather than offered and inert.
|
|
145
|
+
const retrievable = node !== null && node.kind !== "folder";
|
|
146
|
+
const linkable = retrievable;
|
|
147
|
+
// ⚠️ Absent, not disabled — the rule at the top of this file. An import needs a folder to file
|
|
148
|
+
// into; a document has nowhere to put a subtree, and a greyed-out entry would promise it does.
|
|
149
|
+
const importable = node !== null && node.kind === "folder";
|
|
150
|
+
// ⚠️ Only a table has a header to change, so only a table carries the entry — absent, not
|
|
151
|
+
// disabled, the rule at the top of this file. Until #531 the header could be changed over MCP and
|
|
152
|
+
// nowhere else: a person had strictly less reach on their own table than a model did.
|
|
153
|
+
const isTable = node !== null && node.kind === "table";
|
|
154
|
+
const nodeImport = useNodeImport({ where: title });
|
|
155
|
+
|
|
156
|
+
// What a change here makes stale, and the door a rename takes: both live in
|
|
157
|
+
// `rename-resource` since #823, because the tree renames through the same two rules from its own
|
|
158
|
+
// row. A copy of either would be a second place for one of them to drift.
|
|
159
|
+
const refresh = useResourceRefresh(target);
|
|
160
|
+
const rename = useResourceRename(target, () => setRenaming(false));
|
|
161
|
+
|
|
162
|
+
// Both records take the same conflict route as a rename: `baseUpdatedAt` from the row that was on
|
|
163
|
+
// screen, so archiving something somebody else has just changed is refused rather than silently
|
|
164
|
+
// applied to a different state than the one that was read.
|
|
165
|
+
/**
|
|
166
|
+
* Offering a document as a slash command, or taking the offer back (#775).
|
|
167
|
+
*
|
|
168
|
+
* ⚠️ It writes through `node_update` rather than a door of its own: the name is a mutable field
|
|
169
|
+
* of the document, and `update` is the verb this repository gives mutable fields. A dedicated
|
|
170
|
+
* `node_prompt_set` would be a second write path onto one column, with its own conflict handling
|
|
171
|
+
* to keep in step.
|
|
172
|
+
*/
|
|
173
|
+
const offerPrompt = useMutation({
|
|
174
|
+
mutationFn: async (promptName: string | null) => {
|
|
175
|
+
if (target.type !== "node") throw new Error("Only a document can be offered");
|
|
176
|
+
await data.updateNode({
|
|
177
|
+
nodeId: target.node.id,
|
|
178
|
+
baseUpdatedAt: target.node.updatedAt,
|
|
179
|
+
promptName,
|
|
180
|
+
idempotencyKey: crypto.randomUUID(),
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
onSuccess: async () => {
|
|
184
|
+
await refresh();
|
|
185
|
+
setPromptOpen(false);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const archive = useMutation({
|
|
190
|
+
mutationFn: async () => {
|
|
191
|
+
if (target.type === "flow") {
|
|
192
|
+
await data.archiveFlow({
|
|
193
|
+
flowId: target.flow.id,
|
|
194
|
+
baseUpdatedAt: target.flow.updatedAt,
|
|
195
|
+
archived: true,
|
|
196
|
+
idempotencyKey: crypto.randomUUID(),
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
await data.archiveNode({
|
|
201
|
+
nodeId: target.node.id,
|
|
202
|
+
baseUpdatedAt: target.node.updatedAt,
|
|
203
|
+
archived: true,
|
|
204
|
+
idempotencyKey: crypto.randomUUID(),
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
onSuccess: async () => {
|
|
208
|
+
await refresh();
|
|
209
|
+
// The row is gone from the tree. If it was also what the screen was showing, the screen has
|
|
210
|
+
// nothing left to show either.
|
|
211
|
+
//
|
|
212
|
+
// ⚠️ It drops the selection where it stands rather than always landing on /nodes: a flow
|
|
213
|
+
// archived from the flow screen would otherwise throw the reader onto another screen for no
|
|
214
|
+
// reason they could see. A flow reached through the shared tree stays on /nodes, which is
|
|
215
|
+
// where it was being read.
|
|
216
|
+
if (selected === id) {
|
|
217
|
+
await navigate({ to: pathname === "/flows" ? "/flows" : "/nodes", search: {} });
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// An attachment already is one original file. Every structured Intel resource needs the bundle
|
|
223
|
+
// that carries its type and relationships, but wrapping uploaded bytes in a zip would make the
|
|
224
|
+
// one Export entry give a person something other than what they put in (#699).
|
|
225
|
+
const exportResource = useMutation({
|
|
226
|
+
mutationFn: async () => {
|
|
227
|
+
if (node?.kind === "attachment") {
|
|
228
|
+
return { blob: await data.getNodeAttachment(node.id), filename: title };
|
|
229
|
+
}
|
|
230
|
+
const blob =
|
|
231
|
+
target.type === "flow"
|
|
232
|
+
? await data.exportFlowBundle(target.flow.id)
|
|
233
|
+
: await data.exportNodeBundle(target.node.id);
|
|
234
|
+
return { blob, filename: `${title}.zip` };
|
|
235
|
+
},
|
|
236
|
+
onSuccess: ({ blob, filename }) => {
|
|
237
|
+
const url = URL.createObjectURL(blob);
|
|
238
|
+
const anchor = document.createElement("a");
|
|
239
|
+
anchor.href = url;
|
|
240
|
+
anchor.download = filename;
|
|
241
|
+
document.body.append(anchor);
|
|
242
|
+
anchor.click();
|
|
243
|
+
anchor.remove();
|
|
244
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const failure = rename.isError
|
|
249
|
+
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
250
|
+
: archive.isError
|
|
251
|
+
? archive.error
|
|
252
|
+
: undefined;
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
<DropdownMenu>
|
|
257
|
+
<DropdownMenuTrigger
|
|
258
|
+
data-resource-menu=""
|
|
259
|
+
aria-label={i18n.t("resource.menu", { title })}
|
|
260
|
+
className={
|
|
261
|
+
variant === "row"
|
|
262
|
+
? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
263
|
+
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
264
|
+
}
|
|
265
|
+
>
|
|
266
|
+
<Ellipsis aria-hidden="true" className="size-4" />
|
|
267
|
+
</DropdownMenuTrigger>
|
|
268
|
+
<DropdownMenuContent align="start" className="min-w-40 whitespace-nowrap">
|
|
269
|
+
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
|
270
|
+
<Pencil aria-hidden="true" />
|
|
271
|
+
{i18n.t("resource.rename")}
|
|
272
|
+
</DropdownMenuItem>
|
|
273
|
+
{/* Directly under the rename, because they are the same gesture at two levels: one names
|
|
274
|
+
the table, the other names its columns. A reader looking for "change what this is
|
|
275
|
+
called" finds both without reading the rest of the menu (#531). */}
|
|
276
|
+
{isTable ? (
|
|
277
|
+
<DropdownMenuItem onSelect={() => setColumnsOpen(true)}>
|
|
278
|
+
<Columns3 aria-hidden="true" />
|
|
279
|
+
{i18n.t("resource.columns")}
|
|
280
|
+
</DropdownMenuItem>
|
|
281
|
+
) : null}
|
|
282
|
+
{/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
|
|
283
|
+
shortcut; it does not need a second label beside this action. */}
|
|
284
|
+
{/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
|
|
285
|
+
on a resource's own title line, so there is no rendered tree level to name. Passing the
|
|
286
|
+
record's parent instead would claim to know a level — and for a shared row it would
|
|
287
|
+
name the sharer's folder, which is exactly the wrong one (#446). `useTreeMove` reads
|
|
288
|
+
the absence and cleans up both levels such a row can be standing in. */}
|
|
289
|
+
<DropdownMenuItem onSelect={() => move.start(entryOf(target), undefined, null)}>
|
|
290
|
+
<CornerLeftUp aria-hidden="true" />
|
|
291
|
+
{i18n.t("resource.move")}
|
|
292
|
+
</DropdownMenuItem>
|
|
293
|
+
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
294
|
+
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
295
|
+
not on its content.
|
|
296
|
+
|
|
297
|
+
⚠️ A table has NO second entry beside this one (#532). It used to carry "Download CSV"
|
|
298
|
+
here, and for a table WITH a header both handed out the same bytes: `bundle.ts` writes
|
|
299
|
+
such a node into the zip as `<title>.csv`. Two ways to one file are two strings, two
|
|
300
|
+
catalogs and two tests — the zip costs one step more and is the whole difference.
|
|
301
|
+
|
|
302
|
+
⚠️ The one case where they differed is the table that has no header yet: `downloadCsv`
|
|
303
|
+
refused it out loud, `tableCsv` joins no segments and writes a file of zero bytes
|
|
304
|
+
(#534). That is the export's gap on every kind it can hit, not something this entry
|
|
305
|
+
was covering — which is why it is fixed there and not by keeping a second entry.
|
|
306
|
+
|
|
307
|
+
⚠️ The entry carried a three-clause description under its name until 2026-08-19, put
|
|
308
|
+
there by #433/ADR-0006 so that an irreversible loss was readable BEFORE the gesture: a
|
|
309
|
+
bundle takes one version per node, shares and runs stay behind, and a published flow
|
|
310
|
+
travels as its published graph (#585). Jack removed it — it was the only entry running
|
|
311
|
+
over two lines and it pushed the menu apart. What the reader now gets instead is
|
|
312
|
+
`excluded` in the zip's `manifest.json` and `BundleImportResult.excluded` after an
|
|
313
|
+
import, both of which are read only once the zip exists. That is a deliberate trade,
|
|
314
|
+
recorded in ADR-0006 and in #636, and NOT a leftover to be restored. */}
|
|
315
|
+
<DropdownMenuItem onSelect={() => exportResource.mutate()}>
|
|
316
|
+
<FolderDown aria-hidden="true" />
|
|
317
|
+
{i18n.t("resource.export")}
|
|
318
|
+
</DropdownMenuItem>
|
|
319
|
+
{/* The other half of the same round trip, next to it rather than in the tree's plus
|
|
320
|
+
(#346): one word in the menu, and every way in behind it.
|
|
321
|
+
|
|
322
|
+
⚠️ It was a submenu of two until #822 — a zip and a folder — and a single file was
|
|
323
|
+
missing from it, because the entry for that one lived only in the tree's plus. All
|
|
324
|
+
three answer the same question, so the submenu became one entry and the window behind
|
|
325
|
+
it carries the choice. */}
|
|
326
|
+
{importable ? (
|
|
327
|
+
<DropdownMenuItem onSelect={() => nodeImport.open(id)}>
|
|
328
|
+
<FolderUp aria-hidden="true" />
|
|
329
|
+
{i18n.t("resource.import")}
|
|
330
|
+
</DropdownMenuItem>
|
|
331
|
+
) : null}
|
|
332
|
+
{/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
|
|
333
|
+
which is why it is in the menu and not in the title line (#72). */}
|
|
334
|
+
{node === null ? (
|
|
335
|
+
<DropdownMenuItem onSelect={() => validate.mutate()}>
|
|
336
|
+
<ShieldCheck aria-hidden="true" />
|
|
337
|
+
{i18n.t("resource.validate")}
|
|
338
|
+
</DropdownMenuItem>
|
|
339
|
+
) : null}
|
|
340
|
+
{linkable ? (
|
|
341
|
+
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
342
|
+
<Link2 aria-hidden="true" />
|
|
343
|
+
{i18n.t("resource.links")}
|
|
344
|
+
</DropdownMenuItem>
|
|
345
|
+
) : null}
|
|
346
|
+
{/* ⚠️ Only a document, and only ever an icon and a word here. The command itself stands in
|
|
347
|
+
the title line of the open node; repeating it inside the menu would be a second place
|
|
348
|
+
saying the same thing to a reader who is already looking at the first (#775). */}
|
|
349
|
+
{node !== null && node.kind === "document" ? (
|
|
350
|
+
<DropdownMenuItem onSelect={() => setPromptOpen(true)}>
|
|
351
|
+
<SlashSquare aria-hidden="true" />
|
|
352
|
+
{i18n.t("prompt.menu")}
|
|
353
|
+
</DropdownMenuItem>
|
|
354
|
+
) : null}
|
|
355
|
+
{/* Every kind of resource is shared here, a flow included since #530. What a grant on one
|
|
356
|
+
flow does NOT reach is said in the dialog rather than refused here. */}
|
|
357
|
+
<DropdownMenuItem onSelect={() => setSharing(true)}>
|
|
358
|
+
<Share2 aria-hidden="true" />
|
|
359
|
+
{i18n.t("resource.share")}
|
|
360
|
+
</DropdownMenuItem>
|
|
361
|
+
{(node === null || node.kind !== "folder") && (
|
|
362
|
+
<DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
|
|
363
|
+
<History aria-hidden="true" />
|
|
364
|
+
{i18n.t("node.versions")}
|
|
365
|
+
</DropdownMenuItem>
|
|
366
|
+
)}
|
|
367
|
+
{/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
|
|
368
|
+
loses is reach — it leaves the tree, cannot be started, and stops resolving as another
|
|
369
|
+
flow's callee. */}
|
|
370
|
+
<DropdownMenuItem onSelect={() => archive.mutate()}>
|
|
371
|
+
<Archive aria-hidden="true" />
|
|
372
|
+
{i18n.t("resource.archive")}
|
|
373
|
+
</DropdownMenuItem>
|
|
374
|
+
</DropdownMenuContent>
|
|
375
|
+
</DropdownMenu>
|
|
376
|
+
{/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
|
|
377
|
+
the screen instead, where it is read whatever the title line asked for. */}
|
|
378
|
+
{failure !== undefined && failure !== null ? (
|
|
379
|
+
<MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
|
|
380
|
+
) : null}
|
|
381
|
+
{/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
|
|
382
|
+
arrive, and "the change was not saved" would send the reader looking for a change. */}
|
|
383
|
+
{exportResource.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
|
|
384
|
+
{/* The import's own sentence, and its own pickers — only where the entry exists, because two
|
|
385
|
+
file inputs on every document's menu would be two elements nothing can ever open. */}
|
|
386
|
+
{importable ? nodeImport.inputs : null}
|
|
387
|
+
{/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
|
|
388
|
+
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
389
|
+
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
390
|
+
{move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
|
|
391
|
+
{/* ⚠️ Every reason at once, not the first one. Somebody with two missing tools should not have
|
|
392
|
+
to ask twice — and a success says so out loud, because an empty menu after a click reads
|
|
393
|
+
as "nothing happened" rather than "nothing is in the way". */}
|
|
394
|
+
{validation ? (
|
|
395
|
+
<Modal title={i18n.t("flows.validate")} close={() => setValidation(null)}>
|
|
396
|
+
{validation.problems.length === 0 ? (
|
|
397
|
+
<p className="text-sm">{i18n.t("flows.validateReady")}</p>
|
|
398
|
+
) : (
|
|
399
|
+
<ul className="space-y-2">
|
|
400
|
+
{/* ⚠️ Grouped by code, one entry per reason with every detail under it (#324).
|
|
401
|
+
`validateFlow` files one problem per sub-flow node, so a flow calling two
|
|
402
|
+
unpublished flows — the NORMAL case of that error, not its edge — produced two
|
|
403
|
+
entries whose head sentence is the same line of text, one under the other, and
|
|
404
|
+
two React children under one key.
|
|
405
|
+
|
|
406
|
+
Grouping rather than making the keys unique, because the head sentence is per CODE
|
|
407
|
+
(`flows.problem.<code>` is one string) and repeating it under itself says nothing
|
|
408
|
+
the reader did not just read. What differs is the detail, and every detail is
|
|
409
|
+
still here — which is the promise the comment above makes: somebody with two
|
|
410
|
+
missing tools should not have to ask twice.
|
|
411
|
+
|
|
412
|
+
⚠️ What identifies a detail is its POSITION in `problems`, not its text (#461).
|
|
413
|
+
Two details of one code are routinely the same sentence: `collectRunProblems`
|
|
414
|
+
files one problem per sub-flow NODE and words it with that node's label
|
|
415
|
+
(`packages/intel/src/flows/flows.ts`), and calling the same flow twice is the normal
|
|
416
|
+
case. Under `key={detail}` those two collided on one key, and React does not
|
|
417
|
+
answer that with a console warning alone — it drops one of the siblings on the
|
|
418
|
+
next render, so the reader sees ONE entry where there are two causes. */}
|
|
419
|
+
{[
|
|
420
|
+
...validation.problems
|
|
421
|
+
.reduce((byCode, problem, at) => {
|
|
422
|
+
byCode.set(problem.code, [
|
|
423
|
+
...(byCode.get(problem.code) ?? []),
|
|
424
|
+
{ at, detail: problem.detail },
|
|
425
|
+
]);
|
|
426
|
+
return byCode;
|
|
427
|
+
}, new Map<string, { at: number; detail: string }[]>())
|
|
428
|
+
.entries(),
|
|
429
|
+
].map(([code, details]) => (
|
|
430
|
+
<li key={code} className="rounded-md border p-3 text-sm">
|
|
431
|
+
<span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
|
|
432
|
+
{details.map(({ at, detail }) => (
|
|
433
|
+
<span key={at} className="mt-1 block text-xs text-muted-foreground">
|
|
434
|
+
{detail}
|
|
435
|
+
</span>
|
|
436
|
+
))}
|
|
437
|
+
</li>
|
|
438
|
+
))}
|
|
439
|
+
</ul>
|
|
440
|
+
)}
|
|
441
|
+
{/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
|
|
442
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
443
|
+
{i18n.t("flows.validateWhen", {
|
|
444
|
+
when: dateTime.at(validation.checkedAt),
|
|
445
|
+
})}
|
|
446
|
+
</p>
|
|
447
|
+
</Modal>
|
|
448
|
+
) : null}
|
|
449
|
+
{move.dialog}
|
|
450
|
+
{renaming ? (
|
|
451
|
+
<RenameDialog
|
|
452
|
+
title={title}
|
|
453
|
+
pending={rename.isPending}
|
|
454
|
+
error={rename.isError ? rename.error : null}
|
|
455
|
+
close={() => {
|
|
456
|
+
rename.reset();
|
|
457
|
+
setRenaming(false);
|
|
458
|
+
}}
|
|
459
|
+
submit={(next) => rename.mutate(next)}
|
|
460
|
+
/>
|
|
461
|
+
) : null}
|
|
462
|
+
{sharing ? <SharePanel target={target} close={() => setSharing(false)} /> : null}
|
|
463
|
+
{linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
|
|
464
|
+
{promptOpen && node ? (
|
|
465
|
+
<PromptDialog
|
|
466
|
+
node={node}
|
|
467
|
+
pending={offerPrompt.isPending}
|
|
468
|
+
error={offerPrompt.error}
|
|
469
|
+
close={() => {
|
|
470
|
+
offerPrompt.reset();
|
|
471
|
+
setPromptOpen(false);
|
|
472
|
+
}}
|
|
473
|
+
submit={(promptName) => offerPrompt.mutate(promptName)}
|
|
474
|
+
/>
|
|
475
|
+
) : null}
|
|
476
|
+
{columnsOpen && node ? (
|
|
477
|
+
<TableColumnsDialog node={node} close={() => setColumnsOpen(false)} />
|
|
478
|
+
) : null}
|
|
479
|
+
{versionsOpen ? (
|
|
480
|
+
<VersionHistory target={target} close={() => setVersionsOpen(false)} />
|
|
481
|
+
) : null}
|
|
482
|
+
</>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function VersionHistory({ target, close }: { target: ResourceTarget; close(): void }) {
|
|
487
|
+
const { data } = useIntelRouterContext();
|
|
488
|
+
const i18n = useI18n();
|
|
489
|
+
const id = idOf(target);
|
|
490
|
+
const versions = useQuery({
|
|
491
|
+
queryKey: [target.type === "flow" ? "flow-versions" : "node-versions", id],
|
|
492
|
+
queryFn: async () => {
|
|
493
|
+
const result =
|
|
494
|
+
target.type === "flow" ? await data.listFlowVersions(id) : await data.listNodeVersions(id);
|
|
495
|
+
return result.items.map((version) => ({
|
|
496
|
+
id: version.id,
|
|
497
|
+
sequence: version.sequence,
|
|
498
|
+
createdAt: version.createdAt,
|
|
499
|
+
}));
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
// ⚠️ A Modal, like every other panel in this menu — NOT an `absolute` drawer. `absolute` measures
|
|
503
|
+
// against the nearest positioned ancestor, and this menu has no say in what that is: on the title
|
|
504
|
+
// line it is the title row (`title-row.tsx`, `relative z-10`, around 100px tall), which clipped
|
|
505
|
+
// the list to its first entry on every viewport; in the folder listing it is the table container
|
|
506
|
+
// (`table.tsx`, `relative … overflow-x-auto`), which clips it differently again (#628). The list
|
|
507
|
+
// scrolls INSIDE the dialog instead, the way `NodeLinksPanel` further down does it.
|
|
508
|
+
return (
|
|
509
|
+
<Modal title={i18n.t("node.versions")} close={close}>
|
|
510
|
+
{versions.isPending ? (
|
|
511
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
512
|
+
) : null}
|
|
513
|
+
{versions.isError ? (
|
|
514
|
+
<p role="alert" className="text-sm text-destructive">
|
|
515
|
+
{i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
|
|
516
|
+
</p>
|
|
517
|
+
) : null}
|
|
518
|
+
<ol className="max-h-72 space-y-3 overflow-y-auto">
|
|
519
|
+
{versions.data?.map((version) => (
|
|
520
|
+
<li key={version.id} className="rounded-md border p-3 text-sm">
|
|
521
|
+
<span className="font-medium">
|
|
522
|
+
{i18n.t("node.version", { sequence: version.sequence })}
|
|
523
|
+
</span>
|
|
524
|
+
<RelativeTime
|
|
525
|
+
value={version.createdAt}
|
|
526
|
+
className="mt-1 block text-xs text-muted-foreground"
|
|
527
|
+
/>
|
|
528
|
+
</li>
|
|
529
|
+
))}
|
|
530
|
+
</ol>
|
|
531
|
+
</Modal>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
|
|
536
|
+
function MenuFailure({ children }: { children: React.ReactNode }) {
|
|
537
|
+
return (
|
|
538
|
+
<p
|
|
539
|
+
role="alert"
|
|
540
|
+
className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
541
|
+
>
|
|
542
|
+
{children}
|
|
543
|
+
</p>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* ⚠️ **A taken name is the one refusal this dialog words itself**, because it is the one the reader
|
|
549
|
+
* can act on inside the dialog: change the name and try again. Every other refusal keeps the
|
|
550
|
+
* server's sentence through `resourceErrorKey`.
|
|
551
|
+
*
|
|
552
|
+
* The name of the HOLDER is deliberately not brought over. The server names it — it is the half a
|
|
553
|
+
* model reading over MCP needs, and it knows who may be told — but on this screen the reader is
|
|
554
|
+
* about to type a different name either way, and a title out of somewhere they may not look is a
|
|
555
|
+
* sentence with nothing to do about it.
|
|
556
|
+
*/
|
|
557
|
+
function isPromptNameTaken(error: unknown): boolean {
|
|
558
|
+
return (
|
|
559
|
+
typeof error === "object" &&
|
|
560
|
+
error !== null &&
|
|
561
|
+
"code" in error &&
|
|
562
|
+
(error as { code: unknown }).code === "prompt_name_taken"
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The one place a document is offered as a slash command, or stops being one (#775).
|
|
568
|
+
*
|
|
569
|
+
* ⚠️ **No "that name is free" box, and it is a decision rather than an omission.** Free is the
|
|
570
|
+
* normal case; a box that says the self-evident thing every time the dialog opens is read twice and
|
|
571
|
+
* then never again. Only the TAKEN name is reported, under the field, with `Save` disabled while it
|
|
572
|
+
* stands — the state the reader has to do something about is the only one that speaks.
|
|
573
|
+
*
|
|
574
|
+
* ⚠️ **The empty field is how a command is given up.** There is no separate switch: `promptName`
|
|
575
|
+
* has two states and the field has two states, and a checkbox beside it would invent a third that
|
|
576
|
+
* nothing can store — offered, without a name.
|
|
577
|
+
*
|
|
578
|
+
* ⚠️ **The refusal comes from the SERVER and is only mirrored here.** Uniqueness spans every
|
|
579
|
+
* document and flow in the installation, which no dialog can know; what this does is stop sending
|
|
580
|
+
* the same name a second time. Deciding freeness in the browser would be the race this whole guard
|
|
581
|
+
* exists against.
|
|
582
|
+
*/
|
|
583
|
+
function PromptDialog({
|
|
584
|
+
node,
|
|
585
|
+
pending,
|
|
586
|
+
error,
|
|
587
|
+
close,
|
|
588
|
+
submit,
|
|
589
|
+
}: {
|
|
590
|
+
node: Node;
|
|
591
|
+
pending: boolean;
|
|
592
|
+
error: unknown;
|
|
593
|
+
close(): void;
|
|
594
|
+
submit(promptName: string | null): void;
|
|
595
|
+
}) {
|
|
596
|
+
const i18n = useI18n();
|
|
597
|
+
const [next, setNext] = useState(node.promptName ?? "");
|
|
598
|
+
// ⚠️ **Which value was SENT, not which one was refused**, and the difference is what lets `Save`
|
|
599
|
+
// come back the moment the reader types something else. A refusal about `billing` says nothing
|
|
600
|
+
// about `billing-monthly`; a message that outlived its name would block the very repair it asks
|
|
601
|
+
// for. Recorded at submit time rather than derived from the error in an effect — the error says
|
|
602
|
+
// THAT something was refused, and only the form knows what it sent.
|
|
603
|
+
const [sent, setSent] = useState<string | null>(null);
|
|
604
|
+
const trimmed = next.trim();
|
|
605
|
+
const grammar = /^[a-z0-9-]{1,40}$/;
|
|
606
|
+
const malformed = trimmed.length > 0 && !grammar.test(trimmed);
|
|
607
|
+
const stillRefused = isPromptNameTaken(error) && sent === trimmed;
|
|
608
|
+
const unchanged = (node.promptName ?? "") === trimmed;
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<Modal title={i18n.t("prompt.title")} close={close}>
|
|
612
|
+
<form
|
|
613
|
+
className="space-y-4"
|
|
614
|
+
onSubmit={(event) => {
|
|
615
|
+
event.preventDefault();
|
|
616
|
+
if (pending || malformed || stillRefused || unchanged) return;
|
|
617
|
+
setSent(trimmed);
|
|
618
|
+
submit(trimmed.length === 0 ? null : trimmed);
|
|
619
|
+
}}
|
|
620
|
+
>
|
|
621
|
+
<label className="block text-sm font-medium">
|
|
622
|
+
{i18n.t("prompt.field")}
|
|
623
|
+
<span className="mt-2 flex items-center rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
|
624
|
+
<span aria-hidden="true" className="pl-3 text-muted-foreground">
|
|
625
|
+
/
|
|
626
|
+
</span>
|
|
627
|
+
<input
|
|
628
|
+
// biome-ignore lint/a11y/noAutofocus: the dialog opens to take this one value
|
|
629
|
+
autoFocus
|
|
630
|
+
value={next}
|
|
631
|
+
onChange={(event) => setNext(event.target.value)}
|
|
632
|
+
className="w-full bg-transparent px-2 py-2 outline-none"
|
|
633
|
+
/>
|
|
634
|
+
</span>
|
|
635
|
+
</label>
|
|
636
|
+
{malformed ? (
|
|
637
|
+
<p role="alert" className="text-sm text-destructive">
|
|
638
|
+
{i18n.t("prompt.invalid")}
|
|
639
|
+
</p>
|
|
640
|
+
) : null}
|
|
641
|
+
{stillRefused ? (
|
|
642
|
+
<p role="alert" className="text-sm text-destructive">
|
|
643
|
+
{i18n.t("prompt.taken", { name: trimmed })}
|
|
644
|
+
</p>
|
|
645
|
+
) : null}
|
|
646
|
+
{/* Everything else the field refuses is said by the server, and its sentence is the one that
|
|
647
|
+
knows what happened. */}
|
|
648
|
+
{error && !isPromptNameTaken(error) ? (
|
|
649
|
+
<p role="alert" className="text-sm text-destructive">
|
|
650
|
+
{i18n.t(resourceErrorKey(error))}
|
|
651
|
+
</p>
|
|
652
|
+
) : null}
|
|
653
|
+
<p className="text-sm text-muted-foreground">{i18n.t("prompt.help")}</p>
|
|
654
|
+
<button
|
|
655
|
+
type="submit"
|
|
656
|
+
disabled={pending || malformed || stillRefused || unchanged}
|
|
657
|
+
className="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"
|
|
658
|
+
>
|
|
659
|
+
{pending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
660
|
+
</button>
|
|
661
|
+
</form>
|
|
662
|
+
</Modal>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function RenameDialog({
|
|
667
|
+
title,
|
|
668
|
+
pending,
|
|
669
|
+
error,
|
|
670
|
+
close,
|
|
671
|
+
submit,
|
|
672
|
+
}: {
|
|
673
|
+
title: string;
|
|
674
|
+
pending: boolean;
|
|
675
|
+
error: unknown;
|
|
676
|
+
close(): void;
|
|
677
|
+
submit(title: string): void;
|
|
678
|
+
}) {
|
|
679
|
+
const i18n = useI18n();
|
|
680
|
+
const [next, setNext] = useState(title);
|
|
681
|
+
const trimmed = next.trim();
|
|
682
|
+
return (
|
|
683
|
+
<Modal title={i18n.t("resource.renameTitle", { title })} close={close}>
|
|
684
|
+
<form
|
|
685
|
+
className="space-y-4"
|
|
686
|
+
onSubmit={(event) => {
|
|
687
|
+
event.preventDefault();
|
|
688
|
+
if (!pending && trimmed.length > 0 && trimmed !== title) submit(trimmed);
|
|
689
|
+
}}
|
|
690
|
+
>
|
|
691
|
+
{error ? (
|
|
692
|
+
<p role="alert" className="text-sm text-destructive">
|
|
693
|
+
{i18n.t(resourceErrorKey(error))}
|
|
694
|
+
</p>
|
|
695
|
+
) : null}
|
|
696
|
+
<label className="block text-sm font-medium">
|
|
697
|
+
{i18n.t("common.title")}
|
|
698
|
+
<input
|
|
699
|
+
required
|
|
700
|
+
// biome-ignore lint/a11y/noAutofocus: the dialog opens to take this one value
|
|
701
|
+
autoFocus
|
|
702
|
+
value={next}
|
|
703
|
+
onChange={(event) => setNext(event.target.value)}
|
|
704
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
705
|
+
/>
|
|
706
|
+
</label>
|
|
707
|
+
<button
|
|
708
|
+
type="submit"
|
|
709
|
+
disabled={pending || trimmed.length === 0 || trimmed === title}
|
|
710
|
+
className="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"
|
|
711
|
+
>
|
|
712
|
+
{pending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
713
|
+
</button>
|
|
714
|
+
</form>
|
|
715
|
+
</Modal>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* What links to this document and what it links to — as a reading, not as a form (#41).
|
|
721
|
+
*
|
|
722
|
+
* ⚠️ There is nothing to create here any more. A relationship is written in the text of the
|
|
723
|
+
* document it belongs to, and this shows the result. The backlinks are the half that could never be
|
|
724
|
+
* written here anyway: "who points at me" is not something the author of this document decides.
|
|
725
|
+
*
|
|
726
|
+
* ⚠️ Every row is a link whose two ends this reader may see — the server filters the list through
|
|
727
|
+
* the same predicate as every other read. A relationship reaching into a part of the tree they may
|
|
728
|
+
* not enter is absent rather than shown without a name.
|
|
729
|
+
*/
|
|
730
|
+
function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
731
|
+
const { data } = useIntelRouterContext();
|
|
732
|
+
const i18n = useI18n();
|
|
733
|
+
const navigate = useNavigate();
|
|
734
|
+
const links = useQuery({
|
|
735
|
+
queryKey: ["node-links", node.id],
|
|
736
|
+
queryFn: () => data.listNodeLinks(node.id),
|
|
737
|
+
});
|
|
738
|
+
// The dialog needs the names of the documents at the other end, and the graph is the one
|
|
739
|
+
// authorized list of them the UI can ask for without walking the whole tree again. It is asked
|
|
740
|
+
// for here rather than by the screen, because here is where it is read.
|
|
741
|
+
const nodeGraph = useQuery({
|
|
742
|
+
queryKey: ["node-graph"],
|
|
743
|
+
queryFn: () => data.getNodeGraph(),
|
|
744
|
+
});
|
|
745
|
+
const titles = new Map(
|
|
746
|
+
(nodeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
return (
|
|
750
|
+
<Modal title={i18n.t("node.links")} close={close}>
|
|
751
|
+
<p className="mb-4 text-sm text-muted-foreground">{i18n.t("node.linksHelp")}</p>
|
|
752
|
+
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
|
753
|
+
{links.data?.items.map((link) => {
|
|
754
|
+
const outgoing = link.sourceNodeId === node.id;
|
|
755
|
+
const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
|
|
756
|
+
return (
|
|
757
|
+
<li key={link.id}>
|
|
758
|
+
<button
|
|
759
|
+
type="button"
|
|
760
|
+
onClick={() => {
|
|
761
|
+
close();
|
|
762
|
+
void navigate({ to: "/nodes", search: { select: otherId } });
|
|
763
|
+
}}
|
|
764
|
+
className="flex w-full items-center gap-3 rounded-md border p-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
765
|
+
>
|
|
766
|
+
<span className="min-w-0 text-sm">
|
|
767
|
+
<span className="block truncate font-medium">
|
|
768
|
+
{titles.get(otherId) ?? otherId}
|
|
769
|
+
</span>
|
|
770
|
+
<span className="text-xs text-muted-foreground">
|
|
771
|
+
{outgoing ? i18n.t("node.linkOutgoing") : i18n.t("node.linkIncoming")}
|
|
772
|
+
</span>
|
|
773
|
+
</span>
|
|
774
|
+
</button>
|
|
775
|
+
</li>
|
|
776
|
+
);
|
|
777
|
+
})}
|
|
778
|
+
{links.data?.items.length === 0 && (
|
|
779
|
+
<li className="text-sm text-muted-foreground">{i18n.t("node.noLinks")}</li>
|
|
780
|
+
)}
|
|
781
|
+
</ul>
|
|
782
|
+
</Modal>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// The one share dialog in the product, for every kind of resource that can be shared — a folder, a
|
|
787
|
+
// document, a table, and since #530 a flow on its own. Which verbs it offers is the server's
|
|
788
|
+
// answer, not this component's: `execute` never appears on a document, because a document has
|
|
789
|
+
// nothing to run, and all four appear on a flow.
|
|
790
|
+
/**
|
|
791
|
+
* Which of the three principals the dialog can SET (#431).
|
|
792
|
+
*
|
|
793
|
+
* ⚠️ `user` is deliberately absent, and it is a decision rather than an omission. Setting one means
|
|
794
|
+
* naming a Gate user id, and Intel has no way to turn a person into an id or an id into a person
|
|
795
|
+
* (#261) — so the only offer this dialog could make is a field for a raw ULID, which is worse than
|
|
796
|
+
* the address it would replace: unverifiable when typed and unreadable afterwards. It stays reachable
|
|
797
|
+
* over the API and over MCP, where the caller already holds the id. The day #261 lands it becomes a
|
|
798
|
+
* picker, not a text field.
|
|
799
|
+
*
|
|
800
|
+
* ⚠️ `organization` IS here, and the reason is the asymmetry it removes: the dialog could already
|
|
801
|
+
* DRAW an organization grant ("Everyone in the organization") and not make one, and MCP could make
|
|
802
|
+
* one all along — a person had strictly less reach than a model on the same tree. It needs nothing
|
|
803
|
+
* Intel does not have, because there is nothing to name.
|
|
804
|
+
*/
|
|
805
|
+
type SharePrincipalKind = "email" | "organization";
|
|
806
|
+
|
|
807
|
+
// What the grant just made does not cover, in the two directions it can fail to (#530): documents
|
|
808
|
+
// the grantee cannot read, and flows the grantee cannot start. Both are `null` until an answer
|
|
809
|
+
// arrives, so "no warning yet" and "nothing to warn about" stay distinguishable.
|
|
810
|
+
type ShareWarnings = { unreadable: UnreadableNodes; unrunnable: UnreadableNodes } | null;
|
|
811
|
+
|
|
812
|
+
// Whether there is anything to say. A warning with neither a title nor a count is silence, and
|
|
813
|
+
// drawing an empty box for it would make every ordinary grant look like it had a caveat.
|
|
814
|
+
function withheld(value: UnreadableNodes): boolean {
|
|
815
|
+
return value.titles.length > 0 || value.hidden > 0;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function SharePanel({ target, close }: { target: ResourceTarget; close(): void }) {
|
|
819
|
+
const { data } = useIntelRouterContext();
|
|
820
|
+
const i18n = useI18n();
|
|
821
|
+
const queryClient = useQueryClient();
|
|
822
|
+
const [principalKind, setPrincipalKind] = useState<SharePrincipalKind>("email");
|
|
823
|
+
const [email, setEmail] = useState("");
|
|
824
|
+
const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
|
|
825
|
+
// What the grant just made does not cover. It survives the form being cleared, because it is the
|
|
826
|
+
// answer to the question the user just asked and they need a moment to read it.
|
|
827
|
+
const [warnings, setWarnings] = useState<ShareWarnings>(null);
|
|
828
|
+
const isFlow = target.type === "flow";
|
|
829
|
+
const resourceId = idOf(target);
|
|
830
|
+
const grants = useQuery({
|
|
831
|
+
queryKey: ["resource-grants", target.type, resourceId],
|
|
832
|
+
queryFn: () => (isFlow ? data.listFlowGrants(resourceId) : data.listGrants(resourceId)),
|
|
833
|
+
});
|
|
834
|
+
// ⚠️ TWO questions, and neither list answers the other's. `listGrants` names the grants THIS node
|
|
835
|
+
// carries — the ones this screen may withdraw — and it is the one that brings `applicableVerbs`,
|
|
836
|
+
// which decides what the form below may offer at all (ADR-0004 §2). The effective list is the
|
|
837
|
+
// DISPLAY answer: it crosses every folder above and names owners, who reach the node without a
|
|
838
|
+
// revocable row of their own.
|
|
839
|
+
//
|
|
840
|
+
// ⚠️ Showing only the first was the bug in #803: the circles in the header read the effective
|
|
841
|
+
// list, this dialog read the direct one, and the two answered "who has access" differently — the
|
|
842
|
+
// dialog stayed empty while the header showed people. The same key as the header's, so both are
|
|
843
|
+
// invalidated by one mutation and can never drift apart again.
|
|
844
|
+
const effective = useQuery({
|
|
845
|
+
queryKey: ["effective-node-grants", target.type, resourceId],
|
|
846
|
+
queryFn: () =>
|
|
847
|
+
isFlow ? data.listEffectiveFlowAccess(resourceId) : data.listEffectiveAccess(resourceId),
|
|
848
|
+
retry: false,
|
|
849
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
850
|
+
});
|
|
851
|
+
// ⚠️ Both queries are invalidated by every mutation below, and the flow key is under the same
|
|
852
|
+
// effective prefix as the node key: a grant on a flow changes what the summary of the folder
|
|
853
|
+
// above it shows, and the other way round.
|
|
854
|
+
const invalidate = async () => {
|
|
855
|
+
await Promise.all([
|
|
856
|
+
queryClient.invalidateQueries({ queryKey: ["resource-grants", target.type, resourceId] }),
|
|
857
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
858
|
+
]);
|
|
859
|
+
};
|
|
860
|
+
const share = useMutation({
|
|
861
|
+
// One request per verb, because one grant is one verb. The keys differ so a retry of the whole
|
|
862
|
+
// form replays each verb on its own rather than collapsing them into one.
|
|
863
|
+
mutationFn: async () => {
|
|
864
|
+
let last: ShareWarnings = null;
|
|
865
|
+
for (const verb of verbs) {
|
|
866
|
+
const principal =
|
|
867
|
+
principalKind === "organization"
|
|
868
|
+
? ({ type: "organization" } as const)
|
|
869
|
+
: ({ type: "email", email } as const);
|
|
870
|
+
// The last verb's answer is the one kept: every request describes the access in force after
|
|
871
|
+
// it, so the newest is the only one still true.
|
|
872
|
+
const result = isFlow
|
|
873
|
+
? await data.shareFlow({
|
|
874
|
+
flowId: resourceId,
|
|
875
|
+
principal,
|
|
876
|
+
verb,
|
|
877
|
+
expiresAt: null,
|
|
878
|
+
idempotencyKey: crypto.randomUUID(),
|
|
879
|
+
})
|
|
880
|
+
: await data.shareNode({
|
|
881
|
+
resourceId,
|
|
882
|
+
principal,
|
|
883
|
+
verb,
|
|
884
|
+
expiresAt: null,
|
|
885
|
+
idempotencyKey: crypto.randomUUID(),
|
|
886
|
+
});
|
|
887
|
+
last = { unreadable: result.unreadable, unrunnable: result.unrunnable };
|
|
888
|
+
}
|
|
889
|
+
return last;
|
|
890
|
+
},
|
|
891
|
+
onSuccess: (result) => {
|
|
892
|
+
setEmail("");
|
|
893
|
+
// ⚠️ The choice is cleared with the address, not left standing. It survived a successful grant
|
|
894
|
+
// until the review of #431 caught it, and "everyone in the organization" is the one setting
|
|
895
|
+
// that must never be the quiet default for the NEXT grant somebody makes in the same dialog.
|
|
896
|
+
setPrincipalKind("email");
|
|
897
|
+
setWarnings(result);
|
|
898
|
+
},
|
|
899
|
+
// ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
|
|
900
|
+
// granted, the fourth refused. On `onSuccess` the list would then still show the state from
|
|
901
|
+
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
902
|
+
// things to be silently wrong about, access is the worst.
|
|
903
|
+
onSettled: invalidate,
|
|
904
|
+
});
|
|
905
|
+
const revoke = useMutation({
|
|
906
|
+
mutationFn: (grantId: string) =>
|
|
907
|
+
isFlow
|
|
908
|
+
? data.revokeFlowGrant({
|
|
909
|
+
flowId: resourceId,
|
|
910
|
+
grantId,
|
|
911
|
+
idempotencyKey: crypto.randomUUID(),
|
|
912
|
+
})
|
|
913
|
+
: data.revokeGrant({
|
|
914
|
+
resourceId,
|
|
915
|
+
grantId,
|
|
916
|
+
idempotencyKey: crypto.randomUUID(),
|
|
917
|
+
}),
|
|
918
|
+
onSettled: invalidate,
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* The one refusal in this dialog that names something the reader has to act on.
|
|
923
|
+
*
|
|
924
|
+
* ⚠️ Taking back `organization` + `execute` on a folder is refused with `409
|
|
925
|
+
* folder_execute_in_use` while flows from outside still call into it (`nodes.ts`,
|
|
926
|
+
* `revokeGrant`), and the CALLERS are what makes that refusal actionable. Rendering it as the
|
|
927
|
+
* generic "access was not changed" threw exactly that away and left the reader with a dialog that
|
|
928
|
+
* refuses and will not say why, for the one grant that is hardest to undo.
|
|
929
|
+
*
|
|
930
|
+
* ⚠️ Since #448 they arrive as data — the flows this actor may see by name, the rest as a count —
|
|
931
|
+
* so the sentence is written here, in the reader's language, exactly like the `shareUnreadable`
|
|
932
|
+
* warning below it. The server still decides WHICH of them may be named; that is an authorization
|
|
933
|
+
* answer and nothing on this side recomputes it.
|
|
934
|
+
*/
|
|
935
|
+
const revokeError = revoke.error instanceof IntelRequestError ? revoke.error : null;
|
|
936
|
+
const revokeInUse = revokeError?.code === "folder_execute_in_use" ? revokeError : null;
|
|
937
|
+
|
|
938
|
+
return (
|
|
939
|
+
<Modal title={i18n.t("node.share")} close={close}>
|
|
940
|
+
{revokeInUse ? (
|
|
941
|
+
<div role="alert" className="mb-4 space-y-1 text-sm text-destructive">
|
|
942
|
+
<p>{i18n.t("node.revokeInUse")}</p>
|
|
943
|
+
{/* An API that predates #448 sends no `callers`, and its sentence is then the only place
|
|
944
|
+
the names exist at all. Showing it beats showing nothing — losing the titles is the
|
|
945
|
+
failure this ticket is about. */}
|
|
946
|
+
{revokeInUse.callers === null ? (
|
|
947
|
+
<p className="text-xs">{revokeInUse.message}</p>
|
|
948
|
+
) : (
|
|
949
|
+
<p className="text-xs">
|
|
950
|
+
{revokeInUse.callers.titles.length > 0
|
|
951
|
+
? i18n.t("node.revokeInUseCallers", {
|
|
952
|
+
titles: revokeInUse.callers.titles.join(", "),
|
|
953
|
+
})
|
|
954
|
+
: i18n.t("node.revokeInUseCallersHidden", { count: revokeInUse.callers.hidden })}
|
|
955
|
+
{revokeInUse.callers.titles.length > 0 && revokeInUse.callers.hidden > 0
|
|
956
|
+
? ` ${i18n.t("node.revokeInUseCallersMore", { count: revokeInUse.callers.hidden })}`
|
|
957
|
+
: ""}
|
|
958
|
+
{` ${i18n.t("node.revokeInUseHint")}`}
|
|
959
|
+
</p>
|
|
960
|
+
)}
|
|
961
|
+
</div>
|
|
962
|
+
) : (
|
|
963
|
+
(share.isError || revoke.isError || grants.isError) && (
|
|
964
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
965
|
+
{i18n.t("node.shareFailed")}
|
|
966
|
+
</p>
|
|
967
|
+
)
|
|
968
|
+
)}
|
|
969
|
+
{/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
|
|
970
|
+
given. A node reference across the folder edge is a possible failure, not a way around
|
|
971
|
+
permissions, so nothing here blocks anything (ADR-0004 §4). */}
|
|
972
|
+
{warnings && withheld(warnings.unreadable) && (
|
|
973
|
+
<div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
|
|
974
|
+
{warnings.unreadable.titles.length > 0 ? (
|
|
975
|
+
<p>
|
|
976
|
+
{i18n.t(`${target.type}.shareUnreadable`, {
|
|
977
|
+
titles: warnings.unreadable.titles.join(", "),
|
|
978
|
+
})}
|
|
979
|
+
{warnings.unreadable.hidden > 0
|
|
980
|
+
? ` ${i18n.t(`${target.type}.shareUnreadableMore`, { count: warnings.unreadable.hidden })}`
|
|
981
|
+
: ""}
|
|
982
|
+
</p>
|
|
983
|
+
) : (
|
|
984
|
+
<p>
|
|
985
|
+
{i18n.t(`${target.type}.shareUnreadableHidden`, {
|
|
986
|
+
count: warnings.unreadable.hidden,
|
|
987
|
+
})}
|
|
988
|
+
</p>
|
|
989
|
+
)}
|
|
990
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
991
|
+
{i18n.t(`${target.type}.shareUnreadableHint`)}
|
|
992
|
+
</p>
|
|
993
|
+
</div>
|
|
994
|
+
)}
|
|
995
|
+
{/* The half a folder grant never needed: a grant on ONE flow stops at that flow, so the flows
|
|
996
|
+
it calls are somebody else's to grant. `status` and not `alert` for the same reason as
|
|
997
|
+
above — the access was given, and this says what it does not reach (#530). */}
|
|
998
|
+
{warnings && withheld(warnings.unrunnable) && (
|
|
999
|
+
<div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
|
|
1000
|
+
{warnings.unrunnable.titles.length > 0 ? (
|
|
1001
|
+
<p>
|
|
1002
|
+
{i18n.t("flow.shareUnrunnable", { titles: warnings.unrunnable.titles.join(", ") })}
|
|
1003
|
+
{warnings.unrunnable.hidden > 0
|
|
1004
|
+
? ` ${i18n.t("flow.shareUnrunnableMore", { count: warnings.unrunnable.hidden })}`
|
|
1005
|
+
: ""}
|
|
1006
|
+
</p>
|
|
1007
|
+
) : (
|
|
1008
|
+
<p>{i18n.t("flow.shareUnrunnableHidden", { count: warnings.unrunnable.hidden })}</p>
|
|
1009
|
+
)}
|
|
1010
|
+
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("flow.shareUnrunnableHint")}</p>
|
|
1011
|
+
</div>
|
|
1012
|
+
)}
|
|
1013
|
+
{effective.data && (effective.data.items.length > 0 || effective.data.ownerIds.length > 0) ? (
|
|
1014
|
+
<div className="mb-5">
|
|
1015
|
+
<AccessSummary
|
|
1016
|
+
grants={effective.data.items}
|
|
1017
|
+
ownerIds={effective.data.ownerIds}
|
|
1018
|
+
// Withdrawal is offered for the grants that sit HERE. An inherited one is in force but
|
|
1019
|
+
// belongs to the folder it was written on, and the reader may not manage that folder.
|
|
1020
|
+
revocableOn={resourceId}
|
|
1021
|
+
// Named only where the reader may see the folder — the answer leaves out the rest, and
|
|
1022
|
+
// the row then says "inherited" without saying from where.
|
|
1023
|
+
sources={effective.data.sources}
|
|
1024
|
+
onRevoke={(grantId) => {
|
|
1025
|
+
share.reset();
|
|
1026
|
+
revoke.mutate(grantId);
|
|
1027
|
+
}}
|
|
1028
|
+
/>
|
|
1029
|
+
</div>
|
|
1030
|
+
) : null}
|
|
1031
|
+
<form
|
|
1032
|
+
onSubmit={(event) => {
|
|
1033
|
+
event.preventDefault();
|
|
1034
|
+
// ⚠️ The other mutation's error is cleared before this one starts, in both directions. A
|
|
1035
|
+
// refused withdrawal keeps its `409` until something resets it, and `revokeInUse` takes
|
|
1036
|
+
// precedence over the general sentence — so without this, a share that failed AFTER a
|
|
1037
|
+
// refused withdrawal said nothing at all, and the reader was left reading a sentence
|
|
1038
|
+
// about the wrong action. That is the exact failure mode #430 removed one level up.
|
|
1039
|
+
revoke.reset();
|
|
1040
|
+
share.mutate();
|
|
1041
|
+
}}
|
|
1042
|
+
className="space-y-4 border-t pt-5"
|
|
1043
|
+
>
|
|
1044
|
+
{/* ⚠️ Two of the three principals, and the missing one is a decision — see
|
|
1045
|
+
`SharePrincipalKind`. Radios rather than a select: there are two, and a reader has to see
|
|
1046
|
+
that granting to the whole organization is a thing this dialog does at all. */}
|
|
1047
|
+
<fieldset className="space-y-2">
|
|
1048
|
+
<legend className="text-sm font-medium">{i18n.t("node.shareWith")}</legend>
|
|
1049
|
+
{(["email", "organization"] as const).map((kind) => (
|
|
1050
|
+
<label key={kind} className="flex items-center gap-2 text-sm">
|
|
1051
|
+
<input
|
|
1052
|
+
type="radio"
|
|
1053
|
+
name="share-principal"
|
|
1054
|
+
checked={principalKind === kind}
|
|
1055
|
+
onChange={() => setPrincipalKind(kind)}
|
|
1056
|
+
aria-describedby={kind === "organization" ? "share-organization-hint" : undefined}
|
|
1057
|
+
className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1058
|
+
/>
|
|
1059
|
+
<span>
|
|
1060
|
+
{i18n.t(kind === "email" ? "node.shareWithEmail" : "node.shareWithOrganization")}
|
|
1061
|
+
</span>
|
|
1062
|
+
</label>
|
|
1063
|
+
))}
|
|
1064
|
+
</fieldset>
|
|
1065
|
+
{principalKind === "email" ? (
|
|
1066
|
+
<div>
|
|
1067
|
+
{/* ⚠️ The hint stands OUTSIDE the label and is tied on with `aria-describedby`. Inside
|
|
1068
|
+
it, its words would become part of the field's accessible NAME — a screen reader
|
|
1069
|
+
would announce the whole paragraph as the label of the input, and every query by
|
|
1070
|
+
label would have to spell it out too. */}
|
|
1071
|
+
<label className="block text-sm font-medium">
|
|
1072
|
+
{i18n.t("node.email")}
|
|
1073
|
+
<input
|
|
1074
|
+
type="email"
|
|
1075
|
+
required
|
|
1076
|
+
value={email}
|
|
1077
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
1078
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1079
|
+
/>
|
|
1080
|
+
</label>
|
|
1081
|
+
</div>
|
|
1082
|
+
) : (
|
|
1083
|
+
/* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
|
|
1084
|
+
screen — not on the warning inside it. A live region has to exist before its content
|
|
1085
|
+
changes, so putting it on a paragraph that appears at the same moment announces
|
|
1086
|
+
nothing. The gap it closes: `Run` is ticked while the focus sits in the verb fieldset,
|
|
1087
|
+
far from here, and the sentence about what is being made would otherwise arrive in
|
|
1088
|
+
silence. (The other way in is covered already: `aria-describedby` on the radio reads
|
|
1089
|
+
this whole box out when the radio takes focus.) */
|
|
1090
|
+
<div
|
|
1091
|
+
id="share-organization-hint"
|
|
1092
|
+
aria-live="polite"
|
|
1093
|
+
className="space-y-2 rounded-md border bg-muted p-3 text-xs"
|
|
1094
|
+
>
|
|
1095
|
+
<p className="text-muted-foreground">{i18n.t("node.shareOrganizationHint")}</p>
|
|
1096
|
+
{/* ⚠️ `organization` + `execute` on a folder is not one grant among four: it is the
|
|
1097
|
+
definition of a LIBRARY (ADR-0004 §3) — the one grant a flow may call across a folder
|
|
1098
|
+
edge for, so flows from anywhere in the tree may then reach in. Two clicks in this
|
|
1099
|
+
dialog now do what used to take an API call, and the reader has to be told what they
|
|
1100
|
+
are about to make BEFORE they make it.
|
|
1101
|
+
|
|
1102
|
+
⚠️ The second sentence is the one that matters most, because it is about the way
|
|
1103
|
+
back: `revokeGrant` refuses with `folder_execute_in_use` while callers remain, so
|
|
1104
|
+
this is the single hardest grant in the product to undo. Naming the consequence after
|
|
1105
|
+
the fact would be the "wrong sentence" class anchrd/core#111 collects.
|
|
1106
|
+
|
|
1107
|
+
It is a warning and not a block, deliberately: the library is a documented, wanted
|
|
1108
|
+
shape, MCP has always been able to make one, and refusing it here would put a person
|
|
1109
|
+
back below a model on the same tree — the very asymmetry this ticket removed. */}
|
|
1110
|
+
{/* ⚠️ Only on a node, and that is not an oversight. What makes a folder a library is
|
|
1111
|
+
`execute` for the organization ON THE FOLDER: `callReach` reads `node_grants` along
|
|
1112
|
+
the callee's ancestors and nothing else, so the same grant on a flow itself makes no
|
|
1113
|
+
library and reaches no caller outside its subtree. Showing this line there would
|
|
1114
|
+
warn about a consequence that cannot happen — and about a refusal on the way back
|
|
1115
|
+
that would never come (#530). */}
|
|
1116
|
+
{!isFlow && verbs.includes("execute") ? (
|
|
1117
|
+
<p role="note" className="font-medium text-destructive">
|
|
1118
|
+
{i18n.t("node.shareLibraryWarning")}
|
|
1119
|
+
</p>
|
|
1120
|
+
) : null}
|
|
1121
|
+
</div>
|
|
1122
|
+
)}
|
|
1123
|
+
<fieldset className="space-y-2">
|
|
1124
|
+
<legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
|
|
1125
|
+
{(grants.data?.applicableVerbs ?? []).map((verb) => (
|
|
1126
|
+
<label key={verb} className="flex items-center gap-2 text-sm">
|
|
1127
|
+
<input
|
|
1128
|
+
type="checkbox"
|
|
1129
|
+
checked={verbs.includes(verb)}
|
|
1130
|
+
onChange={(event) =>
|
|
1131
|
+
setVerbs((current) =>
|
|
1132
|
+
event.target.checked
|
|
1133
|
+
? [...current, verb]
|
|
1134
|
+
: current.filter((candidate) => candidate !== verb),
|
|
1135
|
+
)
|
|
1136
|
+
}
|
|
1137
|
+
className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1138
|
+
/>
|
|
1139
|
+
<span>{i18n.t(`node.verb.${verb}`)}</span>
|
|
1140
|
+
<span className="text-xs text-muted-foreground">
|
|
1141
|
+
{i18n.t(verbHintKey(verb, target.type))}
|
|
1142
|
+
</span>
|
|
1143
|
+
</label>
|
|
1144
|
+
))}
|
|
1145
|
+
</fieldset>
|
|
1146
|
+
<button
|
|
1147
|
+
type="submit"
|
|
1148
|
+
disabled={share.isPending || verbs.length === 0}
|
|
1149
|
+
className="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"
|
|
1150
|
+
>
|
|
1151
|
+
{i18n.t("node.shareAction")}
|
|
1152
|
+
</button>
|
|
1153
|
+
</form>
|
|
1154
|
+
</Modal>
|
|
1155
|
+
);
|
|
1156
|
+
}
|