diff --git a/.github/renovate.json b/.github/renovate.json index a540853063..99959762b0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -74,6 +74,11 @@ ], "minimumReleaseAge": null, "groupName": "better-auth" + }, + { + "description": "CodeMirror and Lezer move together: @tale/ui's code editor and Milkdown share one copy of each, and two copies of @codemirror/state or @codemirror/view break the editor (packages/ui/src/components/forms/code-editor/deps.test.ts)", + "matchPackageNames": ["/^@codemirror\\//", "/^@lezer\\//"], + "groupName": "codemirror" } ] } diff --git a/bun.lock b/bun.lock index 40f7c9d3ee..ccee22cc9f 100644 --- a/bun.lock +++ b/bun.lock @@ -131,10 +131,22 @@ "tale-seo-compile": "./bin/seo-compile.ts", }, "dependencies": { + "@codemirror/autocomplete": "6.20.3", + "@codemirror/commands": "6.10.4", + "@codemirror/lang-javascript": "6.2.5", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-yaml": "6.1.3", + "@codemirror/language": "6.12.4", + "@codemirror/search": "6.7.1", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.8", "@fontsource/inter": "5.2.8", "@hookform/resolvers": "5.2.2", "@iconify-json/lucide": "1.2.122", "@iconify/react": "6.0.2", + "@lezer/common": "1.5.2", + "@lezer/highlight": "1.2.3", + "@lezer/markdown": "1.7.2", "@microlink/react-json-view": "1.31.18", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-dialog": "1.1.15", @@ -310,7 +322,6 @@ "@tanstack/react-router": "1.168.10", "@tanstack/react-table": "8.21.3", "@xmldom/xmldom": "0.8.15", - "@xyflow/react": "12.10.2", "acorn": "8.18.0", "ajv": "8.20.0", "aws4fetch": "^1.0.20", diff --git a/configs/platform/system/connectors/confluence/connector.yml b/configs/platform/system/connectors/confluence/connector.yml index 9993b3961e..411ffca78d 100644 --- a/configs/platform/system/connectors/confluence/connector.yml +++ b/configs/platform/system/connectors/confluence/connector.yml @@ -16,6 +16,12 @@ auth: - method: basic actions: - name: list_pages + title: List pages + i18n: + de: + title: Seiten auflisten + fr: + title: Lister les pages description: >- List a Confluence space's pages. Cursor pagination is handled internally (soft-capped; `truncated: true` beyond the cap). Archived pages are @@ -141,6 +147,12 @@ actions: }; exampleInput: { spaceKey: 'ENG' } - name: get_page + title: Get page + i18n: + de: + title: Seite abrufen + fr: + title: Récupérer une page description: >- Fetch a single page's rendered content by pageId, convert it to plain text, and store it as a text/plain file. diff --git a/configs/platform/system/connectors/conversation/connector.yml b/configs/platform/system/connectors/conversation/connector.yml index f3e3c47559..ffe894df7a 100644 --- a/configs/platform/system/connectors/conversation/connector.yml +++ b/configs/platform/system/connectors/conversation/connector.yml @@ -4,6 +4,11 @@ # in one place. `platform` auth — nothing to connect. name: conversation displayName: Conversations +i18n: + de: + displayName: Konversationen + fr: + displayName: Conversations description: >- Pull mailbox traffic into the org Inbox and expose a one-shot sync and a multi-credential inbox list for scheduled mail packs. @@ -14,6 +19,12 @@ auth: - method: platform actions: - name: sync_mailbox + title: Sync mailboxes + i18n: + de: + title: Postfächer synchronisieren + fr: + title: Synchroniser les boîtes mail description: >- List new messages from every active credential on the named mail connector, fetch each body, and ingest them into conversations (inbound @@ -50,6 +61,12 @@ actions: impl: conversation.sync_mailbox exampleInput: { connectorSlug: 'imap-smtp', limit: 25 } - name: list_mailbox_messages + title: List mailbox messages + i18n: + de: + title: Nachrichten der Postfächer auflisten + fr: + title: Lister les messages des boîtes mail description: >- List the newest inbox messages from every active credential on the named mail connector — what triage digests over. IMAP UIDs are scoped per @@ -87,6 +104,12 @@ actions: impl: conversation.list_mailbox_messages exampleInput: { connectorSlug: 'imap-smtp', limit: 25 } - name: list_untriaged + title: List untriaged conversations + i18n: + de: + title: Nicht triagierte Konversationen auflisten + fr: + title: Lister les conversations à trier description: >- List the open Inbox conversations of one mail connector that are waiting on the team — the newest message is the customer's — and that no triage @@ -128,6 +151,12 @@ actions: impl: conversation.list_untriaged exampleInput: { connectorSlug: 'gmail', limit: 25 } - name: record_triage + title: Record triage + i18n: + de: + title: Triage festhalten + fr: + title: Enregistrer le triage description: >- Record a triage pass on Inbox conversations: the verdict is stamped on each conversation, and its priority is set where no person has set one. @@ -180,6 +209,12 @@ actions: ], } - name: ingest_emails + title: Import received emails + i18n: + de: + title: Empfangene E-Mails importieren + fr: + title: Importer les e-mails reçus description: Ingest already-fetched inbound emails into conversations. effects: write input: @@ -222,6 +257,12 @@ actions: ], } - name: ingest_sent_emails + title: Import sent emails + i18n: + de: + title: Gesendete E-Mails importieren + fr: + title: Importer les e-mails envoyés description: Ingest already-fetched Sent-folder emails into conversations. effects: write input: @@ -265,6 +306,12 @@ actions: ], } - name: draft_reply + title: Draft reply + i18n: + de: + title: Antwortentwurf erstellen + fr: + title: Rédiger un brouillon de réponse description: >- Propose a reply on a conversation and leave it for a person. The draft appears in the Inbox as a pending message; sending it is a human act, and @@ -301,6 +348,12 @@ actions: body: 'Yes, we still have it in stock. Would you like us to hold one?', } - name: query_sync_cursor + title: Get last sync time + i18n: + de: + title: Zeitpunkt der letzten Synchronisierung abrufen + fr: + title: Récupérer l’heure de la dernière synchronisation description: The latest synced message timestamp for a mailbox direction. effects: read input: diff --git a/configs/platform/system/connectors/discord/connector.yml b/configs/platform/system/connectors/discord/connector.yml index cdddb65e56..a7a40741b4 100644 --- a/configs/platform/system/connectors/discord/connector.yml +++ b/configs/platform/system/connectors/discord/connector.yml @@ -19,6 +19,12 @@ auth: scheme: Bot actions: - name: list_guilds + title: List servers + i18n: + de: + title: Server auflisten + fr: + title: Lister les serveurs description: List the guilds (servers) the bot is a member of. effects: read input: @@ -46,6 +52,12 @@ actions: return { guilds: r.json() }; exampleInput: { limit: 50 } - name: get_guild + title: Get server + i18n: + de: + title: Server abrufen + fr: + title: Récupérer un serveur description: Get a single guild (server) by ID. effects: read input: @@ -66,6 +78,12 @@ actions: return { guild: r.json() }; exampleInput: { guild_id: '100000000000000001' } - name: list_channels + title: List channels + i18n: + de: + title: Kanäle auflisten + fr: + title: Lister les salons description: List all channels in a guild. effects: read input: @@ -86,6 +104,12 @@ actions: return { channels: r.json() }; exampleInput: { guild_id: '100000000000000001' } - name: list_messages + title: List messages + i18n: + de: + title: Nachrichten auflisten + fr: + title: Lister les messages description: Fetch recent messages from a channel. effects: read input: @@ -120,6 +144,12 @@ actions: return { messages: r.json() }; exampleInput: { channel_id: '300000000000000001', limit: 20 } - name: send_message + title: Send message + i18n: + de: + title: Nachricht senden + fr: + title: Envoyer un message description: Send a message to a channel. effects: write input: @@ -154,6 +184,12 @@ actions: exampleInput: { channel_id: '300000000000000001', content: 'Deploy finished ✅' } - name: get_user + title: Get user + i18n: + de: + title: Benutzer abrufen + fr: + title: Récupérer un utilisateur description: Get a Discord user by ID. effects: read input: @@ -174,6 +210,12 @@ actions: return { user: r.json() }; exampleInput: { user_id: '200000000000000001' } - name: list_members + title: List members + i18n: + de: + title: Mitglieder auflisten + fr: + title: Lister les membres description: List the members of a guild. effects: read input: @@ -202,6 +244,12 @@ actions: return { members: r.json() }; exampleInput: { guild_id: '100000000000000001', limit: 100 } - name: create_channel + title: Create channel + i18n: + de: + title: Kanal erstellen + fr: + title: Créer un salon description: Create a new channel in a guild. effects: write input: diff --git a/configs/platform/system/connectors/document/connector.yml b/configs/platform/system/connectors/document/connector.yml index bcc557f5c3..67fc7649ea 100644 --- a/configs/platform/system/connectors/document/connector.yml +++ b/configs/platform/system/connectors/document/connector.yml @@ -3,6 +3,11 @@ # text) into a folder as a visible document. Native-backed; `platform` auth. name: document displayName: Documents +i18n: + de: + displayName: Dokumente + fr: + displayName: Documents description: >- List a folder's stored files and file produced artifacts into folders — the platform's own document tree as automation steps. @@ -12,6 +17,12 @@ auth: - method: platform actions: - name: list + title: List files in folder + i18n: + de: + title: Dateien im Ordner auflisten + fr: + title: Lister les fichiers d’un dossier description: >- The files stored inside one folder — direct children by default; recursive walks the subfolder tree and prefixes each file name with its @@ -49,6 +60,12 @@ actions: impl: document.list exampleInput: { folderId: 'fld_1' } - name: create + title: Save document to folder + i18n: + de: + title: Dokument im Ordner ablegen + fr: + title: Enregistrer un document dans un dossier description: >- File a document into a folder: claim a harvested blob by storageId, or store inline text. Idempotent per (folder, name) — re-running refreshes diff --git a/configs/platform/system/connectors/github/connector.yml b/configs/platform/system/connectors/github/connector.yml index 165d563aa2..f560c99683 100644 --- a/configs/platform/system/connectors/github/connector.yml +++ b/configs/platform/system/connectors/github/connector.yml @@ -14,6 +14,12 @@ auth: - method: bearer actions: - name: list_repos + title: List repositories + i18n: + de: + title: Repositories auflisten + fr: + title: Lister les dépôts description: List repositories for the authenticated user. effects: read input: @@ -46,6 +52,12 @@ actions: return { repos: r.json() }; exampleInput: { visibility: 'all', per_page: 20 } - name: get_repo + title: Get repository + i18n: + de: + title: Repository abrufen + fr: + title: Récupérer un dépôt description: Get a single repository by owner and name. effects: read input: @@ -70,6 +82,12 @@ actions: return { repo: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world' } - name: list_issues + title: List issues + i18n: + de: + title: Issues auflisten + fr: + title: Lister les issues description: List issues for a repository. effects: read input: @@ -115,6 +133,12 @@ actions: return { issues: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world', state: 'open' } - name: get_issue + title: Get issue + i18n: + de: + title: Issue abrufen + fr: + title: Récupérer une issue description: Get a single issue by number. effects: read input: @@ -140,6 +164,12 @@ actions: return { issue: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world', issue_number: 1 } - name: create_issue + title: Create issue + i18n: + de: + title: Issue erstellen + fr: + title: Créer une issue description: Create a new issue in a repository. effects: write input: @@ -179,6 +209,12 @@ actions: exampleInput: { owner: 'octocat', repo: 'hello-world', title: 'Something is broken' } - name: list_pull_requests + title: List pull requests + i18n: + de: + title: Pull Requests auflisten + fr: + title: Lister les pull requests description: List pull requests for a repository. effects: read input: @@ -229,6 +265,12 @@ actions: return { pull_requests: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world', state: 'open' } - name: get_pull_request + title: Get pull request + i18n: + de: + title: Pull Request abrufen + fr: + title: Récupérer une pull request description: Get a single pull request by number. effects: read input: @@ -254,6 +296,12 @@ actions: return { pull_request: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world', pull_number: 1 } - name: create_pull_request + title: Create pull request + i18n: + de: + title: Pull Request erstellen + fr: + title: Créer une pull request description: Create a new pull request in a repository. effects: write input: @@ -301,6 +349,12 @@ actions: base: 'main', } - name: merge_pull_request + title: Merge pull request + i18n: + de: + title: Pull Request mergen + fr: + title: Fusionner une pull request description: Merge a pull request into its base branch. effects: write input: @@ -348,6 +402,12 @@ actions: merge_method: 'squash', } - name: get_pull_request_files + title: List pull request files + i18n: + de: + title: Dateien eines Pull Requests auflisten + fr: + title: Lister les fichiers d’une pull request description: List the files changed in a pull request (filename, status, additions, deletions, patch). effects: read input: @@ -374,6 +434,12 @@ actions: return { files: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world', pull_number: 1 } - name: get_pull_request_diff + title: Get pull request diff + i18n: + de: + title: Diff eines Pull Requests abrufen + fr: + title: Récupérer le diff d’une pull request description: Get the raw unified diff for a pull request. effects: read input: @@ -395,6 +461,12 @@ actions: return { diff: r.text() }; exampleInput: { owner: 'octocat', repo: 'hello-world', pull_number: 1 } - name: create_pull_request_review + title: Submit pull request review + i18n: + de: + title: Review für einen Pull Request abgeben + fr: + title: Envoyer une revue de pull request description: Submit a review on a pull request (APPROVE, REQUEST_CHANGES, or COMMENT) with an optional body and inline comments. effects: write input: @@ -444,6 +516,12 @@ actions: event: 'APPROVE', } - name: create_pull_request_review_comment + title: Add review comment + i18n: + de: + title: Review-Kommentar hinzufügen + fr: + title: Ajouter un commentaire de revue description: Add a single inline comment on a line of a pull request diff. effects: write input: @@ -485,6 +563,12 @@ actions: path: 'src/index.ts', } - name: list_issue_comments + title: List issue comments + i18n: + de: + title: Kommentare eines Issues auflisten + fr: + title: Lister les commentaires d’une issue description: List comments on an issue or pull request conversation. effects: read input: @@ -512,6 +596,12 @@ actions: return { comments: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world', issue_number: 1 } - name: create_issue_comment + title: Comment on issue + i18n: + de: + title: Issue kommentieren + fr: + title: Commenter une issue description: Add a comment to an issue or pull request conversation. effects: write input: @@ -543,6 +633,12 @@ actions: body: 'Thanks for the report!', } - name: update_issue + title: Update issue + i18n: + de: + title: Issue aktualisieren + fr: + title: Mettre à jour une issue description: Update an issue's title, body, state, labels, assignees, or milestone. effects: write input: @@ -584,6 +680,12 @@ actions: state: 'closed', } - name: add_labels_to_issue + title: Add labels to issue + i18n: + de: + title: Labels zu einem Issue hinzufügen + fr: + title: Ajouter des étiquettes à une issue description: Add labels to an issue or pull request (additive, does not remove existing labels). effects: write input: @@ -614,6 +716,12 @@ actions: labels: ['bug', 'triage'], } - name: list_commits + title: List commits + i18n: + de: + title: Commits auflisten + fr: + title: Lister les commits description: List commits for a repository. effects: read input: @@ -648,6 +756,12 @@ actions: return { commits: r.json() }; exampleInput: { owner: 'octocat', repo: 'hello-world' } - name: search_code + title: Search code + i18n: + de: + title: Code durchsuchen + fr: + title: Rechercher du code description: Search for code across GitHub repositories. effects: read input: @@ -676,6 +790,12 @@ actions: return { total_count: j.total_count, items: j.items }; exampleInput: { q: 'addEventListener repo:octocat/hello-world' } - name: list_import_issues + title: List issues to import + i18n: + de: + title: Issues für den Import auflisten + fr: + title: Lister les issues à importer description: Read matching issues for task intake. Follows up to 20 pages per run, returning an opaque nextCursor for continuation without skipping a partially consumed page. effects: read input: @@ -699,6 +819,12 @@ actions: impl: github.list_import_issues exampleInput: { owner: example, repo: web, limit: 100 } - name: get_import_issue + title: Refresh imported issue + i18n: + de: + title: Importiertes Issue aktualisieren + fr: + title: Actualiser une issue importée description: Refresh a previously imported issue by immutable source identity, including closed or resolved issues outside the discovery filter. Marks a missing source unavailable without changing its Tale task. effects: read input: @@ -723,6 +849,12 @@ actions: externalId: example/web#1 externalIssue: { id: '1', title: Example, description: Details, url: 'https://example.com/issues/1', state: open, syncedAt: 0 } - name: refresh_import_issues + title: Refresh imported issues + i18n: + de: + title: Importierte Issues aktualisieren + fr: + title: Actualiser les issues importées description: Refresh up to 500 linked source snapshots with bounded request concurrency. Closed and unavailable issues remain linked to their existing Tale tasks. effects: read input: diff --git a/configs/platform/system/connectors/glitchtip/connector.yml b/configs/platform/system/connectors/glitchtip/connector.yml index 61f403273e..662e6fbd62 100644 --- a/configs/platform/system/connectors/glitchtip/connector.yml +++ b/configs/platform/system/connectors/glitchtip/connector.yml @@ -12,6 +12,12 @@ auth: - method: bearer actions: - name: list_import_issues + title: List issues to import + i18n: + de: + title: Issues für den Import auflisten + fr: + title: Lister les issues à importer description: Read matching issues for task intake. Follows up to 20 pages per run, returning an opaque nextCursor for continuation without skipping a partially consumed page. effects: read input: @@ -35,6 +41,12 @@ actions: impl: glitchtip.list_import_issues exampleInput: { organization: example, project: web, limit: 100 } - name: get_import_issue + title: Refresh imported issue + i18n: + de: + title: Importiertes Issue aktualisieren + fr: + title: Actualiser une issue importée description: Refresh a previously imported issue by immutable source identity, including closed or resolved issues outside the discovery filter. Marks a missing source unavailable without changing its Tale task. effects: read input: @@ -59,6 +71,12 @@ actions: externalId: example/web#1 externalIssue: { id: '1', title: Example, description: Details, url: 'https://example.com/issues/1', state: open, syncedAt: 0 } - name: refresh_import_issues + title: Refresh imported issues + i18n: + de: + title: Importierte Issues aktualisieren + fr: + title: Actualiser les issues importées description: Refresh up to 500 linked source snapshots with bounded request concurrency. Closed and unavailable issues remain linked to their existing Tale tasks. effects: read input: diff --git a/configs/platform/system/connectors/gmail/connector.yml b/configs/platform/system/connectors/gmail/connector.yml index 997e16232e..727503be39 100644 --- a/configs/platform/system/connectors/gmail/connector.yml +++ b/configs/platform/system/connectors/gmail/connector.yml @@ -23,6 +23,12 @@ auth: - https://www.googleapis.com/auth/gmail.modify actions: - name: list_messages + title: List messages + i18n: + de: + title: Nachrichten auflisten + fr: + title: Lister les messages description: List message and thread IDs from the mailbox, optionally filtered by a Gmail search query or labels. Draft messages are excluded. effects: read input: @@ -58,6 +64,12 @@ actions: return { messages: j.messages || [], nextPageToken: j.nextPageToken || '' }; exampleInput: { q: 'is:unread', maxResults: 25 } - name: get_message + title: Get message + i18n: + de: + title: Nachricht abrufen + fr: + title: Récupérer un message description: Fetch a single email message by ID with full headers, body, and attachment metadata. Optionally download and store all attachments. effects: read input: @@ -108,6 +120,12 @@ actions: return { message, attachments }; exampleInput: { messageId: '18c1a2b3c4d5e6f7', format: 'full' } - name: get_attachments + title: Download attachments + i18n: + de: + title: Anhänge herunterladen + fr: + title: Télécharger les pièces jointes description: Download every file attachment on a message and store each one. Returns a fileId and URL per attachment. effects: read input: @@ -149,6 +167,12 @@ actions: return { attachments }; exampleInput: { messageId: '18c1a2b3c4d5e6f7' } - name: search_messages + title: Search messages + i18n: + de: + title: Nachrichten durchsuchen + fr: + title: Rechercher des messages description: Search messages using Gmail query syntax and return the matching message and thread IDs. effects: read input: @@ -179,6 +203,12 @@ actions: return { messages: j.messages || [], nextPageToken: j.nextPageToken || '' }; exampleInput: { query: 'subject:invoice has:attachment', maxResults: 25 } - name: send_message + title: Send email + i18n: + de: + title: E-Mail senden + fr: + title: Envoyer un e-mail description: Compose and send an email message via Gmail. Supports replying into a thread and sending with file attachments. effects: write input: @@ -275,6 +305,12 @@ actions: body: 'This is a test message.', } - name: get_profile + title: Get mailbox address + i18n: + de: + title: Adresse des Postfachs abrufen + fr: + title: Récupérer l’adresse de la boîte mail description: The address of the connected mailbox (the account the credential authorizes), as the mail sync reads it to tell the mailbox's own mail from a customer's. effects: read input: @@ -292,6 +328,12 @@ actions: return { emailAddress: String(j.emailAddress || '') }; exampleInput: {} - name: list_labels + title: List labels + i18n: + de: + title: Labels auflisten + fr: + title: Lister les libellés description: List all labels in the mailbox, including system labels (INBOX, SENT, etc.) and user-created labels. effects: read input: @@ -309,6 +351,12 @@ actions: return { labels: j.labels || [] }; exampleInput: {} - name: get_thread + title: Get thread + i18n: + de: + title: E-Mail-Verlauf abrufen + fr: + title: Récupérer un fil de discussion description: Fetch a complete email thread (conversation) with all of its messages. effects: read input: @@ -333,6 +381,12 @@ actions: return { thread: r.json() }; exampleInput: { threadId: '18c1a2b3c4d5e6f7' } - name: list_drafts + title: List drafts + i18n: + de: + title: Entwürfe auflisten + fr: + title: Lister les brouillons description: List draft messages in the mailbox. effects: read input: @@ -359,6 +413,12 @@ actions: return { drafts: j.drafts || [], nextPageToken: j.nextPageToken || '' }; exampleInput: { maxResults: 25 } - name: get_attachment + title: Download attachment + i18n: + de: + title: Anhang herunterladen + fr: + title: Télécharger une pièce jointe description: Download a single email attachment by message ID and attachment ID, storing it through file storage. effects: read input: diff --git a/configs/platform/system/connectors/google-drive/connector.yml b/configs/platform/system/connectors/google-drive/connector.yml index ecc2de5f8b..64365d5cac 100644 --- a/configs/platform/system/connectors/google-drive/connector.yml +++ b/configs/platform/system/connectors/google-drive/connector.yml @@ -19,6 +19,12 @@ auth: - https://www.googleapis.com/auth/drive.readonly actions: - name: list_files + title: List files + i18n: + de: + title: Dateien auflisten + fr: + title: Lister les fichiers description: List files in a Google Drive folder. Pagination is handled internally — the operation returns the complete file list in one call. When recursive is true, subfolders are traversed breadth-first and each file's relative path is reported as subfolderPath. effects: read input: @@ -113,6 +119,12 @@ actions: return { files, truncated }; exampleInput: { folderId: '1AbCdEfGhIjKlMnOpQrStUvWxYz', recursive: false } - name: download_file + title: Download file + i18n: + de: + title: Datei herunterladen + fr: + title: Télécharger un fichier description: Download a single binary file by Drive fileId and store it in file storage. Native Google Workspace formats (Docs/Sheets/Slides) are not supported by this operation. effects: read input: diff --git a/configs/platform/system/connectors/imap-smtp/connector.yml b/configs/platform/system/connectors/imap-smtp/connector.yml index 7b35b1433d..63a967836e 100644 --- a/configs/platform/system/connectors/imap-smtp/connector.yml +++ b/configs/platform/system/connectors/imap-smtp/connector.yml @@ -6,6 +6,11 @@ # server/port config the native module reads. name: imap-smtp displayName: IMAP / SMTP Mailbox +i18n: + de: + displayName: IMAP-/SMTP-Postfach + fr: + displayName: Boîte mail IMAP / SMTP description: >- Connect a private IMAP + SMTP mail server to Conversations — no Gmail or Outlook account required. Optional separate SMTP login for a relay @@ -58,6 +63,12 @@ auth: - method: basic actions: - name: list_messages + title: List messages + i18n: + de: + title: Nachrichten auflisten + fr: + title: Lister les messages description: >- Fetch messages from an IMAP mailbox as standard email objects (from, to, subject, body, attachments). Omit mailbox for INBOX; pass `sent` to read @@ -99,6 +110,12 @@ actions: impl: imap-smtp.list_messages exampleInput: { mailbox: 'INBOX', limit: 25 } - name: get_message + title: Get message + i18n: + de: + title: Nachricht abrufen + fr: + title: Récupérer un message description: >- Fetch one message by IMAP UID with plain/HTML body, file attachments (metadata plus base64 content when under 100 MiB; a larger part is @@ -142,6 +159,12 @@ actions: impl: imap-smtp.get_message exampleInput: { uid: '42', mailbox: 'INBOX' } - name: send + title: Send email + i18n: + de: + title: E-Mail senden + fr: + title: Envoyer un e-mail description: Send an email through the configured SMTP server. effects: write input: diff --git a/configs/platform/system/connectors/jev/connector.yml b/configs/platform/system/connectors/jev/connector.yml index 502070aaa4..2a09a51556 100644 --- a/configs/platform/system/connectors/jev/connector.yml +++ b/configs/platform/system/connectors/jev/connector.yml @@ -11,6 +11,11 @@ # tokens are free, so a decision costs a fraction of the chat call it replaces. name: jev displayName: Jev decisions +i18n: + de: + displayName: Jev-Entscheidungen + fr: + displayName: Décisions Jev description: >- Ask typed questions about a piece of state and get back calibrated answers — a choice from a fixed set, a score against ordered levels, or the probability @@ -25,6 +30,12 @@ auth: - method: api-key actions: - name: decide + title: Make a decision + i18n: + de: + title: Entscheidung treffen + fr: + title: Prendre une décision description: >- Judge one piece of state against named questions, all answered in a single pass. Use it to gate an action — whether to send, escalate, or diff --git a/configs/platform/system/connectors/outlook/connector.yml b/configs/platform/system/connectors/outlook/connector.yml index 4d0dfb82ec..f7a22f75ff 100644 --- a/configs/platform/system/connectors/outlook/connector.yml +++ b/configs/platform/system/connectors/outlook/connector.yml @@ -26,6 +26,12 @@ auth: - User.Read actions: - name: list_messages + title: List messages + i18n: + de: + title: Nachrichten auflisten + fr: + title: Lister les messages description: List messages from the mailbox, newest first. Draft messages are excluded. effects: read input: @@ -164,6 +170,12 @@ actions: return { messages, nextLink: j['@odata.nextLink'] || '' }; exampleInput: { top: 25 } - name: get_message + title: Get message + i18n: + de: + title: Nachricht abrufen + fr: + title: Récupérer un message description: Fetch a single email message by ID, optionally downloading and storing its file attachments. effects: read input: @@ -228,6 +240,12 @@ actions: return { message, attachments }; exampleInput: { messageId: 'AAMkAGI2THk...', includeAttachments: true } - name: get_attachments + title: Download attachments + i18n: + de: + title: Anhänge herunterladen + fr: + title: Télécharger les pièces jointes description: Download every file attachment on a message and store each one. Returns a fileId and URL per attachment. effects: read input: @@ -277,6 +295,12 @@ actions: return { attachments }; exampleInput: { messageId: 'AAMkAGI2THk...' } - name: search_messages + title: Search messages + i18n: + de: + title: Nachrichten durchsuchen + fr: + title: Rechercher des messages description: Search the mailbox by keyword across subject, body, and sender. effects: read input: @@ -323,6 +347,12 @@ actions: return { messages: j.value || [], nextLink: j['@odata.nextLink'] || '' }; exampleInput: { query: 'invoice', top: 25 } - name: send_message + title: Send email + i18n: + de: + title: E-Mail senden + fr: + title: Envoyer un e-mail description: Send an email message, optionally with file attachments. The message is composed as a draft so its internet message ID can be returned, then sent. effects: write input: @@ -394,6 +424,12 @@ actions: body: 'This is a test message.', } - name: get_profile + title: Get mailbox address + i18n: + de: + title: Adresse des Postfachs abrufen + fr: + title: Récupérer l’adresse de la boîte mail description: The address of the connected mailbox (the signed-in account's mail address, else its principal name), as the mail sync reads it to tell the mailbox's own mail from a customer's. effects: read input: @@ -412,6 +448,12 @@ actions: return { emailAddress: String(j.mail || j.userPrincipalName || '') }; exampleInput: {} - name: list_events + title: List events + i18n: + de: + title: Termine auflisten + fr: + title: Lister les événements description: List calendar events, earliest start first, optionally within a date range. effects: read input: @@ -468,6 +510,12 @@ actions: endDateTime: '2025-06-30T23:59:59', } - name: get_event + title: Get event + i18n: + de: + title: Termin abrufen + fr: + title: Récupérer un événement description: Fetch a single calendar event by ID. effects: read input: @@ -503,6 +551,12 @@ actions: return { event: r.json() }; exampleInput: { eventId: 'AAMkAGI2TG93...' } - name: create_event + title: Create event + i18n: + de: + title: Termin erstellen + fr: + title: Créer un événement description: Create a calendar event, optionally with a location and attendees. effects: write input: @@ -580,6 +634,12 @@ actions: timeZone: 'Europe/Berlin', } - name: list_contacts + title: List contacts + i18n: + de: + title: Kontakte auflisten + fr: + title: Lister les contacts description: List contacts from the default contacts folder, ordered by display name. effects: read input: @@ -634,6 +694,12 @@ actions: return { contacts: j.value || [], nextLink: j['@odata.nextLink'] || '' }; exampleInput: { top: 25, search: 'Ada' } - name: get_contact + title: Get contact + i18n: + de: + title: Kontakt abrufen + fr: + title: Récupérer un contact description: Fetch a single contact by ID. effects: read input: diff --git a/configs/platform/system/connectors/sandbox/connector.yml b/configs/platform/system/connectors/sandbox/connector.yml index f1749ad3c4..62e5cd513f 100644 --- a/configs/platform/system/connectors/sandbox/connector.yml +++ b/configs/platform/system/connectors/sandbox/connector.yml @@ -6,6 +6,11 @@ # so there is no credential and nothing to connect. name: sandbox displayName: Sandbox scripts +i18n: + de: + displayName: Sandbox-Skripte + fr: + displayName: Scripts de sandbox description: >- Run a deterministic script from a skill bundle in the organization's sandbox: staged input folders in, harvested output files and a parsed @@ -16,6 +21,12 @@ auth: - method: platform actions: - name: run_script + title: Run script + i18n: + de: + title: Skript ausführen + fr: + title: Exécuter un script description: >- Run one script that ships inside an org skill bundle, inside the automation run's sandbox session. The bundle is staged at diff --git a/configs/platform/system/connectors/shopify/connector.yml b/configs/platform/system/connectors/shopify/connector.yml index 89f0af053a..3fa892919a 100644 --- a/configs/platform/system/connectors/shopify/connector.yml +++ b/configs/platform/system/connectors/shopify/connector.yml @@ -17,6 +17,12 @@ auth: - method: api-key actions: - name: list_products + title: List products + i18n: + de: + title: Produkte auflisten + fr: + title: Lister les produits description: List products in the store, newest first, with cursor pagination. effects: read input: @@ -80,6 +86,12 @@ actions: return { products: r.json().products || [], nextPageInfo: m ? m[1] : '' }; exampleInput: { limit: 50, status: 'active' } - name: get_product + title: Get product + i18n: + de: + title: Produkt abrufen + fr: + title: Récupérer un produit description: Get a single product by ID, including its variants and images. effects: read input: @@ -114,6 +126,12 @@ actions: return { product: r.json().product }; exampleInput: { resourceId: '8123456789012' } - name: list_customers + title: List customers + i18n: + de: + title: Kunden auflisten + fr: + title: Lister les clients description: List customers of the store with cursor pagination. effects: read input: @@ -172,6 +190,12 @@ actions: return { customers: r.json().customers || [], nextPageInfo: m ? m[1] : '' }; exampleInput: { limit: 50 } - name: get_customer + title: Get customer + i18n: + de: + title: Kunden abrufen + fr: + title: Récupérer un client description: Get a single customer by ID, including contact details and order totals. effects: read input: @@ -207,6 +231,12 @@ actions: return { customer: r.json().customer }; exampleInput: { resourceId: '7123456789012' } - name: list_orders + title: List orders + i18n: + de: + title: Bestellungen auflisten + fr: + title: Lister les commandes description: List orders in the store with cursor pagination. effects: read input: @@ -270,6 +300,12 @@ actions: return { orders: r.json().orders || [], nextPageInfo: m ? m[1] : '' }; exampleInput: { limit: 50, status: 'any' } - name: get_order + title: Get order + i18n: + de: + title: Bestellung abrufen + fr: + title: Récupérer une commande description: Get a single order by ID, including its line items and totals. effects: read input: @@ -305,6 +341,12 @@ actions: return { order: r.json().order }; exampleInput: { resourceId: '5123456789012' } - name: count_products + title: Count products + i18n: + de: + title: Produkte zählen + fr: + title: Compter les produits description: Get the total number of products in the store. effects: read input: @@ -324,6 +366,12 @@ actions: if (!r.status || r.status >= 400) throw new Error(`Shopify count_products failed (${r.status}): ${r.text()}`); return { count: r.json().count || 0 }; - name: count_customers + title: Count customers + i18n: + de: + title: Kunden zählen + fr: + title: Compter les clients description: Get the total number of customers in the store. effects: read input: @@ -343,6 +391,12 @@ actions: if (!r.status || r.status >= 400) throw new Error(`Shopify count_customers failed (${r.status}): ${r.text()}`); return { count: r.json().count || 0 }; - name: count_orders + title: Count orders + i18n: + de: + title: Bestellungen zählen + fr: + title: Compter les commandes description: Get the total number of orders in the store. effects: read input: diff --git a/configs/platform/system/connectors/slack/connector.yml b/configs/platform/system/connectors/slack/connector.yml index 30fc7016ee..2ccda5d871 100644 --- a/configs/platform/system/connectors/slack/connector.yml +++ b/configs/platform/system/connectors/slack/connector.yml @@ -33,6 +33,12 @@ auth: - files:write actions: - name: list_channels + title: List channels + i18n: + de: + title: Channels auflisten + fr: + title: Lister les canaux description: List the conversations (channels, DMs, group messages) the bot can see. effects: read input: @@ -62,6 +68,12 @@ actions: return { channels: j.channels || [], next_cursor: (j.response_metadata && j.response_metadata.next_cursor) || null }; exampleInput: { types: 'public_channel', limit: 100 } - name: get_channel + title: Get channel + i18n: + de: + title: Channel abrufen + fr: + title: Récupérer un canal description: Get the details of one channel. effects: read input: @@ -84,6 +96,12 @@ actions: return { channel: j.channel }; exampleInput: { channel: 'C01234ABCDE' } - name: list_messages + title: List messages + i18n: + de: + title: Nachrichten auflisten + fr: + title: Lister les messages description: Fetch the message history of a channel. effects: read input: @@ -122,6 +140,12 @@ actions: return { messages: j.messages || [], has_more: !!j.has_more, next_cursor: (j.response_metadata && j.response_metadata.next_cursor) || null }; exampleInput: { channel: 'C01234ABCDE', limit: 50 } - name: send_message + title: Send message + i18n: + de: + title: Nachricht senden + fr: + title: Envoyer un message description: Send a message to a Slack channel or thread. effects: write input: @@ -160,6 +184,12 @@ actions: return { channel: j.channel, ts: j.ts }; exampleInput: { channel: 'C01234ABCDE', text: 'Deploy finished.' } - name: list_users + title: List workspace members + i18n: + de: + title: Workspace-Mitglieder auflisten + fr: + title: Lister les membres de l’espace de travail description: List the members of the Slack workspace. effects: read input: @@ -186,6 +216,12 @@ actions: return { users: j.members || [], next_cursor: (j.response_metadata && j.response_metadata.next_cursor) || null }; exampleInput: { limit: 100 } - name: get_user + title: Get user + i18n: + de: + title: Benutzer abrufen + fr: + title: Récupérer un utilisateur description: Get the profile of one workspace user. effects: read input: @@ -208,6 +244,12 @@ actions: return { user: j.user }; exampleInput: { user: 'U01234ABCDE' } - name: upload_file + title: Upload file + i18n: + de: + title: Datei hochladen + fr: + title: Téléverser un fichier description: Upload a text file and share it to a Slack channel. effects: write input: diff --git a/configs/platform/system/connectors/task/connector.yml b/configs/platform/system/connectors/task/connector.yml index d454ef4a13..61a02604cf 100644 --- a/configs/platform/system/connectors/task/connector.yml +++ b/configs/platform/system/connectors/task/connector.yml @@ -5,6 +5,11 @@ # connect. name: task displayName: Tasks +i18n: + de: + displayName: Aufgaben + fr: + displayName: Tâches description: >- Read a task, move its status, write or read its discussion comments, put its project agent to work, and keep a scheduled issue import's position @@ -15,6 +20,12 @@ auth: - method: platform actions: - name: get + title: Get task + i18n: + de: + title: Aufgabe abrufen + fr: + title: Récupérer une tâche description: >- Read one task's title, status, project, external linkage, and description. description is the text as stored, each mention a link @@ -43,6 +54,12 @@ actions: impl: task.get exampleInput: { taskId: 'tsk_1' } - name: update_status + title: Change task status + i18n: + de: + title: Aufgabenstatus ändern + fr: + title: Changer le statut de la tâche description: >- Move a task to a new status. The automation acts as the workflow actor. Completing is not on offer — done stays reserved for the human review @@ -67,6 +84,12 @@ actions: impl: task.update_status exampleInput: { taskId: 'tsk_1', status: 'in_progress' } - name: comment + title: Comment on task + i18n: + de: + title: Aufgabe kommentieren + fr: + title: Commenter la tâche description: Post a comment on the task's discussion timeline. effects: write input: @@ -95,6 +118,12 @@ actions: impl: task.comment exampleInput: { taskId: 'tsk_1', body: 'Prepared the return.' } - name: list_comments + title: List task comments + i18n: + de: + title: Kommentare der Aufgabe auflisten + fr: + title: Lister les commentaires de la tâche description: >- Read the task's discussion comments, optionally only those newer than the last comment containing a marker (a delivery anchor) and only from @@ -130,6 +159,12 @@ actions: impl: task.list_comments exampleInput: { taskId: 'tsk_1', authorTypes: [user], limit: 20 } - name: start_agent + title: Start project agent + i18n: + de: + title: Projektagenten starten + fr: + title: Lancer l’agent de projet description: >- Put the task's project agent to work, or first assign it to the agent named by agentId (an agent of the same project), and answer the run it @@ -187,6 +222,12 @@ actions: impl: task.start_agent exampleInput: { taskId: 'tsk_1', feedback: 'Scheduled occurrence 2026-09-30 09:00 Europe/Zurich.' } - name: upsert + title: Import issue as task + i18n: + de: + title: Issue als Aufgabe importieren + fr: + title: Importer une issue comme tâche description: Import an external issue into a project's backlog, reusing its existing task on repeat imports. Keeps local status, assignee, and non-empty description. A title over 200 or a description over 20,000 UTF-16 code units is cut to that length, ending in "…"; a blank title is refused. Returns the title the task carries. Does not start an agent. effects: write input: @@ -219,6 +260,12 @@ actions: impl: task.upsert exampleInput: { projectId: 'proj_mock', externalSystem: github, externalId: 'octocat/demo#1', title: Example issue } - name: list_external_issues + title: List imported issues + i18n: + de: + title: Importierte Issues auflisten + fr: + title: Lister les issues importées description: List previously imported issue sources for this project and immutable upstream repository/project. Oldest snapshots come first so repeated runs make progress through large boards. effects: read input: @@ -241,6 +288,12 @@ actions: impl: task.list_external_issues exampleInput: { projectId: proj_mock, externalSystem: github, repositoryId: 1 } - name: get_import_cursor + title: Get import position + i18n: + de: + title: Importposition abrufen + fr: + title: Récupérer la position de l’import description: >- Where a scheduled issue import of this project's source resumes: the cursor its last saved batch left, so each occurrence imports one bounded @@ -273,6 +326,12 @@ actions: impl: task.get_import_cursor exampleInput: { projectId: proj_mock, externalSystem: github, source: example/web } - name: save_import_cursor + title: Save import position + i18n: + de: + title: Importposition speichern + fr: + title: Enregistrer la position de l’import description: >- Record how far a scheduled issue import got: revision is the one get_import_cursor answered for this batch, and next the importer's @@ -301,6 +360,12 @@ actions: impl: task.save_import_cursor exampleInput: { projectId: proj_mock, externalSystem: github, source: example/web, revision: '1', next: '' } - name: upsert_issues + title: Import issues as tasks + i18n: + de: + title: Issues als Aufgaben importieren + fr: + title: Importer des issues comme tâches description: Atomically import or refresh a batch of issue snapshots. New tasks require an open source; repeat sync keeps Tale edits and status. Titles and descriptions are cut to 200 and 20,000 UTF-16 code units, ending in "…"; a blank title refuses the batch, naming the item. Returns one result per source with the title its task carries, and taskId null when a closed source has no task. effects: write input: diff --git a/configs/platform/system/connectors/tavily/connector.yml b/configs/platform/system/connectors/tavily/connector.yml index f9de82434b..500a6e77f5 100644 --- a/configs/platform/system/connectors/tavily/connector.yml +++ b/configs/platform/system/connectors/tavily/connector.yml @@ -13,6 +13,12 @@ auth: - method: api-key actions: - name: search + title: Search the web + i18n: + de: + title: Im Web suchen + fr: + title: Rechercher sur le web description: >- Search the open web via Tavily. Returns top results with title, URL, content snippet, and score. Use 'basic' depth for quick facts, @@ -84,6 +90,12 @@ actions: search_depth: 'advanced', } - name: extract + title: Extract page text + i18n: + de: + title: Seitentext extrahieren + fr: + title: Extraire le texte des pages description: >- Fetch cleaned main-article text for one or more URLs. Use for deep reads of the most promising search results. diff --git a/configs/platform/system/connectors/teams/connector.yml b/configs/platform/system/connectors/teams/connector.yml index 09db565d3f..cb294753ae 100644 --- a/configs/platform/system/connectors/teams/connector.yml +++ b/configs/platform/system/connectors/teams/connector.yml @@ -26,6 +26,12 @@ auth: - Chat.ReadWrite actions: - name: list_teams + title: List teams + i18n: + de: + title: Teams auflisten + fr: + title: Lister les équipes description: List the teams the authenticated user has joined. effects: read input: @@ -43,6 +49,12 @@ actions: return { teams: j.value || [], nextLink: j['@odata.nextLink'] || null }; exampleInput: {} - name: get_team + title: Get team + i18n: + de: + title: Team abrufen + fr: + title: Récupérer une équipe description: Get a single team by ID. effects: read input: @@ -63,6 +75,12 @@ actions: return { team: r.json() }; exampleInput: { teamId: '19:team-demo@thread.tacv2' } - name: list_channels + title: List channels + i18n: + de: + title: Kanäle auflisten + fr: + title: Lister les canaux description: List all channels in a team. effects: read input: @@ -84,6 +102,12 @@ actions: return { channels: j.value || [], nextLink: j['@odata.nextLink'] || null }; exampleInput: { teamId: '19:team-demo@thread.tacv2' } - name: get_channel + title: Get channel + i18n: + de: + title: Kanal abrufen + fr: + title: Récupérer un canal description: Get a single channel by ID. effects: read input: @@ -111,6 +135,12 @@ actions: channelId: '19:channel-general@thread.tacv2', } - name: list_messages + title: List channel messages + i18n: + de: + title: Kanalnachrichten auflisten + fr: + title: Lister les messages d’un canal description: Fetch messages from a channel. effects: read input: @@ -147,6 +177,12 @@ actions: top: 20, } - name: send_message + title: Send channel message + i18n: + de: + title: Nachricht im Kanal senden + fr: + title: Envoyer un message dans un canal description: Send a message to a channel. effects: write input: @@ -183,6 +219,12 @@ actions: content: 'Deploy finished.', } - name: list_members + title: List team members + i18n: + de: + title: Teammitglieder auflisten + fr: + title: Lister les membres de l’équipe description: List the members of a team. effects: read input: @@ -212,6 +254,12 @@ actions: return { members: j.value || [], nextLink: j['@odata.nextLink'] || null }; exampleInput: { teamId: '19:team-demo@thread.tacv2', top: 100 } - name: list_chats + title: List chats + i18n: + de: + title: Chats auflisten + fr: + title: Lister les conversations description: List the chats the authenticated user is part of. effects: read input: @@ -233,6 +281,12 @@ actions: return { chats: j.value || [], nextLink: j['@odata.nextLink'] || null }; exampleInput: { top: 20 } - name: send_chat_message + title: Send chat message + i18n: + de: + title: Chatnachricht senden + fr: + title: Envoyer un message dans une conversation description: Send a message to a chat. effects: write input: diff --git a/configs/platform/system/connectors/twilio/connector.yml b/configs/platform/system/connectors/twilio/connector.yml index b33a1b5b72..9ac5f02deb 100644 --- a/configs/platform/system/connectors/twilio/connector.yml +++ b/configs/platform/system/connectors/twilio/connector.yml @@ -16,6 +16,12 @@ auth: - method: basic actions: - name: send_sms + title: Send SMS + i18n: + de: + title: SMS senden + fr: + title: Envoyer un SMS description: Send an SMS message via Twilio. effects: write input: @@ -52,6 +58,12 @@ actions: body: 'Your order has shipped.', } - name: list_messages + title: List SMS messages + i18n: + de: + title: SMS auflisten + fr: + title: Lister les SMS description: List SMS messages, optionally filtered by number or send date. effects: read input: @@ -96,6 +108,12 @@ actions: return { messages: j.messages || [], next_page_uri: j.next_page_uri || null }; exampleInput: { to: '+15551234567', pageSize: 20 } - name: get_message + title: Get SMS message + i18n: + de: + title: SMS abrufen + fr: + title: Récupérer un SMS description: Get one SMS message by its SID. effects: read input: @@ -118,6 +136,12 @@ actions: return { message: r.json() }; exampleInput: { messageSid: 'SM1234567890abcdef1234567890abcdef' } - name: list_calls + title: List calls + i18n: + de: + title: Anrufe auflisten + fr: + title: Lister les appels description: List voice calls, optionally filtered by number or status. effects: read input: @@ -161,6 +185,12 @@ actions: return { calls: j.calls || [], next_page_uri: j.next_page_uri || null }; exampleInput: { status: 'completed', pageSize: 20 } - name: make_call + title: Start call + i18n: + de: + title: Anruf starten + fr: + title: Lancer un appel description: Start an outbound voice call via Twilio. effects: write input: @@ -201,6 +231,12 @@ actions: url: 'https://example.com/twiml/greeting.xml', } - name: get_account + title: Get account + i18n: + de: + title: Konto abrufen + fr: + title: Récupérer le compte description: Get the Twilio account the credential belongs to. effects: read input: @@ -219,6 +255,12 @@ actions: return { account: r.json() }; exampleInput: {} - name: list_phone_numbers + title: List phone numbers + i18n: + de: + title: Telefonnummern auflisten + fr: + title: Lister les numéros de téléphone description: List the incoming phone numbers the Twilio account owns. effects: read input: diff --git a/configs/platform/system/connectors/webdav/connector.yml b/configs/platform/system/connectors/webdav/connector.yml index 8155f5cefa..0aa1c32fbd 100644 --- a/configs/platform/system/connectors/webdav/connector.yml +++ b/configs/platform/system/connectors/webdav/connector.yml @@ -5,6 +5,11 @@ # Basic auth carries a WebDAV app password. name: webdav displayName: WebDAV Files +i18n: + de: + displayName: WebDAV-Dateien + fr: + displayName: Fichiers WebDAV description: >- Read, write, and list files in the organization's WebDAV store — the same files the /dav endpoint serves. @@ -14,6 +19,12 @@ auth: - method: basic actions: - name: list + title: List folder contents + i18n: + de: + title: Ordnerinhalt auflisten + fr: + title: Lister le contenu d’un dossier description: List the entries directly under a folder path. effects: read input: @@ -36,6 +47,12 @@ actions: impl: webdav.list exampleInput: { path: '/reports' } - name: read + title: Read file + i18n: + de: + title: Datei lesen + fr: + title: Lire un fichier description: Read a file's contents. effects: read input: @@ -51,6 +68,12 @@ actions: impl: webdav.read exampleInput: { path: '/reports/q3.md' } - name: write + title: Write file + i18n: + de: + title: Datei schreiben + fr: + title: Écrire un fichier description: Create or overwrite a file with the given contents. effects: write input: @@ -69,6 +92,12 @@ actions: impl: webdav.write exampleInput: { path: '/reports/summary.md', content: '# Summary' } - name: delete + title: Delete file or folder + i18n: + de: + title: Datei oder Ordner löschen + fr: + title: Supprimer un fichier ou un dossier description: Delete a file or folder. effects: write input: diff --git a/design/docs/app.md b/design/docs/app.md index f48d5e77a8..dbc4f7ddc1 100644 --- a/design/docs/app.md +++ b/design/docs/app.md @@ -127,6 +127,16 @@ what is there, and the page shows the one thing you opened. "improve with AI" rewrite. Specs: `design-system.md` → _Conversations_. - **Knowledge** — its pages (Documents/Knowledge entries/Websites/Products/Contacts) as a tab strip under the header, each a `DataTable`. Specs: `design-system.md` → _Knowledge_. +- **Automation canvas** — the layout engine places every box, nobody does: **Start** on top (what + starts a run, what it receives), **End** at the bottom (what it returns, how a run ends), each node + below the nodes it reads. A condition is a pill in words above its node, splitting into **Yes** + (left) and **No** (right) when the node has an alternative; a frame means for-each or repeat; a + solid line reads output, a dashed one only orders, a dotted one ends the run; a dashed box may not + run. One Tab stop with arrow keys, a List view as the text alternative, the Paths list to light up + a path. Build it from data with `@tale/ui/flow/workflow-canvas`; never draw a node by hand. Guides: + [`workflow-canvas.md`](../../services/ui-docs/content/components/workflow-canvas.md), + [`workflow-paths.md`](../../services/ui-docs/content/components/workflow-paths.md), + [`workflow-playback.md`](../../services/ui-docs/content/components/workflow-playback.md). - **Auth, settings, automations, agents, onboarding** — each has a `.pen` under `design/sources/platform/`. ## Interaction conventions (hold these everywhere) diff --git a/design/sources/platform/design-system.md b/design/sources/platform/design-system.md index fbc0159217..efbf18a3c3 100644 --- a/design/sources/platform/design-system.md +++ b/design/sources/platform/design-system.md @@ -700,7 +700,11 @@ Used in AI chat responses for section hierarchy within rich text output. **Code body** (`ny4z0`): - Vertical layout, `padding: 16`, `gap: 2`, fill `#F9FAFB` -- Code lines: JetBrains Mono 13px, `#111827`, `lineHeight: 1.6` +- Code lines: JetBrains Mono 13px, `lineHeight: 1.6`, coloured by the `--code-*` palette in + `packages/ui/src/globals.css`: `--code-foreground` for plain text and one `--code-token-*` per + syntax role (keyword, string, template expression, constant, function, parameter, comment, + punctuation). The palette is AA on the code-block surface in both themes, and every read-only + code block and the code editor share it — never colour code with hex values. **Dark mode overrides:** @@ -708,7 +712,7 @@ Used in AI chat responses for section hierarchy within rich text output. - Header bottom border: `#374151` - Language label + copy button: `#9CA3AF` - Code body fill: `#111827` -- Code lines: `#E5E7EB` +- Code lines: the dark values of the same `--code-*` variables **Behavior:** diff --git a/docs/de/develop/connectors.md b/docs/de/develop/connectors.md index cd3c789ef9..498af61aeb 100644 --- a/docs/de/develop/connectors.md +++ b/docs/de/develop/connectors.md @@ -25,6 +25,8 @@ auth: - method: api-key ``` +Ein Connector, dessen Name aus gewöhnlichen Wörtern besteht, etwa `task` (Tasks), deklariert zusätzlich `i18n.de.displayName` und `i18n.fr.displayName`. Ein Connector, der nach einem Produkt heißt, etwa Tavily, behält seinen Namen in jeder Sprache. + ### Erlaubte Ziele festlegen | Feld | Bedeutung | @@ -47,6 +49,7 @@ Ein neuer Connector ist ein Quellcodebeitrag. Die Laufzeit liest den Plattformka | Feld | Vertrag für Autor und Aufrufer | | --- | --- | | `name`, `description` | Stabiler Aktionsname in snake_case und eine Erklärung zum Einsatzzweck | +| `title`, `i18n` | Die Aktion in Worten für Menschen: ein kurzer englischer `title`, nur am Anfang großgeschrieben und ohne den Namen des Connectors („List issues“), dazu `i18n.de.title` und `i18n.fr.title` (und `de-CH`, wo die Schweizer Schreibung abweicht, etwa beim ß). Der Automatisierungs-Canvas zeigt eine Node als „GitHub · Issues auflisten“ in der Sprache der Lesenden; fehlt einer mitgelieferten Aktion einer der drei, schlagen die Tests des Katalogs fehl | | `input` | Objekt-JSON-Schema, vor der Ausführung validiert; Felder beschreiben und Pflichtfelder markieren | | `output` | Ergebnissignatur im TypeScript-Stil; Dokumentation, keine Laufzeitvalidierung der Ausgabe | | `effects` | `read` oder `write`; Schreibaktionen durchlaufen die Genehmigungsrichtlinie | diff --git a/docs/de/platform/automations/approvals-in-workflows.md b/docs/de/platform/automations/approvals-in-workflows.md index 390fb8f46c..a9927e2560 100644 --- a/docs/de/platform/automations/approvals-in-workflows.md +++ b/docs/de/platform/automations/approvals-in-workflows.md @@ -3,7 +3,7 @@ title: Auf einen wartenden Workflow reagieren description: Finde einen pausierten Automationslauf, prüfe einen geplanten Schreibzugriff oder beantworte eine Rückfrage des Agenten. --- -Ein Lauf kann auf eine Entscheidung vor einem Connector-Schreibzugriff warten oder auf Informationen, die ein Agent zum Fortfahren braucht. In den Laufdetails siehst du, welche Antwort nötig ist. Ein wartender Lauf ist noch nicht abgeschlossen, auch wenn vorherige Knoten erfolgreich waren. +Ein Lauf kann auf eine Entscheidung vor einem Connector-Schreibzugriff warten oder auf Informationen, die ein Agent zum Fortfahren braucht. In den Laufdetails siehst du, welche Antwort nötig ist. Ein wartender Lauf ist noch nicht abgeschlossen, auch wenn vorherige Nodes erfolgreich waren. ## Den wartenden Lauf finden @@ -15,19 +15,19 @@ Eine Freigabekarte nennt eine Connector-Aktion und zeigt ihre geplanten Eingaben Lies die Aktion und die Angaben unter **Der Schritt würde aufrufen mit** genau. Prüfe Empfänger oder Ziel, den Inhalt und alle Kennungen, die bestimmen, was geändert wird. -Wähle **Freigeben**, um die Aktion zu erlauben. Der Lauf wird fortgesetzt und versucht den Schreibzugriff; kontrolliere danach Knotenergebnis und Auswirkungen. Wähle **Ablehnen**, wenn die Anfrage falsch ist oder nicht ausgeführt werden soll. Die Ablehnung verhindert diese Aktion und lässt den Lauf fehlschlagen. +Wähle **Freigeben**, um die Aktion zu erlauben. Der Lauf wird fortgesetzt und versucht den Schreibzugriff; kontrolliere danach das Ergebnis der Node und die Auswirkungen. Wähle **Ablehnen**, wenn die Anfrage falsch ist oder nicht ausgeführt werden soll. Die Ablehnung verhindert diese Aktion und lässt den Lauf fehlschlagen. Auch wenn etwas schiefgeht, zeigt die Karte, wo der Lauf steht. Lässt sich die Freigabe nicht laden, sagt die Karte das und bietet **Erneut versuchen** an; der Lauf wartet weiter, bis jemand entscheidet. Wurde deine Entscheidung nicht gespeichert, meldet die Karte das und behält **Freigeben** und **Ablehnen**, damit du noch einmal wählen kannst. Hat jemand anderes zuerst entschieden, zeigt die Karte die gespeicherte Entscheidung. -Ein Live-Lauf prüft vor der Freigabeanfrage, ob der Connector ein nutzbares Credential hat: Ist keines konfiguriert, schlägt der Knoten mit diesem Grund fehl, statt auf eine Entscheidung zu warten. +Ein Live-Lauf prüft vor der Freigabeanfrage, ob der Connector ein nutzbares Credential hat: Ist keines konfiguriert, schlägt die Node mit diesem Grund fehl, statt auf eine Entscheidung zu warten. Auf der Freigabekarte kannst du keine Parameter ändern. Lehne eine falsche Anfrage ab, korrigiere den Workflow oder seine Eingaben und teste die Änderung vor einem neuen Live-Lauf. Änderungen an der Freigaberichtlinie geben eine bereits offene Karte nicht frei. [Freigabekonzepte](/de/platform/approvals/concepts) erklärt den Ablauf; die [Konfiguration der Freigaberichtlinie](/de/self-hosted/configuration/approvals) beschreibt die Regeln für den Betrieb. ## Eine Rückfrage des Agenten beantworten -Nutzt ein Agent-Knoten `ask_human`, zeigen die Laufdetails **Der Agent braucht deine Antwort, um weiterzumachen**. Beantworte vorgegebene Auswahlfragen direkt auf der Karte. Bei einer offenen Frage schreibst du unter **Deine Antwort** einen Text und klickst auf **Antwort senden & fortsetzen**. +Nutzt eine Agent-Node `ask_human`, zeigen die Laufdetails **Der Agent braucht deine Antwort, um weiterzumachen**. Beantworte vorgegebene Auswahlfragen direkt auf der Karte. Bei einer offenen Frage schreibst du unter **Deine Antwort** einen Text und klickst auf **Antwort senden & fortsetzen**. -Gib die fehlende Information möglichst konkret an. Fragt der Agent nach einem Dokument, nenne das Dokument oder seine Kennung, statt ihn nur zum Fortfahren aufzufordern. Der wartende Knoten wird mit deiner Antwort fortgesetzt. Später kann der Lauf eine weitere Antwort oder eine Freigabe benötigen. Inhaber, Admins und Entwickler antworten in den Laufdetails. Bearbeitet der Lauf eine Aufgabe, erscheint die Rückfrage auch in der Aufgabe, und dort antwortet jede Person, die die Aufgabe öffnen kann. +Gib die fehlende Information möglichst konkret an. Fragt der Agent nach einem Dokument, nenne das Dokument oder seine Kennung, statt ihn nur zum Fortfahren aufzufordern. Die wartende Node wird mit deiner Antwort fortgesetzt. Später kann der Lauf eine weitere Antwort oder eine Freigabe benötigen. Inhaber, Admins und Entwickler antworten in den Laufdetails. Bearbeitet der Lauf eine Aufgabe, erscheint die Rückfrage auch in der Aufgabe, und dort antwortet jede Person, die die Aufgabe öffnen kann. ## Den Workflow korrigieren und testen @@ -35,7 +35,7 @@ Eine Workflow-Definition zu ändern ist ein anderer Vorgang als auf ihren laufen -![Der Workflow-Editor zeigt den Automationsgraphen und einen Bereich zum Konfigurieren des ausgewählten Knotens.](/images/platform/automation-editor-canvas.webp) +![Der Workflow-Editor zeigt den Graphen der Automatisierung und einen Bereich zum Konfigurieren der ausgewählten Node.](/images/platform/automation-editor-canvas.webp) diff --git a/docs/de/platform/automations/assistant.md b/docs/de/platform/automations/assistant.md index 9a598e9524..1427d49f56 100644 --- a/docs/de/platform/automations/assistant.md +++ b/docs/de/platform/automations/assistant.md @@ -3,25 +3,25 @@ title: Den Weg zur Automatisierung wählen description: Ändere Workflows direkt im visuellen Editor oder lass deinen Coding-Agent Automatisierungen über den MCP-Endpoint von Tale bearbeiten. --- -Bearbeite eine Automatisierung direkt auf ihrer Arbeitsfläche oder lass deinen Coding-Agent sie über den MCP-Endpoint von Tale bearbeiten. Einen KI-Assistenten im Editor gibt es nicht: Mit KI arbeitest du an Automatisierungen über einen Coding-Agent wie Claude Code oder Codex, den du mit deinem API-Schlüssel verbindest. Beide Wege speichern Versionen desselben Workflows und nutzen dieselben Prüf- und Bereitstellungsregeln. Automatisierungen erstellen und live schalten dürfen Inhaber, Admins und Entwickler. +Ändere die Felder einer Automatisierung direkt im Editor oder lass deinen Coding-Agent sie über den MCP-Endpoint von Tale erstellen und ändern. Einen KI-Assistenten im Editor gibt es nicht: Mit KI arbeitest du an Automatisierungen über einen Coding-Agent wie Claude Code oder Codex, den du mit deinem API-Schlüssel verbindest. Beide Wege speichern Versionen desselben Workflows und nutzen dieselben Prüf- und Bereitstellungsregeln. Automatisierungen erstellen und live schalten dürfen Inhaber, Admins und Entwickler. -## Eine Änderung im visuellen Editor vornehmen +## Eine Änderung im Editor vornehmen -Öffne **Automatisierungen** und wähle den Workflow. Klicke auf einen Knoten, um Eingaben, Modell, Code oder andere Einstellungen zu prüfen. Speichere die Änderung mit einer Versionsnachricht, führe einen Test aus und stelle nach bestandenen Prüfungen die gewünschte Version bereit. +Öffne **Automatisierungen** und wähle den Workflow. Wähle eine Node, um Eingabe, Modell, Code oder andere Einstellungen zu prüfen, und ändere das Feld, das du brauchst. Speichere die Änderung mit einer Versionsnachricht, führe einen Test aus und stelle nach bestandenen Prüfungen die gewünschte Version bereit. Der Canvas ordnet sich anhand der Referenzen zwischen den Nodes selbst an; du fügst darin keine Nodes hinzu und zeichnest keine Verbindungen. - + -![Der Editor für Automatisierungen zeigt den Workflow-Graphen und die Eingabefelder des ausgewählten Knotens in einer Seitenleiste.](/images/platform/automation-editor-canvas.webp) +![Der Automatisierungs-Editor zeigt den Workflow zwischen Start und Ende und die Felder der ausgewählten Node in einer Seitenleiste.](/images/platform/automation-editor-canvas.webp) -[Der Workflow-Editor](/de/platform/automations/editor) erklärt diese Schritte, die Prüfung eines Laufs und die Rückkehr zu einer früheren Version. Die Arbeitsfläche enthält keinen Chat-Assistenten. +[Der Workflow-Editor](/de/platform/automations/editor) erklärt diese Schritte, das Lesen des Canvas, die Prüfung eines Laufs und die Rückkehr zu einer früheren Version. Der Canvas enthält keinen Chat-Assistenten. -## Einen externen Assistenten über MCP nutzen +## Mit deinem Coding-Agent ändern -Verbinde deinen Coding-Agent mit dem Endpoint unter **Einstellungen > API > MCP** und deinem persönlichen API-Schlüssel; fertige Konfigurationen findest du unter [Tale aus deinem Editor oder einem Skript nutzen](/de/develop/use-tale-from-your-editor). Sag ihm, was die Automatisierung entgegennehmen und liefern soll und welche Systeme sie verändern darf. Lass ihn die vorhandenen Automatisierungen und das, was deine Organisation hat, ansehen, bevor er eine weitere erstellt. +Der Weg dorthin ist **Mit deinem Coding-Agent bearbeiten**, die letzte Schaltfläche oben rechts im Canvas des Editors. Ihr Dialog zeigt den Namen der Automatisierung, den du dem Agent gibst, **MCP einrichten**, das **Einstellungen > API > MCP** öffnet, und einen Link zur Anleitung, mit der du einen Coding-Agent verbindest. Verbinde deinen Coding-Agent mit dem Endpoint unter **Einstellungen > API > MCP** und deinem persönlichen API-Schlüssel; fertige Konfigurationen findest du unter [Tale aus deinem Editor oder einem Skript nutzen](/de/develop/use-tale-from-your-editor). Sag ihm, was die Automatisierung entgegennehmen und liefern soll und welche Systeme sie verändern darf. Lass ihn die vorhandenen Automatisierungen und das, was deine Organisation hat, ansehen, bevor er eine weitere erstellt. -Dein Agent arbeitet an einer Automatisierung so, wie du es im Editor tust. Er liest die Referenz und die aktuelle Version, validiert seine Änderung, führt sie mit den Mocks aus und lässt die Tests der Automatisierung laufen. Dann speichert er eine neue Version und nennt dabei die Version, von der er ausgegangen ist. Hat inzwischen jemand eine neuere Version gespeichert, lehnt Tale das Speichern ab; der Agent liest dann diese Version und führt seine Änderung zuerst zusammen. Startet er eine gespeicherte Version mit den Mocks, erscheint der Lauf im Tab **Läufe** der Automatisierung als **Test**-Lauf mit **Von dir gestartet (API)**, sodass du öffnen kannst, was er ausgeführt hat. +Dein Agent arbeitet an einer Automatisierung so, wie du es im Editor tust. Er liest die Referenz und die aktuelle Version, validiert seine Änderung, führt sie mit den Mocks aus und lässt die Tests der Automatisierung laufen. Dann speichert er eine neue Version und nennt dabei die Version, von der er ausgegangen ist. Hat inzwischen jemand eine neuere Version gespeichert, lehnt Tale das Speichern ab; der Agent liest dann diese Version und führt seine Änderung zuerst zusammen. Startet er eine gespeicherte Version mit den Mocks, erscheint der Lauf im Tab **Läufe** der Automatisierung als **Test**-Lauf mit **Von dir gestartet (API)**, sodass du öffnen kannst, was er ausgeführt hat. Eine Version, die der Agent speichert, erscheint im Canvas, während du die Automatisierung ansiehst. Speichern erstellt eine Version, schaltet sie aber nicht live. Bevor dein Agent live schaltet, löscht, einen Trigger setzt oder eine Automatisierung in Projekten installiert, fragt dich ein Client wie Claude Code, der Tales Kennzeichnung dieser Tools beachtet, um dein Ja – selbst wenn du den Agent andere Tools ohne Rückfrage ausführen lässt. Prüfe die Version und ihre Testergebnisse, bevor du zustimmst. Jede Änderung deines Agents steht im [Audit-Log](/de/platform/admin/governance/audit-logs) mit **Quelle** Coding-Agent, und der [MCP-Endpoint](/de/develop/mcp-endpoint) listet jedes Tool, das er nutzen kann. diff --git a/docs/de/platform/automations/concepts.md b/docs/de/platform/automations/concepts.md index dee499a35a..588ae16976 100644 --- a/docs/de/platform/automations/concepts.md +++ b/docs/de/platform/automations/concepts.md @@ -42,17 +42,17 @@ tests: input: { invoiceId: 'inv-1' } ``` -Der `ui`-Block speichert die Positionen auf dem Canvas. Verschieben ändert die Anordnung, nicht die Ausführung einer Node. +Tale ordnet den Canvas anhand der Verweise zwischen den Nodes an, niemand platziert eine Node von Hand. Ein `ui`-Block ist freie Metadaten: Tale behält ihn unverändert und ignoriert ihn. ### Kanten entstehen, sie werden nicht deklariert -Es gibt keine Kantenliste. Eine Node liest eine andere, indem sie sie referenziert — `{{ nodes.invoice.output.id }}` —, und genau diese Referenz _ist_ die Kante, die der Canvas zeichnet. Die Reihenfolge ergibt sich aus einer topologischen Sortierung über diese abgeleiteten Kanten. Deshalb verschwindet mit einer gelöschten Referenz auch ein Pfeil, und deshalb weist die Plattform zwei Nodes zurück, die einander lesen. +Es gibt keine Kantenliste. Eine Node liest eine andere, indem sie sie referenziert — `{{ nodes.invoice.output.id }}` —, und genau diese Referenz _ist_ die Kante, die der Canvas zeichnet. Die Reihenfolge ergibt sich aus einer topologischen Sortierung über diese abgeleiteten Kanten. Deshalb verschwindet mit einer gelöschten Referenz auch eine Linie, und deshalb weist die Plattform zwei Nodes zurück, die einander lesen. Templates nutzen eine einzige `{{ }}`-Grammatik aus JavaScript-Ausdrücken über `input`, `nodes..output` und, innerhalb einer iterierenden Node, `item` und `index`. ### Die Ablaufsteuerung sitzt an der Node -Verzweigen und Wiederholen sind Felder an einer Node statt eigener Schritttypen. Der Canvas zeigt sie deshalb als Badges an genau der Box, die sie betreffen. +Verzweigen und Wiederholen sind Felder an einer Node statt eigener Schritttypen. Der Canvas zeichnet jedes davon dort, wo es wirkt: Aus `when` wird eine Bedingung über ihrer Node, eine Alternative per `elseOf` hängt als Zweig **Nein** an dieser Bedingung, `forEach` und `repeatUntil` setzen die Node in einen Rahmen, und `onError: continue` gibt ihr einen Chip. | Feld | Wirkung | | ---------------------------- | --------------------------------------------------------------------------------------- | @@ -80,6 +80,12 @@ Eine **strukturierte** Ausgabe hat benannte Felder, die du über `nodes..out Ein Werkzeug ohne Ausgabeschema liefert unstrukturierte Ausgabe. Soll daraus strukturierte Eingabe für weitere Schritte entstehen, nutze eine `llm`-Node mit `outputSchema`. Die Validierung nennt bei einem Fehler die ungültige Referenz und die zulässigen Felder oder Kontexte. Korrigiere die Referenz, bevor du erneut speicherst. +## Pfade, die ein Lauf nehmen kann {#paths} + +Jede Bedingung und jede Node, die fehlschlagen darf, während der Lauf weitergeht, eröffnet einem Lauf zwei Möglichkeiten. Tale probiert jede Kombination davon aus und behält die unterschiedlichen Wege, die ein erfolgreicher Lauf nehmen kann; jeder davon ist ein Pfad. Ein Pfad nennt die Bedingungen, die über ihn entscheiden, etwa welche Nodes laufen, welche übersprungen werden und welche fehlschlagen, während der Lauf weitergeht, und die Nodes, die auf ihm laufen. Eine Node, die auf jedem Pfad läuft, läuft immer; eine Node, die auf keinem läuft, kann nie laufen, und Tale warnt davor. + +Tale führt bis zu 32 Pfade auf und zählt die übrigen. Bei mehr als 12 Bedingungen und hingenommenen Fehlern sind die Kombinationen zu viele zum Durchgehen, deshalb führt Tale dann keinen Pfad auf; es sagt aber weiterhin bei jeder Node, wann sie läuft. Außerdem nennt Tale die Nodes, deren Fehler den Lauf beendet, und was jede von ihnen fehlschlagen lassen kann. Der Editor zeigt die Pfade im Canvas, wie [Den möglichen Pfaden folgen](/de/platform/automations/editor#paths) beschreibt; ein Client des [MCP-Endpoints](/de/develop/mcp-endpoint) liest dieselben Pfade aus `analysis.paths`. + ## Was Tale vor einem Lauf prüft {#checks} Tale prüft das ganze Dokument, wenn du es speicherst, wenn du eine Version bereitstellst und wann immer ein Client `validate_automation` aufruft. Ein **Fehler** beschreibt etwas, das sicher scheitert, oder Code, der die Analysegrenzen überschreitet. Er verhindert Speichern wie Bereitstellen. Eine **Warnung** zeigt auf etwas, das scheitern kann oder nichts Nützliches tut. Sie verhindert weder Speichern noch Bereitstellen; du entscheidest selbst, ob du etwas änderst. Jedes Problem nennt seine Node und sein Feld und, in einem Template, einer Bedingung oder in Code, den genauen Ausdruck. diff --git a/docs/de/platform/automations/editor.md b/docs/de/platform/automations/editor.md index 4d35471157..68f8c8efa8 100644 --- a/docs/de/platform/automations/editor.md +++ b/docs/de/platform/automations/editor.md @@ -1,25 +1,25 @@ --- title: Der Workflow-Editor -description: Prüfe und ändere Knoten, gib Testdaten ein, speichere eine Version und schalte sie live oder kehre zu einer früheren zurück. +description: Lies eine Automatisierung im Canvas, folge ihren möglichen Pfaden, ändere Felder einer Node, speichere eine Version und schalte sie live oder kehre zu einer früheren zurück. --- -Im Workflow-Editor änderst du den Ablauf einer Automatisierung und wählst die gespeicherte Version für Live-Läufe. Änderungen brauchen Entwickler-, Admin- oder Inhaberrechte. Speichern, Testen und Bereitstellen sind getrennte Schritte: Die Arbeit an einem Entwurf lässt die bereitgestellte Version bestehen. +Im Workflow-Editor liest du, was eine Automatisierung tut, änderst ihre Felder und wählst die gespeicherte Version für Live-Läufe. Größere Änderungen, etwa neue Nodes, kommen von einem Coding-Agent über MCP. Änderungen brauchen Entwickler-, Admin- oder Inhaberrechte. Speichern, Testen und Bereitstellen sind getrennte Schritte: Die Arbeit an einem Entwurf lässt die bereitgestellte Version bestehen. Öffne **Automatisierungen** und wähle einen Eintrag. Er öffnet sich im Tab **Editor**. Öffnest du eine Automatisierung im Tab **Automatisierungen** eines Projekts, beginnt der Navigationspfad mit diesem Projekt: Wähle den Projektnamen, um zum Projekt zurückzukehren, oder **Automatisierungen**, um zu seinen Automatisierungen zurückzukehren. Ob du eine Automatisierung in einem Projekt oder in der Liste öffnest, die Navigationsleiste markiert **Automatisierungen**. Für einen neuen Ablauf beginne mit [Automatisierungen erstellen oder importieren](/de/platform/automations/catalog). | Tab | Wofür du ihn nutzt | | --- | --- | -| **Editor** | Den Workflow ändern, eine gespeicherte Version testen und die Live-Version wählen. | +| **Editor** | Den Workflow lesen und ändern, eine gespeicherte Version testen und die Live-Version wählen. | | **Allgemein** | Festlegen, was die Automatisierung startet und welche Projekte sie nutzen können. | | **Läufe** | Die letzten Ausführungen prüfen und den vollständigen Datensatz eines Laufs öffnen. | Die Auswahl **Version** bleibt auf Desktop und Smartphone rechts neben den Tabs Editor, Allgemein und Läufe. Sie zeigt Versionsnachrichten, Datum, Testergebnisse und die Live-Markierung. Wähle eine Zeile, um diese Version zu öffnen. Am Desktop stehen die Laufaktionen neben den Tabs, zusammen mit **Speichern** und **Verwerfen**; unter **Allgemein** stehen dort nur **Speichern** und **Verwerfen**. Ein Punkt an einem Tab kennzeichnet dessen ungespeicherte Änderungen. Beim Verlassen des Tabs oder einem Versionswechsel fragt Tale, wie du damit fortfahren möchtest. -Auf dem Smartphone startet eine geöffnete Automatisierung mit kompakter Navigation. Die Arbeitsfläche des Editors nutzt die verfügbare Höhe. Lauf- und Bereitstellungsaktionen befinden sich innerhalb der Arbeitsfläche neben den Zoom-Steuerelementen. Wenn du einen Knoten auswählst, öffnen sich seine Felder — mit Speichern und Verwerfen — in einem Bereich am unteren Bildschirmrand. +Auf dem Smartphone startet eine geöffnete Automatisierung mit kompakter Navigation. Der Canvas des Editors nutzt die verfügbare Höhe, und Lauf- und Bereitstellungsaktionen stehen in einer Leiste am unteren Rand des Canvas. Wenn du eine Node auswählst, öffnen sich ihre Felder — mit Speichern und Verwerfen — in einem Bereich am unteren Bildschirmrand. - + -![Der Workflow-Editor zeigt verbundene Knoten und die Felder des ausgewählten Knotens neben dem Canvas.](/images/platform/automation-editor-canvas.webp) +![Der Workflow-Editor zeigt die Nodes von Gmail triage inbox zwischen Start und Ende, eine in Worten formulierte Bedingung über einer Node und die Felder der ausgewählten Node neben dem Canvas.](/images/platform/automation-editor-canvas.webp) @@ -27,25 +27,113 @@ Zum Wechseln musst du nicht zur Liste zurück: Klick im Navigationspfad auf den ## Den Canvas lesen -Jeder Kasten ist ein Knoten. Seine Beschriftung nennt Schritt und Typ; **Liest** zeigt verwendete Ausgaben anderer Knoten. Pfeile entstehen aus Referenzen wie `{{ nodes.draft.output.text }}`. Ändere die Referenz, um eine Abhängigkeit zu ändern. Das Zeichnen eines Pfeils erstellt keine Abhängigkeit. +Tale zeichnet den Canvas aus dem Dokument der Automatisierung und ordnet ihn selbst an: **Start** steht oben, **Ende** unten, und jede Node steht unter den Nodes, die sie liest. So liest sich der Canvas von oben nach unten in der Reihenfolge, in der ein Lauf vorgeht. Niemand platziert einen Kasten, und Zeichnen verbindet nichts. Eine Linie entsteht aus einer Referenz wie `{{ nodes.draft.output.text }}`; um zu ändern, was eine Node liest, änderst du die Referenz. -Kennzeichnungen zeigen Bedingungen und Schleifen wie `when`, `else of`, `for each`, `repeat until` und `continue on error`. Eine Zykluswarnung bedeutet, dass mehrere Knoten voneinander abhängen. Entferne die kreisförmige Referenz, bevor du eine ausführbare Version speicherst. +### Start und Ende -## Einen Knoten bearbeiten +**Start** zeigt, was einen Lauf startet und was er erhält. Unter **Startet** steht der Trigger in Worten, etwa ein Zeitplan mit Zeitzone und nächstem Lauf, und ob der Trigger aus ist oder auf eine Live-Version wartet. Danach folgt **Von Hand, über die API oder MCP**, denn diese Starts sind immer möglich. Unter **Eingabe** stehen die Felder der Laufeingabe mit ihrem Typ und der Angabe, ob sie Pflicht sind. Würde der Trigger Läufe mit einer Eingabe starten, die die Automatisierung ablehnt, sagt Start das. -Wähle einen Kasten, um seine Felder zu öffnen. Auf einem breiten Bildschirm erscheint der Bereich neben dem Canvas; ohne ausgewählten Knoten nutzt der Canvas die ganze Breite. Auf schmaleren Bildschirmen öffnen sich die Felder in einem Dialog über dem Canvas. Ein `transform` hat **Code**, ein `llm` Felder für Prompt, Modell und Ausgabeschema. Ein `agent` ergänzt Agent-Laufzeit und Ausstattung. Die **Modell**-Auswahl einer `llm`- oder `agent`-Node listet die Modelle, die die verbundenen Anbieter deiner Organisation bedienen; ein nicht aufgeführtes Modell lässt sich eingeben, doch **Probleme** warnt dann, dass ein Live-Lauf an dieser Node fehlschlägt, bis sein Anbieter verbunden ist. **Eingabe** enthält JSON-Werte und Referenzen für diesen Knoten. Unvollständiges JSON wird gemeldet und ändert den Knoten nicht. +**Ende** zeigt, was ein erfolgreicher Lauf zurückgibt und wie ein Lauf enden kann. Unter **Gibt zurück** steht die Ausgabe, etwa **Die Ausgabe von Report**, oder ihre Felder mit den Nodes, aus denen sie stammen; ein Feld, das bei manchen Läufen leer bleibt, trägt **kann leer sein**. Unter **Endet** stehen die drei Ausgänge: **Erfolgreich** gibt die Ausgabe zurück, **Fehlgeschlagen** tritt ein, wenn eine der Nodes fehlschlägt, die den Lauf stoppen, und **Gestoppt**, wenn jemand den Lauf stoppt. -Öffne **Ablaufsteuerung** für Bedingungen und Wiederholungen. Hat der Knoten welche, ist der Abschnitt schon offen. Mit **Schließen** kehrst du zum Canvas zurück. Auf einem breiten Bildschirm schließt sich der Bereich auch, wenn du auf den leeren Canvas klickst oder Escape außerhalb eines Textfelds drückst. Trigger und Projekteinstellungen der Automatisierung findest du im Tab **Allgemein**. [Automatisierungsgrundlagen](/de/platform/automations/concepts) erklärt Knotentypen und Ausdrücke. +### Nodes + +Jede Node ist ein Kasten. Die erste Zeile zeigt ihr Symbol und ihren Titel, der aus ihrer ID entsteht: Aus `open_issues` wird **Open issues**. Die nächste Zeile nennt die Art der Node: Connector und Aktion, etwa **GitHub · Issues auflisten**, **Transformation**, **Sprachmodell** oder **Agent** mit ihrem Modell oder die Automatisierung, die sie aufruft. Weiß Tale, was die Node zurückgibt, zeigt eine Zeile die Struktur, etwa `{ issues: object[] }`. Die unterste Zeile sagt, was die Node liest, etwa **Liest Issues und die Laufeingabe (owner, repo)**, oder **Liest keine andere Node**. + +Chips und kleine Symbole ergänzen, was die Anordnung nicht zeigen kann. **Läuft bei Fehler weiter** markiert eine Node, deren Fehler der Lauf hinnimmt, und **Läuft nie** eine Node, die keine Kombination von Bedingungen erreicht. Ein Schild markiert eine Node, die Daten in einem verbundenen Dienst ändert; dort kann ein Live-Lauf auf eine Freigabe warten. Eine Sprechblase markiert einen Agent, der eine Frage stellen kann, und eine durchgestrichene Nadel ein Modell ohne festen Anbieter. Zeig auf ein Symbol, um seinen Satz zu lesen; ein Screenreader hört ihn mit dem Kasten. Ein Kasten mit Problemen zeigt ihre Anzahl in seiner ersten Zeile. + +### Bedingungen und Zweige + +Die Bedingung einer Node (`when`) steht als Pille über ihr und sagt die Bedingung in Worten, etwa „total von Score größer als 1.000 ist“. Die Node darunter läuft nur, wenn die Bedingung zutrifft. Ist eine andere Node ihre Alternative (`elseOf`), teilt sich die Bedingung in zwei Linien: **Ja** führt zur Node links, die läuft, wenn die Bedingung zutrifft, und **Nein** zu ihrer Alternative rechts. Eine Bedingung, die Tale nicht in Worte fassen kann, zeigt den Ausdruck selbst in Codeschrift. + +```yaml +nodes: + - id: escalate + type: transform + when: '{{ nodes.score.output.total > 1000 }}' + input: { total: '{{ nodes.score.output.total }}' } + code: 'return { text: "Escalate " + input.total };' + - id: file + type: transform + elseOf: escalate + input: { total: '{{ nodes.score.output.total }}' } + code: 'return { text: "File " + input.total };' +``` + +In diesem Ausschnitt lautet die Bedingung über Escalate „total von Score größer als 1.000 ist“, **Ja** führt zu Escalate und **Nein** zu File. Zeig auf eine Bedingung oder auf ihr **Ja** oder **Nein**, um die Pfade hervorzuheben, die durch sie führen. + +### Linien, Rahmen und gestrichelte Kästen + +Eine durchgezogene Linie bedeutet, dass die untere Node die Ausgabe der oberen liest. Eine gestrichelte Linie bedeutet, dass die untere Node nach der oberen läuft, ohne ihre Ausgabe zu lesen, etwa weil ihre Bedingung sie liest. Eine gepunktete Linie zu Ende verlässt die letzte Node eines Laufs, deren Ausgabe Ende nicht zurückgibt. Die Linien **Ja** und **Nein** haben eigene Farben. + +Ein Rahmen um eine Node zeigt, dass sie mehrmals läuft: einmal für jedes Element einer Liste (**Für jedes Element von …**) oder erneut, bis eine Bedingung zutrifft (**Wiederholt sich, bis …, höchstens 5×**). Ein gestrichelter Kasten ist eine Node, die vielleicht nicht läuft; nach einem Lauf ist es eine Node, die nicht gelaufen ist. **Legende** neben den Zoom-Steuerelementen erklärt jede Art von Linie und Kasten. + +### Tastatur und Listenansicht + +Das Diagramm ist ein einziger Halt in der Tab-Reihenfolge. Springst du mit Tab hinein, landet der Fokus auf Start; die Pfeiltasten folgen den Linien von Kasten zu Kasten und entlang einer Reihe, Pos1 und Ende springen zu Start und Ende, und die Eingabetaste öffnet den Kasten im Fokus. Ein Screenreader liest zu jedem Kasten Titel, Art und was er liest; bei einer Bedingung hört er, über welche Node sie entscheidet. + +Mit dem Ansichtsschalter oben links im Canvas wechselst du zwischen **Canvas**, **Liste** und **Quelltext**. **Liste** zeigt dieselben Nodes in der Reihenfolge des Laufs, jede mit dem, was sie liest, und mit ihrer Bedingung in Worten; auch dort öffnet die Eingabetaste eine Node. Ist der Canvas sehr schmal, beginnt er mit **Liste**. Die Adresse behält die Ansicht und die geöffnete Node, sodass ein geteilter Link beides wieder öffnet. + +### Wenn eine neue Version eintrifft {#new-versions} + +Während du die Automatisierung ansiehst, kann jemand eine neue Version speichern, etwa ein Coding-Agent über MCP. Siehst du die neueste Version an und hast keine ungespeicherten Änderungen, wechselt der Canvas zur neuen: Die Kästen gleiten an ihre neuen Plätze, neue Kästen blenden sich ein, geänderte erhalten einmal einen Ring, und ein Screenreader hört „Jetzt wird v6 angezeigt.“. Die geöffnete Node bleibt offen, solange es sie noch gibt. Hast du ungespeicherte Änderungen, bewegt sich nichts. Ein Hinweis über dem Canvas meldet **Eine neuere Version wurde gespeichert**, und **v6 zeigen und Entwurf verwerfen** wechselt zu ihr. + +## Den möglichen Pfaden folgen {#paths} + +Die Bedingungen eines Laufs entscheiden, welche Nodes laufen. Die Pfad-Schaltfläche oben rechts im Canvas zählt die Wege, die ein erfolgreicher Lauf nehmen kann, etwa **3 Pfade**, und öffnet **Mögliche Pfade**. Jeder Pfad nennt die Bedingungen, die über ihn entscheiden, etwa „Triage läuft“ oder „Propose schlägt fehl, der Lauf geht weiter“, und wie viele Nodes auf ihm laufen. + +Zeig auf einen Pfad oder wechsle mit den Pfeiltasten zu ihm, um ihn im Canvas als Vorschau zu sehen. Klick ihn an oder drück die Eingabetaste, damit er angezeigt bleibt: Nodes abseits des Pfads werden gestrichelt und sagen, warum sie nicht laufen, Ende markiert die Ausgaben, die auf diesem Pfad leer bleiben, und ein Screenreader hört, welcher Pfad angezeigt wird. **Alle zeigen** oder Esc zeigt wieder jede Node. Die Liste bleibt offen, während du Nodes auswählst, damit du einen Pfad mit den Feldern einer Node vergleichen kannst. + +Unter **Beendet den Lauf, wenn sie fehlschlägt** nennt die Liste die Nodes, deren Fehler den Lauf stoppt, und was jede von ihnen fehlschlagen lassen kann. Zeig auf eine davon, um alle rot einzukreisen; wähle eine aus, um sie zu öffnen. + +Auf dem Smartphone öffnet sich die Liste in einem Bereich am unteren Bildschirmrand. Wählst du einen Pfad, schließt sich der Bereich, und oben im Canvas bleibt eine Pille mit dem Namen des Pfads und **Alle zeigen**. Nimmt jeder Lauf denselben Pfad, sagt die Liste das. Bei mehr als 12 Bedingungen und hingenommenen Fehlern gibt es zu viele Pfade für eine Liste; **Wann sie läuft** sagt trotzdem bei jeder Node, wann sie läuft. Ein Canvas mit einem Zyklus hat keine Pfad-Schaltfläche. [Pfade, die ein Lauf nehmen kann](/de/platform/automations/concepts#paths) erklärt, wie Tale die Pfade ermittelt. + +## Eine Node bearbeiten + +Wähle einen Kasten, um ihn zu öffnen. Auf einem breiten Bildschirm öffnet sich der Inspektor neben dem Canvas; ohne ausgewählte Node nutzt der Canvas die ganze Breite. Auf schmaleren Bildschirmen öffnet er sich in einem Bereich über dem Canvas. Sein Kopf zeigt den Titel der Node, ihre Art und ihre ID mit **Node-ID kopieren**; darunter stehen die Probleme, die zu ihr gehören. **Wann sie läuft** fasst zusammen, welchen Platz die Node im Ablauf hat: bei jedem Lauf, auf einigen Pfaden oder nie, warum sie übersprungen werden kann und was passiert, wenn sie fehlschlägt, etwa „Schlägt sie fehl, stoppt der Lauf mit ihrem Fehler.“ + +Danach folgen drei Tabs: + +- **Felder** enthält, was du ändern kannst. Ein `transform` hat **Code**, ein `llm` **Prompt**, **System-Prompt**, **Modell** und **Ausgabeschema**, ein `agent` zusätzlich Agent-Laufzeit und Ausstattung. **Eingabe** enthält die JSON-Werte und Referenzen, die die Node erhält. +- **Struktur** zeigt, was die Node erhält und zurückgibt, woher Tale diese Struktur kennt und welche Nodes ihre Ausgabe lesen. Wähle eine lesende Node, um sie zu öffnen. **Als TypeScript zeigen** zeigt dieselbe Struktur als Typ. +- **Letzter Lauf** zeigt **Aufgelöste Eingabe**, **Ausgabe** und Effekte der Node in dem Lauf, den der Canvas zeigt. Der Tab erscheint, solange der Canvas einen Lauf zeigt. + +Die **Modell**-Auswahl einer `llm`- oder `agent`-Node listet die Modelle, die die verbundenen Anbieter deiner Organisation bedienen; ein nicht aufgeführtes Modell lässt sich eingeben, doch **Probleme** warnt dann, dass ein Live-Lauf an dieser Node fehlschlägt, bis sein Anbieter verbunden ist. + +Öffne **Ablaufsteuerung** für Bedingung, Wiederholung und Fehlerbehandlung der Node; nutzt die Node eines davon, ist der Abschnitt schon offen. **Wenn**, **Für jedes** und **Wiederholen bis** nehmen Ausdrücke auf. **Sonst zu** bietet nur Nodes mit einer Bedingung an, und **Keine** entfernt die Alternative. **Maximale Wiederholungen** erscheint mit **Wiederholen bis** und nimmt eine ganze Zahl von 1 bis 20. **Bei Fehler** wählt zwischen **Lauf stoppen** und **Ohne sie weiterlaufen**; geht der Lauf weiter, wird jede Node übersprungen, die die Ausgabe der fehlgeschlagenen Node liest. Unter einer Bedingung, einer Liste oder einer Alternative sagt ein Satz in Worten, was die Einstellung bewirkt. + +Mit **Schließen** kehrst du zum Canvas zurück. Auf einem breiten Bildschirm schließt sich der Bereich auch, wenn du auf den leeren Canvas klickst oder außerhalb eines Textfelds Esc drückst. Trigger und Projekteinstellungen der Automatisierung findest du im Tab **Allgemein**. [Automatisierungsgrundlagen](/de/platform/automations/concepts) erklärt Node-Typen und Ausdrücke. + +### Code, Prompts und JSON + +Code, Prompts, Bedingungen und JSON-Felder sind Code-Editoren. Sie färben die Syntax und jedes `{{ }}`-Template ein und kennen die Automatisierung. Tippst du `{{` in einen Prompt, erscheinen die schließenden Klammern mit dem Cursor dazwischen; nach `nodes.` siehst du nur die Nodes, die vorher laufen, und nach `.output.` die Felder dieser Node mit ihren Typen. Strg+Leertaste öffnet die Vorschläge überall. Zeig auf eine Referenz, um ihren Typ zu sehen, oder drück ⌘K ⌘I (Strg+K Strg+I), damit der Typ an der Cursorposition angezeigt und vorgelesen wird. + +Kurz nachdem du aufhörst zu tippen, ist ein Problem genau dort unterstrichen, wo es steht. F8 und Umschalt+F8 springen zum nächsten und vorherigen Problem und lesen es vor; ⌘. (Strg+.) wendet eine vorgeschlagene Korrektur an, etwa den ähnlichsten Node-Namen. In einem mehrzeiligen Feld rückt Tab ein; um es mit der Tastatur zu verlassen, drück Esc und dann Tab. **Editor vergrößern** öffnet ein langes Feld in einem größeren Editor, und **Zurück zum Feld** kehrt mit deiner Änderung und deinem Cursor an derselben Stelle zurück. + +Ein JSON-Feld wie **Eingabe** ändert die Node erst, wenn sein Text gültiges JSON der richtigen Art ist. Während du tippst, behält die Node ihren letzten gültigen Wert, und das Feld sagt, was fehlt, etwa „Das muss ein JSON-Objekt in geschweiften Klammern sein.“ + +### Eingaben bei Start, Ausgabe bei Ende + +Wähle **Start**, um zu sehen, was die Automatisierung startet. **Trigger** nennt es in Worten; **In Allgemein ändern** öffnet den Tab **Allgemein**, in dem du den Trigger einstellst. Unter **Felder** zeigt **Eingaben** die Felder der Laufeingabe als Baum, und **Eingabeschema** enthält das JSON-Schema dahinter, das du bearbeiten kannst. **Struktur** zeigt die Eingabe so, wie Tale sie liest, und **Letzter Lauf** die Eingabe des angezeigten Laufs. + +Wähle **Ende**, um zu sehen, was ein Lauf zurückgibt. **Wie ein Lauf endet** nennt die drei Ausgänge; unter **Fehlgeschlagen** ist jede Node, deren Fehler den Lauf stoppt, eine Schaltfläche, die sie öffnet. Unter **Felder** enthält **Ausgabe** den JSON-Wert, den ein erfolgreicher Lauf zurückgibt, mit Templates wie `{{ nodes.report.output }}`. **Struktur** zeigt die Struktur der Ausgabe und **Letzter Lauf** die Ausgabe des angezeigten Laufs. + +## Den Quelltext lesen + +Wähle im Ansichtsschalter **Quelltext**, um das ganze Dokument als YAML zu lesen: eingefärbt, mit Zeilennummern, Einklappen und Suche (⌘F oder Strg+F). Jedes Problem, das die Prüfung gefunden hat, ist in der Zeile unterstrichen, die es betrifft. So hat auch ein Problem in einem Teil ohne eigenes Feld, etwa in einem Test oder im Namen, einen Ort, an dem du es liest. Der Quelltext ist schreibgeschützt: **YAML kopieren** kopiert ihn, und **YAML herunterladen** speichert ihn als Datei, die nach Automatisierung und Version benannt ist, etwa `gmail-triage-inbox-v3.yml`; solange du ungespeicherte Änderungen hast, kommt `-draft` dazu. Um das Dokument zu ändern, nutze die Felder oder deinen Coding-Agent. + +## Mit deinem Coding-Agent bearbeiten + +Größere Änderungen, etwa neue Nodes oder ein umgebauter Ablauf, kommen von einem Coding-Agent wie Claude Code, Codex oder Cursor, der mit dem MCP-Server von Tale verbunden ist. **Mit deinem Coding-Agent bearbeiten** ist in jeder Ansicht die letzte Schaltfläche oben rechts im Canvas und die Hauptaktion einer Automatisierung, die noch keine Nodes hat. Ihr Dialog zeigt den Namen der Automatisierung, den du dem Agent gibst, **MCP einrichten**, das **Einstellungen > API > MCP** öffnet, und **So verbindest du einen Coding-Agent**, das die Anleitung zum [MCP-Endpoint](/de/develop/mcp-endpoint) öffnet. Der Agent liest die Automatisierung, ändert und prüft sie und speichert eine neue Version, die dann im Canvas erscheint, wie [Wenn eine neue Version eintrifft](#new-versions) beschreibt. ## Probleme finden und beheben -Während du bearbeitest, prüft Tale den Entwurf so, wie es auch jedes Speichern prüft. Kurz nachdem du aufhörst zu tippen, zeigt die Schaltfläche **Probleme** neben **Speichern**, was die Prüfung gefunden hat: ein rotes Fehlersymbol und ein gelbes Warnsymbol, jeweils mit ihrer Anzahl, oder **Keine Probleme**. Auf dem Smartphone sitzt die Schaltfläche in der Leiste über dem Canvas. Ein Fehler ist etwas, woran ein Lauf scheitern würde, etwa eine Referenz auf einen Knoten, den es nicht gibt. Eine Warnung ist etwas, das schiefgehen kann, etwa das Lesen der Ausgabe eines Knotens, der manchmal übersprungen wird. Ein Knoten mit Problemen zeigt dieselben Zahlen auf seinem Kasten, und ein Feld mit einem Problem erklärt es direkt darunter. +Während du bearbeitest, prüft Tale den Entwurf so, wie es auch jedes Speichern prüft. Kurz nachdem du aufhörst zu tippen, zeigt die Schaltfläche **Probleme** neben **Speichern**, was die Prüfung gefunden hat: ein rotes Fehlersymbol und ein gelbes Warnsymbol, jeweils mit ihrer Anzahl, oder **Keine Probleme**. Auf dem Smartphone sitzt die Schaltfläche in der Leiste über dem Canvas. Ein Fehler ist etwas, woran ein Lauf scheitern würde, etwa eine Referenz auf eine Node, die es nicht gibt. Eine Warnung ist etwas, das schiefgehen kann, etwa das Lesen der Ausgabe einer Node, die manchmal übersprungen wird. Eine Node, eine Bedingung, Start oder Ende mit Problemen zeigt dieselben Zahlen auf ihrem Kasten, und ein Feld mit einem Problem erklärt es direkt darunter. -Klicke auf **Probleme**, um sie aufzulisten. Auf einem breiten Bildschirm öffnet sich die Liste unter dem Canvas, auf schmaleren Bildschirmen in einem eigenen Bereich. Jeder Eintrag sagt, was falsch ist, wo, warum und wie du es behebst. **Technische Details** zeigt die Meldung der Engine selbst, und der Code neben dem Titel hilft dir bei der Suche oder im Support. **Alle**, **Fehler** und **Warnungen** filtern die Liste, Escape schließt sie. +Klicke auf **Probleme**, um sie aufzulisten. Auf einem breiten Bildschirm öffnet sich die Liste unter dem Canvas, auf schmaleren Bildschirmen in einem eigenen Bereich. Jeder Eintrag sagt, was falsch ist, wo, warum und wie du es behebst. **Technische Details** zeigt die Meldung der Engine selbst, und der Code neben dem Titel hilft dir bei der Suche oder im Support. **Alle**, **Fehler** und **Warnungen** filtern die Liste, Esc schließt sie. -Wähle einen Eintrag oder drücke darauf die Eingabetaste, um dorthin zu gelangen: Der Knoten öffnet sich, sein Feld erhält den Fokus, und wo das Feld den Text so zeigt, wie er gespeichert ist, ist die Stelle markiert, die das Problem verursacht. Ein Problem ohne eigenes Feld, etwa ein Modell, das deine Organisation nicht bereitstellt, steht unter **Probleme in dieser Node** oben in den Feldern des Knotens. Ein Problem in einem Teil der Automatisierung, den der Editor nicht zeigt, etwa in ihrer Ausgabe oder ihren Tests, sagt, dass es sich hier nicht bearbeiten lässt; ändere diesen Teil über MCP, die API oder ein hochgeladenes Paket. +Wähle einen Eintrag oder drücke darauf die Eingabetaste, um dorthin zu gelangen: Die Node öffnet sich, ihr Feld erhält den Fokus, und die Stelle, die das Problem verursacht, ist markiert. Ein Problem ohne eigenes Feld, etwa ein Modell, das deine Organisation nicht bereitstellt, steht unter **Probleme in dieser Node** oben in den Feldern der Node. Ein Problem in den Eingaben öffnet **Start**, eines in der Ausgabe öffnet **Ende**, und eines in einem anderen Teil des Dokuments, etwa in einem Test oder im Namen, öffnet **Quelltext** an dieser Zeile. Ein Problem in einer Node, die dein Entwurf nicht mehr hat, sagt „Ändere das mit deinem Coding-Agent.“ -Solange Fehler bestehen, ist **Speichern** deaktiviert und nennt den Grund, zum Beispiel „Behebe 1 Fehler, um zu speichern“. Auf dem Smartphone steht **Speichern** unter den Feldern eines Knotens; dort öffnet **Probleme anzeigen** neben diesem Grund die Liste. Warnungen verhindern weder das Speichern noch das Bereitstellen. Kann Tale den Entwurf nicht prüfen, etwa weil die Verbindung abgebrochen ist, zeigt die Schaltfläche **Prüfung fehlgeschlagen**, und du kannst trotzdem speichern: Jedes Speichern wird auf dem Server erneut geprüft. Wird ein Speichern oder Bereitstellen wegen Fehlern abgelehnt, öffnet sich die Liste mit den Problemen des Servers und beginnt beim ersten Fehler. +Solange Fehler bestehen, ist **Speichern** deaktiviert und nennt den Grund, zum Beispiel „Behebe 1 Fehler, um zu speichern“. Auf dem Smartphone steht **Speichern** unter den Feldern einer Node; dort öffnet **Probleme anzeigen** neben diesem Grund die Liste. Warnungen verhindern weder das Speichern noch das Bereitstellen. Kann Tale den Entwurf nicht prüfen, etwa weil die Verbindung abgebrochen ist, zeigt die Schaltfläche **Prüfung fehlgeschlagen**, und du kannst trotzdem speichern: Jedes Speichern wird auf dem Server erneut geprüft. Wird ein Speichern oder Bereitstellen wegen Fehlern abgelehnt, öffnet sich die Liste mit den Problemen des Servers und beginnt beim ersten Fehler. Die Prüfung läuft nur für Entwickler, Admins und Inhaber, also die Rollen, die speichern dürfen. [Was Tale vor einem Lauf prüft](/de/platform/automations/concepts#checks) erklärt jede Art von Problem. @@ -53,7 +141,7 @@ Die Prüfung läuft nur für Entwickler, Admins und Inhaber, also die Rollen, di 1. Ändere die nötigen Felder und klicke auf **Speichern**. 2. Erkläre die Änderung in der **Notiz zur Version** und wähle **Version speichern**. Eine neue Version entsteht; frühere Fassungen bleiben erhalten. Hat jemand während deiner Bearbeitung eine andere Version gespeichert, lehnt Tale das Speichern ab und fragt nach: **Meine Änderungen verwerfen und neu laden** zeigt die neuere Version, **Trotzdem speichern** legt deine Version darüber an — die neuere bleibt im Versionsverlauf, die aktuelle Version ist dann aber deine. -3. Klicke auf **Testlauf**. Hat der Workflow ein Eingabeschema, fülle im Dialog **Eingabe für den Lauf (JSON)** aus. Öffne **Eingabeschema**, um Pflichtfelder und Typen zu prüfen. Ungültiges JSON oder unpassende Werte verhindern den Start. +3. Klicke auf **Testlauf**. Hat der Workflow ein Eingabeschema, tippe die Eingabe als JSON in **Eingabe für den Lauf (JSON)**; beim Tippen eines Schlüssels schlägt das Feld die Feldnamen des Schemas vor, und ⌘Enter (Strg+Enter) startet den Lauf. Öffne **Eingabeschema**, um Pflichtfelder und Typen zu sehen. Ungültiges JSON oder unpassende Werte verhindern den Start. 4. Starte den Test, wechsle zum Tab **Läufe** und öffne seinen Eintrag. Vergleiche aufgelöste Eingabe, Ausgabe und geplante Aktionen mit dem erwarteten Ergebnis. Braucht ein Workflow `owner` und `repo`, könnte seine Eingabe so aussehen: @@ -71,7 +159,7 @@ Maßgeblich ist das tatsächliche Schema des Workflows. Ein Zahlenfeld braucht e -![Der Testlauf-Dialog zeigt JSON-Werte für owner und repo und das aufgeklappte Eingabeschema.](/images/platform/automation-run-input.webp) +![Der Testlauf-Dialog zeigt JSON-Werte für owner und repo im Code-Editor und das aufgeklappte Eingabeschema als Liste von Feldern.](/images/platform/automation-run-input.webp) @@ -85,7 +173,7 @@ Ein Trigger nutzt ebenfalls die bereitgestellte Version. Richte ihn ein, wenn wi ## Ein Ergebnis untersuchen -**Letzten Lauf einblenden** legt Laufzustände über den Canvas. Wähle einen Knoten für die Angaben zu diesem Lauf: aufgelöste Eingabe, Ausgabe und Effekte. Häufig findest du so eine falsche Referenz. Vergleiche die Eingabe des fehlgeschlagenen Knotens mit der Ausgabe seiner Quelle. +Sobald die Automatisierung gelaufen ist, zeigt der Canvas ihren letzten Lauf: Die unterste Zeile jeder Node sagt, wie sie endete, etwa **Erfolgreich** oder **Übersprungen**, und jede Bedingung zeigt, wie sie entschieden hat, **Ja** oder **Nein**. Die Augen-Schaltfläche oben rechts im Canvas, **Letzten Lauf ausblenden**, nimmt den Lauf vom Canvas, und **Letzten Lauf einblenden** zeigt ihn wieder. Wähle eine Node und öffne **Letzter Lauf**, um ihre **Aufgelöste Eingabe**, **Ausgabe** und Effekte zu sehen. Häufig findest du so eine falsche Referenz: Vergleiche die Eingabe der fehlgeschlagenen Node mit der Ausgabe der Node, die sie liest. Wechsle zu **Läufe** und öffne den vollständigen Datensatz. Die Tabs bleiben sichtbar; **Läufe** ist aktiv. Mit **Editor** kehrst du zum Workflow zurück. Prüfe Test- oder Live-Modus und bereits ausgeführte Aktionen, bevor du erneut startest. [Ausführungsprotokolle](/de/platform/automations/execution-logs) erklärt Wartezustände, Fehler, automatische Wiederholungen und Abbruch. diff --git a/docs/de/platform/automations/execution-logs.md b/docs/de/platform/automations/execution-logs.md index 54fdceb852..9c30d6d7f3 100644 --- a/docs/de/platform/automations/execution-logs.md +++ b/docs/de/platform/automations/execution-logs.md @@ -7,7 +7,7 @@ description: Verfolge einen Lauf bis zur betroffenen Node, prüfe protokollierte -![Die Seite eines Testlaufs von Triage GitHub issues, markiert mit Succeeded, Test und v1 und von dir gestartet, mit Start- und Endzeit über dem Workflow-Graphen, in dem die Nodes issues, open issues, score und report jeweils Ran zeigen; unter dem Graphen beginnt die Liste der Auswirkungen des Laufs.](/images/platform/automation-run-detail.webp) +![Die Seite eines Testlaufs von Triage GitHub issues, markiert mit Succeeded, Test und v1 und von dir gestartet, mit Start- und Endzeit über dem Workflow-Graphen, in dem die Nodes issues, open issues, score und report jeweils Succeeded zeigen; unter dem Graphen beginnt die Liste der Auswirkungen des Laufs.](/images/platform/automation-run-detail.webp) @@ -36,9 +36,11 @@ Lass den Lauf während der Prüfung zurückgehalten. **Stopp anfordern** bittet ## Die betroffene Node untersuchen -Wähle eine Node auf dem Canvas des Laufs. **Aufgelöste Eingabe** zeigt die Werte nach der Vorlagenauswertung, **Ausgabe** das Ergebnis des Schritts. So unterscheidest du einen falschen Verweis von einem Dienstausfall. +Ein fehlgeschlagener Lauf öffnet sich mit der fehlgeschlagenen Node im Blick. Sie ist rot umrahmt, und ihre unterste Zeile zeigt die erste Zeile ihres Fehlers; die Nodes, über die der Lauf zu ihr kam, treten hervor, während die übrigen zurücktreten; und Ende sagt, wo der Lauf fehlschlug, etwa **Fehlgeschlagen bei Propose**. -Node-Zustände sind unter anderem **Gelaufen**, **Übersprungen**, **Fehlgeschlagen**, **Nie erreicht**, **Noch nicht erreicht** und bei einem gestoppten Lauf **Hier gestoppt** für die Node, an der der Lauf beim Stoppen stand. Eine Node kann wegen einer falschen Bedingung, einer Abhängigkeit, eines anderen Zweigs oder einer Weiterlaufregel übersprungen werden. Das ist nicht immer ein Fehler. +Wähle eine Node auf dem Canvas des Laufs, um ihren Tab **Letzter Lauf** zu öffnen. **Aufgelöste Eingabe** zeigt die Werte nach der Vorlagenauswertung, **Ausgabe** das Ergebnis des Schritts. So unterscheidest du einen falschen Verweis von einem Dienstausfall. + +Im Canvas sagt die unterste Zeile jeder Node, wie sie endete: **Erfolgreich**, **Fehlgeschlagen**, **Übersprungen**, **Nicht ausgeführt**, **Noch nicht erreicht** oder bei einem gestoppten Lauf **Hier gestoppt** für die Node, an der der Lauf beim Stoppen stand. Jede Bedingung zeigt, wie sie entschieden hat, **Ja** oder **Nein**. Die Kennzeichnung im Tab **Letzter Lauf** nennt dieselben Zustände **Gelaufen**, **Fehlgeschlagen**, **Übersprungen**, **Nie erreicht**, **Noch nicht erreicht** und **Hier gestoppt**. Eine Node kann wegen einer falschen Bedingung, einer Abhängigkeit, eines anderen Zweigs oder einer Weiterlaufregel übersprungen werden. Das ist nicht immer ein Fehler. Beispielsweise kann eine Erinnerungs-Node den Kundennamen, aber eine leere Rechnungs-ID erhalten. Prüfe die Ausgabe davor. Verwendet der Datensatz inzwischen ein anderes Feld, korrigiere den Verweis statt der Mail-Zugangsdaten. Prüfe danach die aufgelöste Eingabe in einem neuen Testlauf. diff --git a/docs/de/tutorials/editor/workflow-with-approvals.md b/docs/de/tutorials/editor/workflow-with-approvals.md index 0e8a1f9fb1..c6db6c3d26 100644 --- a/docs/de/tutorials/editor/workflow-with-approvals.md +++ b/docs/de/tutorials/editor/workflow-with-approvals.md @@ -13,7 +13,7 @@ Der Mock-Test braucht keine Postfach-Zugangsdaten. Für einen tatsächlich freig ## Das Beispiel importieren -Speichere den folgenden Inhalt als `workflow.yml`. Der Knoten `draft` liefert festen Text, damit das Ergebnis leicht prüfbar ist. `send` liest ihn; diese Referenzen erzeugen die Verbindung auf dem Canvas. +Speichere den folgenden Inhalt als `workflow.yml`. Die Node `draft` liefert festen Text, damit das Ergebnis leicht prüfbar ist. `send` liest ihn; diese Referenzen erzeugen die Verbindung auf dem Canvas. ```yaml version: 1 @@ -60,7 +60,7 @@ Existiert der Name bereits, fügt der Upload eine weitere Version hinzu. Wähle Klicke im **Editor** auf **Testlauf**. Dieses Beispiel braucht keine Laufzeiteingabe und kann mit einem leeren Objekt laufen. Wechsle zu **Läufe**. Dort sollte ein **Erfolgreich** abgeschlossener Test erscheinen. -Öffne den Lauf und prüfe auf dem Canvas, ob beide Knoten den Status **Gelaufen** zeigen. Wähle `send` und prüfe die aufgelöste Eingabe. Der Empfänger muss `reviewer@example.com` sein, der Betreff `Approval practice` und der Text der Satz aus `draft`. In diesem Modus antwortet ein deterministischer Mock des Connectors. Es wird keine E-Mail gesendet und keine Freigabekarte angezeigt. +Öffne den Lauf und prüfe auf dem Canvas, ob beide Nodes **Erfolgreich** zeigen. Wähle `send` und prüfe die aufgelöste Eingabe. Der Empfänger muss `reviewer@example.com` sein, der Betreff `Approval practice` und der Text der Satz aus `draft`. In diesem Modus antwortet ein deterministischer Mock des Connectors. Es wird keine E-Mail gesendet und keine Freigabekarte angezeigt. Der Workflow enthält einen Test, der den Effekt `imap-smtp.send` erwartet. Ein erfolgreicher Mock prüft Ablauf und vorgesehenen Aufruf. Er belegt weder gültige Postfach-Zugangsdaten noch die Zustellung. @@ -68,7 +68,7 @@ Der Workflow enthält einen Test, der den Effekt `imap-smtp.send` erwartet. Ein Kehre zum **Editor** zurück und klicke auf **v1 live schalten**, um die getestete Version live zu schalten. Lass den Trigger unkonfiguriert; diese Übung startet einmal von Hand. -Wähle **Live ausführen**, lies Bestätigung und Organisationsumfang und bestätige. Wechsle zu **Läufe** und öffne den neuen Lauf mit dem Status **Wartet**. Die Freigabekarte sollte den Titel **Wartet auf deine Freigabe: imap-smtp.send**, den Knoten `send` sowie dessen geplante Eingabe unter **Der Schritt würde aufrufen mit** zeigen. Empfänger, Betreff und Text müssen dem Mock-Test entsprechen. +Wähle **Live ausführen**, lies Bestätigung und Organisationsumfang und bestätige. Wechsle zu **Läufe** und öffne den neuen Lauf mit dem Status **Wartet**. Die Freigabekarte sollte den Titel **Wartet auf deine Freigabe: imap-smtp.send**, die Node `send` sowie deren geplante Eingabe unter **Der Schritt würde aufrufen mit** zeigen. Empfänger, Betreff und Text müssen dem Mock-Test entsprechen. Wartet der Lauf nicht, prüfe Status und Richtlinie, bevor du fortfährst. Ein fehlgeschlagener Connector-Aufruf beweist nicht, dass eine Freigabe angefordert wurde. diff --git a/docs/en/develop/connectors.md b/docs/en/develop/connectors.md index 2d4cc00851..d7f5a628b6 100644 --- a/docs/en/develop/connectors.md +++ b/docs/en/develop/connectors.md @@ -25,6 +25,8 @@ auth: - method: api-key ``` +A connector named with ordinary words, such as `task` (Tasks), also declares `i18n.de.displayName` and `i18n.fr.displayName`. A connector named after a product, such as Tavily, keeps its name in every language. + ### Set the destination boundary | Field | Meaning | @@ -47,6 +49,7 @@ Adding a connector is a source contribution. The runtime reads the platform cata | Field | Contract for the author and caller | | --- | --- | | `name`, `description` | Stable snake_case action name and an explanation of when to use it | +| `title`, `i18n` | The action in words for people: a short English `title` in sentence case, without the connector's name ("List issues"), plus `i18n.de.title` and `i18n.fr.title` (and `de-CH` where the Swiss spelling differs). The automation canvas shows a node as "GitHub · List issues" in the reader's language; a shipped action without all three fails the catalog's tests | | `input` | Object JSON Schema, validated before execution; describe fields and mark required ones | | `output` | TypeScript-style signature describing the result; this is documentation, not a runtime output validator | | `effects` | `read` or `write`; writes pass through approval policy | diff --git a/docs/en/platform/automations/assistant.md b/docs/en/platform/automations/assistant.md index 60d35994b0..c13f3762e4 100644 --- a/docs/en/platform/automations/assistant.md +++ b/docs/en/platform/automations/assistant.md @@ -3,25 +3,25 @@ title: Choose how to author an automation description: Use the visual editor for direct changes, or have your coding agent edit automations through Tale’s MCP endpoint. --- -Edit an automation directly on its canvas, or let your coding agent edit it through Tale’s MCP endpoint. Tale has no AI assistant inside the editor: a coding agent such as Claude Code or Codex, connected with your API key, is how you work on automations with AI. Both routes save versions of the same workflow and use the same validation and deployment rules. Authoring and deploying automations take the Owner, Admin or Developer role. +Change an automation's fields directly in the editor, or let your coding agent build and change it through Tale’s MCP endpoint. Tale has no AI assistant inside the editor: a coding agent such as Claude Code or Codex, connected with your API key, is how you work on automations with AI. Both routes save versions of the same workflow and use the same validation and deployment rules. Authoring and deploying automations take the Owner, Admin or Developer role. -## Make a change in the visual editor +## Make a change in the editor -Open **Automations** and select the workflow. Select a node to inspect its input, model, code, or other configuration. Save the change with a version message, run a test, and deploy the intended version when its checks pass. +Open **Automations** and select the workflow. Select a node to inspect its input, model, code, or other configuration, and change the field you need. Save the change with a version message, run a test, and deploy the intended version when its checks pass. The canvas arranges itself from the references between nodes; you don't add nodes or draw connections on it. -![The automation editor shows a workflow graph and the selected node’s input fields in a side panel.](/images/platform/automation-editor-canvas.webp) +![The automation editor shows the workflow between Start and End and the selected node’s fields in a side panel.](/images/platform/automation-editor-canvas.webp) -[The workflow editor](/platform/automations/editor) covers these steps in detail, including inspecting a run and returning to an earlier version. The canvas does not include a conversational assistant panel. +[The workflow editor](/platform/automations/editor) covers these steps in detail, including reading the canvas, inspecting a run and returning to an earlier version. The canvas does not include a conversational assistant panel. -## Use an external assistant through MCP +## Change it with your coding agent -Connect your coding agent with the endpoint under **Settings > API > MCP** and your personal API key; [Use Tale from your editor or a script](/develop/use-tale-from-your-editor) has ready configurations. Tell it what the automation should take in, what it should produce, and which systems it may change. Ask it to look at the existing automations and at what your organization has before it creates another one. +**Edit with your coding agent**, the last button at the top right of the editor's canvas, is the way in. Its dialog shows the automation's name to give the agent, **Set up MCP**, which opens **Settings > API > MCP**, and a link to the guide for connecting a coding agent. Connect your coding agent with the endpoint under **Settings > API > MCP** and your personal API key; [Use Tale from your editor or a script](/develop/use-tale-from-your-editor) has ready configurations. Tell it what the automation should take in, what it should produce, and which systems it may change. Ask it to look at the existing automations and at what your organization has before it creates another one. -Your agent works on an automation the way you do in the editor. It reads the reference and the current version, validates its change, runs it on the mocks and runs the automation’s tests, then saves a new version that names the version it started from. If someone saved a newer version in the meantime, Tale refuses the save, and the agent reads that version and merges its change first. When it starts a saved version on the mocks, the run appears in the automation’s **Runs** tab as a **Test** run, **Started by you (API)**, so you can open what it ran. +Your agent works on an automation the way you do in the editor. It reads the reference and the current version, validates its change, runs it on the mocks and runs the automation’s tests, then saves a new version that names the version it started from. If someone saved a newer version in the meantime, Tale refuses the save, and the agent reads that version and merges its change first. When it starts a saved version on the mocks, the run appears in the automation’s **Runs** tab as a **Test** run, **Started by you (API)**, so you can open what it ran. A version the agent saves appears on the canvas while you look at the automation. Saving creates a version; it does not make it live. Before your agent deploys, deletes, sets a trigger or installs an automation in projects, a client that honors Tale’s marking for these tools, such as Claude Code, asks for your yes, even when you let the agent run other tools without asking. Review the version and its test results before you agree. Every change your agent makes is in the [audit log](/platform/admin/governance/audit-logs) with **Source** Coding agent, and the [MCP endpoint](/develop/mcp-endpoint) lists every tool it can use. @@ -29,4 +29,4 @@ Saving creates a version; it does not make it live. Before your agent deploys, d An agent node inside an automation performs work during a run. It is separate from the coding agent you use to write the workflow. Similarly, an [approval](/platform/approvals/concepts) authorizes one pending operation during execution; it does not approve a proposed edit to the workflow definition. Your coding agent never decides an approval: a run it starts that needs one waits until a person decides in Tale. -Choose [the editor](/platform/automations/editor) for a change you want to make directly, or [MCP](/develop/mcp-endpoint) for authoring from your own client. Start from [an existing automation](/platform/automations/catalog) when a suitable one is already available. +Choose [the editor](/platform/automations/editor) for a change you want to make directly, or [MCP](/develop/mcp-endpoint) for authoring with your coding agent. Start from [an existing automation](/platform/automations/catalog) when a suitable one is already available. diff --git a/docs/en/platform/automations/concepts.md b/docs/en/platform/automations/concepts.md index 62d03049ec..4bba99939e 100644 --- a/docs/en/platform/automations/concepts.md +++ b/docs/en/platform/automations/concepts.md @@ -42,17 +42,17 @@ tests: input: { invoiceId: 'inv-1' } ``` -The `ui` block stores canvas positions. Moving a node changes the layout, without changing its execution. +Tale lays out the canvas from the references between nodes, so nobody places a node. A `ui` block is free metadata: Tale keeps it as written and ignores it. ### Edges are derived, not declared -There is no edge list. One node reads another by referencing it — `{{ nodes.invoice.output.id }}` — and that reference _is_ the edge the canvas draws. Execution order is a topological sort over those derived edges, which is why deleting a reference also removes an arrow, and why two nodes that read each other are refused as a cycle. +There is no edge list. One node reads another by referencing it — `{{ nodes.invoice.output.id }}` — and that reference _is_ the edge the canvas draws. Execution order is a topological sort over those derived edges, which is why deleting a reference also removes a line, and why two nodes that read each other are refused as a cycle. Templates use a single `{{ }}` JavaScript-expression grammar over `input`, `nodes..output`, and, inside an iterating node, `item` and `index`. ### Control flow rides on the node -Branching and looping are fields on a node rather than separate step types, so the canvas shows them as badges on the box they affect. +Branching and looping are fields on a node rather than separate step types. The canvas draws each one where it acts: a `when` becomes a condition above its node, an `elseOf` alternative hangs from that condition as its **No** branch, `forEach` and `repeatUntil` put the node in a frame, and `onError: continue` adds a chip to the node. | Field | What it does | | ---------------------------- | ------------------------------------------------------------------------ | @@ -80,6 +80,12 @@ A **structured** output has named fields that you can reference with `nodes. A tool without an output schema is unstructured. To turn its text into structured data for later steps, use an `llm` node with an `outputSchema`. Validation errors identify the invalid reference and the fields or context that are allowed. Correct that reference before saving again. +## Paths a run can take {#paths} + +Each condition, and each node that may fail while the run goes on, gives a run two ways to continue. Tale tries every combination of them and keeps the distinct ways a successful run can go; each one is a path. A path names the conditions that decide it, such as which nodes run, which are skipped and which fail while the run goes on, and the nodes that run on it. A node that runs on every path always runs; a node that runs on none can never run, and Tale warns about it. + +Tale lists up to 32 paths and counts the rest. With more than 12 conditions and tolerated failures, the combinations are too many to go through, so Tale lists no path; it still says for each node when it runs. Separately, Tale names the nodes whose failure ends the run and what can make each one fail. The editor shows the paths on the canvas, as [Follow the possible paths](/platform/automations/editor#paths) describes; a client of the [MCP endpoint](/develop/mcp-endpoint) reads the same paths from `analysis.paths`. + ## What Tale checks before a run {#checks} Tale checks the whole document when you save it, when you deploy a version, and whenever a client calls `validate_automation`. An **error** describes a definite failure or code that exceeds the analysis limits, and it stops both saving and deploying. A **warning** points at something that can fail or does no useful work; it never stops a save or a deployment, so you decide whether to act on it. Each problem names its node and field and, inside a template, a condition, or code, the exact expression. diff --git a/docs/en/platform/automations/editor.md b/docs/en/platform/automations/editor.md index 8cf204ccf2..7ec4585e16 100644 --- a/docs/en/platform/automations/editor.md +++ b/docs/en/platform/automations/editor.md @@ -1,25 +1,25 @@ --- title: The workflow editor -description: Inspect and edit nodes, supply test input, save a version, and deploy or roll back an automation. +description: Read an automation on its canvas, follow its possible paths, edit node fields, save a version, and deploy or roll back. --- -Use the workflow editor to change what an automation does and decide which saved version runs live. You need Developer, Admin, or Owner permissions to make changes. Saving, testing, and deployment are separate steps: editing a draft leaves the deployed version in place. +Use the workflow editor to read what an automation does, change its fields, and decide which saved version runs live. Larger changes, such as new nodes, come from a coding agent through MCP. You need Developer, Admin, or Owner permissions to make changes. Saving, testing, and deployment are separate steps: editing a draft leaves the deployed version in place. Open **Automations**, then select an automation. It opens on **Editor**. An automation you open from a project's **Automations** tab shows that project at the start of the breadcrumb trail: choose the project's name to return to the project, or **Automations** to return to its automations. Whether you open an automation from a project or from the automation list, the rail marks **Automations**. To create one first, use [Create or import an automation](/platform/automations/catalog). | Tab | Use it to | | --- | --- | -| **Editor** | Change the workflow, test a saved version and choose what runs live. | +| **Editor** | Read and change the workflow, test a saved version and choose what runs live. | | **General** | Choose what starts the automation and which projects can use it. | | **Runs** | Inspect recent executions and open a run’s full record. | The **Version** selector stays at the right of the Editor, General and Runs tabs on desktop and mobile. Open it to read version messages, dates, test results and the live marker, then select a row to open that version. On desktop, run actions sit beside the tabs together with **Save** and **Discard**; on **General**, only **Save** and **Discard** sit there. A dot on a tab marks its unsaved changes. Leaving the tab or switching versions asks you to resolve those changes first. -On a phone, opening an automation starts with compact navigation. The editor canvas fills the available height, and its run and deploy controls sit inside the canvas beside the zoom controls. Selecting a node opens its fields — and Save and Discard — in a panel at the bottom of the screen. +On a phone, opening an automation starts with compact navigation. The editor canvas fills the available height, and its run and deploy controls sit in a toolbar at the bottom of the canvas. Selecting a node opens its fields — and Save and Discard — in a panel at the bottom of the screen. -![The workflow editor shows connected nodes and the selected node’s fields beside the canvas.](/images/platform/automation-editor-canvas.webp) +![The workflow editor shows the nodes of Gmail triage inbox between Start and End, a condition in words above one node, and the selected node’s fields beside the canvas.](/images/platform/automation-editor-canvas.webp) @@ -27,23 +27,111 @@ To switch without returning to the list, click the current automation's name in ## Read the canvas -Each box is a node. Its label identifies the step and type; **Reads** lists the nodes whose outputs it uses. Arrows come from references such as `{{ nodes.draft.output.text }}`. Edit the reference to change the dependency. The canvas does not create dependencies by drawing an arrow. +Tale draws the canvas from the automation's document and arranges it for you: **Start** sits at the top, **End** at the bottom, and every node sits below the nodes it reads, so the canvas reads from top to bottom in the order a run goes. Nobody places a box, and drawing does not connect anything. A line comes from a reference such as `{{ nodes.draft.output.text }}`; to change what a node reads, change the reference. -Badges show conditions and loops: `when`, `else of`, `for each`, `repeat until`, and `continue on error`. A cycle warning means two or more nodes depend on one another; remove the circular reference before saving a runnable version. +### Start and End + +**Start** says what starts a run and what it receives. Under **Starts**, it lists the trigger in words, such as a schedule with its time zone and next run, and says when the trigger is off or waits for a live version. **By hand, the API or MCP** follows, because those starts are always possible. Under **Input**, it lists the fields of the run input with their type and whether they are required. When the trigger would start runs whose input the automation refuses, Start says so. + +**End** says what a successful run returns and how a run can end. Under **Returns**, it names the output, such as **The output of Report**, or lists its fields and the nodes they come from; a field that is empty on some runs says **may be empty**. Under **Ends**, it lists the three outcomes: **Succeeded** returns the output, **Failed** happens when one of the nodes that stop the run fails, and **Stopped** happens when someone stops the run. + +### Nodes + +Each node is a box. Its first line shows the node's icon and its title, made from its ID: `open_issues` reads **Open issues**. The next line says what kind of node it is: the connector and action, such as **GitHub · List issues**, **Transform**, **Language model** or **Agent** with its model, or the automation it calls. When Tale knows what the node returns, a line shows the shape, such as `{ issues: object[] }`. The bottom line says what the node reads, such as **Reads Issues and run input (owner, repo)**, or **Reads no other node**. + +Chips and small icons add what the layout cannot show. **Continues on error** marks a node whose failure the run tolerates, and **Never runs** a node that no combination of conditions reaches. A shield marks a node that changes data in a connected service, where a live run may wait for an approval; a speech bubble marks an agent that may ask a question; a crossed-out pin marks a model with no pinned provider. Point at an icon to read its sentence; a screen reader hears it with the box. A box with problems shows their counts in its first line. + +### Conditions and branches + +A node's condition (`when`) stands above it as a pill that says the condition in words, such as "total of Score is greater than 1,000". The node below runs only if the condition holds. When another node is its alternative (`elseOf`), the condition splits into two lines: **Yes** leads to the node on the left, which runs when the condition holds, and **No** leads to its alternative on the right. A condition that Tale cannot put into words shows the expression itself, in code type. + +```yaml +nodes: + - id: escalate + type: transform + when: '{{ nodes.score.output.total > 1000 }}' + input: { total: '{{ nodes.score.output.total }}' } + code: 'return { text: "Escalate " + input.total };' + - id: file + type: transform + elseOf: escalate + input: { total: '{{ nodes.score.output.total }}' } + code: 'return { text: "File " + input.total };' +``` + +In this excerpt, the condition above Escalate reads "total of Score is greater than 1,000", **Yes** leads to Escalate and **No** to File. Point at a condition, or at its **Yes** or **No**, to light up the paths through it. + +### Lines, frames and dashed boxes + +A solid line means the lower node reads the output of the upper one. A dashed line means the lower node runs after the upper one without reading its output, as when its condition reads it. A dotted line into End leaves the last node of a run whose output End does not return. The **Yes** and **No** lines have their own colours. + +A frame around a node says it runs more than once: once per item of a list (**For each item of …**) or again until a condition holds (**Repeats until …, at most 5×**). A dashed box is a node that may not run; after a run, it is a node that didn't run. **Legend**, beside the zoom controls, explains each kind of line and box. + +### Keyboard and the List view + +The chart is one stop in the Tab order. Tab to it and the focus lands on Start; the arrow keys follow the lines from box to box and move along a row, Home and End jump to Start and End, and Enter opens the box in focus. A screen reader hears each box's title, its kind and what it reads; for a condition, it hears which node the condition decides on. + +The view switch at the top left of the canvas changes between **Canvas**, **List** and **Source**. **List** shows the same nodes in run order, each with what it reads and its condition in words, and Enter opens a node from there too. When the canvas is very narrow, it starts in **List**. The address keeps the view and the open node, so a link you share opens both. + +### When a new version arrives {#new-versions} + +Someone can save a new version while you look at the automation, for example a coding agent through MCP. If you are looking at the latest version and have no unsaved changes, the canvas switches to the new one: boxes glide to their new places, new boxes fade in, changed ones are ringed once, and a screen reader hears "Now showing v6.". The node you had open stays open if it still exists. If you have unsaved changes, nothing moves. A notice above the canvas says **A newer version was saved**, and **Show v6 and discard my draft** switches to it. + +## Follow the possible paths {#paths} + +A run's conditions decide which nodes run. The paths button at the top right of the canvas counts the ways a successful run can go, such as **3 paths**, and opens **Possible paths**. Each path names the conditions that decide it, such as "Triage runs" or "Propose fails, the run goes on", and how many nodes run on it. + +Point at a path, or move to it with the arrow keys, to preview it on the canvas. Click it or press Enter to keep it shown: nodes off the path turn dashed and say why they don't run, End marks the outputs that stay empty on that path, and a screen reader hears which path is shown. **Show all**, or Escape, shows every node again. The list stays open while you select nodes, so you can compare a path with a node's fields. + +Under **Ends the run when it fails**, the list names the nodes whose failure stops the run, with what can make each one fail. Point at one to ring all of them in red; select one to open it. + +On a phone, the list opens in a panel at the bottom of the screen. Choosing a path closes the panel and leaves a pill at the top of the canvas that names the path, with **Show all**. When every run takes the same path, the list says so. An automation with more than 12 conditions and tolerated failures has too many paths to list; each node's **When it runs** still says when it runs. A canvas with a cycle has no paths button. [Paths a run can take](/platform/automations/concepts#paths) explains how Tale works the paths out. ## Edit a node -Select a box to open its fields. On a wide screen, the panel opens beside the canvas; until you select a node, the canvas takes the full width. On narrower screens, the fields open in a dialog over the canvas. A `transform` has **Code**; an `llm` has prompt, model, and output-schema fields; an `agent` also has agent runtime and equipment. The **Model** picker of an `llm` or `agent` node lists the models your organization’s connected providers serve; a model that is not listed can still be typed, but **Problems** then warns that a live run would fail at that node until its provider is connected. **Input** contains JSON values and references passed to the node. Incomplete JSON is reported and does not update the node. +Select a box to open it. On a wide screen, the inspector opens beside the canvas; until you select a node, the canvas takes the full width. On narrower screens, it opens in a panel over the canvas. Its header shows the node's title, its kind, and its ID with **Copy node ID**; problems that belong to the node are listed under it. **When it runs** sums up the node's place in the flow: on every run, on some of the paths or never, why it can be skipped, and what happens when it fails, such as "If it fails, the run stops with its error." + +Three tabs follow: + +- **Fields** holds what you can change. A `transform` has **Code**; an `llm` has **Prompt**, **System prompt**, **Model** and **Output schema**; an `agent` also has its agent runtime and equipment. **Input** contains the JSON values and references passed to the node. +- **Shape** shows what the node receives and returns, where Tale got that shape from, and which nodes read its output. Select a reader to open it. **Show as TypeScript** shows the same shape as a type. +- **Last run** shows the node's **Resolved input**, **Output** and effects in the run shown on the canvas. It appears while the canvas shows a run. + +The **Model** picker of an `llm` or `agent` node lists the models your organization’s connected providers serve; a model that is not listed can still be typed, but **Problems** then warns that a live run would fail at that node until its provider is connected. + +Open **Control flow** for the node's condition, iteration and failure handling; it is already open on a node that uses one of them. **When**, **For each** and **Repeat until** take expressions. **Else of** offers only nodes that have a condition, and **None** removes the alternative. **Maximum repeats** appears with **Repeat until** and takes a whole number from 1 to 20. **On error** chooses between **Stop the run** and **Continue without it**; continuing skips every node that reads the failed node's output. Under a condition, a list or an alternative, a sentence says in words what the setting does. + +Use **Close** to return to the canvas. On a wide screen, clicking the empty canvas or pressing Escape outside a text field also closes the panel. The automation's trigger and project settings are on the **General** tab. [Automation concepts](/platform/automations/concepts) explains the node types and expression rules. + +### Code, prompts and JSON + +Code, prompts, conditions and JSON fields are code editors. They colour the syntax and every `{{ }}` template, and they know the automation. Type `{{` in a prompt and the closing braces appear with the cursor between them; type `nodes.` to see only the nodes that run earlier, and `.output.` to see that node's fields with their types. Ctrl+Space opens the suggestions anywhere. Point at a reference to see its type, or press ⌘K ⌘I (Ctrl+K Ctrl+I) to have the type at the cursor shown and read out. + +A moment after you stop typing, a problem is underlined exactly where it is. F8 and Shift+F8 move to the next and previous problem and read it out; ⌘. (Ctrl+.) applies a suggested fix, such as the closest node name. In a field with several lines, Tab indents; to leave it with the keyboard, press Escape, then Tab. **Expand editor** opens a long field in a larger editor, and **Back to the field** returns to it with your edit and your cursor in place. + +A JSON field such as **Input** changes the node only when its text is valid JSON of the right kind. While you type, the node keeps its last valid value and the field says what is missing, such as "This must be a JSON object, in curly braces." + +### Start inputs and End output + +Select **Start** to see what starts the automation. **Trigger** lists it in words; **Change in General** opens the **General** tab, where you set the trigger. Under **Fields**, **Inputs** shows the fields of the run input as a tree, and **Input schema** holds the JSON Schema behind them, which you can edit. **Shape** shows the input as Tale reads it, and **Last run** the input of the run shown. + +Select **End** to see what a run returns. **How a run ends** lists the three outcomes; under **Failed**, each node whose failure stops the run is a button that opens it. Under **Fields**, **Output** holds the JSON value a successful run returns, with templates such as `{{ nodes.report.output }}`. **Shape** shows the output's shape, and **Last run** the output of the run shown. + +## Read the source + +Choose **Source** in the view switch to read the whole document as YAML, highlighted, with line numbers, folding and search (⌘F or Ctrl+F). Every problem the check found is underlined at the line it concerns, so a problem in a part without a field of its own, such as a test or the name, has a place to be read. The source is read-only: **Copy YAML** copies it, and **Download YAML** saves it as a file named after the automation and version, such as `gmail-triage-inbox-v3.yml`, with `-draft` added while you have unsaved changes. To change the document, use the fields or your coding agent. + +## Edit with your coding agent -Open **Control flow** for conditions and iteration; it is already open on a node that has one. Use **Close** to return to the canvas. On a wide screen, clicking the empty canvas or pressing Escape outside a text field also closes the panel. The automation's trigger and project settings are on the **General** tab. [Automation concepts](/platform/automations/concepts) explains the node types and expression rules. +Larger changes, such as adding nodes or reworking the flow, come from a coding agent such as Claude Code, Codex or Cursor, connected to Tale's MCP server. **Edit with your coding agent** is the last button at the top right of the canvas in every view, and the main action of an automation that has no nodes yet. Its dialog shows the automation's name to give the agent, **Set up MCP**, which opens **Settings > API > MCP**, and **How to connect a coding agent**, which opens the [MCP endpoint](/develop/mcp-endpoint) guide. The agent reads the automation, changes and checks it, and saves a new version, which then appears on the canvas as [When a new version arrives](#new-versions) describes. ## Find and fix problems -While you edit, Tale checks the draft the same way it checks a save. A moment after you stop typing, the **Problems** button beside **Save** shows what the check found: a red error icon and an amber warning icon, each with its count, or **No problems**. On a phone, the button sits in the toolbar over the canvas. An error is something a run would fail on, such as a reference to a node that does not exist. A warning is something that might go wrong, such as reading the output of a node that is sometimes skipped. A node with problems shows the same counts on its box, and a field with a problem explains it under the field. +While you edit, Tale checks the draft the same way it checks a save. A moment after you stop typing, the **Problems** button beside **Save** shows what the check found: a red error icon and an amber warning icon, each with its count, or **No problems**. On a phone, the button sits in the toolbar over the canvas. An error is something a run would fail on, such as a reference to a node that does not exist. A warning is something that might go wrong, such as reading the output of a node that is sometimes skipped. A node, a condition, Start or End with problems shows the same counts on its box, and a field with a problem explains it under the field. Click **Problems** to list them. On a wide screen the list opens under the canvas; on narrower screens it opens in a panel. Each entry says what is wrong, where it is, why, and how to fix it. **Technical details** shows the engine's own message, and the code beside the title helps when you search or ask for support. **All**, **Errors** and **Warnings** filter the list, and Escape closes it. -Select an entry, or press Enter on it, to go there: the node opens, its field takes focus, and where the field shows the text as written, the part that causes the problem is selected. A problem with no field of its own, such as a model the organization does not serve, is listed under **Problems in this node** at the top of the node's fields. A problem in a part of the automation the editor does not show, such as its output or its tests, says that it can't be edited here; change that part through MCP, the API or an uploaded package. +Select an entry, or press Enter on it, to go there: the node opens, its field takes focus, and the part that causes the problem is selected. A problem with no field of its own, such as a model the organization does not serve, is listed under **Problems in this node** at the top of the node's fields. A problem in the inputs opens **Start**, one in the output opens **End**, and one in any other part of the document, such as a test or the name, opens **Source** at that line. A problem in a node that your draft no longer has says "Change this with your coding agent." While errors remain, **Save** is disabled and says why, for example "Fix 1 error to save". On a phone, where **Save** sits under a node's fields, **Show problems** beside that reason opens the list. Warnings never block saving or deploying. If Tale can't check the draft, for example because the connection dropped, the button shows **Couldn't check** and you can still save: every save is checked again on the server. When a save or a deploy is refused because of errors, the list opens with the server's problems, starting at the first error. @@ -53,7 +141,7 @@ The check runs only for Developers, Admins and Owners, the roles that can save. 1. Edit the required fields and click **Save**. 2. Enter a **Version message** that explains the change, then **Save version**. This appends a version and preserves earlier ones. If someone saved another version while you were editing, Tale refuses the save and asks: **Discard my changes and reload** shows the newer version, **Save anyway** appends your version on top of it — the newer one stays in the version history, but the latest version is then yours. -3. Click **Test run**. If the workflow declares an input schema, fill **Run input (JSON)** in the dialog. Expand **Input schema** to inspect required fields and types. Invalid JSON or a schema mismatch prevents the start. +3. Click **Test run**. If the workflow declares an input schema, type the input as JSON in **Run input (JSON)**; the field suggests the schema's field names as you type a key, and ⌘Enter (Ctrl+Enter) starts the run. Expand **Input schema** to see the required fields and their types. Invalid JSON or a schema mismatch prevents the start. 4. Start the test, switch to the **Runs** tab and inspect its row. Open it to compare the resolved input, output, and proposed operations with your expected result. For a workflow requiring `owner` and `repo`, an input might be: @@ -71,7 +159,7 @@ Use the workflow's actual schema. A field typed as a number must receive a JSON -![The Test run dialog shows owner and repo JSON values and the expanded input schema.](/images/platform/automation-run-input.webp) +![The Test run dialog shows owner and repo JSON values in the code editor and the expanded input schema as a list of fields.](/images/platform/automation-run-input.webp) @@ -85,7 +173,7 @@ A trigger also runs the deployed version. Set it up only when you are ready for ## Diagnose a result -**Show last run** overlays run states on the canvas. Select a node to see **In this run**, its **Resolved input**, **Output**, and effects. This is often enough to find a wrong reference: compare the input received by the failed node with the output of the node it reads. +Once the automation has run, the canvas shows its latest run: each node's bottom line says how it ended, such as **Succeeded** or **Skipped**, and each condition shows how it decided, **Yes** or **No**. The eye button at the top right of the canvas, **Hide last run**, removes the run from the canvas, and **Show last run** brings it back. Select a node and open **Last run** to see its **Resolved input**, **Output**, and effects. This is often enough to find a wrong reference: compare the input received by the failed node with the output of the node it reads. Switch to **Runs** and open a row for the full record. The tabs remain visible with **Runs** active; **Editor** returns to the workflow. Check whether it was a test or live run and inspect already completed operations before starting another run. [Execution logs](/platform/automations/execution-logs) explains waiting, failures, automatic retries, and stopping. diff --git a/docs/en/platform/automations/execution-logs.md b/docs/en/platform/automations/execution-logs.md index 0528024cb8..921770a446 100644 --- a/docs/en/platform/automations/execution-logs.md +++ b/docs/en/platform/automations/execution-logs.md @@ -7,7 +7,7 @@ Open an automation, switch to its **Runs** tab and select a row to understand wh -![The run page of a test run of Triage GitHub issues, marked Succeeded, Test, and v1 and started by you, with its start and finish times above the workflow graph, where the issues, open issues, score, and report nodes each show Ran; the run's effects list begins below the graph.](/images/platform/automation-run-detail.webp) +![The run page of a test run of Triage GitHub issues, marked Succeeded, Test, and v1 and started by you, with its start and finish times above the workflow graph, where the issues, open issues, score, and report nodes each show Succeeded; the run's effects list begins below the graph.](/images/platform/automation-run-detail.webp) @@ -36,9 +36,11 @@ Leave the run on hold while you investigate. **Request stop** asks Tale to stop ## Inspect the node that matters -Select a node on the run’s canvas. **Resolved input** shows the actual values after template evaluation; **Output** shows what the step returned. These fields distinguish a bad reference from a service failure. +A failed run opens with the node that failed in view. The node is framed in red and its bottom line shows the first line of its error; the nodes the run went through to reach it stand out while the others step back; and End says where the run failed, such as **Failed at Propose**. -Node states include **Ran**, **Skipped**, **Failed**, **Never reached**, **Not reached yet** and, on a stopped run, **Stopped here** for the node the run was on when it was stopped. A skipped node may have a false condition, an unmet dependency, an alternate branch or a failure rule that permits continuation. Do not assume every skipped node is an error. +Select a node on the run’s canvas to open its **Last run** tab. **Resolved input** shows the actual values after template evaluation; **Output** shows what the step returned. These fields distinguish a bad reference from a service failure. + +On the canvas, each node's bottom line says how it ended: **Succeeded**, **Failed**, **Skipped**, **Not run**, **Not reached yet** or, on a stopped run, **Stopped here** for the node the run was on when it was stopped. Each condition shows how it decided, **Yes** or **No**. The badge on the **Last run** tab names the same states **Ran**, **Failed**, **Skipped**, **Never reached**, **Not reached yet** and **Stopped here**. A skipped node may have a false condition, an unmet dependency, an alternate branch or a failure rule that permits continuation. Do not assume every skipped node is an error. For example, a reminder node may receive a customer name but an empty invoice ID. Inspect its upstream output: if the record now uses another field, correct the reference there rather than replacing the mail credential. Verify the corrected resolved input in a new test run. diff --git a/docs/en/tutorials/editor/workflow-with-approvals.md b/docs/en/tutorials/editor/workflow-with-approvals.md index a2f2891a03..4d26d2e603 100644 --- a/docs/en/tutorials/editor/workflow-with-approvals.md +++ b/docs/en/tutorials/editor/workflow-with-approvals.md @@ -60,7 +60,7 @@ If that name already exists, uploading adds another version. Use a different wor On **Editor**, click **Test run**. This example has no runtime input, so it can run with an empty object. Switch to **Runs**. The list should show a **Succeeded** test run. -Open the run and check that the canvas shows both nodes as **Ran**. Select `send` and inspect its resolved input. The recipient should be `reviewer@example.com`, the subject `Approval practice`, and the text the sentence from `draft`. The connector uses a deterministic mock in this mode. No email is sent and no approval card appears. +Open the run and check that the canvas marks both nodes **Succeeded**. Select `send` and inspect its resolved input. The recipient should be `reviewer@example.com`, the subject `Approval practice`, and the text the sentence from `draft`. The connector uses a deterministic mock in this mode. No email is sent and no approval card appears. The workflow includes a test expecting the `imap-smtp.send` effect. A passing mock confirms the graph and proposed call; it does not prove mailbox credentials or message delivery. diff --git a/docs/fr/develop/connectors.md b/docs/fr/develop/connectors.md index 4766be4305..e8f74f6437 100644 --- a/docs/fr/develop/connectors.md +++ b/docs/fr/develop/connectors.md @@ -25,6 +25,8 @@ auth: - method: api-key ``` +Un connector dont le nom est fait de mots courants, comme `task` (Tasks), déclare aussi `i18n.de.displayName` et `i18n.fr.displayName`. Un connector qui porte le nom d’un produit, comme Tavily, garde ce nom dans toutes les langues. + ### Définir les destinations autorisées | Champ | Signification | @@ -47,6 +49,7 @@ Ajouter un connector demande une contribution au code source. L'exécution lit l | Champ | Contrat pour l'auteur et l'appelant | | --- | --- | | `name`, `description` | Nom stable en snake_case et explication de l'usage de l'action | +| `title`, `i18n` | L’action en mots pour les personnes : un `title` anglais court, avec une seule majuscule initiale et sans le nom du connector (« List issues »), plus `i18n.de.title` et `i18n.fr.title` (et `de-CH` quand l’orthographe suisse diffère). Le canevas d’automatisation affiche un nœud sous la forme « GitHub · Lister les issues » dans la langue du lecteur ; une action fournie à laquelle il en manque un fait échouer les tests du catalogue | | `input` | Schéma JSON objet, validé avant exécution ; décrire les champs et signaler ceux requis | | `output` | Signature du résultat au style TypeScript ; documentation, pas validation des sorties à l'exécution | | `effects` | `read` ou `write` ; les écritures passent par la politique d'approbation | diff --git a/docs/fr/platform/automations/assistant.md b/docs/fr/platform/automations/assistant.md index d0d97a5304..36c795c9b9 100644 --- a/docs/fr/platform/automations/assistant.md +++ b/docs/fr/platform/automations/assistant.md @@ -3,25 +3,25 @@ title: Choisir comment créer une automatisation description: Modifie directement le workflow dans l’éditeur visuel ou laisse ton agent de code modifier les automatisations par l’endpoint MCP de Tale. --- -Modifie une automatisation sur son canevas, ou laisse ton agent de code la modifier par l’endpoint MCP de Tale. L’éditeur n’a pas d’assistant IA intégré : pour travailler sur tes automatisations avec l’IA, tu passes par un agent de code comme Claude Code ou Codex, connecté avec ta clé API. Les deux chemins enregistrent des versions du même workflow et suivent les mêmes règles de validation et de déploiement. Créer des automatisations et les mettre en service demande le rôle Propriétaire, Admin ou Développeur. +Modifie les champs d’une automatisation directement dans l’éditeur, ou laisse ton agent de code la créer et la modifier par l’endpoint MCP de Tale. L’éditeur n’a pas d’assistant IA intégré : pour travailler sur tes automatisations avec l’IA, tu passes par un agent de code comme Claude Code ou Codex, connecté avec ta clé API. Les deux chemins enregistrent des versions du même workflow et suivent les mêmes règles de validation et de déploiement. Créer des automatisations et les mettre en service demande le rôle Propriétaire, Admin ou Développeur. -## Modifier le workflow dans l’éditeur visuel +## Modifier le workflow dans l’éditeur -Ouvre **Automatisations** et sélectionne le workflow. Choisis un nœud pour examiner ses entrées, son modèle, son code ou ses autres réglages. Enregistre la modification avec un message de version, lance un test, puis déploie la version souhaitée lorsque ses vérifications passent. +Ouvre **Automatisations** et sélectionne le workflow. Choisis un nœud pour examiner son entrée, son modèle, son code ou ses autres réglages, et modifie le champ voulu. Enregistre la modification avec un message de version, lance un test, puis déploie la version souhaitée lorsque ses vérifications passent. Le canevas se dispose lui-même à partir des références entre les nœuds ; tu n’y ajoutes pas de nœuds et n’y traces pas de liaisons. -![L’éditeur d’automatisation montre le graphe du workflow et les champs d’entrée du nœud sélectionné dans un panneau latéral.](/images/platform/automation-editor-canvas.webp) +![L’éditeur d’automatisation montre le workflow entre Début et Fin et les champs du nœud sélectionné dans un panneau latéral.](/images/platform/automation-editor-canvas.webp) -[L’éditeur de workflow](/fr/platform/automations/editor) détaille ces étapes, l’examen d’une exécution et le retour à une version antérieure. Le canevas n’inclut pas de panneau d’assistant conversationnel. +[L’éditeur de workflow](/fr/platform/automations/editor) détaille ces étapes, la lecture du canevas, l’examen d’une exécution et le retour à une version antérieure. Le canevas n’inclut pas de panneau d’assistant conversationnel. -## Utiliser un assistant externe avec MCP +## Modifier avec ton agent de code -Connecte ton agent de code avec l’endpoint indiqué sous **Paramètres > API > MCP** et ta clé API personnelle ; [Utiliser Tale depuis ton éditeur ou un script](/fr/develop/use-tale-from-your-editor) propose des configurations prêtes à l’emploi. Dis-lui ce que l’automatisation doit recevoir, ce qu’elle doit produire et quels systèmes elle peut modifier. Demande-lui d’examiner les automatisations existantes et ce dont ton organisation dispose avant d’en créer une autre. +Le point de départ est **Modifier avec ton agent de code**, le dernier bouton en haut à droite du canevas de l’éditeur. Son dialogue montre le nom de l’automatisation à donner à l’agent, **Configurer MCP**, qui ouvre **Paramètres > API > MCP**, et un lien vers le guide pour connecter un agent de code. Connecte ton agent de code avec l’endpoint indiqué sous **Paramètres > API > MCP** et ta clé API personnelle ; [Utiliser Tale depuis ton éditeur ou un script](/fr/develop/use-tale-from-your-editor) propose des configurations prêtes à l’emploi. Dis-lui ce que l’automatisation doit recevoir, ce qu’elle doit produire et quels systèmes elle peut modifier. Demande-lui d’examiner les automatisations existantes et ce dont ton organisation dispose avant d’en créer une autre. -Ton agent travaille sur une automatisation comme toi dans l’éditeur. Il lit la référence et la version actuelle, valide sa modification, l’exécute avec les simulations et lance les tests de l’automatisation, puis enregistre une nouvelle version en indiquant celle dont il est parti. Si quelqu’un a enregistré une version plus récente entre-temps, Tale refuse l’enregistrement ; l’agent lit alors cette version et y intègre d’abord sa modification. Quand il lance une version enregistrée avec les simulations, l’exécution apparaît dans l’onglet **Exécutions** de l’automatisation comme exécution **Essai**, **Lancée par toi (API)**, pour que tu puisses ouvrir ce qu’il a exécuté. +Ton agent travaille sur une automatisation comme toi dans l’éditeur. Il lit la référence et la version actuelle, valide sa modification, l’exécute avec les simulations et lance les tests de l’automatisation, puis enregistre une nouvelle version en indiquant celle dont il est parti. Si quelqu’un a enregistré une version plus récente entre-temps, Tale refuse l’enregistrement ; l’agent lit alors cette version et y intègre d’abord sa modification. Quand il lance une version enregistrée avec les simulations, l’exécution apparaît dans l’onglet **Exécutions** de l’automatisation comme exécution **Essai**, **Lancée par toi (API)**, pour que tu puisses ouvrir ce qu’il a exécuté. Une version enregistrée par l’agent apparaît sur le canevas pendant que tu consultes l’automatisation. Enregistrer crée une version sans la mettre en service. Avant que ton agent mette en service, supprime, définisse un déclencheur ou installe une automatisation dans des projets, un client qui respecte le marquage de Tale pour ces outils, comme Claude Code, te demande ton accord, même si tu laisses l’agent lancer d’autres outils sans demander. Examine la version et ses résultats de test avant d’accepter. Chaque modification de ton agent figure dans le [journal d’audit](/fr/platform/admin/governance/audit-logs) avec **Source** Agent de code, et l’[endpoint MCP](/fr/develop/mcp-endpoint) liste chaque outil qu’il peut utiliser. @@ -29,4 +29,4 @@ Enregistrer crée une version sans la mettre en service. Avant que ton agent met Un nœud agent accomplit du travail pendant une exécution. Il est distinct de l’agent de code qui t’aide à écrire le workflow. De même, une [approbation](/fr/platform/approvals/concepts) autorise une opération en attente pendant l’exécution ; elle ne valide pas une modification proposée de la définition. Ton agent de code ne décide jamais d’une approbation : si une exécution qu’il lance en demande une, elle attend qu’une personne décide dans Tale. -Choisis [l’éditeur](/fr/platform/automations/editor) pour une modification directe ou [MCP](/fr/develop/mcp-endpoint) pour travailler depuis ton client. Pars d’[une automatisation existante](/fr/platform/automations/catalog) lorsqu’une solution adaptée est déjà disponible. +Choisis [l’éditeur](/fr/platform/automations/editor) pour une modification directe ou [MCP](/fr/develop/mcp-endpoint) pour travailler avec ton agent de code. Pars d’[une automatisation existante](/fr/platform/automations/catalog) lorsqu’une solution adaptée est déjà disponible. diff --git a/docs/fr/platform/automations/concepts.md b/docs/fr/platform/automations/concepts.md index 5e0d0a6802..1f43388154 100644 --- a/docs/fr/platform/automations/concepts.md +++ b/docs/fr/platform/automations/concepts.md @@ -42,17 +42,17 @@ tests: input: { invoiceId: 'inv-1' } ``` -Le bloc `ui` conserve la disposition du canvas. Déplacer un nœud modifie sa position, sans changer son exécution. +Tale dispose le canevas à partir des références entre les nœuds : personne ne place un nœud à la main. Un bloc `ui` contient des métadonnées libres que Tale conserve telles quelles et ignore. ### Les liaisons se déduisent, elles ne se déclarent pas -Il n’y a pas de liste de liaisons. Un nœud en lit un autre en le référençant — `{{ nodes.invoice.output.id }}` — et cette référence _est_ la liaison que trace le canvas. L’ordre d’exécution est un tri topologique sur ces liaisons déduites : supprimer une référence retire donc aussi une flèche, et deux nœuds qui se lisent l’un l’autre sont refusés comme une boucle. +Il n’y a pas de liste de liaisons. Un nœud en lit un autre en le référençant — `{{ nodes.invoice.output.id }}` — et cette référence _est_ la liaison que trace le canevas. L’ordre d’exécution est un tri topologique sur ces liaisons déduites : supprimer une référence retire donc aussi un trait, et deux nœuds qui se lisent l’un l’autre sont refusés comme une boucle. Les templates utilisent une seule grammaire `{{ }}` d’expressions JavaScript sur `input`, `nodes..output` et, à l’intérieur d’un nœud qui itère, `item` et `index`. ### Le contrôle du flux vit sur le nœud -Brancher et répéter sont des champs du nœud plutôt que des types d’étape à part. Le canvas les montre donc comme des badges sur la boîte qu’ils concernent. +Brancher et répéter sont des champs du nœud plutôt que des types d’étape à part. Le canevas dessine chacun d’eux là où il agit : un `when` devient une condition au-dessus de son nœud, une alternative `elseOf` part de cette condition comme sa branche **Non**, `forEach` et `repeatUntil` placent le nœud dans un cadre, et `onError: continue` lui ajoute une puce. | Champ | Ce qu’il fait | | ---------------------------- | --------------------------------------------------------------------------------------------- | @@ -80,6 +80,12 @@ Une sortie **structurée** possède des champs nommés, accessibles avec `nodes. Un outil sans schéma de sortie produit une sortie non structurée. Pour transformer son texte en données structurées utilisables par les étapes suivantes, ajoute un nœud `llm` avec un `outputSchema`. En cas d’erreur, la validation indique la référence incorrecte et les champs ou contextes autorisés. Corrige-la avant d’enregistrer à nouveau. +## Les chemins qu’une exécution peut prendre {#paths} + +Chaque condition, et chaque nœud qui peut échouer pendant que l’exécution continue, ouvre deux possibilités à une exécution. Tale essaie chaque combinaison et garde les différentes façons dont une exécution réussie peut se dérouler ; chacune est un chemin. Un chemin nomme les conditions qui le décident, comme les nœuds qui s’exécutent, ceux qui sont ignorés et ceux qui échouent pendant que l’exécution continue, ainsi que les nœuds qui s’exécutent sur ce chemin. Un nœud qui s’exécute sur chaque chemin s’exécute toujours ; un nœud qui ne s’exécute sur aucun ne peut jamais s’exécuter, et Tale le signale par un avertissement. + +Tale liste jusqu’à 32 chemins et compte les autres. Au-delà de 12 conditions et échecs tolérés, les combinaisons sont trop nombreuses pour être parcourues : Tale ne liste alors aucun chemin, mais indique toujours, pour chaque nœud, quand il s’exécute. Tale nomme aussi les nœuds dont l’échec termine l’exécution et ce qui peut faire échouer chacun d’eux. L’éditeur montre les chemins sur le canevas, comme le décrit [Suivre les chemins possibles](/fr/platform/automations/editor#paths) ; un client du [point d’accès MCP](/fr/develop/mcp-endpoint) lit les mêmes chemins dans `analysis.paths`. + ## Ce que Tale vérifie avant une exécution {#checks} Tale vérifie le document entier quand tu l’enregistres, quand tu déploies une version et chaque fois qu’un client appelle `validate_automation`. Une **erreur** décrit un échec certain ou du code qui dépasse les limites d’analyse : elle empêche d’enregistrer comme de déployer. Un **avertissement** signale ce qui peut échouer ou ne sert à rien. Il n’empêche jamais d’enregistrer ni de déployer, c’est donc toi qui décides d’agir. Chaque problème nomme son nœud et son champ et, dans un template, une condition ou du code, l’expression exacte. diff --git a/docs/fr/platform/automations/editor.md b/docs/fr/platform/automations/editor.md index 1cbcd5e0bd..ade7d8eb7e 100644 --- a/docs/fr/platform/automations/editor.md +++ b/docs/fr/platform/automations/editor.md @@ -1,49 +1,137 @@ --- title: L’éditeur de workflow -description: Examine et modifie les nœuds, fournis les données de test, puis enregistre, déploie ou rétablis une version. +description: Lis une automatisation sur son canevas, suis ses chemins possibles, modifie les champs d’un nœud, enregistre une version, puis déploie-la ou rétablis-en une autre. --- -L’éditeur de workflow permet de modifier le comportement d’une automatisation et de choisir sa version active. Il faut les droits Développeur, Admin ou Propriétaire pour apporter des changements. Enregistrer, tester et mettre en service sont des étapes distinctes : modifier un brouillon laisse la version déployée en place. +L’éditeur de workflow permet de lire ce que fait une automatisation, de modifier ses champs et de choisir la version enregistrée qui s’exécute en service. Les changements plus importants, comme de nouveaux nœuds, passent par un agent de code via MCP. Il faut les droits Développeur, Admin ou Propriétaire pour apporter des changements. Enregistrer, tester et mettre en service sont des étapes distinctes : modifier un brouillon laisse la version déployée en place. Ouvre **Automatisations**, puis sélectionne une automatisation. Elle s’ouvre dans l’onglet **Éditeur**. Une automatisation ouverte depuis l’onglet **Automatisations** d’un projet affiche ce projet au début du fil d’Ariane : choisis le nom du projet pour revenir au projet, ou **Automatisations** pour revenir à ses automatisations. Que tu ouvres une automatisation depuis un projet ou depuis la liste, la barre de navigation met **Automatisations** en évidence. Pour en créer une, consulte [Créer ou importer une automatisation](/fr/platform/automations/catalog). | Onglet | Utilisation | | --- | --- | -| **Éditeur** | Modifier le workflow, tester une version enregistrée et choisir celle à mettre en service. | +| **Éditeur** | Lire et modifier le workflow, tester une version enregistrée et choisir celle à mettre en service. | | **Général** | Choisir ce qui démarre l’automatisation et les projets qui peuvent l’utiliser. | | **Exécutions** | Examiner les derniers lancements et ouvrir le détail d’une exécution. | Le sélecteur **Version** reste à droite des onglets Éditeur, Général et Exécutions, sur ordinateur comme sur téléphone. Il affiche les messages de version, les dates, les résultats des tests et la version en service. Sélectionne une ligne pour ouvrir cette version. Sur ordinateur, les commandes d’exécution se trouvent à côté des onglets, avec **Enregistrer** et **Abandonner** ; dans **Général**, seuls **Enregistrer** et **Abandonner** y figurent. Un point sur un onglet signale ses modifications non enregistrées. Avant de quitter cet onglet ou de changer de version, Tale te demande quoi en faire. -Sur téléphone, la navigation est compacte à l’ouverture d’une automatisation. Le canevas de l’éditeur occupe la hauteur disponible. Les commandes d’exécution et de mise en service se trouvent dans le canevas, à côté du zoom. Sélectionne un nœud pour ouvrir ses champs — avec Enregistrer et Abandonner — dans un panneau au bas de l’écran. +Sur téléphone, la navigation est compacte à l’ouverture d’une automatisation. Le canevas de l’éditeur occupe la hauteur disponible, et les commandes d’exécution et de mise en service se trouvent dans une barre au bas du canevas. Sélectionne un nœud pour ouvrir ses champs — avec Enregistrer et Abandonner — dans un panneau au bas de l’écran. - + -![L’éditeur montre les nœuds connectés et les champs du nœud sélectionné à côté du canvas.](/images/platform/automation-editor-canvas.webp) +![L’éditeur montre les nœuds de Gmail triage inbox entre Début et Fin, une condition formulée en mots au-dessus d’un nœud et les champs du nœud sélectionné à côté du canevas.](/images/platform/automation-editor-canvas.webp) Pour passer à une autre automatisation sans revenir à la liste, clique sur le nom de celle qui est ouverte dans le fil d’Ariane. Le menu garde toutes les automatisations de l’organisation, même après un changement de projet, sauf celles associées uniquement à des projets que tu ne peux pas ouvrir. Celles qui ne sont rattachées à aucun projet apparaissent en premier, puis viennent celles liées à des projets. Une ligne horizontale sépare les deux groupes. Cherche par nom ou par slug, puis sélectionne une entrée. Tu conserves l’onglet ouvert. Depuis le détail d’une exécution, tu arrives sur la liste **Exécutions** de l’autre automatisation. Le numéro de version sélectionné n’est pas repris : **Éditeur** affiche la dernière version enregistrée de cette autre automatisation. -## Lire le canvas +## Lire le canevas -Chaque bloc est un nœud. Son libellé indique l’étape et le type ; **Lit** désigne les nœuds dont il utilise la sortie. Les flèches viennent des références, comme `{{ nodes.draft.output.text }}`. Modifie la référence pour changer la dépendance ; dessiner une flèche ne crée pas de dépendance. +Tale dessine le canevas à partir du document de l’automatisation et le dispose lui-même : **Début** se trouve en haut, **Fin** en bas, et chaque nœud se place sous les nœuds qu’il lit. Le canevas se lit donc de haut en bas, dans l’ordre que suit une exécution. Personne ne place un bloc, et dessiner ne relie rien. Un trait vient d’une référence comme `{{ nodes.draft.output.text }}` ; pour changer ce qu’un nœud lit, modifie la référence. -Les badges indiquent les conditions et boucles : `when`, `else of`, `for each`, `repeat until` et `continue on error`. Un avertissement de cycle signifie que plusieurs nœuds dépendent les uns des autres. Supprime la référence circulaire avant d’enregistrer une version exécutable. +### Début et Fin + +**Début** indique ce qui démarre une exécution et ce qu’elle reçoit. Sous **Démarre**, il décrit le déclencheur en mots, par exemple une planification avec son fuseau horaire et sa prochaine exécution, et précise si le déclencheur est désactivé ou attend une version en service. Vient ensuite **À la main, via l’API ou MCP**, car ces démarrages sont toujours possibles. Sous **Entrée**, il liste les champs de l’entrée de l’exécution, avec leur type et leur caractère obligatoire. Quand le déclencheur lancerait des exécutions dont l’automatisation refuse l’entrée, Début le signale. + +**Fin** indique ce que renvoie une exécution réussie et comment une exécution peut se terminer. Sous **Renvoie**, elle nomme la sortie, par exemple **La sortie de Report**, ou liste ses champs et les nœuds dont ils viennent ; un champ vide lors de certaines exécutions porte **peut être vide**. Sous **Se termine**, elle liste les trois issues : **Réussie** renvoie la sortie, **En échec** survient quand l’un des nœuds qui arrêtent l’exécution échoue, et **Arrêtée** quand quelqu’un arrête l’exécution. + +### Nœuds + +Chaque nœud est un bloc. Sa première ligne montre son icône et son titre, tiré de son ID : `open_issues` devient **Open issues**. La ligne suivante indique de quel type de nœud il s’agit : le connecteur et l’action, comme **GitHub · Lister les issues**, **Transformation**, **Modèle de langage** ou **Agent** avec son modèle, ou l’automatisation qu’il appelle. Quand Tale sait ce que le nœud renvoie, une ligne en montre la structure, par exemple `{ issues: object[] }`. La ligne du bas indique ce que le nœud lit, par exemple **Lit Issues et l’entrée (owner, repo)**, ou **Ne lit aucun autre nœud**. + +Des puces et de petites icônes ajoutent ce que la disposition ne montre pas. **Continue en cas d’erreur** marque un nœud dont l’exécution tolère l’échec, et **Ne s'exécute jamais** un nœud qu’aucune combinaison de conditions n’atteint. Un bouclier marque un nœud qui modifie des données dans un service connecté, où une exécution réelle peut attendre une approbation ; une bulle marque un agent qui peut poser une question ; une épingle barrée marque un modèle sans fournisseur fixé. Pointe sur une icône pour lire sa phrase ; un lecteur d’écran l’entend avec le bloc. Un bloc qui a des problèmes en affiche le nombre sur sa première ligne. + +### Conditions et branches + +La condition d’un nœud (`when`) se place au-dessus de lui, dans une pastille qui la formule en mots, par exemple « total de Score est supérieur à 1 000 ». Le nœud du dessous ne s’exécute que si la condition est remplie. Quand un autre nœud est son alternative (`elseOf`), la condition se divise en deux traits : **Oui** mène au nœud de gauche, qui s’exécute quand la condition est remplie, et **Non** à son alternative, à droite. Une condition que Tale ne peut pas formuler en mots affiche l’expression elle-même, en police de code. + +```yaml +nodes: + - id: escalate + type: transform + when: '{{ nodes.score.output.total > 1000 }}' + input: { total: '{{ nodes.score.output.total }}' } + code: 'return { text: "Escalate " + input.total };' + - id: file + type: transform + elseOf: escalate + input: { total: '{{ nodes.score.output.total }}' } + code: 'return { text: "File " + input.total };' +``` + +Dans cet extrait, la condition au-dessus d’Escalate indique « total de Score est supérieur à 1 000 », **Oui** mène à Escalate et **Non** à File. Pointe sur une condition, ou sur son **Oui** ou son **Non**, pour mettre en évidence les chemins qui passent par elle. + +### Traits, cadres et blocs tiretés + +Un trait plein signifie que le nœud du dessous lit la sortie de celui du dessus. Un trait tireté signifie que le nœud du dessous s’exécute après celui du dessus sans lire sa sortie, par exemple parce que sa condition la lit. Un trait pointillé vers Fin part du dernier nœud d’une exécution dont Fin ne renvoie pas la sortie. Les traits **Oui** et **Non** ont leurs propres couleurs. + +Un cadre autour d’un nœud indique qu’il s’exécute plusieurs fois : une fois par élément d’une liste (**Pour chaque élément de …**) ou à nouveau jusqu’à ce qu’une condition soit remplie (**Se répète jusqu’à : …, 5 fois au plus**). Un bloc tireté est un nœud qui peut ne pas s’exécuter ; après une exécution, c’est un nœud qui ne s’est pas exécuté. **Légende**, à côté du zoom, explique chaque type de trait et de bloc. + +### Clavier et vue Liste + +Le diagramme est un seul arrêt dans l’ordre de tabulation. Atteins-le avec Tab et le focus se pose sur Début ; les flèches suivent les traits de bloc en bloc et le long d’une rangée, Origine et Fin sautent à Début et à Fin, et Entrée ouvre le bloc qui a le focus. Un lecteur d’écran annonce pour chaque bloc son titre, son type et ce qu’il lit ; pour une condition, il annonce le nœud sur lequel elle décide. + +Le sélecteur de vue en haut à gauche du canevas passe de **Canevas** à **Liste** et à **Source**. **Liste** montre les mêmes nœuds dans l’ordre de l’exécution, chacun avec ce qu’il lit et sa condition en mots ; Entrée y ouvre aussi un nœud. Quand le canevas est très étroit, il s’ouvre sur **Liste**. L’adresse garde la vue et le nœud ouvert, si bien qu’un lien partagé les rouvre tous les deux. + +### Quand une nouvelle version arrive {#new-versions} + +Quelqu’un peut enregistrer une nouvelle version pendant que tu consultes l’automatisation, par exemple un agent de code via MCP. Si tu consultes la dernière version sans modifications non enregistrées, le canevas passe à la nouvelle : les blocs glissent vers leur nouvelle place, les nouveaux apparaissent en fondu, ceux qui ont changé s’entourent une fois d’un anneau, et un lecteur d’écran annonce « Affichage de la v6. ». Le nœud ouvert le reste s’il existe encore. Si tu as des modifications non enregistrées, rien ne bouge. Un avis au-dessus du canevas indique **Une version plus récente a été enregistrée**, et **Afficher la v6 et abandonner mon brouillon** y passe. + +## Suivre les chemins possibles {#paths} + +Les conditions d’une exécution décident des nœuds qui s’exécutent. Le bouton des chemins, en haut à droite du canevas, compte les chemins qu’une exécution réussie peut prendre, par exemple **3 chemins**, et ouvre **Chemins possibles**. Chaque chemin nomme les conditions qui le décident, comme « Triage s’exécute » ou « Propose échoue, l’exécution continue », et le nombre de nœuds qui s’y exécutent. + +Pointe sur un chemin, ou atteins-le avec les flèches, pour le prévisualiser sur le canevas. Clique dessus ou appuie sur Entrée pour le garder affiché : les nœuds hors du chemin deviennent tiretés et disent pourquoi ils ne s’exécutent pas, Fin marque les sorties qui restent vides sur ce chemin, et un lecteur d’écran annonce le chemin affiché. **Tout afficher**, ou Échap, affiche de nouveau chaque nœud. La liste reste ouverte pendant que tu sélectionnes des nœuds, pour comparer un chemin avec les champs d’un nœud. + +Sous **Arrête l’exécution en cas d’échec**, la liste nomme les nœuds dont l’échec arrête l’exécution, avec ce qui peut faire échouer chacun d’eux. Pointe sur l’un d’eux pour les entourer tous de rouge ; sélectionne-en un pour l’ouvrir. + +Sur téléphone, la liste s’ouvre dans un panneau au bas de l’écran. Choisir un chemin ferme le panneau et laisse en haut du canevas une pastille qui nomme le chemin, avec **Tout afficher**. Quand chaque exécution suit le même chemin, la liste le dit. Au-delà de 12 conditions et échecs tolérés, les chemins sont trop nombreux pour être listés ; **Quand il s’exécute** indique toujours, pour chaque nœud, quand il s’exécute. Un canevas qui contient un cycle n’a pas de bouton des chemins. [Les chemins qu’une exécution peut prendre](/fr/platform/automations/concepts#paths) explique comment Tale les détermine. ## Modifier un nœud -Sélectionne un bloc pour ouvrir ses champs. Sur un écran large, le panneau apparaît à côté du canvas ; sans nœud sélectionné, le canvas occupe toute la largeur. Sur un écran plus étroit, les champs s’ouvrent dans un dialogue au-dessus du canvas. Un `transform` possède du **Code** ; un `llm`, des champs de prompt, modèle et schéma de sortie ; un `agent` ajoute l’environnement d’agent et l’équipement. Le sélecteur **Modèle** d’un nœud `llm` ou `agent` liste les modèles servis par les fournisseurs connectés de ton organisation ; un modèle absent de la liste peut être saisi, mais **Problèmes** avertit alors qu’une exécution en direct échouerait à ce nœud tant que son fournisseur n’est pas connecté. **Entrée** contient les valeurs JSON et références transmises au nœud. Un JSON incomplet est signalé et ne met pas le nœud à jour. +Sélectionne un bloc pour l’ouvrir. Sur un écran large, l’inspecteur s’ouvre à côté du canevas ; sans nœud sélectionné, le canevas occupe toute la largeur. Sur un écran plus étroit, il s’ouvre dans un panneau au-dessus du canevas. Son en-tête montre le titre du nœud, son type et son ID avec **Copier l’ID du nœud** ; les problèmes propres au nœud sont listés dessous. **Quand il s’exécute** résume la place du nœud dans le flux : à chaque exécution, sur certains chemins ou jamais, pourquoi il peut être ignoré et ce qui se passe s’il échoue, par exemple « S’il échoue, l’exécution s’arrête avec son erreur. » + +Trois onglets suivent : + +- **Champs** contient ce que tu peux modifier. Un `transform` possède du **Code** ; un `llm`, **Prompt**, **Prompt système**, **Modèle** et **Schéma de sortie** ; un `agent` y ajoute l’environnement d’agent et l’équipement. **Entrée** contient les valeurs JSON et références transmises au nœud. +- **Structure** montre ce que le nœud reçoit et renvoie, d’où Tale tient cette structure et quels nœuds lisent sa sortie. Sélectionne un lecteur pour l’ouvrir. **Afficher en TypeScript** montre la même structure sous forme de type. +- **Dernière exécution** montre l’**Entrée résolue**, la **Sortie** et les effets du nœud dans l’exécution affichée sur le canevas. L’onglet apparaît tant que le canevas montre une exécution. + +Le sélecteur **Modèle** d’un nœud `llm` ou `agent` liste les modèles servis par les fournisseurs connectés de ton organisation ; un modèle absent de la liste peut être saisi, mais **Problèmes** avertit alors qu’une exécution réelle échouerait à ce nœud tant que son fournisseur n’est pas connecté. + +Ouvre **Contrôle du flux** pour la condition, l’itération et la gestion des échecs du nœud ; si le nœud en utilise une, la section est déjà ouverte. **Si**, **Pour chaque** et **Répéter jusqu’à** prennent des expressions. **Sinon de** ne propose que des nœuds qui ont une condition, et **Aucun** retire l’alternative. **Répétitions maximales** apparaît avec **Répéter jusqu’à** et prend un nombre entier de 1 à 20. **En cas d’erreur** choisit entre **Arrêter l’exécution** et **Continuer sans lui** ; continuer ignore chaque nœud qui lit la sortie du nœud en échec. Sous une condition, une liste ou une alternative, une phrase dit en mots ce que fait le réglage. + +Utilise **Fermer** pour revenir au canevas. Sur un écran large, tu peux aussi fermer le panneau en cliquant sur le fond du canevas ou en appuyant sur Échap hors d’un champ de texte. Les réglages du déclencheur et des projets se trouvent dans l’onglet **Général**. [Concepts d’automatisation](/fr/platform/automations/concepts) explique les types de nœuds et les expressions. + +### Code, prompts et JSON + +Le code, les prompts, les conditions et les champs JSON sont des éditeurs de code. Ils colorent la syntaxe et chaque template `{{ }}`, et connaissent l’automatisation. Tape `{{` dans un prompt et les accolades fermantes apparaissent, avec le curseur entre les deux ; tape `nodes.` pour ne voir que les nœuds qui s’exécutent avant, et `.output.` pour voir les champs de ce nœud avec leurs types. Ctrl+Espace ouvre les suggestions partout. Pointe sur une référence pour voir son type, ou appuie sur ⌘K ⌘I (Ctrl+K Ctrl+I) pour afficher et faire lire le type à la position du curseur. + +Un instant après ta dernière frappe, un problème est souligné exactement là où il se trouve. F8 et Maj+F8 passent au problème suivant et précédent et le lisent ; ⌘. (Ctrl+.) applique une correction proposée, comme le nom de nœud le plus proche. Dans un champ de plusieurs lignes, Tab indente ; pour le quitter au clavier, appuie sur Échap, puis sur Tab. **Agrandir l'éditeur** ouvre un long champ dans un éditeur plus grand, et **Revenir au champ** t’y ramène avec ta modification et ton curseur au même endroit. + +Un champ JSON comme **Entrée** ne modifie le nœud que si son texte est du JSON valide du bon type. Pendant la saisie, le nœud garde sa dernière valeur valide, et le champ indique ce qui manque, par exemple « Ce doit être un objet JSON, entre accolades. » + +### Entrées de Début et sortie de Fin + +Sélectionne **Début** pour voir ce qui démarre l’automatisation. **Déclencheur** le décrit en mots ; **Modifier dans Général** ouvre l’onglet **Général**, où tu règles le déclencheur. Sous **Champs**, **Entrées** montre les champs de l’entrée de l’exécution sous forme d’arbre, et **Schéma des données** contient le schéma JSON qui les définit, que tu peux modifier. **Structure** montre l’entrée telle que Tale la lit, et **Dernière exécution** l’entrée de l’exécution affichée. + +Sélectionne **Fin** pour voir ce que renvoie une exécution. **Comment une exécution se termine** liste les trois issues ; sous **En échec**, chaque nœud dont l’échec arrête l’exécution est un bouton qui l’ouvre. Sous **Champs**, **Sortie** contient la valeur JSON que renvoie une exécution réussie, avec des templates comme `{{ nodes.report.output }}`. **Structure** montre la structure de la sortie, et **Dernière exécution** la sortie de l’exécution affichée. + +## Lire la source + +Choisis **Source** dans le sélecteur de vue pour lire tout le document en YAML, coloré, avec numéros de ligne, repli et recherche (⌘F ou Ctrl+F). Chaque problème trouvé par la vérification est souligné sur la ligne qu’il concerne : un problème dans une partie sans champ propre, comme un test ou le nom, a ainsi un endroit où le lire. La source est en lecture seule : **Copier le YAML** la copie, et **Télécharger le YAML** l’enregistre dans un fichier nommé d’après l’automatisation et la version, par exemple `gmail-triage-inbox-v3.yml`, avec `-draft` en plus tant que tu as des modifications non enregistrées. Pour modifier le document, utilise les champs ou ton agent de code. + +## Modifier avec ton agent de code -Ouvre **Contrôle du flux** pour les conditions et répétitions. Si le nœud en a, la section est déjà ouverte. Utilise **Fermer** pour revenir au canvas. Sur un écran large, tu peux aussi fermer le panneau en cliquant sur le fond du canvas ou en appuyant sur Échap hors d’un champ de texte. Les réglages du déclencheur et des projets se trouvent dans l’onglet **Général**. [Concepts d’automatisation](/fr/platform/automations/concepts) explique les types de nœuds et les expressions. +Les changements plus importants, comme ajouter des nœuds ou remanier le flux, passent par un agent de code comme Claude Code, Codex ou Cursor, connecté au serveur MCP de Tale. **Modifier avec ton agent de code** est le dernier bouton en haut à droite du canevas, dans chaque vue, et l’action principale d’une automatisation qui n’a pas encore de nœud. Son dialogue montre le nom de l’automatisation à donner à l’agent, **Configurer MCP**, qui ouvre **Paramètres > API > MCP**, et **Connecter un agent de code**, qui ouvre le guide du [point d’accès MCP](/fr/develop/mcp-endpoint). L’agent lit l’automatisation, la modifie, la vérifie et enregistre une nouvelle version, qui apparaît ensuite sur le canevas comme le décrit [Quand une nouvelle version arrive](#new-versions). ## Trouver et corriger les problèmes -Pendant que tu modifies, Tale vérifie le brouillon comme il vérifie chaque enregistrement. Un instant après ta dernière frappe, le bouton **Problèmes** à côté d’**Enregistrer** montre ce que la vérification a trouvé : une icône d’erreur rouge et une icône d’avertissement orange, chacune avec son nombre, ou **Aucun problème**. Sur un téléphone, le bouton se trouve dans la barre au-dessus du canevas. Une erreur fait échouer une exécution, par exemple une référence à un nœud qui n’existe pas. Un avertissement signale un risque, par exemple la lecture de la sortie d’un nœud parfois ignoré. Un nœud qui a des problèmes affiche les mêmes nombres sur son bloc, et un champ concerné explique le problème juste en dessous. +Pendant que tu modifies, Tale vérifie le brouillon comme il vérifie chaque enregistrement. Un instant après ta dernière frappe, le bouton **Problèmes** à côté d’**Enregistrer** montre ce que la vérification a trouvé : une icône d’erreur rouge et une icône d’avertissement orange, chacune avec son nombre, ou **Aucun problème**. Sur un téléphone, le bouton se trouve dans la barre au-dessus du canevas. Une erreur fait échouer une exécution, par exemple une référence à un nœud qui n’existe pas. Un avertissement signale un risque, par exemple la lecture de la sortie d’un nœud parfois ignoré. Un nœud, une condition, Début ou Fin qui a des problèmes affiche les mêmes nombres sur son bloc, et un champ concerné explique le problème juste en dessous. -Clique sur **Problèmes** pour les lister. Sur un écran large, la liste s’ouvre sous le canvas ; sur un écran plus étroit, dans un panneau. Chaque entrée indique ce qui ne va pas, où, pourquoi et comment le corriger. **Détails techniques** affiche le message du moteur lui-même, et le code à côté du titre t’aide pour une recherche ou une demande d’assistance. **Tous**, **Erreurs** et **Avertissements** filtrent la liste ; Échap la ferme. +Clique sur **Problèmes** pour les lister. Sur un écran large, la liste s’ouvre sous le canevas ; sur un écran plus étroit, dans un panneau. Chaque entrée indique ce qui ne va pas, où, pourquoi et comment le corriger. **Détails techniques** affiche le message du moteur lui-même, et le code à côté du titre t’aide pour une recherche ou une demande d’assistance. **Tous**, **Erreurs** et **Avertissements** filtrent la liste ; Échap la ferme. -Sélectionne une entrée, ou appuie sur Entrée dessus, pour t’y rendre : le nœud s’ouvre, son champ reçoit le focus et, si le champ affiche le texte tel qu’il est enregistré, la partie en cause est sélectionnée. Un problème sans champ à lui, par exemple un modèle que ton organisation ne sert pas, figure sous **Problèmes de ce nœud** en haut des champs du nœud. Un problème dans une partie de l’automatisation que l’éditeur n’affiche pas, comme sa sortie ou ses tests, indique qu’elle ne se modifie pas ici ; modifie cette partie via MCP, l’API ou un paquet téléversé. +Sélectionne une entrée, ou appuie sur Entrée dessus, pour t’y rendre : le nœud s’ouvre, son champ reçoit le focus et la partie en cause est sélectionnée. Un problème sans champ à lui, par exemple un modèle que ton organisation ne sert pas, figure sous **Problèmes de ce nœud** en haut des champs du nœud. Un problème dans les entrées ouvre **Début**, un problème dans la sortie ouvre **Fin**, et un problème dans une autre partie du document, comme un test ou le nom, ouvre **Source** sur cette ligne. Un problème dans un nœud que ton brouillon n’a plus indique « Modifie-le avec ton agent de code. » Tant qu’il reste des erreurs, **Enregistrer** est désactivé et en donne la raison, par exemple « Corrige 1 erreur pour enregistrer ». Sur un téléphone, où **Enregistrer** se trouve sous les champs d’un nœud, **Afficher les problèmes** à côté de cette raison ouvre la liste. Les avertissements ne bloquent jamais l’enregistrement ni la mise en service. Si Tale ne peut pas vérifier le brouillon, par exemple parce que la connexion a été coupée, le bouton affiche **Vérification impossible** et tu peux quand même enregistrer : chaque enregistrement est vérifié à nouveau sur le serveur. Quand un enregistrement ou une mise en service est refusé à cause d’erreurs, la liste s’ouvre avec les problèmes renvoyés par le serveur, en commençant par la première erreur. @@ -52,8 +140,8 @@ La vérification ne s’exécute que pour les rôles Développeur, Admin et Prop ## Enregistrer et tester une version 1. Modifie les champs nécessaires et clique sur **Enregistrer**. -2. Explique le changement dans la **Note de version**, puis choisis **Enregistrer une version**. Cela ajoute une version et conserve les précédentes. Si quelqu’un a enregistré une autre version pendant ta modification, Tale refuse l’enregistrement et te demande : **Abandonner mes modifications et recharger** affiche la version plus récente, **Enregistrer quand même** ajoute ta version par-dessus — la plus récente reste dans l’historique des versions, mais la dernière version est alors la tienne. -3. Clique sur **Essai**. Si le workflow déclare un schéma d’entrée, remplis **Données de l’exécution (JSON)** dans le dialogue. Déplie **Schéma des données** pour vérifier les champs obligatoires et leurs types. Un JSON invalide ou non conforme au schéma empêche le démarrage. +2. Explique le changement dans la **Note de version**, puis choisis **Enregistrer une version**. Cela ajoute une version et conserve les précédentes. Si quelqu’un a enregistré une autre version pendant ta modification, Tale refuse l’enregistrement et te demande : **Abandonner mes modifications et recharger** affiche la version plus récente, **Enregistrer quand même** ajoute ta version par-dessus — la plus récente reste dans l’historique des versions, mais la dernière version est alors la tienne. +3. Clique sur **Essai**. Si le workflow déclare un schéma d’entrée, saisis l’entrée en JSON dans **Données de l’exécution (JSON)** ; quand tu tapes une clé, le champ propose les noms de champs du schéma, et ⌘Entrée (Ctrl+Entrée) lance l’exécution. Déplie **Schéma des données** pour voir les champs obligatoires et leurs types. Un JSON invalide ou non conforme au schéma empêche le démarrage. 4. Lance le test, passe à l’onglet **Exécutions** et ouvre sa ligne. Compare les données résolues, la sortie et les opérations prévues au résultat attendu. Pour un workflow qui exige `owner` et `repo`, les données pourraient être : @@ -71,7 +159,7 @@ Utilise le schéma réel du workflow. Un champ numérique attend un nombre JSON, -![Le dialogue de test montre les valeurs JSON owner et repo et le schéma des données déplié.](/images/platform/automation-run-input.webp) +![Le dialogue de test montre les valeurs JSON owner et repo dans l’éditeur de code et le schéma des données déplié sous forme de liste de champs.](/images/platform/automation-run-input.webp) @@ -85,7 +173,7 @@ Un déclencheur utilise aussi la version déployée. Configure-le lorsque tu es ## Examiner un résultat -**Afficher la dernière exécution** superpose les états au canvas. Sélectionne un nœud pour consulter les données de cette exécution : entrée résolue, sortie et effets. Cela suffit souvent à trouver une référence incorrecte. Compare l’entrée du nœud échoué à la sortie de sa source. +Dès que l’automatisation s’est exécutée, le canevas montre sa dernière exécution : la ligne du bas de chaque nœud indique comment il s’est terminé, par exemple **Réussi** ou **Ignoré**, et chaque condition montre comment elle a décidé, **Oui** ou **Non**. Le bouton en forme d’œil, en haut à droite du canevas, **Masquer la dernière exécution**, retire l’exécution du canevas, et **Afficher la dernière exécution** la fait revenir. Sélectionne un nœud et ouvre **Dernière exécution** pour voir son **Entrée résolue**, sa **Sortie** et ses effets. Cela suffit souvent à trouver une référence incorrecte : compare l’entrée du nœud en échec à la sortie du nœud qu’il lit. Passe à **Exécutions** et ouvre une ligne pour le détail complet. Les onglets restent visibles, avec **Exécutions** actif. **Éditeur** te ramène au workflow. Vérifie s’il s’agissait d’un test ou d’une exécution réelle et examine les opérations déjà réalisées avant de relancer. [Journaux d’exécution](/fr/platform/automations/execution-logs) explique les attentes, échecs, relances automatiques et arrêts. diff --git a/docs/fr/platform/automations/execution-logs.md b/docs/fr/platform/automations/execution-logs.md index 62cda9cfa1..e416026427 100644 --- a/docs/fr/platform/automations/execution-logs.md +++ b/docs/fr/platform/automations/execution-logs.md @@ -7,7 +7,7 @@ Ouvre une automatisation, passe à son onglet **Exécutions** et choisis une lig -![La page d’une exécution de test de Triage GitHub issues, marquée Succeeded, Test et v1 et lancée par toi, avec ses heures de début et de fin au-dessus du graphe du workflow, où les nœuds issues, open issues, score et report affichent chacun Ran ; la liste des effets de l’exécution commence sous le graphe.](/images/platform/automation-run-detail.webp) +![La page d’une exécution de test de Triage GitHub issues, marquée Succeeded, Test et v1 et lancée par toi, avec ses heures de début et de fin au-dessus du graphe du workflow, où les nœuds issues, open issues, score et report affichent chacun Succeeded ; la liste des effets de l’exécution commence sous le graphe.](/images/platform/automation-run-detail.webp) @@ -36,9 +36,11 @@ Laisse l’exécution suspendue pendant tes vérifications. **Demander l’arrê ## Examiner le nœud concerné -Sélectionne un nœud sur le canvas de l’exécution. **Entrée résolue** montre les valeurs après évaluation des expressions, et **Sortie** le résultat de l’étape. Ces champs distinguent une mauvaise référence d’une défaillance du service. +Une exécution en échec s’ouvre avec le nœud en échec en vue. Il est encadré de rouge et sa ligne du bas montre la première ligne de son erreur ; les nœuds par lesquels l’exécution est passée pour l’atteindre ressortent, tandis que les autres passent au second plan ; et Fin indique où l’exécution a échoué, par exemple **Échec à Propose**. -Les états comprennent **Exécuté**, **Ignoré**, **En échec**, **Jamais atteint**, **Pas encore atteint** et, pour une exécution arrêtée, **Arrêté ici** pour le nœud sur lequel elle se trouvait au moment de l’arrêt. Une condition fausse, une dépendance, une branche alternative ou une règle de poursuite après erreur peut expliquer un nœud ignoré. Ce n’est pas toujours un problème. +Sélectionne un nœud sur le canevas de l’exécution pour ouvrir son onglet **Dernière exécution**. **Entrée résolue** montre les valeurs après évaluation des expressions, et **Sortie** le résultat de l’étape. Ces champs distinguent une mauvaise référence d’une défaillance du service. + +Sur le canevas, la ligne du bas de chaque nœud indique comment il s’est terminé : **Réussi**, **Échoué**, **Ignoré**, **Non exécuté**, **Pas encore atteint** ou, pour une exécution arrêtée, **Arrêté ici** pour le nœud sur lequel elle se trouvait au moment de l’arrêt. Chaque condition montre comment elle a décidé, **Oui** ou **Non**. Le badge de l’onglet **Dernière exécution** nomme les mêmes états **Exécuté**, **En échec**, **Ignoré**, **Jamais atteint**, **Pas encore atteint** et **Arrêté ici**. Une condition fausse, une dépendance, une branche alternative ou une règle de poursuite après erreur peut expliquer un nœud ignoré. Ce n’est pas toujours un problème. Par exemple, un rappel peut recevoir le nom du client mais un identifiant de facture vide. Examine la sortie précédente. Si le champ a été renommé, corrige la référence plutôt que les identifiants de messagerie. Vérifie ensuite l’entrée résolue dans un nouvel essai. diff --git a/docs/fr/tutorials/editor/workflow-with-approvals.md b/docs/fr/tutorials/editor/workflow-with-approvals.md index 00e2024b49..e21c9d3580 100644 --- a/docs/fr/tutorials/editor/workflow-with-approvals.md +++ b/docs/fr/tutorials/editor/workflow-with-approvals.md @@ -60,7 +60,7 @@ Si ce nom existe déjà, l’import ajoute une version. Choisis une autre valeur Dans **Éditeur**, clique sur **Essai**. Cet exemple ne demande aucune donnée d’exécution ; un objet vide suffit. Passe à **Exécutions** : la liste doit afficher une exécution de test au statut **Réussie**. -Ouvre l’exécution et vérifie sur le canvas que les deux nœuds affichent le statut **Exécuté**. Sélectionne `send` et examine ses données résolues. Le destinataire doit être `reviewer@example.com`, l’objet `Approval practice` et le texte la phrase de `draft`. Le connector utilise une simulation déterministe dans ce mode. Aucun e-mail n’est envoyé et aucune carte d’approbation n’apparaît. +Ouvre l’exécution et vérifie sur le canevas que les deux nœuds affichent **Réussi**. Sélectionne `send` et examine ses données résolues. Le destinataire doit être `reviewer@example.com`, l’objet `Approval practice` et le texte la phrase de `draft`. Le connector utilise une simulation déterministe dans ce mode. Aucun e-mail n’est envoyé et aucune carte d’approbation n’apparaît. Le workflow comprend un test qui attend l’effet `imap-smtp.send`. Une simulation réussie vérifie le graphe et l’appel prévu. Elle ne prouve ni la validité des identifiants de messagerie ni la livraison du message. diff --git a/packages/shared/src/schemas/connectors.test.ts b/packages/shared/src/schemas/connectors.test.ts index c919321257..dce944f3fe 100644 --- a/packages/shared/src/schemas/connectors.test.ts +++ b/packages/shared/src/schemas/connectors.test.ts @@ -207,6 +207,62 @@ describe('connectorSchema', () => { ).toBe(false); }); + it('accepts an action title with per-locale overrides', () => { + const github = connectorSchema.parse(GITHUB); + const [create] = github.actions; + const titled = connectorSchema.parse({ + ...github, + actions: [ + { + ...create, + title: 'Create issue', + i18n: { + de: { title: 'Issue erstellen' }, + fr: { title: 'Créer une issue' }, + 'de-CH': { title: 'Issue erstellen' }, + }, + }, + ], + }); + expect(titled.actions[0]?.title).toBe('Create issue'); + expect(titled.actions[0]?.i18n?.fr?.title).toBe('Créer une issue'); + // Untitled stays valid: a surface falls back to the action name. + expect(github.actions[0]?.title).toBeUndefined(); + }); + + it.each([ + ['a blank title', { title: '' }], + [ + 'a locale key outside the tag grammar', + { i18n: { german: { title: 'x' } } }, + ], + ['an unknown key inside a locale', { i18n: { de: { label: 'x' } } }], + ['a title past 80 characters', { title: 'x'.repeat(81) }], + ])('refuses %s on an action', (_case, extra) => { + const github = connectorSchema.parse(GITHUB); + expect( + connectorSchema.safeParse({ + ...github, + actions: [{ ...github.actions[0], ...extra }], + }).success, + ).toBe(false); + }); + + it('accepts per-locale display names on the connector, and only those', () => { + const mailbox = connectorSchema.parse(MAILBOX); + const named = connectorSchema.parse({ + ...mailbox, + i18n: { de: { displayName: 'Postfach' }, fr: { displayName: 'Boîte' } }, + }); + expect(named.i18n?.de?.displayName).toBe('Postfach'); + expect( + connectorSchema.safeParse({ + ...mailbox, + i18n: { de: { description: 'Ein Postfach' } }, + }).success, + ).toBe(false); + }); + it('requires a mock on every action', () => { const github = connectorSchema.parse(GITHUB); const noMock = { diff --git a/packages/shared/src/schemas/connectors.ts b/packages/shared/src/schemas/connectors.ts index afe7cc3638..b8fc22cb1c 100644 --- a/packages/shared/src/schemas/connectors.ts +++ b/packages/shared/src/schemas/connectors.ts @@ -49,6 +49,16 @@ const actionNameSchema = z const displayNameSchema = z.string().min(1).max(200); +/** Locale keys of the per-entry `i18n` channels (`de`, `fr`, `de-CH`) — the + * grammar every declared text uses, as in the automation settings forms and + * the pack manifest. */ +const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/; + +/** An action named for a person: a short verb phrase in sentence case + * ("List issues"), without the connector's name — a surface shows it beside + * the connector's display name ("GitHub · List issues"). */ +const actionTitleSchema = z.string().min(1).max(80); + /** * The auth methods a connector accepts — discriminated on `method`, MULTIPLE * per connector, decoupled from the actions. A credential row references one @@ -183,6 +193,18 @@ const jsonSchemaObjectSchema = z export const connectorActionSchema = z .object({ name: actionNameSchema, + /** The action in words, in English. A surface without one shows the + * humanized action name instead. */ + title: actionTitleSchema.optional(), + /** Per-locale overrides of `title`. The chain every declared text + * follows: the exact tag (`de-CH`), then its base language (`de`), then + * the English `title`. */ + i18n: z + .record( + z.string().regex(LOCALE_RE), + z.object({ title: actionTitleSchema.optional() }).strict(), + ) + .optional(), description: z.string().min(1).max(2000), /** JSON Schema for the action's `input` — machine-validated. */ input: jsonSchemaObjectSchema, @@ -211,6 +233,15 @@ export const connectorSchema = z .object({ name: slugSchema, displayName: displayNameSchema, + /** Per-locale overrides of `displayName`, for a connector named with + * ordinary words ("Tasks") rather than a brand ("GitHub"), which keeps its + * name in every language. Same locale chain as an action's `title`. */ + i18n: z + .record( + z.string().regex(LOCALE_RE), + z.object({ displayName: displayNameSchema.optional() }).strict(), + ) + .optional(), description: z.string().min(1).max(2000), /** Grouping labels for the catalog (open vocabulary). */ tags: z.array(z.string().min(1).max(64)).default([]), diff --git a/packages/ui/README.md b/packages/ui/README.md index 6b2a61354e..e7e2de89fc 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -14,12 +14,12 @@ and stories live together under `src/components//`. | Need | Example imports | | --- | --- | -| Controls and forms | `button`, `icon-button`, `input`, `select`, `checkbox`, `use-form`, `field-shell` | -| Tables and values | `data-table/data-table`, `data-table/column-builders`, `copyable-field`, `json-viewer` | +| Controls and forms | `button`, `icon-button`, `input`, `select`, `checkbox`, `use-form`, `field-shell`, `code-editor` | +| Tables and values | `data-table/data-table`, `data-table/column-builders`, `copyable-field`, `json-viewer`, `schema-tree`, `vendor-icon` | | Layout and navigation | `page-layout`, `adaptive-header`, `sub-panel`, `header-breadcrumbs`, `tab-navigation` | | Dialogs and feedback | `dialog/form-dialog`, `dialog/delete-dialog`, `toaster`, `use-toast`, `use-retry-focus` | | Problems a check found | `issue-list`, `issue-summary`, `issue-severity`, `field-issue-messages`, `issue-focus`, `flow/node-issue-marker` | -| Editing and diagrams | `editor`, `wizard/*`, `catalog/*`, `filters/*`, `flow/*` | +| Editing and diagrams | `editor`, `wizard/*`, `catalog/*`, `filters/*`, `flow/workflow-canvas`, `flow/flow-step-list`, `flow/flow-path-list`, `flow/paths`, `flow/playback`, `flow/playback-bar`, `flow/layout`, `flow/*` | | Documentation sites | `docs/docs-layout`, `docs/docs-header`, `docs/docs-article`, `docs/docs-not-found`, `docs/page-actions`, `search/static-index/*` | | Shared infrastructure | `i18n/*`, `markdown/*`, `seo/*`, `server`, `monitoring/*`, `theme`, `testing/*` | | Large custom collections | `use-virtual-list`; [windowing and focus guidance](https://ui.tale.dev/docs/patterns/list-page#bound-a-custom-collections-rendering) | diff --git a/packages/ui/package.json b/packages/ui/package.json index b580afc9bd..4045b8e062 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -49,6 +49,10 @@ "./checkbox-group": "./src/components/forms/checkbox-group.tsx", "./cn": "./src/lib/cn.ts", "./code-block": "./src/components/data-display/code-block.tsx", + "./code-editor": "./src/components/forms/code-editor/code-editor.tsx", + "./code-editor/locate": "./src/components/forms/code-editor/locate.ts", + "./code-editor/providers": "./src/components/forms/code-editor/providers.ts", + "./code-editor/template-scan": "./src/components/forms/code-editor/template-scan.ts", "./collapsible-details": "./src/components/navigation/collapsible-details.tsx", "./container": "./src/components/layout/container.tsx", "./content-area": "./src/components/layout/content-area.tsx", @@ -143,10 +147,18 @@ "./filters/filter-panel": "./src/components/filters/filter-panel.tsx", "./filters/filter-section": "./src/components/filters/filter-section.tsx", "./flow/edge-palette": "./src/components/flow/edge-palette.ts", - "./flow/elk-layout": "./src/components/flow/layout/elk-layout.ts", "./flow/flow-canvas": "./src/components/flow/flow-canvas.tsx", + "./flow/flow-legend": "./src/components/flow/flow-legend.tsx", + "./flow/flow-path-list": "./src/components/flow/flow-path-list.tsx", + "./flow/flow-step-list": "./src/components/flow/flow-step-list.tsx", + "./flow/layout": "./src/components/flow/layout/index.ts", "./flow/node-issue-marker": "./src/components/flow/node-issue-marker.tsx", - "./flow/use-elk-layout": "./src/components/flow/layout/use-elk-layout.ts", + "./flow/node-status": "./src/components/flow/node-status.tsx", + "./flow/paths": "./src/components/flow/paths/highlight.ts", + "./flow/playback": "./src/components/flow/playback/index.ts", + "./flow/playback-bar": "./src/components/flow/playback/flow-playback-bar.tsx", + "./flow/types": "./src/components/flow/types.ts", + "./flow/workflow-canvas": "./src/components/flow/workflow-canvas.tsx", "./form": "./src/components/forms/form.tsx", "./form-section": "./src/components/forms/form-section.tsx", "./format": "./src/lib/format.ts", @@ -289,6 +301,7 @@ "./responsive-dialog": "./src/components/overlays/responsive-dialog.tsx", "./route-progress-bar": "./src/components/layout/route-progress-bar.tsx", "./schedule-occurrence-list": "./src/components/data-display/schedule-occurrence-list.tsx", + "./schema-tree": "./src/components/data-display/schema-tree.tsx", "./scroll-wheel-chain": "./src/lib/scroll-wheel-chain.ts", "./search": "./src/components/search/index.ts", "./search-input": "./src/components/forms/search-input.tsx", @@ -411,6 +424,7 @@ "./use-list-page": "./src/hooks/use-list-page.ts", "./use-focus-handoff": "./src/hooks/use-focus-handoff.ts", "./use-media-query": "./src/hooks/use-media-query.ts", + "./use-prefers-reduced-motion": "./src/hooks/use-prefers-reduced-motion.ts", "./use-sliding-indicator": "./src/hooks/use-sliding-indicator.ts", "./use-swap-fade": "./src/hooks/use-swap-fade.ts", "./use-resizable": "./src/hooks/use-resizable.ts", @@ -423,6 +437,7 @@ "./use-viewport-visibility": "./src/hooks/use-viewport-visibility.ts", "./use-zoom-pan": "./src/hooks/use-zoom-pan.ts", "./validation-check-item": "./src/components/feedback/validation-check-item.tsx", + "./vendor-icon": "./src/components/data-display/vendor-icon.tsx", "./vite/message-topics": "./src/vite/message-topics.ts", "./vite/yaml": "./src/vite/yaml.ts", "./wizard/use-wizard": "./src/components/wizard/use-wizard.ts", @@ -443,10 +458,22 @@ "test:browser": "bunx vitest --run --project browser" }, "dependencies": { + "@codemirror/autocomplete": "6.20.3", + "@codemirror/commands": "6.10.4", + "@codemirror/lang-javascript": "6.2.5", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-yaml": "6.1.3", + "@codemirror/language": "6.12.4", + "@codemirror/search": "6.7.1", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.8", "@fontsource/inter": "5.2.8", "@hookform/resolvers": "5.2.2", "@iconify-json/lucide": "1.2.122", "@iconify/react": "6.0.2", + "@lezer/common": "1.5.2", + "@lezer/highlight": "1.2.3", + "@lezer/markdown": "1.7.2", "@microlink/react-json-view": "1.31.18", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-dialog": "1.1.15", diff --git a/packages/ui/src/assets.d.ts b/packages/ui/src/assets.d.ts index ed683fc990..4c5f5ee832 100644 --- a/packages/ui/src/assets.d.ts +++ b/packages/ui/src/assets.d.ts @@ -18,3 +18,10 @@ declare module '@fontsource/inter/files/inter-latin-500-normal.woff2?url' { const src: string; export default src; } + +// The ELK layout worker: elkjs's own worker script, emitted as a +// same-origin file the flow layout starts a `Worker` from. +declare module 'elkjs/lib/elk-worker.min.js?url' { + const src: string; + export default src; +} diff --git a/packages/ui/src/components/data-display/schema-tree.browser.test.tsx b/packages/ui/src/components/data-display/schema-tree.browser.test.tsx new file mode 100644 index 0000000000..4f8f4c632c --- /dev/null +++ b/packages/ui/src/components/data-display/schema-tree.browser.test.tsx @@ -0,0 +1,78 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import axe from 'axe-core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; + +import { render, screen } from '@/tests/utils/render'; + +import { SchemaTree, type SchemaTreeSchema } from './schema-tree'; + +import '../../globals.css'; + +afterEach(() => { + cleanup(); + document.documentElement.classList.remove('dark'); +}); + +const SHAPE: SchemaTreeSchema = { + type: 'object', + required: ['reviewed'], + properties: { + reviewed: { type: 'integer', description: 'How many issues were read.' }, + actionable: { + type: 'array', + items: { + type: 'object', + properties: { title: { type: 'string' }, score: { type: 'number' } }, + }, + }, + }, +}; + +describe.each(['light', 'dark'])('SchemaTree (%s)', (theme) => { + it.each(['bg-background', 'bg-card', 'bg-bg-elevated'])( + 'reads at AA on %s', + async (surface) => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + const { container } = render( +
+ + path[0] === 'reviewed' ? 'from Report' : undefined + } + maybeEmpty={(path) => path[0] === 'actionable'} + typeScript="{ reviewed: number; actionable: { title: string }[] }" + /> +
, + ); + const result = await axe.run(container, { + runOnly: ['color-contrast', 'list', 'listitem', 'aria-allowed-attr'], + }); + expect(result.violations).toEqual([]); + expect(result.passes.some((rule) => rule.id === 'color-contrast')).toBe( + true, + ); + }, + ); +}); + +it('opens the TypeScript shape from the keyboard', async () => { + render( + <> + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Before' })); + await userEvent.keyboard('{Tab}'); + const summary = screen.getByText('Show as TypeScript'); + expect(summary).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + const details = summary.closest('details') as HTMLDetailsElement; + expect(details).toHaveAttribute('open'); + const code = details.querySelector('code'); + expect(code).toBeVisible(); + expect(code?.textContent).toContain('reviewed: number'); +}); diff --git a/packages/ui/src/components/data-display/schema-tree.stories.tsx b/packages/ui/src/components/data-display/schema-tree.stories.tsx new file mode 100644 index 0000000000..93b1a77082 --- /dev/null +++ b/packages/ui/src/components/data-display/schema-tree.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SchemaTree, type SchemaTreeSchema } from './schema-tree'; + +const meta: Meta = { + title: 'DataDisplay/SchemaTree', + component: SchemaTree, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'The fields of a value as a form reads them: name, kind in words, required, tags. See the guide on ui.tale.dev.', + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +const INPUT: SchemaTreeSchema = { + type: 'object', + required: ['owner', 'repo'], + properties: { + owner: { + type: 'string', + description: 'The account that owns the repository.', + }, + repo: { type: 'string' }, + limit: { type: 'integer' }, + state: { enum: ['open', 'closed'] }, + issues: { + type: 'array', + items: { + type: 'object', + properties: { title: { type: 'string' }, score: { type: 'number' } }, + }, + }, + }, +}; + +export const Compact: Story = { + args: { schema: INPUT, density: 'compact', maxRows: 3 }, +}; + +export const Comfortable: Story = { + args: { + schema: INPUT, + tagOf: (path) => (path[0] === 'owner' ? 'from the trigger' : undefined), + maybeEmpty: (path) => path[0] === 'issues', + typeScript: '{ owner: string; repo: string; limit?: number }', + }, +}; + +export const NoFields: Story = { + args: { schema: { type: 'array', items: { type: 'number' } } }, +}; diff --git a/packages/ui/src/components/data-display/schema-tree.test.tsx b/packages/ui/src/components/data-display/schema-tree.test.tsx new file mode 100644 index 0000000000..d2570bd8de --- /dev/null +++ b/packages/ui/src/components/data-display/schema-tree.test.tsx @@ -0,0 +1,176 @@ +import type { TFunction } from 'i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { checkAccessibility } from '@/tests/utils/a11y'; +import { render, screen } from '@/tests/utils/render'; + +import { initServiceI18n } from '../../i18n/init-service'; +import { uiMessages } from '../../i18n/messages'; +import { + SchemaTree, + schemaKindLabel, + type SchemaTreeSchema, +} from './schema-tree'; + +let i18n: ReturnType; +beforeAll(() => { + i18n = initServiceI18n({ + bundles: { en: {}, de: {}, fr: {} }, + regional: {}, + packages: [uiMessages], + }); +}); + +function tFor(locale: string): TFunction { + return i18n.getFixedT(locale, 'schemaTree'); +} + +const ISSUES: SchemaTreeSchema = { + type: 'object', + required: ['owner', 'repo'], + properties: { + owner: { + type: 'string', + description: 'The account that owns the repository.', + }, + repo: { type: 'string' }, + limit: { type: 'integer' }, + state: { enum: ['open', 'closed'] }, + labels: { type: 'array', items: { type: 'string' } }, + issues: { + type: 'array', + items: { + type: 'object', + required: ['title'], + properties: { + title: { type: 'string' }, + score: { type: ['number', 'null'] }, + }, + }, + }, + }, +}; + +describe('schemaKindLabel', () => { + it.each([ + ['en', { type: 'string' }, 'text'], + ['en', { type: 'integer' }, 'a whole number'], + ['en', { type: 'array', items: { type: 'object' } }, 'list of objects'], + ['en', { type: 'array', items: { type: 'array' } }, 'list of lists'], + ['en', { type: 'array' }, 'list of values'], + ['en', { type: ['string', 'null'] }, 'text or empty'], + [ + 'en', + { anyOf: [{ type: 'number' }, { type: 'boolean' }] }, + 'a number or true or false', + ], + ['en', { properties: { a: {} } }, 'an object'], + ['en', {}, 'anything'], + ['de', { type: 'array', items: { type: 'string' } }, 'Liste von Texten'], + ['de', { type: 'boolean' }, 'wahr oder falsch'], + // French carries the preposition in the plural, so "objets" elides. + ['fr', { type: 'array', items: { type: 'object' } }, "liste d'objets"], + ['fr', { type: 'array', items: { type: 'number' } }, 'liste de nombres'], + ] as const)('%s: %j reads "%s"', (locale, schema, expected) => { + expect(schemaKindLabel(tFor(locale), schema, locale)).toBe(expected); + }); + + it('quotes the values of an enum the way each language does', () => { + const schema = { enum: ['draft', 'sent'] }; + expect(schemaKindLabel(tFor('en'), schema, 'en')).toBe( + 'one of “draft” or “sent”', + ); + expect(schemaKindLabel(tFor('de'), schema, 'de')).toBe( + 'eines von „draft“ oder „sent“', + ); + expect(schemaKindLabel(tFor('fr'), schema, 'fr')).toBe( + "l'un de «\u00a0draft\u00a0» ou «\u00a0sent\u00a0»", + ); + expect(schemaKindLabel(tFor('de'), schema, 'de-CH')).toBe( + 'eines von «draft» oder «sent»', + ); + }); +}); + +describe('SchemaTree', () => { + it('lists the top-level fields with their kind, compactly', () => { + render(); + const list = screen.getByRole('list', { name: 'Fields' }); + const rows = [...list.querySelectorAll(':scope > li')].map( + (row) => row.textContent, + ); + expect(rows).toEqual([ + 'owner text · required', + 'repo text · required', + 'limit a whole number', + 'state one of “open” or “closed”', + 'labels list of texts', + 'issues list of objects', + ]); + expect( + screen.queryByText('The account that owns the repository.'), + ).toBeNull(); + }); + + it('stops at maxRows and says how many fields are left', () => { + render(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getByText('4 more fields')).toBeInTheDocument(); + }); + + it('nests the fields of objects and of list items, with descriptions', () => { + render(); + expect( + screen.getByText('The account that owns the repository.'), + ).toBeInTheDocument(); + const issues = screen.getByText('issues').closest('li') as HTMLElement; + const nested = [...issues.querySelectorAll('ul > li')].map( + (row) => row.textContent, + ); + expect(nested).toEqual([ + 'title text · required', + 'score a number or empty · optional', + ]); + // Optional only says so where the object names what is required. + expect(screen.getByText('limit').nextElementSibling?.textContent).toBe( + 'a whole number · optional', + ); + }); + + it('tags fields and says which may be empty', () => { + render( + (path[0] === 'owner' ? 'from the trigger' : undefined)} + maybeEmpty={(path) => path[0] === 'limit'} + />, + ); + expect(screen.getByText('owner').nextElementSibling?.textContent).toBe( + 'text · required · from the trigger', + ); + expect(screen.getByText('limit').nextElementSibling?.textContent).toBe( + 'a whole number · may be empty', + ); + }); + + it('says the kind of a value that has no fields', () => { + render( + , + ); + expect(screen.getByText('list of numbers')).toBeInTheDocument(); + expect(screen.queryByRole('list')).toBeNull(); + }); + + it('offers the shape as TypeScript behind a disclosure', () => { + render(); + expect(screen.getByText('Show as TypeScript')).toBeInTheDocument(); + }); + + it('passes axe audit', async () => { + const { container } = render( + , + ); + await checkAccessibility(container); + }); +}); diff --git a/packages/ui/src/components/data-display/schema-tree.tsx b/packages/ui/src/components/data-display/schema-tree.tsx new file mode 100644 index 0000000000..2a7725524a --- /dev/null +++ b/packages/ui/src/components/data-display/schema-tree.tsx @@ -0,0 +1,351 @@ +'use client'; + +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import { useT } from '../../i18n/client'; +import { cn } from '../../lib/cn'; +import { HighlightedCode } from '../../markdown/highlighted-code'; +import { CollapsibleDetails } from '../navigation/collapsible-details'; + +/** + * The fields a value has, as a reader reads a form: each field's name, what + * kind of value it holds in words ("text", "a number", "list of objects"), + * whether it is required, and where it comes from. For the input a run + * starts with, what a step receives and returns, a webhook's payload. + */ + +/** A JSON Schema subset: what describes the fields of a value. */ +export interface SchemaTreeSchema { + type?: + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'object' + | 'array' + | 'null' + | ReadonlyArray; + properties?: Readonly>; + required?: readonly string[]; + items?: SchemaTreeSchema; + enum?: readonly unknown[]; + anyOf?: readonly SchemaTreeSchema[]; + /** Written by the author; shown as is (document content, not translated). */ + description?: string; +} + +export interface SchemaTreeProps { + schema: SchemaTreeSchema; + /** + * `compact`: the top-level fields, one line each (a node's face, a + * summary). `comfortable`: nested fields and descriptions too. + */ + density?: 'compact' | 'comfortable'; + /** Top-level fields shown before "+n more fields". */ + maxRows?: number; + /** A tag after the kind: "from the trigger", "from Triage". */ + tagOf?: (path: readonly string[]) => string | undefined; + /** Adds "may be empty" to a field. */ + maybeEmpty?: (path: readonly string[]) => boolean; + /** The same shape as TypeScript, behind "Show as TypeScript". */ + typeScript?: string; + 'aria-label'?: string; + className?: string; +} + +type PluralKind = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'object' + | 'array' + | 'any'; + +function typesOf(schema: SchemaTreeSchema): string[] { + if (schema.type === undefined) return []; + return typeof schema.type === 'string' ? [schema.type] : [...schema.type]; +} + +function singular(t: TFunction, kind: string): string { + switch (kind) { + case 'string': + return t('kinds.string'); + case 'number': + return t('kinds.number'); + case 'integer': + return t('kinds.integer'); + case 'boolean': + return t('kinds.boolean'); + case 'object': + return t('kinds.object'); + case 'null': + return t('kinds.null'); + default: + return t('kinds.any'); + } +} + +function plural(t: TFunction, kind: PluralKind): string { + switch (kind) { + case 'string': + return t('kindsPlural.string'); + case 'number': + return t('kindsPlural.number'); + case 'integer': + return t('kindsPlural.integer'); + case 'boolean': + return t('kindsPlural.boolean'); + case 'object': + return t('kindsPlural.object'); + case 'array': + return t('kindsPlural.array'); + default: + return t('kindsPlural.any'); + } +} + +/** The kind a list's items have, in the plural ("texts", "objects"). */ +function itemsKind(schema: SchemaTreeSchema | undefined): PluralKind { + const [first] = schema === undefined ? [] : typesOf(schema); + if ( + first === 'string' || + first === 'number' || + first === 'integer' || + first === 'boolean' || + first === 'object' || + first === 'array' + ) { + return first; + } + if (schema?.properties !== undefined) return 'object'; + return 'any'; +} + +/** Quotation marks of the reader's language around a literal value. */ +function quoted(locale: string, value: unknown): string { + const text = typeof value === 'string' ? value : JSON.stringify(value); + if (locale.startsWith('de-CH')) return `«${text}»`; + if (locale.startsWith('de')) return `„${text}“`; + if (locale.startsWith('fr')) return `« ${text} »`; + return `“${text}”`; +} + +function either(locale: string, parts: string[]): string { + return new Intl.ListFormat(locale, { + style: 'long', + type: 'disjunction', + }).format(parts); +} + +/** + * A schema's kind in words: "text", "a number", "list of objects", + * "one of “draft” or “sent”", "text or empty". + */ +export function schemaKindLabel( + t: TFunction, + schema: SchemaTreeSchema, + locale = 'en', +): string { + if (schema.enum !== undefined && schema.enum.length > 0) { + return t('oneOf', { + values: either( + locale, + schema.enum.map((value) => quoted(locale, value)), + ), + }); + } + if (schema.anyOf !== undefined && schema.anyOf.length > 0) { + return either( + locale, + schema.anyOf.map((option) => schemaKindLabel(t, option, locale)), + ); + } + const types = typesOf(schema); + if (types.length === 0) { + return schema.properties !== undefined ? t('kinds.object') : t('kinds.any'); + } + return either( + locale, + types.map((type) => + type === 'array' + ? t('kinds.array', { item: plural(t, itemsKind(schema.items)) }) + : singular(t, type), + ), + ); +} + +/** The fields to list under a schema: its own, or those of its items. */ +function fieldsOf(schema: SchemaTreeSchema): { + properties: Readonly>; + required: readonly string[]; +} | null { + if (schema.properties !== undefined) { + return { properties: schema.properties, required: schema.required ?? [] }; + } + if (schema.items?.properties !== undefined) { + return { + properties: schema.items.properties, + required: schema.items.required ?? [], + }; + } + return null; +} + +interface RowsProps { + schema: SchemaTreeSchema; + path: readonly string[]; + density: 'compact' | 'comfortable'; + maxRows: number | undefined; + tagOf: SchemaTreeProps['tagOf']; + maybeEmpty: SchemaTreeProps['maybeEmpty']; + t: TFunction; + locale: string; + label?: string; + nested?: boolean; +} + +function Rows({ + schema, + path, + density, + maxRows, + tagOf, + maybeEmpty, + t, + locale, + label, + nested = false, +}: RowsProps) { + const fields = fieldsOf(schema); + if (fields === null) return null; + const names = Object.keys(fields.properties); + const shown = maxRows === undefined ? names : names.slice(0, maxRows); + const hidden = names.length - shown.length; + const comfortable = density === 'comfortable'; + return ( +
    + {shown.map((name) => { + const field = fields.properties[name] ?? {}; + const fieldPath = [...path, name]; + const isRequired = fields.required.includes(name); + const tag = tagOf?.(fieldPath); + const empty = maybeEmpty?.(fieldPath) ?? false; + const notes = [ + schemaKindLabel(t, field, locale), + ...(isRequired + ? [t('required')] + : comfortable && fields.required.length > 0 + ? [t('optional')] + : []), + ...(tag !== undefined ? [tag] : []), + ...(empty ? [t('maybeEmpty')] : []), + ]; + return ( +
  • + + + {name} + {' '} + + {notes.join(' · ')} + + + {comfortable && field.description !== undefined ? ( + + {field.description} + + ) : null} + {comfortable ? ( + + ) : null} +
  • + ); + })} + {hidden > 0 ? ( +
  • + {t('more', { count: hidden })} +
  • + ) : null} +
+ ); +} + +export function SchemaTree({ + schema, + density = 'comfortable', + maxRows, + tagOf, + maybeEmpty, + typeScript, + 'aria-label': ariaLabel, + className, +}: SchemaTreeProps) { + const { t } = useT('schemaTree'); + // The UI language decides the quotes and the "or" of a list of kinds. + const { i18n } = useTranslation(); + const locale = i18n?.resolvedLanguage ?? i18n?.language ?? 'en'; + const hasFields = fieldsOf(schema) !== null; + return ( +
+ {hasFields ? ( + + ) : ( +

+ {schemaKindLabel(t, schema, locale)} +

+ )} + {typeScript !== undefined ? ( + + + + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/data-display/vendor-icon.stories.tsx b/packages/ui/src/components/data-display/vendor-icon.stories.tsx new file mode 100644 index 0000000000..3ea673200d --- /dev/null +++ b/packages/ui/src/components/data-display/vendor-icon.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { VendorIcon } from './vendor-icon'; + +const meta: Meta = { + title: 'DataDisplay/VendorIcon', + component: VendorIcon, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + "A vendor's shipped icon, falling back to the plug glyph when there is none or it fails to load. Decorative: put the vendor's name beside it.", + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const WithoutIcon: Story = { args: {} }; + +/** An image the browser cannot decode fails at once, without a request. */ +export const BrokenIcon: Story = { + args: { iconUrl: 'data:image/svg+xml,%3Csvg' }, +}; + +export const Larger: Story = { args: { className: 'size-8' } }; diff --git a/packages/ui/src/components/data-display/vendor-icon.test.tsx b/packages/ui/src/components/data-display/vendor-icon.test.tsx new file mode 100644 index 0000000000..794f73491a --- /dev/null +++ b/packages/ui/src/components/data-display/vendor-icon.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { render } from '@/tests/utils/render'; + +import { VendorIcon } from './vendor-icon'; + +describe('VendorIcon', () => { + it("shows the vendor's icon as decoration", () => { + const { container } = render(); + const image = container.querySelector('img'); + expect(image).toHaveAttribute('src', '/icons/github.svg'); + expect(image).toHaveAttribute('alt', ''); + }); + + it('falls back to the plug glyph without an icon or when it fails to load', () => { + const { container, rerender } = render(); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('svg')).toHaveAttribute( + 'aria-hidden', + 'true', + ); + + rerender(); + const image = container.querySelector('img') as HTMLImageElement; + fireEvent.error(image); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('svg')).not.toBeNull(); + + // Another vendor's icon gets its own attempt. + rerender(); + expect(container.querySelector('img')).toHaveAttribute( + 'src', + '/icons/slack.svg', + ); + }); +}); diff --git a/services/platform/app/features/settings/credentials/vendor-icon.tsx b/packages/ui/src/components/data-display/vendor-icon.tsx similarity index 81% rename from services/platform/app/features/settings/credentials/vendor-icon.tsx rename to packages/ui/src/components/data-display/vendor-icon.tsx index 73c04c99ce..506d23ae6a 100644 --- a/services/platform/app/features/settings/credentials/vendor-icon.tsx +++ b/packages/ui/src/components/data-display/vendor-icon.tsx @@ -1,12 +1,13 @@ 'use client'; -import { cn } from '@tale/ui/cn'; import { Plug } from 'lucide-react'; import { useState } from 'react'; +import { cn } from '../../lib/cn'; + /** - * A vendor's shipped icon — a connector's or an AI provider's — falling back to - * the generic plug glyph. Not every vendor ships an `icon.svg` (WebDAV + * `@tale/ui/vendor-icon` — a vendor's shipped icon (a connector's, an AI + * provider's), falling back to the generic plug glyph. Not every vendor ships an `icon.svg` (WebDAV * doesn't), and a served icon can still fail to load. Decorative either way: * the vendor's name sits right next to it, so the image carries an empty alt * instead of doubling the heading for screen readers. diff --git a/packages/ui/src/components/dialog/dialog.tsx b/packages/ui/src/components/dialog/dialog.tsx index d6933bf3da..6b5c7e19fa 100644 --- a/packages/ui/src/components/dialog/dialog.tsx +++ b/packages/ui/src/components/dialog/dialog.tsx @@ -9,6 +9,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { ChevronLeft, X } from 'lucide-react'; import * as React from 'react'; +import { respectEscapeClaims } from '../overlays/claims-escape'; import { CLOSE_BUTTON_CLASS } from '../overlays/close-button-class'; import { PagePointerPin } from '../overlays/page-pointer-pin'; @@ -309,6 +310,7 @@ export function Dialog({ else restoreFocus(event); onCloseAutoFocus?.(event); }} + onEscapeKeyDown={respectEscapeClaims()} > {/* Close sits in the header row when headerActions exist, so it diff --git a/packages/ui/src/components/flow/describe.test.ts b/packages/ui/src/components/flow/describe.test.ts new file mode 100644 index 0000000000..6c809835ea --- /dev/null +++ b/packages/ui/src/components/flow/describe.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + describeFlowGraph, + flowListFormat, + type FlowTranslate, +} from './describe'; +import { branchFlowGraph, triageFlowGraph } from './testing/flow-fixtures'; + +/** Echoes the key and its values, so the test reads what was asked for. */ +const t: FlowTranslate = (key, options) => + options === undefined + ? key + : `${key}(${Object.entries(options) + .filter(([name]) => name !== 'ns') + .map(([name, value]) => `${name}=${String(value)}`) + .join(', ')})`; + +const words = ( + graph = triageFlowGraph(), + issues?: Map, +) => + describeFlowGraph(graph, { + t, + tIssues: t, + list: flowListFormat('en'), + issues, + }); + +describe('describeFlowGraph', () => { + it('names steps by title, Start and End by their words, gates by condition', () => { + const triage = words(); + expect(triage.names.get('issues')).toBe('Issues'); + expect(triage.names.get('__start')).toBe('node.entry'); + expect(triage.names.get('__end')).toBe('node.exit'); + const branch = words(branchFlowGraph()); + expect(branch.names.get('__gate:urgent')).toBe( + 'gate.name(node=Urgent, condition=urgent of Classify is true)', + ); + }); + + it('adds the problems to the name', () => { + const named = words( + triageFlowGraph(), + new Map([['score', { errors: 2, warnings: 0 }]]), + ); + expect(named.names.get('score')).toMatch(/^Score nodeSummary/); + }); + + it('says where a step sits, where it comes from and leads, and what it reads', () => { + expect(words().descriptions.get('report')).toBe( + [ + 'node.position(index=5, count=6)', + 'relation.comesFrom(list=Open issues and Score)', + 'relation.leadsTo(list=node.exit)', + 'list.reads(list=Open issues and Score)', + ].join('. ') + '.', + ); + }); + + it('folds a gate into what its targets say', () => { + const branch = words(branchFlowGraph()); + expect(branch.descriptions.get('urgent')).toContain( + 'relation.onlyIf(condition=urgent of Classify is true)', + ); + expect(branch.descriptions.get('low')).toContain( + 'relation.otherwise(node=Normal)', + ); + // An else-if: No leads on to the next condition, named for its node. + expect(branch.descriptions.get('__gate:urgent')).toContain( + 'gate.branches(yes=Urgent, no=Normal)', + ); + }); + + it('puts what a step reads on its strip, and where Start and End lead', () => { + const triage = words(); + expect(triage.strips.get('open_issues')).toBe( + 'list.reads(list=Issues and run input (limit))', + ); + expect(triage.strips.get('__start')).toBe('relation.leadsTo(list=Issues)'); + expect(triage.strips.get('__end')).toBe('relation.comesFrom(list=Report)'); + }); + + it('spells out Start and End when the host gives no description', () => { + const start = words().descriptions.get('__start') ?? ''; + expect(start).toContain('node.section(heading=node.triggers'); + expect(start).toContain( + 'rowWithDetail(label=Every day at 07:00 · UTC, detail=Next Thu 9 Oct, 07:00)', + ); + expect(start).toContain('label=firedAt'); + }); +}); diff --git a/packages/ui/src/components/flow/describe.ts b/packages/ui/src/components/flow/describe.ts new file mode 100644 index 0000000000..1bfe95844a --- /dev/null +++ b/packages/ui/src/components/flow/describe.ts @@ -0,0 +1,400 @@ +import type { IssueCounts } from '../feedback/issue-summary'; +import { flowNodeIssueText } from './node-issue-marker'; +import { FLOW_NODE_STATE } from './node-status'; +import type { FlowFrameState, FlowNodeRunInfo } from './playback/types'; +import type { FlowEdge, FlowGraph, FlowNode, FlowRow } from './types'; + +/** Translates a key of the `flow` namespace (or of `issues`, with `ns`). */ +export type FlowTranslate = ( + key: string, + options?: Record, +) => string; + +/** What a chart says about each node, in words. */ +export interface FlowWords { + /** The accessible name: the title, the kind for Start, End and a + * condition, the problems. */ + names: ReadonlyMap; + /** The accessible description: where the node sits, what it comes from + * and leads to, when it runs, what it reads, and the host's sentences. */ + descriptions: ReadonlyMap; + /** The line along a box's foot: what it reads, or in a run how it + * went, or why it steps back from a highlight. */ + strips: ReadonlyMap; + /** The title of a node as other sentences name it. */ + titles: ReadonlyMap; + /** The sentences under a node in the List view. */ + lines: ReadonlyMap; +} + +const NO_ISSUES: IssueCounts = { errors: 0, warnings: 0 }; + +/** "and"-joined lists in the reader's language. */ +export function flowListFormat( + locale: string, +): (items: readonly string[]) => string { + const format = new Intl.ListFormat(locale, { + style: 'long', + type: 'conjunction', + }); + return (items) => format.format(items); +} + +/** The words of the boxes' kinds. */ +export function flowNodeTitle(node: FlowNode, t: FlowTranslate): string { + if (node.kind === 'entry') return node.label ?? t('node.entry'); + if (node.kind === 'exit') return node.label ?? t('node.exit'); + return node.label; +} + +/** + * A node's run in words: a condition's decision, Start's and End's own + * line, or a step's state (or the host's reason) and its detail — "Failed + * · 1.2 s", "Running · 12 of 50 items". The node the run stopped at says so + * when the host gave no error line. + */ +export function flowRunText( + node: FlowNode, + info: FlowNodeRunInfo, + t: FlowTranslate, + stoppedHere = false, +): string { + const state = + info.state === 'idle' ? '' : t(FLOW_NODE_STATE[info.state].labelKey); + if (node.kind === 'gate') + return info.decision === undefined + ? state + : t(info.decision ? 'state.decidedYes' : 'state.decidedNo'); + if (node.kind === 'entry' || node.kind === 'exit') + return info.detail ?? info.reason ?? state; + const words = + info.reason ?? + (stoppedHere && info.state === 'failed' ? t('state.failedHere') : state); + const detail = + info.detail ?? + (info.items?.total === undefined + ? undefined + : t('group.items', { done: info.items.done, total: info.items.total })) ?? + (info.pass?.max === undefined + ? undefined + : t('group.pass', { pass: info.pass.current, max: info.pass.max })); + return detail === undefined || detail === '' ? words : `${words} · ${detail}`; +} + +/** A node's state in one word for its name; a condition's decision. */ +function runName( + node: FlowNode, + info: FlowNodeRunInfo, + t: FlowTranslate, +): string { + if (node.kind === 'gate' && info.decision !== undefined) + return t(info.decision ? 'state.decidedYes' : 'state.decidedNo'); + return info.state === 'idle' ? '' : t(FLOW_NODE_STATE[info.state].labelKey); +} + +function rowText(row: FlowRow, t: FlowTranslate): string { + const detail = [row.detail, row.note, row.badge?.label] + .filter(Boolean) + .join(', '); + return detail === '' + ? row.label + : t('node.rowWithDetail', { label: row.label, detail }); +} + +/** The gates in front of each step: an only-if gate, or the if/else gate + * whose Yes it is — the condition the step's `when` writes. */ +function gatesOf(graph: FlowGraph): Map { + const kinds = new Map(graph.nodes.map((node) => [node.id, node.kind])); + const gates = new Map(); + for (const edge of graph.edges) { + if (kinds.get(edge.source) !== 'gate') continue; + if (edge.kind !== 'gate' && edge.kind !== 'branch-yes') continue; + gates.set(edge.target, [...(gates.get(edge.target) ?? []), edge.source]); + } + return gates; +} + +/** + * Problems per row of the List view, which has no row for a condition: a + * step's own, with those of the condition in front of it. + */ +export function flowStepIssues( + graph: FlowGraph, + issues: ReadonlyMap | undefined, +): ReadonlyMap { + const out = new Map(issues ?? []); + if (issues === undefined) return out; + for (const [step, gates] of gatesOf(graph)) { + let counts = out.get(step) ?? NO_ISSUES; + for (const gate of gates) { + const own = issues.get(gate); + if (own === undefined) continue; + counts = { + errors: counts.errors + own.errors, + warnings: counts.warnings + own.warnings, + }; + } + out.set(step, counts); + } + return out; +} + +/** + * Every node's name, description, strip and List view lines — one place, + * so the chart and the List view say the same. + */ +export function describeFlowGraph( + graph: FlowGraph, + { + t, + tIssues, + list, + issues, + run, + stoppedAt, + reasons, + }: { + t: FlowTranslate; + /** Reads the `issues` namespace (a node's problem counts). */ + tIssues: FlowTranslate; + list: (items: readonly string[]) => string; + issues?: ReadonlyMap; + /** A run shown on the chart: every node says how it went. */ + run?: FlowFrameState | null; + /** The node the run stopped at, in focus: without an error line its + * strip says the run stopped here. */ + stoppedAt?: string | null; + /** Why a node steps back from a highlight, by id: its strip says so. */ + reasons?: Readonly>; + }, +): FlowWords { + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const titles = new Map( + graph.nodes.map((node) => [node.id, flowNodeTitle(node, t)]), + ); + const drawn = graph.edges.filter((edge) => !edge.layoutOnly); + const guards = gatesOf(graph); + const incoming = new Map(); + const outgoing = new Map(); + for (const edge of drawn) { + incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge]); + outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]); + } + const isGate = (id: string) => byId.get(id)?.kind === 'gate'; + const titleOf = (id: string) => titles.get(id) ?? id; + const unique = (ids: string[]) => [...new Set(ids)]; + + /** "Runs only if …" / "Runs when the condition of … is false" for every + * condition in front of `id`. */ + const conditions = (id: string): string[] => + (incoming.get(id) ?? []) + .filter((edge) => isGate(edge.source)) + .map((edge) => { + const gate = byId.get(edge.source); + if (gate?.kind !== 'gate') return ''; + return edge.kind === 'branch-no' + ? t('relation.otherwise', { node: gate.label }) + : t('relation.onlyIf', { condition: gate.condition }); + }) + .filter((sentence) => sentence !== ''); + + const comesFrom = (id: string): string | null => { + const sources = unique( + (incoming.get(id) ?? []) + .filter((edge) => !isGate(edge.source)) + .map((edge) => titleOf(edge.source)), + ); + return sources.length > 0 + ? t('relation.comesFrom', { list: list(sources) }) + : null; + }; + const leadsTo = (id: string): string | null => { + const targets = unique( + (outgoing.get(id) ?? []).map((edge) => titleOf(edge.target)), + ); + return targets.length > 0 + ? t('relation.leadsTo', { list: list(targets) }) + : null; + }; + const readsOf = (node: FlowNode): string => { + if (node.kind !== 'step') return ''; + if (node.reads !== undefined && node.reads.length > 0) + return t('list.reads', { + list: list(node.reads.map((row) => row.label)), + }); + return node.readsEmpty ?? ''; + }; + const section = (heading: string, rows: readonly FlowRow[], empty: string) => + t('node.section', { + heading, + items: rows.length > 0 ? list(rows.map((row) => rowText(row, t))) : empty, + }); + + const names = new Map(); + const descriptions = new Map(); + const strips = new Map(); + const lines = new Map(); + const count = graph.nodes.length; + + graph.nodes.forEach((node, index) => { + const issueText = flowNodeIssueText( + tIssues, + issues?.get(node.id) ?? NO_ISSUES, + ); + const title = titleOf(node.id); + const plain = + node.kind === 'gate' + ? t('gate.name', { node: node.label, condition: node.condition }) + : title; + const info = run?.nodes[node.id]; + const stateName = info ? runName(node, info, t) : ''; + const base = + stateName === '' + ? plain + : t('node.rowWithDetail', { label: plain, detail: stateName }); + names.set(node.id, issueText === '' ? base : `${base} ${issueText}`); + const runText = info + ? flowRunText(node, info, t, stoppedAt === node.id) + : ''; + const reason = reasons?.[node.id]; + // The run's words, or the highlight's reason, are said once more in the + // description only when they add something to the name's state word. + const extra = [runText === stateName ? '' : runText, reason ?? '']; + + const parts: (string | null | undefined)[] = [ + t('node.position', { index: index + 1, count }), + ]; + let listLines: (string | null | undefined)[] = []; + if (node.kind === 'step') { + const reads = readsOf(node); + strips.set(node.id, reason ?? (runText || reads)); + parts.push(...extra); + parts.push( + comesFrom(node.id), + ...conditions(node.id), + leadsTo(node.id), + reads, + ); + if (node.unreachable) parts.push(t('node.unreachable')); + parts.push(node.description); + listLines = [ + runText, + reason, + reads, + ...conditions(node.id), + // The List view has no row for a condition: its problems are said + // on the step it guards. + ...(guards.get(node.id) ?? []).map((gate) => { + const gateIssues = flowNodeIssueText( + tIssues, + issues?.get(gate) ?? NO_ISSUES, + ); + return gateIssues === '' + ? null + : t('node.rowWithDetail', { + label: titleOf(gate), + detail: gateIssues, + }); + }), + leadsTo(node.id), + node.unreachable ? t('node.unreachable') : null, + ]; + } else if (node.kind === 'gate') { + parts.push(...extra); + const out = outgoing.get(node.id) ?? []; + const yes = out.find((edge) => edge.kind === 'branch-yes'); + const no = out.find((edge) => edge.kind === 'branch-no'); + parts.push(comesFrom(node.id), ...conditions(node.id)); + parts.push( + node.mode === 'if-else' && yes && no + ? t('gate.branches', { + yes: titleOf(yes.target), + no: titleOf(no.target), + }) + : leadsTo(node.id), + ); + parts.push(node.description); + } else if (node.kind === 'entry') { + const leads = leadsTo(node.id); + strips.set(node.id, reason ?? (runText || (leads ?? ''))); + parts.push(...extra, leads); + parts.push( + node.description ?? + [ + node.triggers.length > 0 + ? section(t('node.triggers'), node.triggers, '') + : null, + section( + t('node.inputs'), + node.inputs, + node.inputsEmpty ?? t('node.noInput'), + ), + node.notice?.text, + ] + .filter(Boolean) + .join('. '), + ); + listLines = [ + runText, + node.triggers.length > 0 + ? section(t('node.triggers'), node.triggers, '') + : null, + section( + t('node.inputs'), + node.inputs, + node.inputsEmpty ?? t('node.noInput'), + ), + node.notice?.text, + ]; + } else { + const from = comesFrom(node.id); + strips.set(node.id, reason ?? (runText || (from ?? ''))); + parts.push(...extra, from); + const outcomes = node.outcomes ?? []; + parts.push( + node.description ?? + [ + section( + t('node.outputs'), + node.outputs, + node.outputsEmpty ?? t('node.noOutput'), + ), + node.shape, + outcomes.length > 0 + ? section(t('node.outcomes'), outcomes, '') + : null, + node.notice?.text, + ] + .filter(Boolean) + .join('. '), + ); + listLines = [ + runText, + section( + t('node.outputs'), + node.outputs, + node.outputsEmpty ?? t('node.noOutput'), + ), + outcomes.length > 0 ? section(t('node.outcomes'), outcomes, '') : null, + node.notice?.text, + ]; + } + descriptions.set( + node.id, + parts + .filter( + (part): part is string => typeof part === 'string' && part !== '', + ) + .map((part) => part.replace(/[.\s]+$/u, '')) + .join('. ') + .concat('.'), + ); + lines.set( + node.id, + listLines.filter( + (line): line is string => typeof line === 'string' && line !== '', + ), + ); + }); + return { names, descriptions, strips, titles, lines }; +} diff --git a/packages/ui/src/components/flow/edge-palette.ts b/packages/ui/src/components/flow/edge-palette.ts index f4305574bd..edc415e361 100644 --- a/packages/ui/src/components/flow/edge-palette.ts +++ b/packages/ui/src/components/flow/edge-palette.ts @@ -1,32 +1,78 @@ +import type { FlowEdgeKind } from './types'; + /** * The ONE edge visual language for React Flow canvases built on `FlowCanvas` - * (the automations step editor today). Every edge encodes exactly one of these - * documented meanings (#2370): + * (the automations editor and run page). Every edge encodes exactly one of + * these documented meanings (#2370): * - * - `flow` — nominal progression: the main spine between steps, including - * a loop's exit toward the next step. Calm and neutral so the - * eye follows the happy path; only decisions draw color. + * - `flow` — nominal progression: the spine between steps. Calm and + * neutral so the eye follows the happy path; only decisions + * draw colour. * - `positive` — a decision's yes/true outcome (green = "the check passed"). * - `negative` — a decision's no/false outcome. Amber, **never red**: "No" is - * a branch the author designed, not an error state. - * - `error` — an explicit error/failure route out of a step. The only red - * line on the canvas, reserved for genuine failure handling. - * - * Loop-back edges reuse the `flow` color but render dashed — the *shape* - * encodes the cycle, so color keeps its one meaning. + * a branch the author designed, not an error state. Its own + * variable (`--flow-edge-negative`): the warning amber is + * 2.2:1 on white, too faint for a line, so light mode takes + * amber-700. + * - `error` — the way into a step that failed. The only red line. + * - `emphasis` — a highlighted or travelled edge. + * - `activity` — something moving or running right now. * - * All values are semantic theme tokens (light + dark), never hex. Strokes and - * arrowheads share one width/size so every edge carries the same arrow. + * Shape carries the rest: an `order` edge is dashed, a `completion` edge + * dotted, so colour keeps its one meaning and never speaks alone. All values + * are semantic theme tokens (light + dark), never hex, and each keeps 3:1 + * on the canvas in both themes. */ export const FLOW_EDGE_COLORS = { flow: 'hsl(var(--muted-foreground))', positive: 'hsl(var(--success))', - negative: 'hsl(var(--warning))', + negative: 'var(--flow-edge-negative)', error: 'hsl(var(--destructive))', + emphasis: 'hsl(var(--foreground))', + activity: 'hsl(var(--info-foreground))', } as const; -/** One stroke width for every edge on the canvas. */ +export type FlowEdgeTone = keyof typeof FLOW_EDGE_COLORS; + +/** Every edge colour, for what draws one of each (arrowheads, a legend). */ +export const FLOW_EDGE_TONES: readonly FlowEdgeTone[] = [ + 'flow', + 'positive', + 'negative', + 'error', + 'emphasis', + 'activity', +]; + +/** One stroke width for every edge at rest. */ export const FLOW_EDGE_STROKE_WIDTH = 2; -/** One arrowhead size for every edge on the canvas. */ +/** Stroke widths per state: quiet (outside a highlight), base, emphasis. */ +export const FLOW_EDGE_STROKE = { quiet: 1, base: 2, emphasis: 2.5 } as const; + +/** Dash patterns per edge kind; a kind not listed is solid. */ +export const FLOW_EDGE_DASH = { order: '6 4', completion: '1 4' } as const; + +/** One arrowhead size for React Flow's built-in markers. */ export const FLOW_EDGE_MARKER_SIZE = 18; + +/** The arrowhead `WorkflowCanvas` draws: its length along the edge and its + * width across it, in flow pixels. */ +export const FLOW_EDGE_ARROW = { length: 8, width: 10 } as const; + +/** The radius of an orthogonal route's rounded corners. */ +export const FLOW_EDGE_CORNER_RADIUS = 8; + +/** The colour an edge of this kind is drawn in at rest. */ +export function flowEdgeTone(kind: FlowEdgeKind): FlowEdgeTone { + if (kind === 'branch-yes') return 'positive'; + if (kind === 'branch-no') return 'negative'; + return 'flow'; +} + +/** The dash pattern of an edge of this kind, or `undefined` for solid. */ +export function flowEdgeDash(kind: FlowEdgeKind): string | undefined { + if (kind === 'order') return FLOW_EDGE_DASH.order; + if (kind === 'completion') return FLOW_EDGE_DASH.completion; + return undefined; +} diff --git a/packages/ui/src/components/flow/flow-canvas.browser.test.tsx b/packages/ui/src/components/flow/flow-canvas.browser.test.tsx index e2cadf209e..a4102e9241 100644 --- a/packages/ui/src/components/flow/flow-canvas.browser.test.tsx +++ b/packages/ui/src/components/flow/flow-canvas.browser.test.tsx @@ -2,6 +2,7 @@ import { viewportAtRest } from '@tale/ui/testing/flow'; import type { UserEvent } from '@testing-library/user-event'; import { useReactFlow, type Node } from '@xyflow/react'; import { afterEach, describe, expect, it } from 'vitest'; +import { cdp } from 'vitest/browser'; import { cleanup, render, screen, waitFor } from '@/tests/utils/render'; @@ -9,8 +10,9 @@ import { FlowCanvas } from './flow-canvas'; import '../../globals.css'; -afterEach(() => { +afterEach(async () => { cleanup(); + await cdp().send('Emulation.setEmulatedMedia', { features: [] }); }); // A column of boxes, like an automation: 200×80 each, 160px apart. @@ -139,3 +141,62 @@ describe('FlowCanvas fit (real layout)', () => { await waitFor(() => expect(nodesFitThePane()).toBe(true)); }); }); + +// A column far taller than the pane: 30 boxes, 160px apart. +const TALL: Node[] = Array.from({ length: 30 }, (_, i) => ({ + id: `t${i}`, + position: { x: 0, y: i * 160 }, + width: 200, + height: 80, + data: { label: `Node ${i}` }, +})); + +describe('FlowCanvas auto fit', () => { + it('shows a graph too tall to read whole from its top, at a readable zoom', async () => { + render( +
+ +
, + ); + await waitFor(() => expect(transform()).not.toBe(DEFAULT_VIEW)); + const rest = await viewportAtRest(); + const zoom = Number(/scale\(([\d.]+)\)/.exec(rest)?.[1]); + expect(zoom).toBeGreaterThanOrEqual(0.5); + const pane = document.querySelector('.react-flow')!.getBoundingClientRect(); + const first = document + .querySelector('[data-id="t0"]')! + .getBoundingClientRect(); + // The first box is at the top, centred across the pane. + expect(first.top - pane.top).toBeGreaterThanOrEqual(0); + expect(first.top - pane.top).toBeLessThan(80); + expect( + Math.abs(first.left + first.width / 2 - (pane.left + pane.width / 2)), + ).toBeLessThan(2); + }); + + it('fits a graph that reads whole, like the all policy', async () => { + render( +
+ +
, + ); + await fitted(); + expect(nodesFitThePane()).toBe(true); + }); +}); + +describe('FlowCanvas under reduced motion', () => { + it('zooms at once, without an ease', async () => { + await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }], + }); + const { user } = renderCanvas(); + await fitted(); + await user.click(screen.getByRole('button', { name: 'Zoom in' })); + // One frame later the zoom has landed: nothing eases toward it. + await new Promise((resolve) => requestAnimationFrame(resolve)); + const landed = transform(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(transform()).toBe(landed); + }); +}); diff --git a/packages/ui/src/components/flow/flow-canvas.tsx b/packages/ui/src/components/flow/flow-canvas.tsx index 7ae0d750e2..034f72b480 100644 --- a/packages/ui/src/components/flow/flow-canvas.tsx +++ b/packages/ui/src/components/flow/flow-canvas.tsx @@ -13,11 +13,12 @@ import { ReactFlow, useReactFlow, useStore, + useStoreApi, type FitViewOptions, type ReactFlowProps, type Viewport, } from '@xyflow/react'; -import { Maximize, Minus, Plus, Sparkles } from 'lucide-react'; +import { Maximize, Minus, Plus } from 'lucide-react'; import { useCallback, useEffect, @@ -27,34 +28,78 @@ import { type ReactNode, } from 'react'; +import { usePrefersReducedMotion } from '../../hooks/use-prefers-reduced-motion'; +import { FLOW_TOUCH_TARGET } from './render/chrome'; + +export { FLOW_TOUCH_TARGET }; + +/** The `--ease-out-quint` curve, for viewport moves run from script. */ +export const easeOutQuint = (t: number) => 1 - (1 - t) ** 5; + +/** Viewport move durations: the `--duration-*` tokens, 0 under reduced + * motion (React Flow eases in script, out of reach of the CSS rule). */ +export const FLOW_VIEWPORT_DURATION = { + zoom: 150, + fit: 300, + reveal: 200, +} as const; + +/** How a fit treats a graph too big to show whole at a readable size. */ +export type FlowFitPolicy = 'all' | 'auto'; + +/** Below this zoom an `auto` fit stops shrinking the graph to show it all. */ +const READABLE_ZOOM = 0.5; +/** Room an `auto` fit leaves above the graph's top. */ +const TOP_MARGIN = 24; +/** Room the top corners' controls take — the panels' 15 px inset, a 36 px + * control and a 12 px gap, on the 4-px grid: a fit never puts the first + * box under a view switch or a toolbar. */ +const TOP_CONTROLS_INSET = 64; + /** * The ONE base React Flow canvas every graph editor in the app builds on - * (the automation canvas today). Owns the - * shared chrome and house defaults — theme-reactive `colorMode`, hidden - * attribution, the fit (initial, and kept while the canvas resizes until the - * reader moves the view), the corner zoom cluster (zoom in / out / reset) - * and the bottom-center action toolbar (editor actions + the AI-editor - * toggle) — so editors differ only in nodes/edges/handlers and + * (the workflow canvas). Owns the shared chrome and house defaults — + * theme-reactive `colorMode`, hidden attribution, the fit (initial, and + * kept while the canvas resizes until the reader moves the view), the + * corner zoom cluster (zoom in / out / reset) and the bottom-center action + * toolbar — so editors differ only in nodes/edges/handlers and * minimap/background styling: * - * - `backgroundProps` — always rendered; pass variant/gap/color to style. - * - `minimapProps` — renders a MiniMap when provided. - * - `centerActions` — editor-specific buttons in the bottom-center toolbar. - * - `onOpenAi` — adds the ✨ button to the bottom-center toolbar. + * - `backgroundProps` — always rendered; pass variant/gap/color to style. + * - `minimapProps` — renders a MiniMap when provided. + * - `centerActions` — editor-specific buttons in the bottom-center toolbar. + * - `cornerActions` — extra buttons after zoom/reset in the corner cluster. + * - `topStartActions` / `topEndActions` — the top corners (a view switch, + * the canvas's own verbs). + * - `fitPolicy` — `all` fits every node; `auto` does too while that + * keeps the graph readable (zoom 0.5 or more), else shows its top at a + * readable zoom, centred on its first node. + * - `fitKey` — refit when it changes, while the view is still + * where the last fit left it (the graph was laid out again), easing + * over `refitDuration` (a live relayout glides; a new picture jumps). * - * Everything else spreads onto `` untouched; overlays and - * ``s ride through `children`. + * Viewport moves ease out (quint) over the duration tokens and jump under + * reduced motion. Everything else spreads onto `` untouched; + * overlays and ``s ride through `children`. */ export interface FlowCanvasProps extends ReactFlowProps { backgroundProps?: ComponentProps; minimapProps?: ComponentProps; /** Editor-specific buttons rendered in the bottom-center toolbar. */ centerActions?: ReactNode; - /** Opens the editor's AI assistant panel (✨ in the bottom-center toolbar). */ - onOpenAi?: () => void; - /** Whether the AI assistant panel is open — drives the ✨ button's pressed - * (active) state so it reads as a toggle rather than a one-way open. */ - aiOpen?: boolean; + /** Buttons after zoom in / zoom out / reset view in the corner cluster. */ + cornerActions?: ReactNode; + /** The top-left corner. */ + topStartActions?: ReactNode; + /** The top-right corner. */ + topEndActions?: ReactNode; + /** @default 'all' */ + fitPolicy?: FlowFitPolicy; + /** Refit when this changes, while the view is still the fit. */ + fitKey?: unknown; + /** How long a refit for a new `fitKey` eases, in ms (0 under reduced + * motion). @default 0 */ + refitDuration?: number; } /** The view the last fit left, or `null` while a fit is under way. */ @@ -65,64 +110,166 @@ const sameView = (a: Viewport, b: Viewport) => Math.abs(a.y - b.y) < 0.5 && Math.abs(a.zoom - b.zoom) < 0.001; -/** Fit, then remember where the fit left the view. */ -function useFitAndRemember(memo: FitMemo) { - const { fitView, getViewport } = useReactFlow(); +/** Fit by the policy, then remember where the fit left the view. With + * `topInset`, the fit keeps the graph's top at least that far below the + * canvas's top edge. */ +function useFitAndRemember( + memo: FitMemo, + policy: FlowFitPolicy, + topInset: number, +) { + const { fitView, getViewport, setViewport, getNodes, getNodesBounds } = + useReactFlow(); + const store = useStoreApi(); + const reduced = usePrefersReducedMotion(); return useCallback( async (options?: FitViewOptions) => { memo.current = null; - await fitView(options); + const duration = reduced ? 0 : (options?.duration ?? 0); + const { width, height } = store.getState(); + const minZoom = options?.minZoom ?? store.getState().minZoom; + const nodes = getNodes(); + const padding = + typeof options?.padding === 'number' ? options.padding : 0.1; + // React Flow reads a bare number as a share of each side; the top + // takes whichever is more, that share or the controls' room. + const fitPadding = + topInset > 0 && height > 0 + ? { + top: `${Math.max(topInset, Math.floor((height - height / (1 + padding)) * 0.5))}px` as const, + right: padding, + bottom: padding, + left: padding, + } + : padding; + if (policy === 'auto' && nodes.length > 0 && width > 0 && height > 0) { + const bounds = getNodesBounds(nodes); + const whole = getViewportForBounds( + bounds, + width, + height, + 0, + Number.POSITIVE_INFINITY, + fitPadding, + ); + if (whole.zoom < READABLE_ZOOM) { + // Too big to read whole: show its top at a readable zoom, centred + // on the topmost box (Start), and let the reader scroll on. + const zoom = Math.min( + 1, + Math.max( + READABLE_ZOOM, + minZoom, + width / (bounds.width * (1 + padding)), + ), + ); + const top = [...nodes].sort( + (a, b) => + a.position.y - b.position.y || a.position.x - b.position.x, + )[0]; + const centreX = + top === undefined + ? bounds.x + bounds.width / 2 + : top.position.x + (top.measured?.width ?? top.width ?? 0) / 2; + await setViewport( + { + x: width / 2 - centreX * zoom, + y: Math.max(TOP_MARGIN, topInset) - bounds.y * zoom, + zoom, + }, + { duration, ease: easeOutQuint, interpolate: 'linear' }, + ); + memo.current = getViewport(); + return; + } + } + await fitView({ + ...options, + padding: fitPadding, + duration, + ease: easeOutQuint, + interpolate: 'linear', + }); memo.current = getViewport(); }, - [memo, fitView, getViewport], + [ + memo, + policy, + topInset, + reduced, + store, + fitView, + getViewport, + setViewport, + getNodes, + getNodesBounds, + ], ); } /** - * Keeps the initial fit true while the canvas's box changes size — a window + * Keeps the fit true while the canvas's box changes size — a window * resized, a tab strip wrapping onto a second row, an inspector opening - * beside it. React Flow fits once, against the box it had at mount; a canvas - * that then narrowed or shortened kept that zoom and cut its last nodes off. - * It refits only while the view is still where a fit left it: once anything - * moves it — the reader panning or zooming, the page bringing a picked node - * into sight — the view is theirs until "reset view" fits it again. And only - * to a fit that shows every node: past the zoom floor a fit merely - * re-centres, which would pull a node the page is bringing into sight (a - * picked box beside the inspector) back out of it. + * beside it — and when the graph is laid out again (`fitKey`). It refits + * only while the view is still where a fit left it: once anything moves it — + * the reader panning or zooming, the page bringing a picked node into + * sight — the view is theirs until "reset view" fits it again. Under the + * `all` policy, only to a fit that shows every node: past the zoom floor a + * fit merely re-centres, which would pull a node the page is bringing into + * sight back out of it. * Must render INSIDE . */ function FlowAutoFit({ memo, fitViewOptions, + policy, + topInset, + fitKey, + refitDuration = 0, }: { memo: FitMemo; fitViewOptions?: FitViewOptions; + policy: FlowFitPolicy; + topInset: number; + fitKey: unknown; + refitDuration?: number; }) { const { getViewport, getNodes, getNodesBounds } = useReactFlow(); - const fitAndRemember = useFitAndRemember(memo); + const fitAndRemember = useFitAndRemember(memo, policy, topInset); const minZoom = useStore((state) => state.minZoom); const width = useStore((state) => state.width); const height = useStore((state) => state.height); // Nodes, not "nodes initialized": a canvas that gives its nodes explicit // sizes has no handles to measure, and that flag never turns true for it. const hasNodes = useStore((state) => state.nodeLookup.size > 0); + // The key the last fit was for: a new one eases, a resize jumps. + const fittedKey = useRef<{ key: unknown } | null>(null); useEffect(() => { if (!hasNodes || !width || !height) return undefined; + const newKey = + fittedKey.current !== null && fittedKey.current.key !== fitKey; + fittedKey.current = { key: fitKey }; // Moved since the last fit: the view is not the fit any more. const fitted = memo.current; if (fitted !== null && !sameView(getViewport(), fitted)) return undefined; const frame = requestAnimationFrame(() => { - // The zoom that would show every node, before the floor clamps it. - const { zoom } = getViewportForBounds( - getNodesBounds(getNodes()), - width, - height, - 0, - Number.POSITIVE_INFINITY, - fitViewOptions?.padding ?? 0.1, + if (policy === 'all') { + // The zoom that would show every node, before the floor clamps it. + const { zoom } = getViewportForBounds( + getNodesBounds(getNodes()), + width, + height, + 0, + Number.POSITIVE_INFINITY, + fitViewOptions?.padding ?? 0.1, + ); + if (zoom < (fitViewOptions?.minZoom ?? minZoom)) return; + } + void fitAndRemember( + newKey + ? { ...fitViewOptions, duration: refitDuration } + : fitViewOptions, ); - if (zoom < (fitViewOptions?.minZoom ?? minZoom)) return; - void fitAndRemember(fitViewOptions); }); return () => cancelAnimationFrame(frame); }, [ @@ -131,70 +278,92 @@ function FlowAutoFit({ hasNodes, minZoom, memo, + policy, + fitKey, getViewport, getNodes, getNodesBounds, fitAndRemember, fitViewOptions, + refitDuration, ]); return null; } -/** Corner cluster: zoom in / zoom out / reset view. - * Must render INSIDE — `useReactFlow` reads its store. Resetting - * hands the view back to the fit, which then follows the canvas's size. */ -function FlowCornerControls({ memo }: { memo: FitMemo }) { +/** Corner cluster: zoom in / zoom out / reset view, then the host's own. + * Must render INSIDE — `useReactFlow` reads its store. + * Resetting hands the view back to the fit, which then follows the + * canvas's size. */ +function FlowCornerControls({ + memo, + policy, + topInset, + fitViewOptions, + children, +}: { + memo: FitMemo; + policy: FlowFitPolicy; + topInset: number; + fitViewOptions?: FitViewOptions; + children?: ReactNode; +}) { const { t } = useT('common'); const { zoomIn, zoomOut } = useReactFlow(); - const fitAndRemember = useFitAndRemember(memo); + const reduced = usePrefersReducedMotion(); + const fitAndRemember = useFitAndRemember(memo, policy, topInset); + const zoom = { + duration: reduced ? 0 : FLOW_VIEWPORT_DURATION.zoom, + ease: easeOutQuint, + }; return ( + {children} ); } -/** Bottom-center toolbar: the editor's primary actions (+ the AI toggle). */ -function FlowCenterToolbar({ - centerActions, - onOpenAi, - aiOpen, -}: { - centerActions?: ReactNode; - onOpenAi?: () => void; - aiOpen?: boolean; -}) { - const { t } = useT('common'); - if (!centerActions && !onOpenAi) return null; +/** Bottom-center toolbar: the editor's primary actions. */ +function FlowCenterToolbar({ centerActions }: { centerActions?: ReactNode }) { + if (!centerActions) return null; return ( {centerActions} - {onOpenAi && ( - - )} ); @@ -225,30 +383,58 @@ export function FlowCanvas({ backgroundProps, minimapProps, centerActions, - onOpenAi, - aiOpen, + cornerActions, + topStartActions, + topEndActions, + fitPolicy = 'all', + fitKey, + refitDuration, children, ...flowProps }: FlowCanvasProps) { const { resolvedTheme } = useTheme(); const fitMemo = useRef(null); + // The `auto` fit is the canvas's own: React Flow's built-in fit would show + // a tall graph whole at an unreadable zoom first. + const builtInFit = fitPolicy === 'all' && flowProps.fitView !== false; + const topInset = topStartActions || topEndActions ? TOP_CONTROLS_INSET : 0; return ( {flowProps.fitView !== false && ( - + + )} + {topStartActions && ( + + {topStartActions} + )} - - + {topEndActions && ( + + {topEndActions} + + )} + + {cornerActions} + + {minimapProps && } {children} diff --git a/packages/ui/src/components/flow/flow-legend.browser.test.tsx b/packages/ui/src/components/flow/flow-legend.browser.test.tsx new file mode 100644 index 0000000000..c09a7a434e --- /dev/null +++ b/packages/ui/src/components/flow/flow-legend.browser.test.tsx @@ -0,0 +1,70 @@ +import { cleanup } from '@testing-library/react'; +import axe from 'axe-core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; + +import { ratioAgainst } from '@/tests/utils/contrast'; +import { render, screen, waitFor } from '@/tests/utils/render'; + +import { FlowLegend, type FlowLegendEntry } from './flow-legend'; + +import '../../globals.css'; + +afterEach(() => { + cleanup(); + document.documentElement.classList.remove('dark'); +}); + +const ENTRIES: FlowLegendEntry[] = [ + { id: 'data', swatch: { edge: 'data' }, label: 'Reads the output above' }, + { id: 'order', swatch: { edge: 'order' }, label: 'Runs after it' }, + { id: 'yes', swatch: { edge: 'branch-yes' }, label: 'Yes' }, + { id: 'no', swatch: { edge: 'branch-no' }, label: 'No' }, + { + id: 'done', + swatch: { edge: 'completion' }, + label: 'The run ends after it', + }, + { id: 'dashed', swatch: { node: 'dashed' }, label: 'May not run' }, + { id: 'frame', swatch: { node: 'frame' }, label: 'Runs once per item' }, +]; + +describe('FlowLegend', () => { + it.each(['light', 'dark'])( + 'opens how to read the chart, each mark in its real colour (%s)', + async (theme) => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + render(); + const button = screen.getByRole('button', { name: 'Legend' }); + await userEvent.click(button); + const dialog = await screen.findByRole('dialog', { + name: 'How to read the chart', + }); + // The popover fades in; colours are judged once it is fully there. + await Promise.all( + document.getAnimations().map((animation) => animation.finished), + ); + for (const entry of ENTRIES) + expect(dialog).toHaveTextContent(entry.label); + // Every line keeps 3:1 on the popover. + for (const path of dialog.querySelectorAll('svg path:first-child')) { + expect( + ratioAgainst(dialog, getComputedStyle(path).stroke), + `${theme} swatch`, + ).toBeGreaterThanOrEqual(3); + } + const result = await axe.run(dialog, { + runOnly: ['color-contrast', 'aria-dialog-name', 'list', 'listitem'], + }); + expect(result.violations).toEqual([]); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(document.activeElement).toBe(button); + }, + ); + + it('renders nothing without entries', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/ui/src/components/flow/flow-legend.tsx b/packages/ui/src/components/flow/flow-legend.tsx new file mode 100644 index 0000000000..7b617fe363 --- /dev/null +++ b/packages/ui/src/components/flow/flow-legend.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { CircleHelp } from 'lucide-react'; +import { useId } from 'react'; + +import { useT } from '../../i18n/client'; +import { cn } from '../../lib/cn'; +import { Popover } from '../overlays/popover'; +import { Button } from '../primitives/button'; +import { + FLOW_EDGE_COLORS, + FLOW_EDGE_STROKE, + flowEdgeDash, + flowEdgeTone, +} from './edge-palette'; +import { FLOW_NODE_DASHED, FLOW_TOUCH_TARGET } from './render/chrome'; +import type { FlowEdgeKind } from './types'; + +/** One line of the legend: what a mark looks like and what it means. */ +export interface FlowLegendEntry { + id: string; + /** A line of this edge kind, or one of the boxes. */ + swatch: { edge: FlowEdgeKind } | { node: 'dashed' | 'gate' | 'frame' }; + /** The meaning, in the host's words. */ + label: string; +} + +/** A 32 × 12 picture of the mark, drawn with the canvas's own tokens. */ +function Swatch({ swatch }: { swatch: FlowLegendEntry['swatch'] }) { + if ('edge' in swatch) { + const color = FLOW_EDGE_COLORS[flowEdgeTone(swatch.edge)]; + return ( + + ); + } + return ( +