From 8842e2d2ca5d0b1edb330d8fa1097991e9ba379f Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 08:04:38 +0200 Subject: [PATCH 01/78] feat(shared): let connector actions carry titles in every language An action declares an English title and per-locale overrides (i18n..title), and a connector named with ordinary words its localized display names (i18n..displayName), on the locale grammar every declared text already uses. --- .../shared/src/schemas/connectors.test.ts | 56 +++++++++++++++++++ packages/shared/src/schemas/connectors.ts | 31 ++++++++++ 2 files changed, 87 insertions(+) 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([]), From 880b651cfced46b68776937169871f46c1ff4e30 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 08:04:47 +0200 Subject: [PATCH 02/78] feat(platform): name connector actions in English, German and French MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the 121 shipped connector actions gets a sentence-case title with German and French overrides, and the seven connectors named with ordinary words (Tasks, Documents, Conversations, …) their German and French display names. A guard fails a shipped action without its titles, a sloppy one, a duplicate, and a new connector that is neither a listed brand nor translated. --- .../connectors/confluence/connector.yml | 12 + .../connectors/conversation/connector.yml | 53 ++++ .../system/connectors/discord/connector.yml | 48 ++++ .../system/connectors/document/connector.yml | 17 ++ .../system/connectors/github/connector.yml | 132 +++++++++ .../system/connectors/glitchtip/connector.yml | 18 ++ .../system/connectors/gmail/connector.yml | 60 +++++ .../connectors/google-drive/connector.yml | 12 + .../system/connectors/imap-smtp/connector.yml | 23 ++ .../system/connectors/jev/connector.yml | 11 + .../system/connectors/outlook/connector.yml | 66 +++++ .../system/connectors/sandbox/connector.yml | 11 + .../system/connectors/shopify/connector.yml | 54 ++++ .../system/connectors/slack/connector.yml | 42 +++ .../system/connectors/task/connector.yml | 65 +++++ .../system/connectors/tavily/connector.yml | 12 + .../system/connectors/teams/connector.yml | 54 ++++ .../system/connectors/twilio/connector.yml | 42 +++ .../system/connectors/webdav/connector.yml | 29 ++ .../lib/connectors/action-titles.test.ts | 255 ++++++++++++++++++ 20 files changed, 1016 insertions(+) create mode 100644 services/platform/lib/connectors/action-titles.test.ts 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 62f96e76a1..0f7aa4c03b 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, and external linkage. effects: read input: @@ -39,6 +50,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 @@ -63,6 +80,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: @@ -91,6 +114,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 @@ -124,6 +153,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 @@ -176,6 +211,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: @@ -208,6 +249,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: @@ -230,6 +277,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 @@ -262,6 +315,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 @@ -290,6 +349,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/services/platform/lib/connectors/action-titles.test.ts b/services/platform/lib/connectors/action-titles.test.ts new file mode 100644 index 0000000000..7062e82b07 --- /dev/null +++ b/services/platform/lib/connectors/action-titles.test.ts @@ -0,0 +1,255 @@ +/** + * Every shipped connector action is named for people in every language the + * app ships: an English `title` plus German and French overrides, written the + * way the rest of the product writes labels. The automation canvas shows a + * connector node as "{connector} · {title}" ("GitHub · List issues"), so an + * action without its titles would put a raw `github.list_issues` (or an + * English phrase in a German canvas) in front of the reader. + * + * The connectors themselves are named the same way: a brand ("GitHub") keeps + * its name everywhere, and a connector named with ordinary words ("Tasks") + * carries its German and French display names. Each shipped connector is one + * or the other, so a new one has to be classified when it lands. + */ + +import path from 'node:path'; + +import type { Connector } from '@tale/shared/schemas/connectors'; +import { describe, expect, it } from 'vitest'; + +import { loadConnectorDefinitions } from './catalog'; + +const SYSTEM_ROOT = path.join( + path.dirname(new URL(import.meta.url).pathname), + '../../../../configs/platform/system', +); + +/** The full locales beside English, each of which every title must carry. */ +const REQUIRED_LOCALES = ['de', 'fr'] as const; + +/** Every locale an override may name: the full ones and the sparse Swiss + * German overlay. A key outside it would be text nobody can ever read. */ +const KNOWN_LOCALES = new Set(['de', 'fr', 'de-CH']); + +/** Connectors named after a product: the name is the same in every + * language, so they carry no display-name overrides. */ +const BRAND_CONNECTORS = new Set([ + 'confluence', + 'discord', + 'github', + 'glitchtip', + 'gmail', + 'google-drive', + 'outlook', + 'shopify', + 'slack', + 'tavily', + 'teams', + 'twilio', +]); + +/** A title's casing in English and French: the first letter upper case, every + * later word lower case unless it is an acronym ("Send SMS"). German is left + * out — its nouns are capitalised. */ +function sentenceCaseProblem(title: string): string | undefined { + const words = title.split(/\s+/); + const first = words[0] ?? ''; + if (first.charAt(0) !== first.charAt(0).toLocaleUpperCase()) { + return 'does not start with a capital letter'; + } + for (const word of words.slice(1)) { + const letters = word.replaceAll(/[^\p{L}]/gu, ''); + const isAcronym = letters.length >= 2 && letters === letters.toUpperCase(); + if (!isAcronym && word.charAt(0) !== word.charAt(0).toLocaleLowerCase()) { + return `is not sentence case ("${word}")`; + } + } + return undefined; +} + +/** What is wrong with one title in one language, or undefined. */ +function titleProblem(title: string, locale: string): string | undefined { + if (title !== title.trim()) return 'has surrounding whitespace'; + if (/[.:;!?]$/.test(title)) return 'ends in punctuation'; + if (locale === 'fr' && title.includes("'")) { + return 'uses a straight apostrophe (French titles use ’)'; + } + if (locale === 'en' || locale === 'fr') return sentenceCaseProblem(title); + const first = title.charAt(0); + return first === first.toLocaleUpperCase() + ? undefined + : 'does not start with a capital letter'; +} + +/** Every problem with one connector's display data, as readable lines. */ +function displayProblems(connector: Connector): string[] { + const problems: string[] = []; + const titles = new Map>(); + const note = (locale: string, action: string, title: string) => { + const seen = titles.get(locale) ?? new Map(); + const other = seen.get(title); + if (other !== undefined) { + problems.push( + `${connector.name}.${action}: ${locale} title "${title}" is also ${other}'s`, + ); + } + seen.set(title, action); + titles.set(locale, seen); + }; + + for (const action of connector.actions) { + const at = `${connector.name}.${action.name}`; + if (action.title === undefined) { + problems.push(`${at}: has no title`); + } else { + const problem = titleProblem(action.title, 'en'); + if (problem !== undefined) problems.push(`${at}: en title ${problem}`); + note('en', action.name, action.title); + } + for (const locale of Object.keys(action.i18n ?? {})) { + if (!KNOWN_LOCALES.has(locale)) { + problems.push( + `${at}: names a locale the app does not ship (${locale})`, + ); + } + } + for (const locale of REQUIRED_LOCALES) { + const title = action.i18n?.[locale]?.title; + if (title === undefined) { + problems.push(`${at}: has no ${locale} title`); + continue; + } + const problem = titleProblem(title, locale); + if (problem !== undefined) + problems.push(`${at}: ${locale} title ${problem}`); + note(locale, action.name, title); + } + // Swiss German writes ss for ß: a German title that needs one carries + // its de-CH override. + const german = action.i18n?.de?.title; + const swiss = action.i18n?.['de-CH']?.title; + if ( + german?.includes('ß') === true && + (swiss === undefined || swiss.includes('ß')) + ) { + problems.push(`${at}: de title has ß but no de-CH title without it`); + } + } + + if (BRAND_CONNECTORS.has(connector.name)) { + if (connector.i18n !== undefined) { + problems.push( + `${connector.name}: a brand keeps its name, yet it carries display-name overrides`, + ); + } + } else { + for (const locale of REQUIRED_LOCALES) { + if (connector.i18n?.[locale]?.displayName === undefined) { + problems.push( + `${connector.name}: is not a listed brand and has no ${locale} display name`, + ); + } + } + } + return problems; +} + +const shipped = loadConnectorDefinitions({ root: SYSTEM_ROOT }); + +describe('connector display data', () => { + it('names every shipped action in English, German and French, as labels are written', () => { + expect(shipped.flatMap(displayProblems)).toEqual([]); + // The guard read the whole catalog, not an empty directory. + expect(shipped.flatMap((c) => c.actions).length).toBeGreaterThan(100); + }); + + it('names every shipped connector for its readers: a brand as itself, the rest translated', () => { + for (const connector of shipped) { + const named = + BRAND_CONNECTORS.has(connector.name) || + REQUIRED_LOCALES.every( + (locale) => connector.i18n?.[locale]?.displayName !== undefined, + ); + expect(named, connector.name).toBe(true); + } + }); + + it('bites: an action that lands without its titles, or with a sloppy one, is named', () => { + const github = shipped.find((c) => c.name === 'github'); + if (github === undefined) throw new Error('github ships with the platform'); + const [first, second] = github.actions; + if (first === undefined || second === undefined) { + throw new Error('github ships more than one action'); + } + const broken: Connector = { + ...github, + actions: [ + { ...first, title: undefined, i18n: undefined }, + { + ...second, + title: 'Get A repository.', + i18n: { + de: { title: 'Repository abrufen' }, + fr: { title: "Récupérer l'issue" }, + 'de-AT': { title: 'Repository abrufen' }, + }, + }, + ], + }; + expect(displayProblems(broken)).toEqual([ + `github.${first.name}: has no title`, + `github.${first.name}: has no de title`, + `github.${first.name}: has no fr title`, + `github.${second.name}: en title ends in punctuation`, + `github.${second.name}: names a locale the app does not ship (de-AT)`, + `github.${second.name}: fr title uses a straight apostrophe (French titles use ’)`, + ]); + }); + + it('bites: duplicate titles, a lowercase start, a word in title case, a ß without its Swiss form', () => { + const github = shipped.find((c) => c.name === 'github'); + if (github === undefined) throw new Error('github ships with the platform'); + const [first, second] = github.actions; + if (first === undefined || second === undefined) { + throw new Error('github ships more than one action'); + } + const broken: Connector = { + ...github, + actions: [ + { + ...first, + title: 'List Issues', + i18n: { + de: { title: 'issues auflisten' }, + fr: { title: 'Lister les issues' }, + }, + }, + { + ...second, + title: 'Get issue', + i18n: { + de: { title: 'Issue schließen' }, + fr: { title: 'Lister les issues' }, + }, + }, + ], + }; + expect(displayProblems(broken)).toEqual([ + `github.${first.name}: en title is not sentence case ("Issues")`, + `github.${first.name}: de title does not start with a capital letter`, + `github.${second.name}: fr title "Lister les issues" is also ${first.name}'s`, + `github.${second.name}: de title has ß but no de-CH title without it`, + ]); + }); + + it('bites: a new connector that is neither a listed brand nor translated', () => { + const task = shipped.find((c) => c.name === 'task'); + if (task === undefined) throw new Error('task ships with the platform'); + expect( + displayProblems({ ...task, name: 'calendar', i18n: undefined }), + ).toEqual([ + 'calendar: is not a listed brand and has no de display name', + 'calendar: is not a listed brand and has no fr display name', + ]); + }); +}); From 2a5460ae97ee4b8986d773de8fdb8ee20c30466c Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 08:05:06 +0200 Subject: [PATCH 03/78] feat(platform): serve action titles and connector icons in the catalog GET /automations/catalog/node-types answers, per connector action, its connector and its title with the German and French overrides, and once per connector its display name, overrides and shipped icon as a data URL. The registry carries the display data on the engine's connector surface; the app parses the answer with one zod schema instead of casting it, and the editor and run page read its nodeTypes. --- .../components/automation-editor.tsx | 4 +- .../components/run-detail.test.tsx | 5 +- .../automations/components/run-detail.tsx | 4 +- .../app/features/automations/hooks/backend.ts | 12 +- .../app/lib/backend/automations.test.ts | 77 ++++++++++- .../platform/app/lib/backend/automations.ts | 27 +++- .../app/lib/backend/contract/automations.ts | 11 +- .../connector_catalog.ts | 7 + .../automations/routes.catalog.test.ts | 130 ++++++++++++++++++ .../backend/domains/automations/routes.ts | 35 ++++- .../platform/lib/connectors/registry.test.ts | 13 ++ services/platform/lib/connectors/registry.ts | 5 + services/platform/lib/engine/core/slots.ts | 14 ++ .../lib/shared/schemas/node-type-catalog.ts | 53 +++++++ 14 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 services/platform/backend/domains/automations/routes.catalog.test.ts create mode 100644 services/platform/lib/shared/schemas/node-type-catalog.ts diff --git a/services/platform/app/features/automations/components/automation-editor.tsx b/services/platform/app/features/automations/components/automation-editor.tsx index 2e36dd446b..2b5ef18000 100644 --- a/services/platform/app/features/automations/components/automation-editor.tsx +++ b/services/platform/app/features/automations/components/automation-editor.tsx @@ -424,8 +424,8 @@ function AutomationEditorScope({ ); const nodeTypes = useMemo( - () => mergeNodeTypes(catalogQuery.data), - [catalogQuery.data], + () => mergeNodeTypes(catalogQuery.data?.nodeTypes), + [catalogQuery.data?.nodeTypes], ); // ── Problems ────────────────────────────────────────────────────────── diff --git a/services/platform/app/features/automations/components/run-detail.test.tsx b/services/platform/app/features/automations/components/run-detail.test.tsx index 50fe23d20d..effa589d56 100644 --- a/services/platform/app/features/automations/components/run-detail.test.tsx +++ b/services/platform/app/features/automations/components/run-detail.test.tsx @@ -75,7 +75,10 @@ vi.mock('../hooks/queries', async (importOriginal) => { : state.versionError === undefined ? { data: { document: state.versionDocument }, isError: false } : { data: undefined, isError: true, error: state.versionError }, - useNodeTypeCatalog: () => ({ data: [], isError: false }), + useNodeTypeCatalog: () => ({ + data: { nodeTypes: [], connectors: [] }, + isError: false, + }), useRunPendingAsk: () => ({ data: null }), useRunInDoubt: () => ({ data: { diff --git a/services/platform/app/features/automations/components/run-detail.tsx b/services/platform/app/features/automations/components/run-detail.tsx index 51628f4a4d..becda333bf 100644 --- a/services/platform/app/features/automations/components/run-detail.tsx +++ b/services/platform/app/features/automations/components/run-detail.tsx @@ -195,8 +195,8 @@ function RunDetailBody({ [graph.nodes, projection, run], ); const nodeTypes = useMemo( - () => mergeNodeTypes(catalogQuery.data), - [catalogQuery.data], + () => mergeNodeTypes(catalogQuery.data?.nodeTypes), + [catalogQuery.data?.nodeTypes], ); const runMissing = isMissingAutomationRead({ diff --git a/services/platform/app/features/automations/hooks/backend.ts b/services/platform/app/features/automations/hooks/backend.ts index 83b77c1085..0cc1de91cf 100644 --- a/services/platform/app/features/automations/hooks/backend.ts +++ b/services/platform/app/features/automations/hooks/backend.ts @@ -1,4 +1,4 @@ -import type { ItemOf } from '@/app/lib/backend/contract'; +import type { ReturnsOf } from '@/app/lib/backend/contract'; import { nodeTypes } from '@/lib/engine/core/slots'; /** @@ -17,14 +17,16 @@ import { nodeTypes } from '@/lib/engine/core/slots'; */ /** - * Every node type the engine has registered — core types plus one per - * connector action. An action: reading the connector catalog needs the - * deployment's config tree. + * Every connector action the engine has registered, plus the connectors they + * belong to (display name and icon, once each). An action: reading the + * connector catalog needs the deployment's config tree. */ export const listNodeTypesRef = 'automations/catalog:listNodeTypes'; /** One node type as the editor needs it — the server's own return shape. */ -export type NodeTypeSummary = ItemOf; +export type NodeTypeSummary = ReturnsOf< + typeof listNodeTypesRef +>['nodeTypes'][number]; /** The core node types, read from the engine's own registry so the editor * never carries a hand-written copy of the node grammar. */ diff --git a/services/platform/app/lib/backend/automations.test.ts b/services/platform/app/lib/backend/automations.test.ts index 210263333f..776dc34e9c 100644 --- a/services/platform/app/lib/backend/automations.test.ts +++ b/services/platform/app/lib/backend/automations.test.ts @@ -2,7 +2,11 @@ import { QueryClient } from '@tanstack/react-query'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { automationReadAdapters, automationWriteAdapters } from './automations'; +import { + automationActionQueryAdapters, + automationReadAdapters, + automationWriteAdapters, +} from './automations'; import { backendKey } from './query-keys'; /** @@ -251,3 +255,74 @@ describe('in-doubt adapters', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe('node-type catalog adapter', () => { + const ROW = { + type: 'github.list_issues', + kind: 'connector', + description: 'List issues for a repository.', + allowedFields: ['input', 'credential'], + requiredFields: ['input'], + outputKind: 'structured', + hasEffect: false, + connector: 'github', + title: 'List issues', + i18n: { de: { title: 'Issues auflisten' } }, + }; + + it('reads the actions and the connectors they belong to', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse(200, { + nodeTypes: [ROW], + connectors: [ + { + name: 'github', + displayName: 'GitHub', + iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+', + }, + ], + }), + ); + const fetchCatalog = automationActionQueryAdapters[ + 'automations/catalog:listNodeTypes' + ]?.({ organizationId: 'org1' }, {}); + await expect(fetchCatalog?.()).resolves.toEqual({ + nodeTypes: [ROW], + connectors: [ + { + name: 'github', + displayName: 'GitHub', + iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+', + }, + ], + }); + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + '/api/app/automations/catalog/node-types?orgId=org1', + ); + }); + + it('refuses an answer in a shape it does not read, instead of casting it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse(200, { nodeTypes: [{ ...ROW, kind: 'plugin' }] }), + ); + const fetchCatalog = automationActionQueryAdapters[ + 'automations/catalog:listNodeTypes' + ]?.({ organizationId: 'org1' }, {}); + await expect(fetchCatalog?.()).rejects.toThrow(/unreadable shape/); + expect(warn).toHaveBeenCalled(); + }); + + it('reads an answer without connectors as none', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse(200, { nodeTypes: [ROW] }), + ); + const fetchCatalog = automationActionQueryAdapters[ + 'automations/catalog:listNodeTypes' + ]?.({ organizationId: 'org1' }, {}); + await expect(fetchCatalog?.()).resolves.toEqual({ + nodeTypes: [ROW], + connectors: [], + }); + }); +}); diff --git a/services/platform/app/lib/backend/automations.ts b/services/platform/app/lib/backend/automations.ts index 67b4840d53..29ce285eb1 100644 --- a/services/platform/app/lib/backend/automations.ts +++ b/services/platform/app/lib/backend/automations.ts @@ -8,6 +8,10 @@ */ import type { ReturnsOf } from '@/app/lib/backend/contract'; +import { + nodeTypeCatalogSchema, + type NodeTypeCatalog, +} from '@/lib/shared/schemas/node-type-catalog'; import type { ActionQueryAdapter, @@ -28,7 +32,6 @@ type RunInDoubtResult = ReturnsOf<'automations/queries:getRunInDoubt'>; type OrgAutomationMetricsResult = ReturnsOf<'automations/queries:getOrgAutomationMetrics'>; type ApprovalResult = ReturnsOf<'approvals/queries:getApproval'>; -type NodeTypesResult = ReturnsOf<'automations/catalog:listNodeTypes'>; type AutomationCapabilitiesResult = ReturnsOf<'chat/composer:listAutomationCapabilities'>; type SaveAutomationResult = ReturnsOf<'automations/mutations:saveAutomation'>; @@ -283,6 +286,21 @@ export const automationReadAdapters: Record = { }, }; +/** The node-type catalog, parsed at the boundary: an answer in a shape this + * app does not read is a failed read (the editor then works from the core + * types alone), never a cast. */ +function readNodeTypeCatalog(body: unknown): NodeTypeCatalog { + const parsed = nodeTypeCatalogSchema.safeParse(body); + if (!parsed.success) { + console.warn( + '[automations] the node-type catalog answered in a shape this app does not read', + parsed.error.issues, + ); + throw new Error('The node-type catalog answered in an unreadable shape.'); + } + return parsed.data; +} + /** pg run rows carry `id`; the 0.4 wire uses `_id`. */ function mapRunIds(rows: unknown[]): unknown[] { return rows.map((row) => @@ -298,10 +316,9 @@ export const automationActionQueryAdapters: Record = const orgId = orgOf(args, ctx); if (orgId === undefined) return null; return () => - backendFetch<{ nodeTypes: NodeTypesResult }>( - '/automations/catalog/node-types', - { orgId }, - ).then((body) => body.nodeTypes); + backendFetch('/automations/catalog/node-types', { + orgId, + }).then(readNodeTypeCatalog); }, 'chat/composer:listAutomationCapabilities': (args, ctx) => { const orgId = orgOf(args, ctx); diff --git a/services/platform/app/lib/backend/contract/automations.ts b/services/platform/app/lib/backend/contract/automations.ts index ebebe33e84..8a0603559a 100644 --- a/services/platform/app/lib/backend/contract/automations.ts +++ b/services/platform/app/lib/backend/contract/automations.ts @@ -10,6 +10,7 @@ import type { LegacyRunQuarantine } from '@/lib/engine/api/dispatch'; export type { LegacyRunQuarantine } from '@/lib/engine/api/dispatch'; +import type { NodeTypeCatalog } from '@/lib/shared/schemas/node-type-catalog'; import type { QuestionSet } from '@/lib/shared/schemas/questions'; /** What a `waiting` run is parked on. `approval`, `ask` and `in_doubt` @@ -73,15 +74,7 @@ export interface AutomationsContract { 'automations/catalog:listNodeTypes': { kind: 'action'; args: { organizationId: string }; - returns: Array<{ - hasEffect?: boolean; - type: string; - kind: 'connector' | 'core'; - description: string; - allowedFields: string[]; - requiredFields: string[]; - outputKind: 'structured' | 'unstructured'; - }>; + returns: NodeTypeCatalog; }; 'automations/human_asks:answerAsk': { kind: 'mutation'; diff --git a/services/platform/backend/core/connector_credentials/connector_catalog.ts b/services/platform/backend/core/connector_credentials/connector_catalog.ts index 27738c9445..4d265c6b33 100644 --- a/services/platform/backend/core/connector_credentials/connector_catalog.ts +++ b/services/platform/backend/core/connector_credentials/connector_catalog.ts @@ -87,6 +87,13 @@ function readConnectorIcon( } } +/** A shipped connector's icon as an inline data URL, or `undefined` when it + * ships none — the same image the settings catalog shows, for the other + * surfaces that name a connector (the automation node-type catalog). */ +export function connectorIconUrl(slug: string): string | undefined { + return readConnectorIcon(resolveConnectorsDir(), slug); +} + /** * The shipped connectors as the settings page lists them — slug, display copy, * grouping tags, endpoint mode, accepted auth methods (in declaration order), diff --git a/services/platform/backend/domains/automations/routes.catalog.test.ts b/services/platform/backend/domains/automations/routes.catalog.test.ts new file mode 100644 index 0000000000..5381dcac1f --- /dev/null +++ b/services/platform/backend/domains/automations/routes.catalog.test.ts @@ -0,0 +1,130 @@ +import { Hono, type Context } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { nodeTypeCatalogSchema } from '../../../lib/shared/schemas/node-type-catalog.ts'; +import type { OrgEnv } from '../../auth/org.ts'; + +/** + * The node-type catalog the automation editor works against: + * `GET /catalog/node-types` answers one row per shipped connector action and + * the connectors themselves, once each, with what a person reads on a node — + * the action's title in English, German and French, the connector's display + * name and its icon. Read from the shipped catalog, not a stub, so the answer + * is the one a deployment gives. + */ + +const io = vi.hoisted(() => ({ role: 'developer' })); +vi.mock('../../auth/session.ts', () => ({ + requireSession: + () => async (c: Context, next: () => Promise) => { + c.set('sessionBundle', { user: { id: 'u1' } } as never); + await next(); + }, +})); +vi.mock('../../auth/org.ts', async (original) => ({ + ...(await original()), + requireOrgMember: + () => async (c: Context, next: () => Promise) => { + c.set('orgId', 'o1'); + c.set('orgMember', { role: io.role } as never); + await next(); + }, +})); + +import { createAutomationRoutes } from './routes.ts'; + +async function getCatalog() { + const app = new Hono().route( + '/api/app/automations', + createAutomationRoutes({ sql: {} as never, auth: {} as never }), + ); + return app.request('/api/app/automations/catalog/node-types'); +} + +beforeEach(() => { + io.role = 'developer'; +}); + +describe('GET /catalog/node-types', () => { + it('refuses a member: the catalog serves the people who author automations', async () => { + io.role = 'member'; + expect((await getCatalog()).status).toBe(403); + }); + + it('answers each action with its connector and its title in every shipped language', async () => { + const response = await getCatalog(); + expect(response.status).toBe(200); + // The answer is the shape the app parses: the server and the client + // read one schema. + const catalog = nodeTypeCatalogSchema.parse(await response.json()); + + const listIssues = catalog.nodeTypes.find( + (row) => row.type === 'github.list_issues', + ); + expect(listIssues).toMatchObject({ + kind: 'connector', + connector: 'github', + title: 'List issues', + i18n: { + de: { title: 'Issues auflisten' }, + fr: { title: 'Lister les issues' }, + }, + hasEffect: false, + }); + expect( + catalog.nodeTypes.find((row) => row.type === 'github.create_issue') + ?.hasEffect, + ).toBe(true); + + for (const row of catalog.nodeTypes) { + expect(row.kind, row.type).toBe('connector'); + expect(row.type.startsWith(`${row.connector}.`), row.type).toBe(true); + expect(row.title, row.type).toBeTruthy(); + expect(row.i18n?.de?.title, row.type).toBeTruthy(); + expect(row.i18n?.fr?.title, row.type).toBeTruthy(); + } + const types = catalog.nodeTypes.map((row) => row.type); + expect(types).toEqual([...types].sort((a, b) => a.localeCompare(b))); + }); + + it('names every connector once, with its display name, translations and icon', async () => { + const catalog = nodeTypeCatalogSchema.parse( + await (await getCatalog()).json(), + ); + const names = catalog.connectors.map((connector) => connector.name); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + // Every row's connector is among them, so a face never misses its name. + for (const row of catalog.nodeTypes) { + expect(names, row.type).toContain(row.connector); + } + + const github = catalog.connectors.find((c) => c.name === 'github'); + expect(github?.displayName).toBe('GitHub'); + // A brand keeps its name in every language. + expect(github?.i18n).toBeUndefined(); + // The shipped icon, inline: no extra request, no static route. + expect(github?.iconUrl).toMatch(/^data:image\/svg\+xml;base64,/); + const svg = Buffer.from( + github?.iconUrl?.split(',')[1] ?? '', + 'base64', + ).toString('utf8'); + expect(svg).toContain(' c.name === 'task'); + expect(tasks).toMatchObject({ + displayName: 'Tasks', + i18n: { + de: { displayName: 'Aufgaben' }, + fr: { displayName: 'Tâches' }, + }, + }); + // A connector that ships no icon answers without one; the face falls + // back to its own glyph. + const conversations = catalog.connectors.find( + (c) => c.name === 'conversation', + ); + expect(conversations).toBeDefined(); + expect(conversations?.iconUrl).toBeUndefined(); + }); +}); diff --git a/services/platform/backend/domains/automations/routes.ts b/services/platform/backend/domains/automations/routes.ts index d042832145..2bab9e4c5f 100644 --- a/services/platform/backend/domains/automations/routes.ts +++ b/services/platform/backend/domains/automations/routes.ts @@ -6,6 +6,7 @@ import { registerConnector } from '../../../lib/connectors/registry.ts'; import { dispatch } from '../../../lib/engine/api/dispatch.ts'; import { nodeTypes } from '../../../lib/engine/core/slots.ts'; import { AppError } from '../../../lib/shared/errors/app-error'; +import type { NodeTypeCatalog } from '../../../lib/shared/schemas/node-type-catalog.ts'; import { isRecord } from '../../../lib/utils/type-utils.ts'; import type { Auth } from '../../auth/auth.ts'; import { isAdminOrDeveloperRole } from '../../auth/membership.ts'; @@ -13,6 +14,7 @@ import { requireOrgMember, type OrgEnv } from '../../auth/org.ts'; import { requireSession } from '../../auth/session.ts'; import { assembleAutomationAuthoringHost } from '../../core/automations/authoring_host.ts'; import { + connectorIconUrl, findConnector, loadConnectorDefinitions, } from '../../core/connector_credentials/connector_catalog.ts'; @@ -447,17 +449,22 @@ export function createAutomationRoutes(deps: { }); /** Node-type catalog (the 0.4 `catalog.listNodeTypes` — connector types - * only; the editor folds its own core floor over these). */ + * only; the editor folds its own core floor over these). Each action + * carries its connector and its title in every language it ships in; each + * connector, once, its display name and its icon as a data URL, so a node + * reads "GitHub · List issues" in the reader's language. */ app.get('/catalog/node-types', async (c) => { const denied = requireAuthor(c); if (denied) return denied; - for (const connector of loadConnectorDefinitions()) { + const definitions = loadConnectorDefinitions(); + for (const connector of definitions) { registerConnector(connector); } - const summaries = []; + const nodeTypeRows: NodeTypeCatalog['nodeTypes'] = []; for (const def of nodeTypes().values()) { if (def.kind !== 'connector') continue; - summaries.push({ + const display = def.connector?.display; + nodeTypeRows.push({ type: def.type, kind: def.kind, description: def.description, @@ -465,10 +472,26 @@ export function createAutomationRoutes(deps: { requiredFields: [...def.requiredFields], outputKind: def.outputKind, hasEffect: def.connector?.hasEffect ?? false, + ...(display !== undefined && { connector: display.connector }), + ...(display?.title !== undefined && { title: display.title }), + ...(display?.i18n !== undefined && { i18n: display.i18n }), }); } - summaries.sort((a, b) => a.type.localeCompare(b.type)); - return c.json({ nodeTypes: summaries }); + nodeTypeRows.sort((a, b) => a.type.localeCompare(b.type)); + const connectors: NodeTypeCatalog['connectors'] = []; + for (const connector of definitions) { + const row: NodeTypeCatalog['connectors'][number] = { + name: connector.name, + displayName: connector.displayName, + }; + if (connector.i18n !== undefined) row.i18n = connector.i18n; + const iconUrl = connectorIconUrl(connector.name); + if (iconUrl !== undefined) row.iconUrl = iconUrl; + connectors.push(row); + } + connectors.sort((a, b) => a.name.localeCompare(b.name)); + const body: NodeTypeCatalog = { nodeTypes: nodeTypeRows, connectors }; + return c.json(body); }); /** Run KPIs for the metrics page (member-readable, like the 0.4 query). */ diff --git a/services/platform/lib/connectors/registry.test.ts b/services/platform/lib/connectors/registry.test.ts index 1114fc7b38..d7b46f552a 100644 --- a/services/platform/lib/connectors/registry.test.ts +++ b/services/platform/lib/connectors/registry.test.ts @@ -55,6 +55,19 @@ describe('connector registry', () => { expect(def?.connector?.outputSignature).toContain('number'); }); + it('carries the connector and the localized title for display, nothing the engine reads', () => { + const display = nodeTypes().get(nodeTypeFor('github', 'list_issues')) + ?.connector?.display; + expect(display).toEqual({ + connector: 'github', + title: 'List issues', + i18n: { + de: { title: 'Issues auflisten' }, + fr: { title: 'Lister les issues' }, + }, + }); + }); + it('marks write actions as effectful and read actions as not', () => { expect( nodeTypes().get(nodeTypeFor('github', 'create_issue'))?.connector diff --git a/services/platform/lib/connectors/registry.ts b/services/platform/lib/connectors/registry.ts index 8125a873d3..e2366437d4 100644 --- a/services/platform/lib/connectors/registry.ts +++ b/services/platform/lib/connectors/registry.ts @@ -48,6 +48,11 @@ function toConnector( exampleInput: action.exampleInput, hasEffect: action.effects === 'write', tags: connector.tags, + display: { + connector: connector.name, + ...(action.title !== undefined && { title: action.title }), + ...(action.i18n !== undefined && { i18n: action.i18n }), + }, mock: (input) => codeRunner().runBody( action.mock, diff --git a/services/platform/lib/engine/core/slots.ts b/services/platform/lib/engine/core/slots.ts index ae406e8863..00ca13d424 100644 --- a/services/platform/lib/engine/core/slots.ts +++ b/services/platform/lib/engine/core/slots.ts @@ -34,6 +34,8 @@ export interface ConnectorLike { * and effect recording). */ hasEffect: boolean; tags?: string[]; + /** How a person reads a node of this type; never read by the engine. */ + display?: ConnectorActionDisplay; /** * Deterministic mock: same input → same output, no IO. * @@ -45,6 +47,18 @@ export interface ConnectorLike { live?(input: unknown, ctx: ConnectorContext): Promise; } +/** + * The display half of a connector action: the connector it belongs to (its + * catalog slug, the first half of the node type) and the action's title in + * English with its per-locale overrides (`de`, `fr`, `de-CH`). Surfaces such + * as the automation canvas name a node "GitHub · List issues" from it. + */ +export interface ConnectorActionDisplay { + connector: string; + title?: string; + i18n?: Record; +} + /** * A blob the host persisted on a connector's behalf. `id` is how the platform * addresses it afterwards; `url` is present only when the store can expose a diff --git a/services/platform/lib/shared/schemas/node-type-catalog.ts b/services/platform/lib/shared/schemas/node-type-catalog.ts new file mode 100644 index 0000000000..c6d2b290c7 --- /dev/null +++ b/services/platform/lib/shared/schemas/node-type-catalog.ts @@ -0,0 +1,53 @@ +/** + * The node-type catalog as the automation editor reads it off the wire + * (`GET /api/app/automations/catalog/node-types`): one row per connector + * action, and the connectors those actions belong to, once each. + * + * Besides what the editor validates a node against (fields, output kind, + * whether it writes), a row carries what a person reads on the canvas: the + * connector it belongs to and the action's title in English with its + * per-locale overrides. The connector rows carry the display name (with its + * overrides, for a connector named with ordinary words rather than a brand) + * and the shipped icon as a data URL. The route builds this shape and the app + * parses it at the boundary, so a foreign answer is a failed read, never a + * cast; keys this build does not know are stripped. + */ + +import { z } from 'zod'; + +const nodeTypeSummarySchema = z.object({ + type: z.string(), + kind: z.enum(['connector', 'core']), + description: z.string(), + allowedFields: z.array(z.string()), + requiredFields: z.array(z.string()), + outputKind: z.enum(['structured', 'unstructured']), + hasEffect: z.boolean().optional(), + /** The connector's catalog slug — the first half of `type`. */ + connector: z.string().optional(), + /** The action in words, in English ("List issues"). */ + title: z.string().optional(), + /** Per-locale overrides of `title`: the exact tag, then its base language, + * then the English. */ + i18n: z + .record(z.string(), z.object({ title: z.string().optional() })) + .optional(), +}); + +const connectorDisplaySchema = z.object({ + name: z.string(), + displayName: z.string(), + /** Per-locale overrides of `displayName`; a brand has none. */ + i18n: z + .record(z.string(), z.object({ displayName: z.string().optional() })) + .optional(), + /** The shipped `icon.svg` as a data URL; absent when none ships. */ + iconUrl: z.string().optional(), +}); + +export const nodeTypeCatalogSchema = z.object({ + nodeTypes: z.array(nodeTypeSummarySchema), + connectors: z.array(connectorDisplaySchema).default([]), +}); + +export type NodeTypeCatalog = z.infer; From e775355e3f8665f8d55cc61fac00a6952e0f4931 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 08:05:15 +0200 Subject: [PATCH 04/78] docs: describe connector action titles and translated display names The connector contract pages in English, German and French list the action's title and its i18n overrides, and say when a connector carries translated display names and when a brand keeps its own. --- docs/de/develop/connectors.md | 3 +++ docs/en/develop/connectors.md | 3 +++ docs/fr/develop/connectors.md | 3 +++ 3 files changed, 9 insertions(+) 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/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/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 | From f2ee1b18bbfca29a640e40cf14418726f4307546 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 08:19:29 +0200 Subject: [PATCH 05/78] feat(ui): colour every code block with one AA palette Read-only code (chat, docs, the guides) used Shiki's min-light and min-dark themes, whose comments, parameters, constants and string expressions fell below 4.5:1. The --code-* variables in globals.css now hold one palette, corrected to AA on every code surface in both themes, and Shiki reads it through its css-variables theme: one highlight serves light and dark, so a theme switch no longer tokenizes again. Template-aware grammars (tale-template, json-template, yaml-template, markdown-template) highlight {{ js }} expressions as JavaScript, and only under their own roots, so plain YAML keeps Helm or Jinja braces as text. code-palette.test.ts measures every token on each surface. --- packages/ui/src/globals.css | 75 ++++++ packages/ui/src/lib/code-palette.test.ts | 222 ++++++++++++++++ packages/ui/src/lib/code-roles.ts | 82 ++++++ .../ui/src/markdown/highlighted-code.test.tsx | 17 +- packages/ui/src/markdown/highlighted-code.tsx | 56 ++-- .../src/markdown/shiki-template-grammars.ts | 82 ++++++ packages/ui/src/markdown/shiki.test.ts | 180 ++++++++++--- packages/ui/src/markdown/shiki.ts | 240 ++++++++++-------- 8 files changed, 772 insertions(+), 182 deletions(-) create mode 100644 packages/ui/src/lib/code-palette.test.ts create mode 100644 packages/ui/src/lib/code-roles.ts create mode 100644 packages/ui/src/markdown/shiki-template-grammars.ts diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index 8b277f00bf..1817c5baad 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -253,6 +253,44 @@ --chart-primary: 217 91% 60%; --radius: 0.5rem; --chat-max-width: 48rem; + + /* Code: one palette for every renderer. Shiki's read-only blocks (its + css-variables theme, prefix `--code-`) and the code editor's highlight + style both read these roles, so a value reads the same while it is + edited and while it is shown. Every token colour clears 4.5:1 on + `--background`, `--color-bg-elevated` (the code-block surface) and + `--card`, and on the template tint over each — `code-palette.test.ts` + holds the line. They started from Shiki's min-light, whose comments + (1.69:1), parameters, constants and string expressions failed AA. */ + --code-foreground: #24292e; + --code-background: transparent; + --code-token-keyword: #c62828; + --code-token-string: #2b5581; + --code-token-string-expression: #1f7a35; + --code-token-constant: #1565c0; + --code-token-function: #6f42c1; + --code-token-parameter: #9a5b00; + --code-token-comment: #5f6b76; + --code-token-punctuation: #212121; + --code-token-link: #1565c0; + --code-token-inserted: #1f7a35; + --code-token-deleted: #b31d28; + --code-token-changed: #005cc5; + --code-invalid: hsl(var(--destructive)); + /* Editor chrome. The line numbers match `.code-block-numbered`; the + squiggles are non-text marks and keep 3:1 on every code surface and on + the template tint (amber-600 drops to 2.8:1 on the tint, so the warning + takes amber-700, the colour of the warning glyph). */ + --code-line-number: var(--color-fg-subtle); + --code-line-number-active: hsl(var(--foreground)); + --code-active-line: hsl(var(--foreground) / 0.04); + --code-selection: hsl(var(--info-foreground) / 0.18); + --code-selection-inactive: hsl(var(--foreground) / 0.08); + --code-template-tint: hsl(var(--info-foreground) / 0.05); + --code-match: hsl(var(--info-foreground) / 0.15); + --code-squiggle-error: hsl(var(--destructive)); + --code-squiggle-warning: #b45309; + --code-squiggle-info: hsl(var(--info-foreground)); } .dark { @@ -334,6 +372,43 @@ --chart-warning: 43 96% 56%; --chart-neutral: 0 0% 55%; --chart-primary: 213 94% 68%; + + /* Code (dark). From Shiki's min-dark; plain text is neutral instead of + min-dark's purple, so a prompt written in prose reads as text, and + comments lift to 5:1. */ + --code-foreground: #e1e4e8; + --code-token-keyword: #f97583; + --code-token-string: #9db1c5; + --code-token-string-expression: #ffab70; + --code-token-constant: #79b8ff; + --code-token-function: #b392f0; + --code-token-parameter: #ff9800; + --code-token-comment: #8b949e; + --code-token-punctuation: #bbbbbb; + --code-token-link: #ffab70; + --code-token-inserted: #85e89d; + --code-token-deleted: #f97583; + --code-token-changed: #79b8ff; + --code-active-line: hsl(var(--foreground) / 0.05); + --code-selection: hsl(var(--info-foreground) / 0.3); + --code-selection-inactive: hsl(var(--foreground) / 0.12); + --code-template-tint: hsl(var(--info-foreground) / 0.1); + --code-match: hsl(var(--info-foreground) / 0.25); + --code-squiggle-warning: #f59e0b; +} + +/* Windows contrast themes repaint text in their own colours; the selection + and the problem underlines follow the system's highlight and text colours + so they stay visible. */ +@media (forced-colors: active) { + :root, + .dark { + --code-selection: Highlight; + --code-selection-inactive: Highlight; + --code-squiggle-error: CanvasText; + --code-squiggle-warning: CanvasText; + --code-squiggle-info: CanvasText; + } } @media (prefers-reduced-motion: reduce) { diff --git a/packages/ui/src/lib/code-palette.test.ts b/packages/ui/src/lib/code-palette.test.ts new file mode 100644 index 0000000000..2405abd91e --- /dev/null +++ b/packages/ui/src/lib/code-palette.test.ts @@ -0,0 +1,222 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** + * The code palette (`--code-*` in `globals.css`) is read by every code + * surface: Shiki's read-only blocks in chat, docs and the guides, and the + * code editor. Shiki's min-light and min-dark failed AA there (comments + * 1.69:1 on the code-block surface, parameters 2.06:1, constants 4.40:1), + * so every token colour here must clear 4.5:1 on each surface code sits on + * — the page, the code-block surface and a card — and on the template tint + * over each, in both themes. The editor's line numbers are text (4.5:1); + * its problem underlines are marks (3:1). + */ + +// Comments name tokens too (`--background: …` in prose); read declarations only. +const css = readFileSync(join(__dirname, '..', 'globals.css'), 'utf8').replace( + /\/\*[\s\S]*?\*\//g, + '', +); + +type Rgb = [number, number, number]; + +function block(selector: string): string { + const start = css.indexOf(`\n${selector} {`); + if (start < 0) throw new Error(`no ${selector} block in globals.css`); + return css.slice(start, css.indexOf('\n}', start)); +} + +const THEME_BLOCK = (() => { + const start = css.indexOf('@theme {'); + return css.slice(start, css.indexOf('\n}', start)); +})(); + +function declared(scope: string, name: string): string | undefined { + const match = new RegExp(`--${name}:\\s*([^;]+);`).exec(scope); + return match?.[1].trim(); +} + +/** HSL → sRGB 0..255, the CSS Color 4 algorithm. */ +function hslToRgb(h: number, s: number, l: number): Rgb { + const sat = s / 100; + const light = l / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const a = sat * Math.min(light, 1 - light); + return light - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return [f(0), f(8), f(4)].map((v) => Math.round(v * 255)) as Rgb; +} + +function hexToRgb(hex: string): Rgb { + const digits = hex.replace('#', ''); + return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)) as Rgb; +} + +interface Paint { + rgb: Rgb; + alpha: number; +} + +/** + * Resolves a declared colour in a theme: a hex, `hsl(var(--x))`, + * `hsl(var(--x) / a)` or `var(--color-…)`, following variables through the + * theme's block, then `:root`, then `@theme`. + */ +function resolve(theme: 'light' | 'dark', value: string): Paint { + const scopes = + theme === 'dark' ? [block('.dark'), block(':root')] : [block(':root')]; + const lookup = (name: string): string => { + for (const scope of [...scopes, THEME_BLOCK]) { + const found = declared(scope, name); + if (found !== undefined) return found; + } + throw new Error(`--${name} is not declared`); + }; + const hex = /^#[0-9a-f]{6}$/i.exec(value); + if (hex) return { rgb: hexToRgb(value), alpha: 1 }; + // The legacy tokens hold a bare HSL triple: `0 0% 3.92%`. + const triple = /^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/.exec(value); + if (triple) { + return { + rgb: hslToRgb(Number(triple[1]), Number(triple[2]), Number(triple[3])), + alpha: 1, + }; + } + const hsl = /^hsl\(var\(--([a-z-]+)\)(?:\s*\/\s*([\d.]+))?\)$/.exec(value); + if (hsl) { + const [h, s, l] = lookup(hsl[1]) + .replaceAll('%', '') + .split(/\s+/) + .map(Number); + return { rgb: hslToRgb(h, s, l), alpha: hsl[2] ? Number(hsl[2]) : 1 }; + } + const variable = /^var\(--([a-z-]+)\)$/.exec(value); + if (variable) return resolve(theme, lookup(variable[1])); + throw new Error(`cannot resolve ${value}`); +} + +function token(theme: 'light' | 'dark', name: string): Paint { + const scopes = + theme === 'dark' + ? [block('.dark'), block(':root'), THEME_BLOCK] + : [block(':root'), THEME_BLOCK]; + for (const scope of scopes) { + const found = declared(scope, name); + if (found !== undefined) return resolve(theme, found); + } + throw new Error(`--${name} is not declared`); +} + +function over(top: Paint, base: Rgb): Rgb { + return top.rgb.map((c, i) => + Math.round(c * top.alpha + base[i] * (1 - top.alpha)), + ) as Rgb; +} + +function luminance(rgb: Rgb): number { + const [r, g, b] = rgb.map((c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: Rgb, b: Rgb): number { + const la = luminance(a); + const lb = luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +/** + * The surfaces code sits on, with the template tint over the editor's. The + * tint is the editor's chip behind a `{{ }}` expression; read-only blocks + * draw none, and `muted` is a read-only surface only (the chat's code + * blocks, the document preview). + */ +function surfaces(theme: 'light' | 'dark'): Array<[string, Rgb]> { + const editor: Array<[string, Rgb]> = [ + ['background', token(theme, 'background').rgb], + ['bg-elevated', token(theme, 'color-bg-elevated').rgb], + ['card', token(theme, 'card').rgb], + ]; + const tint = token(theme, 'code-template-tint'); + return [ + ...editor, + ...editor.map(([name, rgb]): [string, Rgb] => [ + `${name} + template`, + over(tint, rgb), + ]), + ['muted', token(theme, 'muted').rgb], + ]; +} + +const TOKENS = [ + 'code-foreground', + 'code-token-keyword', + 'code-token-string', + 'code-token-string-expression', + 'code-token-constant', + 'code-token-function', + 'code-token-parameter', + 'code-token-comment', + 'code-token-punctuation', + 'code-token-link', + 'code-token-inserted', + 'code-token-deleted', + 'code-token-changed', + 'code-invalid', +]; + +const MARKS = [ + 'code-squiggle-error', + 'code-squiggle-warning', + 'code-squiggle-info', +]; + +describe.each(['light', 'dark'] as const)('the %s code palette', (theme) => { + it.each(TOKENS)('--%s clears 4.5:1 on every code surface', (name) => { + const colour = token(theme, name).rgb; + for (const [surface, rgb] of surfaces(theme)) { + expect( + contrast(colour, rgb), + `--${name} on ${surface}`, + ).toBeGreaterThanOrEqual(4.5); + } + }); + + it('keeps the line numbers at 4.5:1 on the editor and code-block surfaces', () => { + for (const name of ['code-line-number', 'code-line-number-active']) { + const colour = token(theme, name).rgb; + for (const [surface, rgb] of surfaces(theme).slice(0, 3)) { + expect( + contrast(colour, rgb), + `--${name} on ${surface}`, + ).toBeGreaterThanOrEqual(4.5); + } + } + }); + + it.each(MARKS)('--%s keeps 3:1 as a mark, tint included', (name) => { + const colour = token(theme, name).rgb; + for (const [surface, rgb] of surfaces(theme)) { + expect( + contrast(colour, rgb), + `--${name} on ${surface}`, + ).toBeGreaterThanOrEqual(3); + } + }); + + it('keeps selected text readable', () => { + const selection = token(theme, 'code-selection'); + const foreground = token(theme, 'code-foreground').rgb; + for (const [surface, rgb] of surfaces(theme).slice(0, 3)) { + expect( + contrast(foreground, over(selection, rgb)), + `foreground on the selection over ${surface}`, + ).toBeGreaterThanOrEqual(4.5); + } + }); +}); diff --git a/packages/ui/src/lib/code-roles.ts b/packages/ui/src/lib/code-roles.ts new file mode 100644 index 0000000000..653ef5a9e8 --- /dev/null +++ b/packages/ui/src/lib/code-roles.ts @@ -0,0 +1,82 @@ +/** + * The colour roles of highlighted code — the one vocabulary the read-only + * renderer (Shiki) and the code editor (CodeMirror) share. + * + * Each role is a CSS variable in `globals.css` (`:root` and `.dark`). Shiki + * reads them through its css-variables theme (prefix `--code-`, so its own + * names line up with ours); the editor's highlight style maps its syntax tags + * onto the same variables. A value therefore has one colour wherever it + * shows, and a theme switch changes the variables, not the highlighted HTML. + */ + +/** + * What a code field holds, for the editor and for read-only highlighting. + * + * - `javascript` — a script: statements, a top-level `return` (a transform + * node's code). + * - `expression` — one JavaScript expression (a bare condition). + * - `json`, `yaml`. + * - `markdown` — GitHub-flavoured Markdown, highlighted only (no preview). + * - `template` — text with `{{ js }}` expressions in it. + * - `text` — plain text. + */ +export type CodeLanguage = + | 'javascript' + | 'expression' + | 'json' + | 'yaml' + | 'markdown' + | 'template' + | 'text'; + +export const CODE_LANGUAGES: readonly CodeLanguage[] = [ + 'javascript', + 'expression', + 'json', + 'yaml', + 'markdown', + 'template', + 'text', +]; + +export const CODE_ROLES = [ + 'foreground', + 'keyword', + 'string', + 'string-expression', + 'constant', + 'function', + 'parameter', + 'comment', + 'punctuation', + 'link', + 'inserted', + 'deleted', + 'changed', + 'invalid', +] as const; + +export type CodeRole = (typeof CODE_ROLES)[number]; + +/** The CSS variable that holds a role's colour. */ +function codeRoleVariable(role: CodeRole): string { + if (role === 'foreground') return '--code-foreground'; + if (role === 'invalid') return '--code-invalid'; + return `--code-token-${role}`; +} + +const ROLE_BY_VARIABLE = new Map( + CODE_ROLES.map((role) => [codeRoleVariable(role), role]), +); + +/** + * The role a highlighted token's colour names (`var(--code-token-keyword)` + * → `keyword`); `null` for a colour outside the palette. Shiki's + * css-variables theme writes exactly these values. + */ +export function codeRoleOfColor(color: string | undefined): CodeRole | null { + if (color === undefined) return null; + const match = /^var\((--code-[a-z-]+)(?:,[^)]*)?\)$/i.exec(color.trim()); + if (match === null) return null; + return ROLE_BY_VARIABLE.get(match[1].toLowerCase()) ?? null; +} diff --git a/packages/ui/src/markdown/highlighted-code.test.tsx b/packages/ui/src/markdown/highlighted-code.test.tsx index 6448fb7ab9..7e7cf363c0 100644 --- a/packages/ui/src/markdown/highlighted-code.test.tsx +++ b/packages/ui/src/markdown/highlighted-code.test.tsx @@ -86,7 +86,7 @@ describe('HighlightedCode viewport work', () => { , ); expect(container.querySelector('code span')).toHaveTextContent('warm'); - expect(peekHighlightedCode).toHaveBeenCalledWith('warm', 'js', 'light'); + expect(peekHighlightedCode).toHaveBeenCalledWith('warm', 'js'); await setVisible(true); expect(highlightCode).not.toHaveBeenCalled(); }); @@ -116,7 +116,7 @@ describe('HighlightedCode viewport work', () => { expect(container.querySelector('code span')).toBeNull(); expect(highlightCode).toHaveBeenCalledOnce(); await setVisible(true); - expect(highlightCode).toHaveBeenLastCalledWith('new', 'js', 'light'); + expect(highlightCode).toHaveBeenLastCalledWith('new', 'js'); expect(highlightCode).toHaveBeenCalledTimes(2); }); @@ -129,21 +129,22 @@ describe('HighlightedCode viewport work', () => { rerender(); expect(container.querySelector('code span')).toBeNull(); await setVisible(true); - expect(highlightCode).toHaveBeenLastCalledWith('value', 'py', 'light'); + expect(highlightCode).toHaveBeenLastCalledWith('value', 'py'); expect(highlightCode).toHaveBeenCalledTimes(2); }); - it('invalidates cached decoration for a different theme', async () => { + // The highlight colours through the `--code-*` variables, which the theme + // switches; tokenizing again on a theme switch flashed plain text. + it('keeps its highlight across a theme switch', async () => { const { container, rerender } = render( , ); await setVisible(true); - await setVisible(false); theme.value = 'dark'; rerender(); - expect(container.querySelector('code span')).toBeNull(); + expect(container.querySelector('code span')).not.toBeNull(); + await setVisible(false); await setVisible(true); - expect(highlightCode).toHaveBeenLastCalledWith('value', 'js', 'dark'); - expect(highlightCode).toHaveBeenCalledTimes(2); + expect(highlightCode).toHaveBeenCalledOnce(); }); }); diff --git a/packages/ui/src/markdown/highlighted-code.tsx b/packages/ui/src/markdown/highlighted-code.tsx index 97ceb98fc3..d36ce99c1b 100644 --- a/packages/ui/src/markdown/highlighted-code.tsx +++ b/packages/ui/src/markdown/highlighted-code.tsx @@ -4,7 +4,6 @@ import { memo, useEffect, useMemo, useState } from 'react'; import { useViewportVisibility } from '../hooks/use-viewport-visibility'; import { useT } from '../i18n/client'; import { cn } from '../lib/cn'; -import { useTheme } from '../theme'; import { highlightCode, peekHighlightedCode } from './shiki'; const LINE_NUMBER_THRESHOLD = 3; @@ -59,7 +58,6 @@ export const HighlightedCode = memo(function HighlightedCode({ className, }: HighlightedCodeProps) { const { t } = useT('markdownCopy'); - const { resolvedTheme } = useTheme(); const { ref, isVisible } = useViewportVisibility(); const [copied, setCopied] = useState(false); @@ -72,19 +70,19 @@ export const HighlightedCode = memo(function HighlightedCode({ () => (code.endsWith('\n') ? code.slice(0, -1) : code), [code], ); - // A snippet highlighted before shows highlighted from the first frame. + // A snippet highlighted before shows highlighted from the first frame. The + // HTML colours through the `--code-*` variables, so a theme switch repaints + // it without highlighting again. const [highlighted, setHighlighted] = useState<{ code: string; language: string | undefined; - theme: string; html: string; } | null>(() => { - const known = peekHighlightedCode(normalisedCode, language, resolvedTheme); + const known = peekHighlightedCode(normalisedCode, language); if (known === null) return null; return { code: normalisedCode, language, - theme: resolvedTheme, html: known.language === 'diff' ? applyDiffLineBackgrounds(known.html) @@ -96,40 +94,34 @@ export const HighlightedCode = memo(function HighlightedCode({ if (!isVisible) return undefined; if ( highlighted?.code === normalisedCode && - highlighted.language === language && - highlighted.theme === resolvedTheme + highlighted.language === language ) return undefined; let cancelled = false; - void highlightCode(normalisedCode, language, resolvedTheme).then( - (result) => { - if (cancelled) return; - if (!result) { - // Oversized input or highlighter init failure — drop the cached - // html so the plain `
` fallback renders the new source.
-          setHighlighted(null);
-          return;
-        }
-        setHighlighted({
-          code: normalisedCode,
-          language,
-          theme: resolvedTheme,
-          html:
-            result.language === 'diff'
-              ? applyDiffLineBackgrounds(result.html)
-              : result.html,
-        });
-      },
-    );
+    void highlightCode(normalisedCode, language).then((result) => {
+      if (cancelled) return;
+      if (!result) {
+        // Oversized input or highlighter init failure — drop the cached
+        // html so the plain `
` fallback renders the new source.
+        setHighlighted(null);
+        return;
+      }
+      setHighlighted({
+        code: normalisedCode,
+        language,
+        html:
+          result.language === 'diff'
+            ? applyDiffLineBackgrounds(result.html)
+            : result.html,
+      });
+    });
     return () => {
       cancelled = true;
     };
-  }, [normalisedCode, language, resolvedTheme, isVisible, highlighted]);
+  }, [normalisedCode, language, isVisible, highlighted]);
 
   const html =
-    highlighted?.code === normalisedCode &&
-    highlighted.language === language &&
-    highlighted.theme === resolvedTheme
+    highlighted?.code === normalisedCode && highlighted.language === language
       ? highlighted.html
       : null;
 
diff --git a/packages/ui/src/markdown/shiki-template-grammars.ts b/packages/ui/src/markdown/shiki-template-grammars.ts
new file mode 100644
index 0000000000..b44f8fcdcd
--- /dev/null
+++ b/packages/ui/src/markdown/shiki-template-grammars.ts
@@ -0,0 +1,82 @@
+import type { LanguageRegistration } from 'shiki/core';
+
+/**
+ * Read-only highlighting for Tale's `{{ js }}` templates.
+ *
+ * One injection grammar finds `{{ … }}` and highlights its body as a
+ * JavaScript expression; four wrapper languages decide where it may fire:
+ *
+ * - `tale-template` — plain text with templates (a forEach, a prompt line);
+ * - `json-template` — templates inside JSON strings (a connector's input);
+ * - `yaml-template` — templates inside YAML scalars (an automation file);
+ * - `markdown-template` — templates anywhere in Markdown but its comments.
+ *
+ * The injection only fires under these wrappers' own root scopes, so a
+ * Helm, Jinja or Go-template YAML file in a chat answer (plain `yaml`) keeps
+ * its braces as text. The body uses the JavaScript grammar's `expression`
+ * rules, which consume brackets and strings, so `{{ "}}" }}` and
+ * `{{ ({ a: { b: 1 } }) }}` close at the right brace. A template the line
+ * ends inside stops at the end of that line — display only; the editor and
+ * the engine judge where a template really ends.
+ */
+
+const INJECTION_SCOPE = 'tale.template.injection';
+
+const templateInjection: LanguageRegistration = {
+  name: 'tale-template-injection',
+  scopeName: INJECTION_SCOPE,
+  injectTo: [
+    'source.tale-template',
+    'source.tale-json',
+    'source.tale-yaml',
+    'text.tale-markdown',
+  ],
+  injectionSelector:
+    'L:source.tale-template, L:source.tale-json string, L:source.tale-yaml string, L:text.tale-markdown -comment',
+  embeddedLangs: ['javascript'],
+  patterns: [{ include: '#template' }],
+  repository: {
+    template: {
+      begin: '\\{\\{',
+      end: '\\}\\}|$',
+      name: 'meta.template.expression.tale',
+      beginCaptures: {
+        0: { name: 'punctuation.definition.template-expression.begin.tale' },
+      },
+      endCaptures: {
+        0: { name: 'punctuation.definition.template-expression.end.tale' },
+      },
+      contentName: 'meta.embedded.expression.tale source.js',
+      patterns: [{ include: 'source.js#expression' }],
+    },
+  },
+};
+
+function wrapper(
+  name: string,
+  scopeName: string,
+  include: string,
+  base: string[],
+): LanguageRegistration {
+  return {
+    name,
+    scopeName,
+    patterns: include === '' ? [] : [{ include }],
+    repository: {},
+    embeddedLangs: [...base, 'tale-template-injection'],
+  };
+}
+
+/**
+ * Every grammar the template languages need, the injection first. Register
+ * them together with `javascript`, `json`, `yaml` and `markdown`.
+ */
+export const TEMPLATE_GRAMMARS: readonly LanguageRegistration[] = [
+  templateInjection,
+  wrapper('tale-template', 'source.tale-template', '', []),
+  wrapper('json-template', 'source.tale-json', 'source.json', ['json']),
+  wrapper('yaml-template', 'source.tale-yaml', 'source.yaml', ['yaml']),
+  wrapper('markdown-template', 'text.tale-markdown', 'text.html.markdown', [
+    'markdown',
+  ]),
+];
diff --git a/packages/ui/src/markdown/shiki.test.ts b/packages/ui/src/markdown/shiki.test.ts
index e7868d38de..36fc6c4f1b 100644
--- a/packages/ui/src/markdown/shiki.test.ts
+++ b/packages/ui/src/markdown/shiki.test.ts
@@ -1,33 +1,66 @@
 import { describe, expect, it } from 'vitest';
 
+import { CODE_LANGUAGES, codeRoleOfColor } from '../lib/code-roles';
 import {
   highlightCode,
   peekHighlightedCode,
   resolveLanguage,
   resolveShikiTheme,
+  shikiLanguageFor,
 } from './shiki';
 
-describe('resolveShikiTheme', () => {
-  it('maps light aliases onto min-light', () => {
-    expect(resolveShikiTheme('light')).toBe('min-light');
-    expect(resolveShikiTheme('github-light')).toBe('min-light');
-    expect(resolveShikiTheme('min-light')).toBe('min-light');
-  });
+/** The text of every `` with its role. */
+function roles(html: string): Array<[string, string | null]> {
+  const out: Array<[string, string | null]> = [];
+  for (const match of html.matchAll(
+    /([^<]*)<\/span>/g,
+  )) {
+    const text = match[2]
+      .replaceAll('<', '<')
+      .replaceAll('&', '&')
+      .replaceAll('"', '"')
+      .replaceAll(''', "'");
+    out.push([text, codeRoleOfColor(match[1])]);
+  }
+  return out;
+}
 
-  it('maps dark aliases onto min-dark', () => {
-    expect(resolveShikiTheme('dark')).toBe('min-dark');
-    expect(resolveShikiTheme('github-dark')).toBe('min-dark');
-    expect(resolveShikiTheme('min-dark')).toBe('min-dark');
+function roleOf(html: string, text: string): string | null | undefined {
+  return roles(html).find(([token]) => token.trim() === text)?.[1];
+}
+
+describe('resolveShikiTheme', () => {
+  it.each([
+    'light',
+    'dark',
+    'github-light',
+    'github-dark',
+    'min-light',
+    'min-dark',
+  ] as const)('maps %s onto the one css-variables theme', (alias) => {
+    expect(resolveShikiTheme(alias)).toBe('tale-code');
   });
 });
 
-describe('highlightCode themes', () => {
-  it('emits min-* theme classes for github-* aliases (#2785)', async () => {
-    const dark = await highlightCode('const x = 1;', 'ts', 'github-dark');
-    expect(dark?.html).toContain('class="shiki min-dark"');
+describe('highlightCode palette', () => {
+  // The read-only blocks failed AA with min-light/min-dark (comments 1.69:1).
+  // They now colour through the `--code-*` variables, the editor's palette.
+  it('colours tokens through the --code-* variables, never a hex value', async () => {
+    const result = await highlightCode(
+      'const x = 1; // note\nreturn "a";',
+      'ts',
+    );
+    expect(result?.html).toContain('class="shiki tale-code"');
+    expect(result?.html).toContain('var(--code-token-keyword)');
+    expect(result?.html).toContain('var(--code-token-comment)');
+    expect(result?.html).not.toMatch(/color:#[0-9a-f]{3,8}/i);
+  });
 
-    const light = await highlightCode('{"a":1}', 'json', 'github-light');
-    expect(light?.html).toContain('class="shiki min-light"');
+  it('gives light and dark the same HTML, cached once', async () => {
+    const code = 'const theme = "any"; // one cache entry';
+    const light = await highlightCode(code, 'ts', 'light');
+    expect(peekHighlightedCode(code, 'ts', 'dark')).toBe(light);
+    expect(await highlightCode(code, 'ts', 'github-dark')).toBe(light);
   });
 });
 
@@ -37,22 +70,18 @@ describe('diff highlighting', () => {
     expect(resolveLanguage('diff')).toBe('diff');
   });
 
-  // REGRESSION: the min-* themes ship no colors for the diff scopes, so a
-  // previewed .patch file rendered fully monochrome. The extended themes must
-  // give added and removed lines distinct colors.
+  // REGRESSION: a theme without colours for the diff scopes rendered a
+  // previewed .patch file fully monochrome. Added and removed lines must keep
+  // distinct colours, and the hunk range its own.
   it('colors added and removed lines distinctly', async () => {
     const result = await highlightCode(
       '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n',
       'patch',
-      'light',
     );
     expect(result?.language).toBe('diff');
-    expect(result?.html).toContain('#22863A');
-    expect(result?.html).toContain('#B31D28');
-
-    const dark = await highlightCode('-old\n+new\n', 'diff', 'dark');
-    expect(dark?.html).toContain('#85E89D');
-    expect(dark?.html).toContain('#F97583');
+    expect(result?.html).toContain('var(--code-token-inserted)');
+    expect(result?.html).toContain('var(--code-token-deleted)');
+    expect(result?.html).toContain('var(--code-token-function)');
   });
 });
 
@@ -61,22 +90,18 @@ describe('remembered highlights', () => {
   // it plain until the highlight arrived.
   it('answers a snippet highlighted before, the same result, from memory', async () => {
     const code = 'export const answer = 42; // remembered';
-    expect(peekHighlightedCode(code, 'ts', 'light')).toBeNull();
+    expect(peekHighlightedCode(code, 'ts')).toBeNull();
 
-    const first = await highlightCode(code, 'ts', 'light');
+    const first = await highlightCode(code, 'ts');
     expect(first).not.toBeNull();
-    expect(peekHighlightedCode(code, 'typescript', 'github-light')).toBe(first);
-    expect(await highlightCode(code, 'ts', 'light')).toBe(first);
+    expect(peekHighlightedCode(code, 'typescript')).toBe(first);
+    expect(await highlightCode(code, 'ts')).toBe(first);
   });
 
-  it('keeps the language and the theme apart', async () => {
-    const code = 'const theme = "dark"; // remembered per theme';
-    const light = await highlightCode(code, 'ts', 'light');
-    expect(peekHighlightedCode(code, 'ts', 'dark')).toBeNull();
-    expect(peekHighlightedCode(code, 'js', 'light')).toBeNull();
-    const dark = await highlightCode(code, 'ts', 'dark');
-    expect(dark?.html).not.toBe(light?.html);
-    expect(peekHighlightedCode(code, 'ts', 'dark')).toBe(dark);
+  it('keeps languages apart', async () => {
+    const code = 'const lang = "ts"; // remembered per language';
+    await highlightCode(code, 'ts');
+    expect(peekHighlightedCode(code, 'js')).toBeNull();
   });
 
   it('remembers nothing for a snippet too large to highlight', async () => {
@@ -85,3 +110,82 @@ describe('remembered highlights', () => {
     expect(peekHighlightedCode(code, 'text')).toBeNull();
   });
 });
+
+describe('template grammars', () => {
+  it('names a grammar for every code language', () => {
+    expect(
+      Object.fromEntries(
+        CODE_LANGUAGES.map((language) => [
+          language,
+          [shikiLanguageFor(language), shikiLanguageFor(language, true)],
+        ]),
+      ),
+    ).toEqual({
+      javascript: ['javascript', 'javascript'],
+      expression: ['javascript', 'javascript'],
+      json: ['json', 'json-template'],
+      yaml: ['yaml', 'yaml-template'],
+      markdown: ['markdown', 'markdown-template'],
+      template: ['tale-template', 'tale-template'],
+      text: ['text', 'tale-template'],
+    });
+  });
+
+  it('resolves the template aliases', () => {
+    expect(resolveLanguage('template')).toBe('tale-template');
+    expect(resolveLanguage('json+template')).toBe('json-template');
+    expect(resolveLanguage('yml+template')).toBe('yaml-template');
+    expect(resolveLanguage('md+template')).toBe('markdown-template');
+  });
+
+  it('highlights the body of a template as JavaScript', async () => {
+    const result = await highlightCode(
+      'Hello {{ nodes.triage.output.name }}!',
+      'tale-template',
+    );
+    expect(result?.language).toBe('tale-template');
+    const html = result?.html ?? '';
+    expect(roleOf(html, '{{')).toBe('keyword');
+    expect(roleOf(html, '}}')).toBe('keyword');
+    expect(roleOf(html, 'nodes')).toBe('constant');
+    expect(roleOf(html, 'Hello')).toBe('foreground');
+  });
+
+  it('closes a template at the brace the expression leaves open', async () => {
+    const html =
+      (await highlightCode('{{ "}}" }} tail', 'tale-template'))?.html ?? '';
+    expect(roleOf(html, '"}}"')).toBe('string-expression');
+    expect(roleOf(html, 'tail')).toBe('foreground');
+  });
+
+  it('finds templates inside JSON strings, not in plain JSON', async () => {
+    const code = '{"to": "{{ input.email }}"}';
+    const templated = (await highlightCode(code, 'json-template'))?.html ?? '';
+    expect(roleOf(templated, 'input')).toBe('constant');
+    const plain = (await highlightCode(code, 'json'))?.html ?? '';
+    expect(roleOf(plain, 'input')).toBeUndefined();
+  });
+
+  // Helm, Jinja and Go templates in a chat answer are `yaml`: their braces
+  // must stay text there.
+  it('leaves templates in plain YAML alone', async () => {
+    const code = 'prompt: hi {{ item.x }}\n';
+    const templated = (await highlightCode(code, 'yaml-template'))?.html ?? '';
+    expect(roleOf(templated, '{{')).toBe('keyword');
+    expect(roleOf(templated, 'item')).toBe('constant');
+    const plain = (await highlightCode(code, 'yaml'))?.html ?? '';
+    expect(roleOf(plain, '{{')).toBeUndefined();
+    expect(roleOf(plain, 'item')).toBeUndefined();
+  });
+
+  it('highlights templates in Markdown prose', async () => {
+    const html =
+      (
+        await highlightCode(
+          'Use {{ nodes.a.output }} *now*',
+          'markdown-template',
+        )
+      )?.html ?? '';
+    expect(roleOf(html, 'nodes')).toBe('constant');
+  });
+});
diff --git a/packages/ui/src/markdown/shiki.ts b/packages/ui/src/markdown/shiki.ts
index f67a61eae3..6def4a5a56 100644
--- a/packages/ui/src/markdown/shiki.ts
+++ b/packages/ui/src/markdown/shiki.ts
@@ -1,46 +1,45 @@
 /**
  * Shared Shiki highlighter singleton.
  *
- * Strategy: the engine, the themes and 39 common grammars load together the
+ * Strategy: the engine, the theme and 39 common grammars load together the
  * first time code is highlighted (or `preloadHighlighter` asks), not with the
  * page: a chat or a docs page without code never needs them. Anything else is
  * lazy-loaded on demand via a runtime dynamic import. Shiki's JS regex engine
  * keeps the bundle off the WASM oniguruma path.
+ *
+ * Colours come from the `--code-*` variables in `globals.css` (Shiki's
+ * css-variables theme), the palette the code editor reads too. One
+ * highlight therefore serves light and dark alike: a theme switch repaints
+ * the variables and never tokenizes again.
  */
 
 import type { HighlighterCore, ThemeRegistration } from 'shiki/core';
 
+import type { CodeLanguage } from '../lib/code-roles';
+import { TEMPLATE_GRAMMARS } from './shiki-template-grammars';
+
 let highlighterPromise: Promise | null = null;
 
+/** The one theme the highlighter loads. */
+export const SHIKI_THEME = 'tale-code';
+
 /**
- * The min-* themes define no colors for the diff grammar's scopes, so a
- * rendered `.patch`/`.diff` file (and every ```diff fence) came out
- * monochrome. Extend a theme with the standard add/del/hunk palette; the
- * theme keeps its name, so `codeToHtml` resolves it unchanged.
+ * The css-variables theme colours a unified diff's added, removed and
+ * changed lines; the hunk ranges and file headers get the function and
+ * changed roles, as the min-* themes' diff colours did before.
  */
-function withDiffColors(
-  theme: ThemeRegistration,
-  palette: { inserted: string; deleted: string; range: string; header: string },
-): ThemeRegistration {
+function withDiffScopes(theme: ThemeRegistration): ThemeRegistration {
   return {
     ...theme,
     tokenColors: [
       ...(theme.tokenColors ?? []),
-      {
-        scope: ['markup.inserted'],
-        settings: { foreground: palette.inserted },
-      },
-      {
-        scope: ['markup.deleted'],
-        settings: { foreground: palette.deleted },
-      },
       {
         scope: ['meta.diff.range', 'punctuation.definition.range.diff'],
-        settings: { foreground: palette.range },
+        settings: { foreground: 'var(--code-token-function)' },
       },
       {
         scope: ['meta.diff.header', 'meta.diff.index'],
-        settings: { foreground: palette.header },
+        settings: { foreground: 'var(--code-token-changed)' },
       },
     ],
   };
@@ -52,69 +51,66 @@ function getHighlighter(): Promise {
       import('shiki/core'),
       import('shiki/engine/javascript'),
     ])
-      .then(([{ createHighlighterCore }, { createJavaScriptRegexEngine }]) =>
-        createHighlighterCore({
-          themes: [
-            import('shiki/themes/min-dark.mjs').then((m) =>
-              withDiffColors(m.default, {
-                inserted: '#85e89d',
-                deleted: '#f97583',
-                range: '#b392f0',
-                header: '#79b8ff',
-              }),
-            ),
-            import('shiki/themes/min-light.mjs').then((m) =>
-              withDiffColors(m.default, {
-                inserted: '#22863a',
-                deleted: '#b31d28',
-                range: '#6f42c1',
-                header: '#005cc5',
-              }),
-            ),
-          ],
-          langs: [
-            import('shiki/langs/bash.mjs'),
-            import('shiki/langs/c.mjs'),
-            import('shiki/langs/cpp.mjs'),
-            import('shiki/langs/csharp.mjs'),
-            import('shiki/langs/css.mjs'),
-            import('shiki/langs/diff.mjs'),
-            import('shiki/langs/docker.mjs'),
-            import('shiki/langs/dotenv.mjs'),
-            import('shiki/langs/elixir.mjs'),
-            import('shiki/langs/go.mjs'),
-            import('shiki/langs/graphql.mjs'),
-            import('shiki/langs/hcl.mjs'),
-            import('shiki/langs/html.mjs'),
-            import('shiki/langs/http.mjs'),
-            import('shiki/langs/ini.mjs'),
-            import('shiki/langs/java.mjs'),
-            import('shiki/langs/javascript.mjs'),
-            import('shiki/langs/json.mjs'),
-            import('shiki/langs/jsx.mjs'),
-            import('shiki/langs/kotlin.mjs'),
-            import('shiki/langs/lua.mjs'),
-            import('shiki/langs/markdown.mjs'),
-            import('shiki/langs/nginx.mjs'),
-            import('shiki/langs/php.mjs'),
-            import('shiki/langs/powershell.mjs'),
-            import('shiki/langs/prisma.mjs'),
-            import('shiki/langs/python.mjs'),
-            import('shiki/langs/ruby.mjs'),
-            import('shiki/langs/rust.mjs'),
-            import('shiki/langs/scala.mjs'),
-            import('shiki/langs/scss.mjs'),
-            import('shiki/langs/sql.mjs'),
-            import('shiki/langs/swift.mjs'),
-            import('shiki/langs/toml.mjs'),
-            import('shiki/langs/tsx.mjs'),
-            import('shiki/langs/typescript.mjs'),
-            import('shiki/langs/xml.mjs'),
-            import('shiki/langs/yaml.mjs'),
-            import('shiki/langs/zig.mjs'),
-          ],
-          engine: createJavaScriptRegexEngine(),
-        }),
+      .then(
+        ([
+          { createCssVariablesTheme, createHighlighterCore },
+          { createJavaScriptRegexEngine },
+        ]) =>
+          createHighlighterCore({
+            themes: [
+              withDiffScopes(
+                createCssVariablesTheme({
+                  name: SHIKI_THEME,
+                  variablePrefix: '--code-',
+                  fontStyle: true,
+                }),
+              ),
+            ],
+            langs: [
+              import('shiki/langs/bash.mjs'),
+              import('shiki/langs/c.mjs'),
+              import('shiki/langs/cpp.mjs'),
+              import('shiki/langs/csharp.mjs'),
+              import('shiki/langs/css.mjs'),
+              import('shiki/langs/diff.mjs'),
+              import('shiki/langs/docker.mjs'),
+              import('shiki/langs/dotenv.mjs'),
+              import('shiki/langs/elixir.mjs'),
+              import('shiki/langs/go.mjs'),
+              import('shiki/langs/graphql.mjs'),
+              import('shiki/langs/hcl.mjs'),
+              import('shiki/langs/html.mjs'),
+              import('shiki/langs/http.mjs'),
+              import('shiki/langs/ini.mjs'),
+              import('shiki/langs/java.mjs'),
+              import('shiki/langs/javascript.mjs'),
+              import('shiki/langs/json.mjs'),
+              import('shiki/langs/jsx.mjs'),
+              import('shiki/langs/kotlin.mjs'),
+              import('shiki/langs/lua.mjs'),
+              import('shiki/langs/markdown.mjs'),
+              import('shiki/langs/nginx.mjs'),
+              import('shiki/langs/php.mjs'),
+              import('shiki/langs/powershell.mjs'),
+              import('shiki/langs/prisma.mjs'),
+              import('shiki/langs/python.mjs'),
+              import('shiki/langs/ruby.mjs'),
+              import('shiki/langs/rust.mjs'),
+              import('shiki/langs/scala.mjs'),
+              import('shiki/langs/scss.mjs'),
+              import('shiki/langs/sql.mjs'),
+              import('shiki/langs/swift.mjs'),
+              import('shiki/langs/toml.mjs'),
+              import('shiki/langs/tsx.mjs'),
+              import('shiki/langs/typescript.mjs'),
+              import('shiki/langs/xml.mjs'),
+              import('shiki/langs/yaml.mjs'),
+              import('shiki/langs/zig.mjs'),
+              // After javascript, json, yaml and markdown, which they embed.
+              ...TEMPLATE_GRAMMARS,
+            ],
+            engine: createJavaScriptRegexEngine(),
+          }),
       )
       .catch((error: unknown) => {
         highlighterPromise = null;
@@ -183,6 +179,14 @@ const LANG_ALIASES: Record = {
   tf: 'hcl',
   // Unified diffs: `.patch`/`.diff` files share the one `diff` grammar.
   patch: 'diff',
+  // Tale's `{{ js }}` templates (see `shiki-template-grammars.ts`).
+  template: 'tale-template',
+  'text+template': 'tale-template',
+  'json+template': 'json-template',
+  'yaml+template': 'yaml-template',
+  'yml+template': 'yaml-template',
+  'markdown+template': 'markdown-template',
+  'md+template': 'markdown-template',
 };
 
 export function resolveLanguage(input: string | undefined): string {
@@ -191,6 +195,33 @@ export function resolveLanguage(input: string | undefined): string {
   return LANG_ALIASES[lower] ?? lower;
 }
 
+/**
+ * The Shiki language that shows a code field's text the way the code editor
+ * highlights it: an expression and a script are both JavaScript, and a field
+ * that may hold `{{ js }}` templates gets its template-aware grammar.
+ * `templates` is implied by `template` and ignored for JavaScript.
+ */
+export function shikiLanguageFor(
+  language: CodeLanguage,
+  templates = false,
+): string {
+  switch (language) {
+    case 'javascript':
+    case 'expression':
+      return 'javascript';
+    case 'template':
+      return 'tale-template';
+    case 'json':
+      return templates ? 'json-template' : 'json';
+    case 'yaml':
+      return templates ? 'yaml-template' : 'yaml';
+    case 'markdown':
+      return templates ? 'markdown-template' : 'markdown';
+    case 'text':
+      return templates ? 'tale-template' : 'text';
+  }
+}
+
 /**
  * Cap on the input size we'll synchronously tokenize on the main thread.
  * Above this, callers should fall back to a plain-text render — Shiki's
@@ -204,7 +235,11 @@ export interface HighlightResult {
   language: string;
 }
 
-type ShikiTheme =
+/**
+ * The theme names callers passed before the palette moved into CSS
+ * variables. Every one of them still works and means the same now.
+ */
+export type ShikiTheme =
   | 'light'
   | 'dark'
   | 'github-light'
@@ -213,14 +248,13 @@ type ShikiTheme =
   | 'min-dark';
 
 /**
- * Map every theme alias a caller may still pass (including the historical
- * `github-*` names) onto the one light/dark pair the highlighter actually
- * loads — the flatter `min-*` themes (#2785).
+ * Map every theme alias a caller may still pass (light or dark, and the
+ * historical `github-*` and `min-*` names) onto the one theme the
+ * highlighter loads. Its colours are CSS variables that follow the page's
+ * theme, so the light/dark choice no longer reaches Shiki at all.
  */
-export function resolveShikiTheme(theme: ShikiTheme): 'min-dark' | 'min-light' {
-  return theme === 'dark' || theme === 'github-dark' || theme === 'min-dark'
-    ? 'min-dark'
-    : 'min-light';
+export function resolveShikiTheme(_theme?: ShikiTheme): typeof SHIKI_THEME {
+  return SHIKI_THEME;
 }
 
 /**
@@ -234,12 +268,9 @@ const highlighted = new Map();
 let highlightedChars = 0;
 const HIGHLIGHTED_MAX_CHARS = 4_000_000;
 
-function highlightKey(
-  code: string,
-  lang: string | undefined,
-  theme: ShikiTheme,
-): string {
-  return `${resolveShikiTheme(theme)}\u0000${resolveLanguage(lang)}\u0000${code}`;
+/** One entry per snippet and language: the HTML is the same in both themes. */
+function highlightKey(code: string, lang: string | undefined): string {
+  return `${resolveLanguage(lang)}\u0000${code}`;
 }
 
 function remember(key: string, result: HighlightResult): void {
@@ -265,9 +296,9 @@ function remember(key: string, result: HighlightResult): void {
 export function peekHighlightedCode(
   code: string,
   lang: string | undefined,
-  theme: ShikiTheme = 'light',
+  _theme?: ShikiTheme,
 ): HighlightResult | null {
-  return highlighted.get(highlightKey(code, lang, theme)) ?? null;
+  return highlighted.get(highlightKey(code, lang)) ?? null;
 }
 
 /**
@@ -278,21 +309,23 @@ export function peekHighlightedCode(
  * Languages outside the eager list are lazy-loaded on first request and
  * cached for subsequent calls. Unknown grammars fall back to plaintext. A
  * snippet highlighted before answers from {@link peekHighlightedCode}'s
- * store without tokenizing again.
+ * store without tokenizing again. The HTML colours through the `--code-*`
+ * variables, so it is right in either theme; `_theme` is accepted for
+ * callers that still pass one.
  */
 export async function highlightCode(
   code: string,
   lang: string | undefined,
-  theme: ShikiTheme = 'light',
+  _theme?: ShikiTheme,
 ): Promise {
   if (code.length > MAX_SHIKI_BYTES) return null;
-  const key = highlightKey(code, lang, theme);
+  const key = highlightKey(code, lang);
   const known = highlighted.get(key);
   if (known !== undefined) {
     remember(key, known);
     return known;
   }
-  const result = await tokenize(code, lang, theme);
+  const result = await tokenize(code, lang);
   if (result !== null) remember(key, result);
   return result;
 }
@@ -300,7 +333,6 @@ export async function highlightCode(
 async function tokenize(
   code: string,
   lang: string | undefined,
-  theme: ShikiTheme,
 ): Promise {
   let highlighter: HighlighterCore;
   try {
@@ -310,7 +342,7 @@ async function tokenize(
     return null;
   }
 
-  const resolvedTheme = resolveShikiTheme(theme);
+  const resolvedTheme = SHIKI_THEME;
   const resolvedLang = resolveLanguage(lang);
 
   // Shiki's `text` grammar is a built-in no-highlight pass — there is no

From c00b7fe153774b8457f0032884b7636e87253e26 Mon Sep 17 00:00:00 2001
From: yannickmonney 
Date: Thu, 8 Oct 2026 08:57:01 +0200
Subject: [PATCH 06/78] feat(ui): let a widget claim Escape inside dialogs and
 sheets

A widget that uses Escape itself, such as a code editor closing its
completion list, sat inside dialogs, sheets and popovers whose layer
closed on the same key: Radix hears Escape first, in the capture phase.

A widget now marks itself with data-claims-escape while it needs the
key, and every @tale/ui overlay passes its onEscapeKeyDown through
respectEscapeClaims, which keeps the layer open for a claimed Escape.
---
 packages/ui/src/components/dialog/dialog.tsx  |   2 +
 .../overlays/claims-escape.test.tsx           | 168 ++++++++++++++++++
 .../src/components/overlays/claims-escape.ts  |  40 +++++
 .../src/components/overlays/dropdown-menu.tsx |   2 +
 .../ui/src/components/overlays/popover.tsx    |   2 +
 .../components/overlays/responsive-dialog.tsx |   3 +
 packages/ui/src/components/overlays/sheet.tsx |   2 +
 7 files changed, 219 insertions(+)
 create mode 100644 packages/ui/src/components/overlays/claims-escape.test.tsx
 create mode 100644 packages/ui/src/components/overlays/claims-escape.ts

diff --git a/packages/ui/src/components/dialog/dialog.tsx b/packages/ui/src/components/dialog/dialog.tsx
index d6933bf3da..23437eb8da 100644
--- a/packages/ui/src/components/dialog/dialog.tsx
+++ b/packages/ui/src/components/dialog/dialog.tsx
@@ -10,6 +10,7 @@ import { ChevronLeft, X } from 'lucide-react';
 import * as React from 'react';
 
 import { CLOSE_BUTTON_CLASS } from '../overlays/close-button-class';
+import { respectEscapeClaims } from '../overlays/claims-escape';
 import { PagePointerPin } from '../overlays/page-pointer-pin';
 
 // Tracks dialog nesting so a child Dialog opened from inside another
@@ -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/overlays/claims-escape.test.tsx b/packages/ui/src/components/overlays/claims-escape.test.tsx
new file mode 100644
index 0000000000..87b1d66b85
--- /dev/null
+++ b/packages/ui/src/components/overlays/claims-escape.test.tsx
@@ -0,0 +1,168 @@
+import { fireEvent } from '@testing-library/react';
+import { useState, type ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { render, screen } from '@/tests/utils/render';
+
+import { Dialog } from '../dialog/dialog';
+import { isEscapeClaimed, respectEscapeClaims } from './claims-escape';
+import { DropdownMenu } from './dropdown-menu';
+import { Popover } from './popover';
+import {
+  ResponsiveDialog,
+  ResponsiveDialogContent,
+  ResponsiveDialogTitle,
+} from './responsive-dialog';
+import { Sheet } from './sheet';
+
+/**
+ * A widget that uses Escape itself (a code editor with its completion list
+ * open) claims it with `data-claims-escape`; the layer it sits in must stay
+ * open for a claimed Escape and close for any other.
+ */
+
+function Claimant({ claims }: { claims: boolean }) {
+  return (
+    
+ +
+ ); +} + +function pressEscape(): void { + const widget = screen.getByRole('textbox', { name: 'Widget' }); + widget.focus(); + fireEvent.keyDown(widget, { key: 'Escape', code: 'Escape' }); +} + +type Layer = (props: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) => ReactNode; + +const LAYERS: Array<[string, Layer]> = [ + [ + 'Dialog', + ({ open, onOpenChange, children }) => ( + + {children} + + ), + ], + [ + 'Sheet', + ({ open, onOpenChange, children }) => ( + + {children} + + ), + ], + [ + 'ResponsiveDialog', + ({ open, onOpenChange, children }) => ( + + + Edit + {children} + + + ), + ], + [ + 'Popover', + ({ open, onOpenChange, children }) => ( + Open} + aria-label="Edit" + > + {children} + + ), + ], +]; + +function Harness({ Layer, claims }: { Layer: Layer; claims: boolean }) { + const [open, setOpen] = useState(true); + return ( + <> +

{open ? 'open' : 'closed'}

+ + + + + ); +} + +describe('respectEscapeClaims', () => { + it('cancels a claimed Escape and passes any other to the handler', () => { + const handler = vi.fn(); + const wrapped = respectEscapeClaims(handler); + const claimant = document.createElement('div'); + claimant.setAttribute('data-claims-escape', ''); + const inner = document.createElement('input'); + claimant.append(inner); + document.body.append(claimant); + + const claimed = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + }); + inner.dispatchEvent(claimed); + wrapped(claimed); + expect(claimed.defaultPrevented).toBe(true); + expect(handler).not.toHaveBeenCalled(); + expect(isEscapeClaimed(claimed)).toBe(true); + + const free = new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + }); + document.body.dispatchEvent(free); + wrapped(free); + expect(free.defaultPrevented).toBe(false); + expect(handler).toHaveBeenCalledOnce(); + claimant.remove(); + }); + + it.each(LAYERS)('%s stays open for a claimed Escape', (_name, Layer) => { + render(); + pressEscape(); + expect(screen.getByText('open')).toBeInTheDocument(); + }); + + it.each(LAYERS)('%s closes for an unclaimed Escape', (_name, Layer) => { + render(); + pressEscape(); + expect(screen.getByText('closed')).toBeInTheDocument(); + }); + + it('DropdownMenu stays open while its content claims Escape', () => { + function Menu() { + const [open, setOpen] = useState(true); + return ( + <> +

{open ? 'open' : 'closed'}

+ Actions} + items={[[{ type: 'item', label: 'Rename', onClick: () => {} }]]} + /> + + ); + } + render(); + const menu = screen.getByRole('menu'); + menu.setAttribute('data-claims-escape', ''); + const item = screen.getByRole('menuitem', { name: 'Rename' }); + item.focus(); + fireEvent.keyDown(item, { key: 'Escape' }); + expect(screen.getByText('open')).toBeInTheDocument(); + menu.removeAttribute('data-claims-escape'); + fireEvent.keyDown(item, { key: 'Escape' }); + expect(screen.getByText('closed')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/overlays/claims-escape.ts b/packages/ui/src/components/overlays/claims-escape.ts new file mode 100644 index 0000000000..48c62a183a --- /dev/null +++ b/packages/ui/src/components/overlays/claims-escape.ts @@ -0,0 +1,40 @@ +/** + * Widgets that use Escape themselves — a code editor closing its completion + * list, then arming "leave" — sit inside dialogs, sheets and popovers whose + * layer closes on Escape. Radix hears Escape first (it listens on the + * document in the capture phase), so without a convention the first Escape + * in a sheet closed the whole sheet under an open list. + * + * The convention: while a widget needs Escape, an element around the focus + * carries `data-claims-escape`. Every `@tale/ui` overlay passes its + * `onEscapeKeyDown` through `respectEscapeClaims`, which cancels the layer's + * dismissal for a claimed Escape; the widget handles the key, and once it + * drops the claim the next Escape closes the layer as usual. + */ + +export const CLAIMS_ESCAPE_ATTRIBUTE = 'data-claims-escape'; + +/** Whether a widget around the event's target has claimed Escape. */ +export function isEscapeClaimed(event: Event): boolean { + const target = event.target; + return ( + target instanceof Element && + target.closest(`[${CLAIMS_ESCAPE_ATTRIBUTE}]`) !== null + ); +} + +/** + * Wraps an overlay's `onEscapeKeyDown`: a claimed Escape is cancelled (the + * layer stays open, `handler` does not run); any other reaches `handler`. + */ +export function respectEscapeClaims( + handler?: (event: E) => void, +): (event: E) => void { + return (event) => { + if (isEscapeClaimed(event)) { + event.preventDefault(); + return; + } + handler?.(event); + }; +} diff --git a/packages/ui/src/components/overlays/dropdown-menu.tsx b/packages/ui/src/components/overlays/dropdown-menu.tsx index 8449fa57d6..e6f0085718 100644 --- a/packages/ui/src/components/overlays/dropdown-menu.tsx +++ b/packages/ui/src/components/overlays/dropdown-menu.tsx @@ -12,6 +12,7 @@ import { } from 'react'; import { cn } from '../../lib/cn'; +import { respectEscapeClaims } from './claims-escape'; import { TooltipContent } from './tooltip'; export interface DropdownMenuActionItem { @@ -479,6 +480,7 @@ export function DropdownMenu({ collisionPadding={collisionPadding ?? 16} onClick={(e) => e.stopPropagation()} onPointerDownOutside={keepTriggerPointerDown} + onEscapeKeyDown={respectEscapeClaims()} style={{ maxHeight: 'min(80vh, var(--radix-dropdown-menu-content-available-height, 80vh))', diff --git a/packages/ui/src/components/overlays/popover.tsx b/packages/ui/src/components/overlays/popover.tsx index 8ce6493aab..0901f572fe 100644 --- a/packages/ui/src/components/overlays/popover.tsx +++ b/packages/ui/src/components/overlays/popover.tsx @@ -4,6 +4,7 @@ import * as PopoverPrimitive from '@radix-ui/react-popover'; import { type ReactNode } from 'react'; import { cn } from '../../lib/cn'; +import { respectEscapeClaims } from './claims-escape'; interface PopoverProps { trigger: ReactNode; @@ -71,6 +72,7 @@ export function Popover({ onOpenAutoFocus={onOpenAutoFocus} onCloseAutoFocus={onCloseAutoFocus} onInteractOutside={onInteractOutside} + onEscapeKeyDown={respectEscapeClaims()} aria-labelledby={ariaLabelledby} aria-label={ariaLabel} className={cn(CONTENT_CLASSES, contentClassName)} diff --git a/packages/ui/src/components/overlays/responsive-dialog.tsx b/packages/ui/src/components/overlays/responsive-dialog.tsx index 282e3f001d..3bb0afe0b7 100644 --- a/packages/ui/src/components/overlays/responsive-dialog.tsx +++ b/packages/ui/src/components/overlays/responsive-dialog.tsx @@ -19,6 +19,7 @@ import { useRestoreFocus } from '../../hooks/use-restore-focus'; import { useT } from '../../i18n/client'; import { cn } from '../../lib/cn'; import { CLOSE_BUTTON_CLASS } from './close-button-class'; +import { respectEscapeClaims } from './claims-escape'; import { PagePointerPin } from './page-pointer-pin'; /** @@ -221,6 +222,7 @@ export const ResponsiveDialogContent = forwardRef< onPointerDownOutside={preventDatePickerDismiss} onInteractOutside={preventDatePickerDismiss} onFocusOutside={preventDatePickerDismiss} + onEscapeKeyDown={respectEscapeClaims()} className={cn( // `outline-none`, as on `Dialog`: when Radix parks focus on the // panel itself (nothing to start in, or the content it held @@ -297,6 +299,7 @@ export const ResponsiveDialogContent = forwardRef< onPointerDownOutside={preventDatePickerDismiss} onInteractOutside={preventDatePickerDismiss} onFocusOutside={preventDatePickerDismiss} + onEscapeKeyDown={respectEscapeClaims()} className={cn( 'bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border p-6 shadow-lg outline-none', // Never exceed the viewport: cap at 90dvh and scroll internally so a diff --git a/packages/ui/src/components/overlays/sheet.tsx b/packages/ui/src/components/overlays/sheet.tsx index 699d27813e..92cd7d1945 100644 --- a/packages/ui/src/components/overlays/sheet.tsx +++ b/packages/ui/src/components/overlays/sheet.tsx @@ -19,6 +19,7 @@ import { type RefObject, } from 'react'; +import { respectEscapeClaims } from './claims-escape'; import { PagePointerPin } from './page-pointer-pin'; // Safe-area padding is layered into the design `p-6` via per-edge calc() so @@ -231,6 +232,7 @@ export function Sheet({ style={widthStyle} onOpenAutoFocus={onOpenAutoFocus} onCloseAutoFocus={restoreFocus} + onEscapeKeyDown={respectEscapeClaims()} // Without a description, opt out of Radix's default // `aria-describedby` (which would otherwise point at a // `Description` id that is never rendered — a dangling ARIA From d63055a98934c24ba4b85d44a947b7642028fe9f Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Thu, 8 Oct 2026 08:57:15 +0200 Subject: [PATCH 07/78] feat(ui): name contenteditable controls and pass go-to its part A code editor's text is a contenteditable element, which