From e14b8092eac9fa295873a40e6903744719d3ec42 Mon Sep 17 00:00:00 2001 From: Marc Chan Date: Fri, 8 May 2026 14:27:46 +0800 Subject: [PATCH] feat: add Orbit activity summaries (#681) * feat: add Orbit activity summaries * fix(orbit): make runs navigable while agent continues * fix(web): widen minimum chat panel * feat: support Orbit template selection * fix(daemon): avoid bogus skill side-file preflight * fix(web): collapse orbit artifact project cards * fix(web): preserve orbit project card titles * fix: improve Orbit run daily briefing * fix: handle Orbit digest data failures * fix: load Orbit templates and connector tools reliably * fix: keep Orbit summary counts consistent Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: apply Orbit template skill context * fix: cache and curate connector tools for Orbit * fix: align Orbit defaults and connector discovery * fix: simplify Orbit template settings * fix: move connectors into settings * fix: compact connector settings catalog * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: prevent connector action button from stretching into pill The icon-only connect/disconnect buttons in the embedded connectors catalog inherited min-width: 92px / 106px from the non-embedded pill rules, overriding the 24px square sizing and causing the buttons to overlap the card head text. Reset min-width to 0 in the embedded icon-only rule so the compact square layout holds. * fix(web): align live artifact file rows * fix: clean up Orbit connector settings lifecycle Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix: address Orbit review regressions Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * feat(web): localize Orbit and connector settings * feat(web): gate Orbit runs without connectors * feat(web): refine connector settings UX * feat(web): safeguard Composio key clearing * fix(web): refresh Composio tool badges * feat(web): show connector logos * feat(daemon): localize Orbit prompt window * fix(daemon): clarify blocked connector callback closes * test(daemon): harden flaky async probes * fix(web): align Indonesian connector locale keys * test(web): align connector browser props * fix(web): preserve explicit credential clears Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): time out Composio logo proxy fetches Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): localize Indonesian connector settings copy Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve discovered connector tools Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve onboarding autosave completion Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): defer Composio catalog cache hydration Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): treat discovery completion independently Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve latest settings draft on close Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): avoid syncing draft Composio key on Orbit run Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): localize Orbit settings copy Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): prefer fresh connector catalog state Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): declare Indonesian locale fallback keys explicitly Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): inline Indonesian fallback strings for CI Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI. Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): restrict proxied connector logos to image MIME types Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * test(e2e): align settings dialog regressions Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): decouple Orbit runs from media sync failures Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): keep SPA catch-all export-compatible Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve Orbit config and workspace routes Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): block SVG in connector logo proxy Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): fall back to static catalog for empty cache Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): disable Orbit run before connector gate resolves Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(desktop): export shipped desktop types Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): restore persisted question form selections Render historical submitted answers directly so reloaded question forms keep their locked selections visible. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): retry forced media sync autosave Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): keep Composio logo timeout through body read Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): refresh Orbit gate after connector auth Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): keep connector detail tool lists intact Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): ignore malformed Orbit summaries Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(e2e): stabilize design-system multi-select flow Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): cap Composio logo cache growth Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): bound proxied Composio logo payloads Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): align autosave settings tests Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): remove stray CSS conflict marker Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fixer: address PR #681 follow-up items Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): restore restart routes and connector flows * fix(web): keep SPA export route static * fix(web): stabilize chat scroll tests --------- Co-authored-by: lefarcen <935902669@qq.com> --- apps/daemon/src/app-config.ts | 43 + apps/daemon/src/connectors/catalog.ts | 14 +- .../src/connectors/composio-curation.ts | 122 + apps/daemon/src/connectors/composio.ts | 240 +- apps/daemon/src/connectors/routes.ts | 215 +- apps/daemon/src/connectors/service.ts | 26 +- apps/daemon/src/mcp-live-artifacts-server.ts | 19 +- apps/daemon/src/orbit.ts | 427 ++++ apps/daemon/src/server.ts | 258 +- apps/daemon/src/skills.ts | 17 +- apps/daemon/src/tools-connectors-cli.ts | 17 +- apps/daemon/src/tools/connectors.ts | 41 +- apps/daemon/tests/agents.test.ts | 24 + apps/daemon/tests/app-config.test.ts | 77 + apps/daemon/tests/composio-config.test.ts | 101 +- apps/daemon/tests/connection-test.test.ts | 9 +- apps/daemon/tests/connectors-routes.test.ts | 303 ++- apps/daemon/tests/connectors-service.test.ts | 174 ++ apps/daemon/tests/orbit.test.ts | 279 ++ apps/daemon/tests/project-watchers.test.ts | 14 +- apps/daemon/tests/prompts/system.test.ts | 9 +- apps/daemon/tests/skills.test.ts | 20 + .../daemon/tests/tools-connectors-cli.test.ts | 96 + apps/web/app/[[...slug]]/page.tsx | 7 +- apps/web/src/App.tsx | 157 +- apps/web/src/components/ConnectorsBrowser.tsx | 1080 ++++++++ apps/web/src/components/DesignFilesPanel.tsx | 2 +- apps/web/src/components/DesignsTab.tsx | 50 +- apps/web/src/components/EntryView.tsx | 661 +---- apps/web/src/components/FileViewer.tsx | 57 +- apps/web/src/components/FileWorkspace.tsx | 2 +- apps/web/src/components/Icon.tsx | 19 + apps/web/src/components/NewProjectPanel.tsx | 19 +- apps/web/src/components/ProjectView.tsx | 7 +- apps/web/src/components/QuestionForm.tsx | 9 +- apps/web/src/components/SettingsDialog.tsx | 1527 ++++++++++- apps/web/src/i18n/locales/ar.ts | 173 +- apps/web/src/i18n/locales/de.ts | 106 + apps/web/src/i18n/locales/en.ts | 173 +- apps/web/src/i18n/locales/es-ES.ts | 106 + apps/web/src/i18n/locales/fa.ts | 173 +- apps/web/src/i18n/locales/fr.ts | 173 +- apps/web/src/i18n/locales/hu.ts | 173 +- apps/web/src/i18n/locales/id.ts | 178 +- apps/web/src/i18n/locales/ja.ts | 106 + apps/web/src/i18n/locales/ko.ts | 173 +- apps/web/src/i18n/locales/pl.ts | 173 +- apps/web/src/i18n/locales/pt-BR.ts | 173 +- apps/web/src/i18n/locales/ru.ts | 173 +- apps/web/src/i18n/locales/tr.ts | 173 +- apps/web/src/i18n/locales/uk.ts | 173 +- apps/web/src/i18n/locales/zh-CN.ts | 177 +- apps/web/src/i18n/locales/zh-TW.ts | 177 +- apps/web/src/i18n/types.ts | 169 +- apps/web/src/index.css | 2246 ++++++++++++++++- apps/web/src/state/config.ts | 51 +- apps/web/src/types.ts | 11 + apps/web/tests/App.test.ts | 92 + .../tests/components/App.connectors.test.tsx | 26 +- .../components/App.mediaProviders.test.tsx | 12 +- .../components/ConnectorsBrowser.test.tsx | 139 + .../tests/components/NewProjectPanel.test.tsx | 2 - .../tests/components/QuestionForm.test.tsx | 46 + .../SettingsDialog.execution.test.tsx | 362 ++- .../components/SettingsDialog.orbit.test.tsx | 100 + .../tests/components/SettingsDialog.test.ts | 247 +- .../chat-scroll-preservation.test.tsx | 19 +- apps/web/tests/i18n/locales.test.ts | 79 + .../tests/runtime/app-route-export.test.ts | 11 + apps/web/tests/state/config.test.ts | 29 + e2e/ui/entry-configuration-flows.test.ts | 117 +- e2e/ui/project-management-flows.test.ts | 60 +- e2e/ui/settings-api-protocol.test.ts | 107 +- packages/contracts/src/api/app-config.ts | 9 + packages/contracts/src/api/connectors.ts | 8 + packages/contracts/src/examples.ts | 4 + .../tests/desktop-package-runtime.test.ts | 25 + 77 files changed, 11584 insertions(+), 1282 deletions(-) create mode 100644 apps/daemon/src/connectors/composio-curation.ts create mode 100644 apps/daemon/src/orbit.ts create mode 100644 apps/daemon/tests/orbit.test.ts create mode 100644 apps/daemon/tests/tools-connectors-cli.test.ts create mode 100644 apps/web/src/components/ConnectorsBrowser.tsx create mode 100644 apps/web/tests/App.test.ts create mode 100644 apps/web/tests/components/ConnectorsBrowser.test.tsx create mode 100644 apps/web/tests/components/QuestionForm.test.tsx create mode 100644 apps/web/tests/components/SettingsDialog.orbit.test.tsx create mode 100644 apps/web/tests/runtime/app-route-export.test.ts create mode 100644 tools/pack/tests/desktop-package-runtime.test.ts diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index c214c89fe..aff91fa5a 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -18,6 +18,12 @@ export interface AgentModelPrefs { export type AgentCliEnvPrefs = Record>; +export interface OrbitConfigPrefs { + enabled: boolean; + time: string; + templateSkillId?: string | null; +} + export interface AppConfigPrefs { onboardingCompleted?: boolean; agentId?: string | null; @@ -27,6 +33,7 @@ export interface AppConfigPrefs { designSystemId?: string | null; disabledSkills?: string[]; disabledDesignSystems?: string[]; + orbit?: OrbitConfigPrefs; } const ALLOWED_KEYS: ReadonlySet = new Set([ @@ -38,6 +45,7 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ 'designSystemId', 'disabledSkills', 'disabledDesignSystems', + 'orbit', ] as const); function configFile(dataDir: string): string { @@ -99,6 +107,33 @@ export function validateAgentCliEnv(raw: unknown): AgentCliEnvPrefs | undefined return Object.keys(result).length > 0 ? result : undefined; } +function isValidOrbitTime(time: string): boolean { + const match = /^(\d{2}):(\d{2})$/.exec(time); + if (!match) return false; + const hours = Number(match[1]); + const minutes = Number(match[2]); + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59; +} + +function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const obj = raw as Record; + const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : false; + const time = typeof obj.time === 'string' && isValidOrbitTime(obj.time) + ? obj.time + : '08:00'; + const orbit: OrbitConfigPrefs = { enabled, time }; + + if (Object.hasOwn(obj, 'templateSkillId')) { + orbit.templateSkillId = typeof obj.templateSkillId === 'string' && obj.templateSkillId.trim() + ? obj.templateSkillId.trim() + : null; + } + + return orbit; +} + export function agentCliEnvForAgent( prefs: AgentCliEnvPrefs | undefined, agentId: string, @@ -145,6 +180,14 @@ function applyConfigValue( delete target[key]; } } + if (key === 'orbit') { + const validated = validateOrbit(value); + if (validated !== undefined) { + target[key] = validated; + } else { + delete target[key]; + } + } } function filterAllowedKeys(obj: Record): AppConfigPrefs { diff --git a/apps/daemon/src/connectors/catalog.ts b/apps/daemon/src/connectors/catalog.ts index 28f5e0f3a..93acc29b9 100644 --- a/apps/daemon/src/connectors/catalog.ts +++ b/apps/daemon/src/connectors/catalog.ts @@ -3,6 +3,7 @@ import type { BoundedJsonObject, BoundedJsonValue } from '../live-artifacts/sche export type ConnectorStatus = 'available' | 'connected' | 'error' | 'disabled'; export type ConnectorToolSideEffect = 'read' | 'write' | 'destructive' | 'unknown'; export type ConnectorToolApproval = 'auto' | 'confirm' | 'disabled'; +export type ConnectorToolUseCase = 'personal_daily_digest'; export interface ConnectorToolSafety { sideEffect: ConnectorToolSideEffect; @@ -10,6 +11,11 @@ export interface ConnectorToolSafety { reason: string; } +export interface ConnectorToolCuration { + useCases?: ConnectorToolUseCase[]; + reason?: string; +} + export interface ConnectorToolDetail { name: string; title: string; @@ -18,6 +24,7 @@ export interface ConnectorToolDetail { outputSchemaJson?: BoundedJsonObject; safety: ConnectorToolSafety; refreshEligible: boolean; + curation?: ConnectorToolCuration; } export interface ConnectorCatalogToolDefinition extends ConnectorToolDetail { @@ -151,6 +158,9 @@ function toolDefinitionToDetail(tool: ConnectorCatalogToolDefinition): Connector ...(tool.outputSchemaJson === undefined ? {} : { outputSchemaJson: cloneBoundedJsonObject(tool.outputSchemaJson) }), safety: { ...tool.safety }, refreshEligible: tool.refreshEligible, + ...(tool.curation === undefined + ? {} + : { curation: { ...(tool.curation.useCases === undefined ? {} : { useCases: [...tool.curation.useCases] }), ...(tool.curation.reason === undefined ? {} : { reason: tool.curation.reason }) } }), }; } @@ -163,7 +173,9 @@ export function connectorDefinitionToDetail(definition: ConnectorCatalogDefiniti ...(definition.description === undefined ? {} : { description: definition.description }), status: definition.disabled ? 'disabled' : 'available', tools: definition.tools.map((tool) => toolDefinitionToDetail(tool)), - ...(definition.featuredToolNames === undefined ? {} : { featuredToolNames: [...definition.featuredToolNames] }), + ...(definition.featuredToolNames === undefined + ? {} + : { featuredToolNames: [...definition.featuredToolNames] }), ...(definition.minimumApproval === undefined ? {} : { minimumApproval: definition.minimumApproval }), auth: { provider: definition.authentication ?? (definition.provider === 'open-design' ? 'local' : 'oauth'), diff --git a/apps/daemon/src/connectors/composio-curation.ts b/apps/daemon/src/connectors/composio-curation.ts new file mode 100644 index 000000000..91df9a2b6 --- /dev/null +++ b/apps/daemon/src/connectors/composio-curation.ts @@ -0,0 +1,122 @@ +import type { ConnectorToolCuration } from './catalog.js'; + +const DAILY_DIGEST_CURATION: ConnectorToolCuration = { + useCases: ['personal_daily_digest'], +}; + +export const COMPOSIO_CURATION_OVERLAY: Readonly>>> = { + gmail: { + gmail_fetch_recent_emails: { ...DAILY_DIGEST_CURATION, reason: 'Recent inbox activity is useful for a personal digest.' }, + gmail_search_emails: { ...DAILY_DIGEST_CURATION, reason: 'Bounded email search can summarize recent personal activity.' }, + }, + googlecalendar: { + googlecalendar_list_events: { ...DAILY_DIGEST_CURATION, reason: 'Upcoming and recent calendar events fit a daily briefing.' }, + googlecalendar_get_events: { ...DAILY_DIGEST_CURATION, reason: 'Calendar event retrieval supports recent schedule summaries.' }, + }, + googledrive: { + googledrive_search: { ...DAILY_DIGEST_CURATION, reason: 'Drive search can surface recently changed personal files.' }, + googledrive_get_file: { ...DAILY_DIGEST_CURATION, reason: 'File metadata helps summarize recent document changes.' }, + googledrive_list_files: { ...DAILY_DIGEST_CURATION, reason: 'File listings can be bounded to recent user file activity.' }, + googledrive_list_changes: { ...DAILY_DIGEST_CURATION, reason: 'Recent Drive changes are a strong daily digest source.' }, + }, + googledocs: { + googledocs_list_documents: { ...DAILY_DIGEST_CURATION, reason: 'Recent document listings support personal work recaps.' }, + googledocs_get_document: { ...DAILY_DIGEST_CURATION, reason: 'Document metadata can summarize recent user-authored docs.' }, + googledocs_search_documents: { ...DAILY_DIGEST_CURATION, reason: 'Bounded document search is useful for a daily digest.' }, + }, + googlesheets: { + googlesheets_list_spreadsheets: { ...DAILY_DIGEST_CURATION, reason: 'Recent spreadsheet activity fits a daily recap.' }, + googlesheets_get_spreadsheet: { ...DAILY_DIGEST_CURATION, reason: 'Spreadsheet metadata can summarize recent user changes.' }, + googlesheets_search_spreadsheets: { ...DAILY_DIGEST_CURATION, reason: 'Bounded spreadsheet search helps find recent activity.' }, + }, + slack: { + slack_list_channels: { ...DAILY_DIGEST_CURATION, reason: 'Conversation discovery is useful when selecting recent message sources.' }, + slack_list_conversations: { ...DAILY_DIGEST_CURATION, reason: 'Conversation discovery is useful when selecting recent message sources.' }, + slack_get_channel_history: { ...DAILY_DIGEST_CURATION, reason: 'Recent Slack history is useful for a personal digest.' }, + slack_fetch_conversation_history: { ...DAILY_DIGEST_CURATION, reason: 'Recent Slack history is useful for a personal digest.' }, + slack_search_messages: { ...DAILY_DIGEST_CURATION, reason: 'Bounded Slack message search can surface recent personal activity.' }, + slack_list_messages: { ...DAILY_DIGEST_CURATION, reason: 'Recent Slack messages are suitable for a daily digest.' }, + }, + github: { + github_get_issue: { ...DAILY_DIGEST_CURATION, reason: 'Repo-scoped issue detail supports a personal digest.' }, + github_list_pull_requests: { ...DAILY_DIGEST_CURATION, reason: 'Repo-scoped PR listing fits a digest when bounded to owned repos.' }, + github_get_pull_request: { ...DAILY_DIGEST_CURATION, reason: 'PR detail is digest-friendly and repo-scoped.' }, + github_list_issues: { ...DAILY_DIGEST_CURATION, reason: 'Repo-scoped issue listing fits a digest when bounded.' }, + github_list_notifications: { ...DAILY_DIGEST_CURATION, reason: 'Authenticated-user notifications are directly digest-relevant.' }, + github_list_events: { ...DAILY_DIGEST_CURATION, reason: 'Recent repo/account events can support a digest when bounded.' }, + github_list_commits: { ...DAILY_DIGEST_CURATION, reason: 'Repo-scoped commit history is digest-friendly.' }, + }, + notion: { + notion_search: { ...DAILY_DIGEST_CURATION, reason: 'Searching Notion pages and databases is useful for a daily recap.' }, + notion_fetch_database: { ...DAILY_DIGEST_CURATION, reason: 'Database reads can summarize recent tasks and notes.' }, + notion_query_database: { ...DAILY_DIGEST_CURATION, reason: 'Database queries support recent activity summaries.' }, + }, + linear: { + linear_list_issues: { ...DAILY_DIGEST_CURATION, reason: 'Recent issue updates are useful in a daily digest.' }, + linear_get_issue: { ...DAILY_DIGEST_CURATION, reason: 'Issue detail supports a concise task recap.' }, + linear_search_issues: { ...DAILY_DIGEST_CURATION, reason: 'Bounded issue search can surface current work.' }, + }, + jira: { + jira_get_issue: { ...DAILY_DIGEST_CURATION, reason: 'Issue detail is useful for personal work summaries.' }, + jira_search_issues: { ...DAILY_DIGEST_CURATION, reason: 'Bounded issue search can surface recent assigned work.' }, + jira_list_issues: { ...DAILY_DIGEST_CURATION, reason: 'Recent issues are suitable for a personal digest.' }, + }, + asana: { + asana_get_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Recent task activity is useful for a daily digest.' }, + asana_list_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Recent task activity is useful for a daily digest.' }, + asana_search_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Bounded task search can surface current work.' }, + }, + todoist: { + todoist_get_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Task lists are a natural daily digest source.' }, + todoist_list_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Task lists are a natural daily digest source.' }, + }, + googletasks: { + googletasks_list_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Recent Google Tasks activity fits a personal digest.' }, + googletasks_get_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Recent Google Tasks activity fits a personal digest.' }, + }, + outlook: { + outlook_list_messages: { ...DAILY_DIGEST_CURATION, reason: 'Recent Outlook email activity is useful for a digest.' }, + outlook_search_emails: { ...DAILY_DIGEST_CURATION, reason: 'Bounded Outlook mail search can surface recent activity.' }, + outlook_list_events: { ...DAILY_DIGEST_CURATION, reason: 'Recent Outlook calendar events fit a daily briefing.' }, + }, + microsoftteams: { + microsoftteams_list_messages: { ...DAILY_DIGEST_CURATION, reason: 'Recent Teams messages are useful for a personal digest.' }, + microsoftteams_get_messages: { ...DAILY_DIGEST_CURATION, reason: 'Recent Teams messages are useful for a personal digest.' }, + microsoftteams_search_messages: { ...DAILY_DIGEST_CURATION, reason: 'Bounded Teams search can surface recent conversation activity.' }, + }, + discord: { + discord_list_messages: { ...DAILY_DIGEST_CURATION, reason: 'Recent Discord messages can contribute to a personal digest.' }, + discord_get_messages: { ...DAILY_DIGEST_CURATION, reason: 'Recent Discord messages can contribute to a personal digest.' }, + discord_search_messages: { ...DAILY_DIGEST_CURATION, reason: 'Bounded Discord search can surface recent conversation activity.' }, + }, + figma: { + figma_get_file: { ...DAILY_DIGEST_CURATION, reason: 'Recent file activity is useful in a design-focused digest.' }, + figma_list_files: { ...DAILY_DIGEST_CURATION, reason: 'Recent file activity is useful in a design-focused digest.' }, + figma_get_comments: { ...DAILY_DIGEST_CURATION, reason: 'Comment activity highlights review work for the day.' }, + }, + sentry: { + sentry_list_issues: { ...DAILY_DIGEST_CURATION, reason: 'Recent issues are strong operational digest material.' }, + sentry_get_issue: { ...DAILY_DIGEST_CURATION, reason: 'Issue detail supports concise operational summaries.' }, + sentry_list_events: { ...DAILY_DIGEST_CURATION, reason: 'Recent events fit an engineering daily digest.' }, + }, + gitlab: { + gitlab_list_merge_requests: { ...DAILY_DIGEST_CURATION, reason: 'Recent merge requests fit a personal digest.' }, + gitlab_get_merge_request: { ...DAILY_DIGEST_CURATION, reason: 'Merge request detail supports concise summaries.' }, + gitlab_list_issues: { ...DAILY_DIGEST_CURATION, reason: 'Recent issue activity is suitable for a daily digest.' }, + gitlab_list_commits: { ...DAILY_DIGEST_CURATION, reason: 'Recent commits are useful for a personal digest.' }, + }, + clickup: { + clickup_get_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Task activity is useful for a daily recap.' }, + clickup_list_tasks: { ...DAILY_DIGEST_CURATION, reason: 'Task activity is useful for a daily recap.' }, + }, + trello: { + trello_get_cards: { ...DAILY_DIGEST_CURATION, reason: 'Card activity is suitable for a personal digest.' }, + trello_list_cards: { ...DAILY_DIGEST_CURATION, reason: 'Card activity is suitable for a personal digest.' }, + trello_search_cards: { ...DAILY_DIGEST_CURATION, reason: 'Bounded card search can surface current work.' }, + }, + hubspot: { + hubspot_list_contacts: { ...DAILY_DIGEST_CURATION, reason: 'Recent contact activity may be useful for CRM digests.' }, + hubspot_list_deals: { ...DAILY_DIGEST_CURATION, reason: 'Recent deal activity may be useful for CRM digests.' }, + hubspot_list_activities: { ...DAILY_DIGEST_CURATION, reason: 'Recent CRM activities can support a daily digest.' }, + }, +}; diff --git a/apps/daemon/src/connectors/composio.ts b/apps/daemon/src/connectors/composio.ts index 430f8e795..f9b9b2546 100644 --- a/apps/daemon/src/connectors/composio.ts +++ b/apps/daemon/src/connectors/composio.ts @@ -1,8 +1,11 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import type { BoundedJsonObject, BoundedJsonValue } from '../live-artifacts/schema.js'; import { defineConnectorTool, type ConnectorCatalogDefinition, type ConnectorCatalogToolDefinition } from './catalog.js'; import { readComposioConfig } from './composio-config.js'; +import { COMPOSIO_CURATION_OVERLAY } from './composio-curation.js'; import { getComposioToolkitMetadata } from './composio-descriptions.js'; import { ConnectorServiceError, type ConnectorCredentialMaterial } from './service.js'; @@ -11,6 +14,7 @@ const DEFAULT_COMPOSIO_TIMEOUT_MS = 30_000; const DEFAULT_COMPOSIO_USER_ID = 'open-design-local-user'; const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; const DISCOVERY_CACHE_TTL_MS = 60_000; +const PERSISTED_CATALOG_REFRESH_MS = 24 * 60 * 60 * 1000; interface ComposioToolkitCatalogEntry { name: string; @@ -18,6 +22,15 @@ interface ComposioToolkitCatalogEntry { category?: string; } +interface PersistedComposioCatalogCache { + schemaVersion: 1; + fetchedAt: string; + provider: 'composio'; + definitions: ConnectorCatalogDefinition[]; +} + +let composioCatalogCacheFilePath = path.join(process.cwd(), '.od', 'connectors', 'composio-catalog-cache.json'); + const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [ { id: 'github', @@ -404,6 +417,10 @@ export class ComposioConnectorProvider { private definitionsGeneration = 0; private readonly authConfigCreationPromises = new Map>(); private readonly pendingConnections = new Map(); + private persistedDefinitions: ConnectorCatalogDefinition[] | undefined; + private persistedFetchedAt: string | undefined; + private refreshTimer: NodeJS.Timeout | undefined; + private refreshTimeout: NodeJS.Timeout | undefined; isConfigured(definition: ConnectorCatalogDefinition): boolean { return Boolean(this.getApiKey() && this.discoveredAuthConfigIds?.[definition.id]); @@ -416,6 +433,51 @@ export class ComposioConnectorProvider { this.authConfigCreationPromises.clear(); } + configureCatalogCache(dataDir: string): void { + composioCatalogCacheFilePath = path.join(dataDir, 'connectors', 'composio-catalog-cache.json'); + this.loadPersistedCatalogCache(); + } + + startCatalogRefreshLoop(): void { + this.stopCatalogRefreshLoop(); + if (this.isPersistedCatalogStale()) this.scheduleCatalogRefresh(0); + this.refreshTimer = setInterval(() => { + void this.refreshCatalogInBackground(); + }, PERSISTED_CATALOG_REFRESH_MS); + this.refreshTimer.unref?.(); + } + + stopCatalogRefreshLoop(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + } + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } + } + + getFastDefinitions(): ConnectorCatalogDefinition[] { + return this.persistedDefinitions && this.persistedDefinitions.length > 0 + ? this.persistedDefinitions + : getStaticComposioCatalogDefinitions(); + } + + getPersistedCatalogMetadata(): { fetchedAt?: string; stale: boolean } { + return { + ...(this.persistedFetchedAt === undefined ? {} : { fetchedAt: this.persistedFetchedAt }), + stale: this.isPersistedCatalogStale(), + }; + } + + async refreshCatalog(signal?: AbortSignal): Promise { + this.invalidateDefinitionsCache(); + const definitions = await this.listDefinitions(signal); + this.setPersistedDefinitions(definitions, new Date().toISOString()); + return definitions; + } + private invalidateDefinitionsCache(): void { this.definitionsGeneration += 1; this.definitionsCache = undefined; @@ -435,6 +497,7 @@ export class ComposioConnectorProvider { if (this.definitionsGeneration === generation) { this.definitionsCache = { definitions, expiresAtMs: Date.now() + DISCOVERY_CACHE_TTL_MS }; } + this.setPersistedDefinitions(definitions, new Date().toISOString()); return definitions; }) .finally(() => { @@ -474,6 +537,58 @@ export class ComposioConnectorProvider { return definitions; } + private scheduleCatalogRefresh(delayMs: number): void { + if (this.refreshTimeout) clearTimeout(this.refreshTimeout); + const timeout = setTimeout(() => { + if (this.refreshTimeout === timeout) this.refreshTimeout = undefined; + void this.refreshCatalogInBackground(); + }, delayMs); + this.refreshTimeout = timeout; + timeout.unref?.(); + } + + private async refreshCatalogInBackground(): Promise { + if (!this.getApiKey()) return; + try { + await this.refreshCatalog(); + } catch { + // Keep startup and background refresh best-effort only. + } + } + + private isPersistedCatalogStale(now = Date.now()): boolean { + if (!this.persistedFetchedAt) return true; + const fetchedAtMs = Date.parse(this.persistedFetchedAt); + return !Number.isFinite(fetchedAtMs) || now - fetchedAtMs >= PERSISTED_CATALOG_REFRESH_MS; + } + + private loadPersistedCatalogCache(): void { + const parsed = readPersistedComposioCatalogCache(composioCatalogCacheFilePath); + if (!parsed) { + this.persistedDefinitions = undefined; + this.persistedFetchedAt = undefined; + return; + } + this.persistedDefinitions = parsed.definitions.map((definition) => cloneConnectorDefinition(definition)); + this.persistedFetchedAt = parsed.fetchedAt; + if (this.isPersistedCatalogStale() && this.getApiKey()) this.scheduleCatalogRefresh(0); + } + + private setPersistedDefinitions(definitions: ConnectorCatalogDefinition[], fetchedAt: string): void { + this.persistedDefinitions = definitions.map((definition) => cloneConnectorDefinition(definition)); + this.persistedFetchedAt = fetchedAt; + try { + writePersistedComposioCatalogCache(composioCatalogCacheFilePath, { + schemaVersion: 1, + fetchedAt, + provider: 'composio', + definitions: this.persistedDefinitions, + }); + } catch (error) { + console.warn('[connectors] Failed to persist Composio catalog cache:', error); + } + } + async getDefinition(connectorId: string, signal?: AbortSignal): Promise { const discovered = (await this.listDefinitions(signal)).find((definition) => definition.id === connectorId); if (discovered) return discovered; @@ -782,7 +897,7 @@ export class ComposioConnectorProvider { const providerToolId = getString(tool.slug) ?? getString(tool.name) ?? `${connectorId.toUpperCase()}_TOOL`; const description = getString(tool.description) ?? getString(tool.human_description) ?? getString(tool.humanDescription) ?? ''; const requiredScopes = getStringArray(tool.scopes ?? tool.oauth_scopes ?? tool.oauthScopes ?? tool.auth_scopes ?? tool.authScopes ?? tool.tags); - return defineConnectorTool({ + return applyComposioToolCuration(defineConnectorTool({ name: `${connectorId}.${normalizeToolName(providerToolId)}`, providerToolId, title: getString(tool.name) ?? titleFromSlug(providerToolId), @@ -790,7 +905,7 @@ export class ComposioConnectorProvider { inputSchemaJson: toBoundedJsonObject(tool.input_parameters ?? tool.inputParameters) ?? { type: 'object', additionalProperties: true }, outputSchemaJson: { type: 'object', additionalProperties: true }, requiredScopes, - }); + }), connectorId, providerToolId); } private connectionToCredentials(_definition: ConnectorCatalogDefinition, providerConnectionId: string, response: ComposioConnectedAccountResponse): ComposioConnectionCompletion { @@ -868,6 +983,7 @@ function mergeToolDefinition(staticTool: ConnectorCatalogToolDefinition, liveToo requiredScopes: liveTool.requiredScopes.length > 0 ? liveTool.requiredScopes : staticTool.requiredScopes, safety: liveTool.safety, refreshEligible: liveTool.refreshEligible, + ...((liveTool.curation ?? staticTool.curation) === undefined ? {} : { curation: liveTool.curation ?? staticTool.curation }), }; } @@ -875,7 +991,12 @@ export const composioConnectorProvider = new ComposioConnectorProvider(); function buildStaticComposioCatalog(): ConnectorCatalogDefinition[] { const definitions = new Map(); - for (const definition of FEATURED_COMPOSIO_CATALOG) definitions.set(definition.id, definition); + for (const definition of FEATURED_COMPOSIO_CATALOG) { + definitions.set(definition.id, { + ...definition, + tools: definition.tools.map((tool) => applyComposioToolCuration(tool, definition.providerConnectorId ?? definition.id, tool.providerToolId)), + }); + } for (const toolkit of DOCUMENTED_COMPOSIO_TOOLKITS) { const id = connectorIdForToolkitSlug(toolkit.slug); if (definitions.has(id)) continue; @@ -907,6 +1028,12 @@ function createComposioCatalogDefinition(toolkit: ComposioToolkitCatalogEntry): export function getStaticComposioCatalogDefinitions(): ConnectorCatalogDefinition[] { return STATIC_COMPOSIO_CATALOG.map((definition) => ({ + ...cloneConnectorDefinition(definition), + })); +} + +function cloneConnectorDefinition(definition: ConnectorCatalogDefinition): ConnectorCatalogDefinition { + return { ...definition, tools: definition.tools.map((tool) => ({ name: tool.name, @@ -916,12 +1043,101 @@ export function getStaticComposioCatalogDefinitions(): ConnectorCatalogDefinitio ...(tool.outputSchemaJson === undefined ? {} : { outputSchemaJson: toBoundedJsonObject(tool.outputSchemaJson)! }), safety: { ...tool.safety }, refreshEligible: tool.refreshEligible, + ...(tool.curation === undefined ? {} : { curation: { ...(tool.curation.useCases === undefined ? {} : { useCases: [...tool.curation.useCases] }), ...(tool.curation.reason === undefined ? {} : { reason: tool.curation.reason }) } }), requiredScopes: [...tool.requiredScopes], ...(tool.providerToolId === undefined ? {} : { providerToolId: tool.providerToolId }), })), allowedToolNames: [...definition.allowedToolNames], ...(definition.featuredToolNames === undefined ? {} : { featuredToolNames: [...definition.featuredToolNames] }), - })); + }; +} + +function normalizePersistedConnectorDefinition(value: unknown): ConnectorCatalogDefinition | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.id !== 'string' || typeof record.name !== 'string' || typeof record.provider !== 'string' || typeof record.category !== 'string') return undefined; + const tools = Array.isArray(record.tools) + ? record.tools.map(normalizePersistedConnectorToolDefinition).filter((tool): tool is ConnectorCatalogToolDefinition => tool !== undefined) + : []; + const definition: ConnectorCatalogDefinition = { + id: record.id, + name: record.name, + provider: record.provider, + category: record.category, + tools, + allowedToolNames: Array.isArray(record.allowedToolNames) ? record.allowedToolNames.filter((item): item is string => typeof item === 'string') : [], + }; + if (typeof record.description === 'string') definition.description = record.description; + if (record.authentication === 'local' || record.authentication === 'none' || record.authentication === 'oauth' || record.authentication === 'composio') { + definition.authentication = record.authentication; + } + if (typeof record.providerConnectorId === 'string') definition.providerConnectorId = record.providerConnectorId; + if (Array.isArray(record.featuredToolNames)) definition.featuredToolNames = record.featuredToolNames.filter((item): item is string => typeof item === 'string'); + if (record.minimumApproval === 'auto' || record.minimumApproval === 'confirm' || record.minimumApproval === 'disabled') { + definition.minimumApproval = record.minimumApproval; + } + if (typeof record.disabled === 'boolean') definition.disabled = record.disabled; + return definition; +} + +function normalizePersistedConnectorToolDefinition(value: unknown): ConnectorCatalogToolDefinition | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if (typeof record.name !== 'string' || typeof record.title !== 'string') return undefined; + if (!record.safety || typeof record.safety !== 'object' || Array.isArray(record.safety)) return undefined; + const safetyRecord = record.safety as Record; + if (typeof safetyRecord.sideEffect !== 'string' || typeof safetyRecord.approval !== 'string' || typeof safetyRecord.reason !== 'string') return undefined; + return { + name: record.name, + title: record.title, + ...(typeof record.description === 'string' ? { description: record.description } : {}), + ...(toBoundedJsonObject(record.inputSchemaJson) === undefined ? {} : { inputSchemaJson: toBoundedJsonObject(record.inputSchemaJson)! }), + ...(toBoundedJsonObject(record.outputSchemaJson) === undefined ? {} : { outputSchemaJson: toBoundedJsonObject(record.outputSchemaJson)! }), + safety: { + sideEffect: safetyRecord.sideEffect as ConnectorCatalogToolDefinition['safety']['sideEffect'], + approval: safetyRecord.approval as ConnectorCatalogToolDefinition['safety']['approval'], + reason: safetyRecord.reason, + }, + refreshEligible: Boolean(record.refreshEligible), + ...(record.curation && typeof record.curation === 'object' && !Array.isArray(record.curation) + ? { + curation: { + ...(((record.curation as Record).useCases && Array.isArray((record.curation as Record).useCases)) + ? { useCases: ((record.curation as Record).useCases as unknown[]).filter((item): item is 'personal_daily_digest' => item === 'personal_daily_digest') } + : {}), + ...(typeof (record.curation as Record).reason === 'string' ? { reason: (record.curation as Record).reason as string } : {}), + }, + } + : {}), + requiredScopes: Array.isArray(record.requiredScopes) ? record.requiredScopes.filter((item): item is string => typeof item === 'string') : [], + ...(typeof record.providerToolId === 'string' ? { providerToolId: record.providerToolId } : {}), + }; +} + +function readPersistedComposioCatalogCache(filePath: string): PersistedComposioCatalogCache | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + if (record.schemaVersion !== 1 || record.provider !== 'composio' || typeof record.fetchedAt !== 'string' || !Array.isArray(record.definitions)) return undefined; + return { + schemaVersion: 1, + provider: 'composio', + fetchedAt: record.fetchedAt, + definitions: record.definitions.map(normalizePersistedConnectorDefinition).filter((definition): definition is ConnectorCatalogDefinition => definition !== undefined), + }; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return undefined; + return undefined; + } +} + +function writePersistedComposioCatalogCache(filePath: string, cache: PersistedComposioCatalogCache): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(cache, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, filePath); + fs.chmodSync(filePath, 0o600); } function getString(value: unknown): string | undefined { @@ -1005,6 +1221,22 @@ function normalizeToolName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'tool'; } +function normalizeProviderToolId(value: string): string { + return normalizeToolName(value); +} + +function applyComposioToolCuration( + tool: ConnectorCatalogToolDefinition, + connectorId: string, + providerToolId: string | undefined, +): ConnectorCatalogToolDefinition { + const connectorKey = normalizeComposioSlug(connectorId); + const overlay = COMPOSIO_CURATION_OVERLAY[connectorKey]; + const toolKey = providerToolId ? normalizeProviderToolId(providerToolId) : undefined; + const curation = toolKey ? overlay?.[toolKey] : undefined; + return curation === undefined ? tool : { ...tool, curation: { ...(tool.curation ?? {}), ...curation } }; +} + function titleFromSlug(value: string): string { return value .replace(/[_-]+/g, ' ') diff --git a/apps/daemon/src/connectors/routes.ts b/apps/daemon/src/connectors/routes.ts index 307cfe8d7..12a42625a 100644 --- a/apps/daemon/src/connectors/routes.ts +++ b/apps/daemon/src/connectors/routes.ts @@ -5,6 +5,7 @@ import type { Express, Request, RequestHandler, Response } from 'express'; import type { ToolTokenGrant } from '../tool-tokens.js'; import { validateBoundedJsonObject } from '../live-artifacts/schema.js'; import { executeConnectorTool, listConnectorTools } from '../tools/connectors.js'; +import type { ConnectorToolUseCase } from './catalog.js'; import { connectorService, ConnectorService, ConnectorServiceError } from './service.js'; type ConnectorApiErrorCode = @@ -21,6 +22,23 @@ type ConnectorApiErrorCode = | 'CONNECTOR_OUTPUT_TOO_LARGE' | 'CONNECTOR_EXECUTION_FAILED'; +const COMPOSIO_LOGO_CACHE_TTL_MS = 1000 * 60 * 60 * 24; +const COMPOSIO_LOGO_FETCH_TIMEOUT_MS = 2_000; +export const COMPOSIO_LOGO_CACHE_MAX_ENTRIES = 128; +const COMPOSIO_LOGO_MAX_BYTES = 1024 * 1024; +const COMPOSIO_LOGO_SLUG_ALIASES: Record = { + zohobooks: 'zoho_books', +}; + +interface CachedComposioLogo { + body: Buffer; + contentType: string; + expiresAtMs: number; +} + +const composioLogoCache = new Map(); +const composioLogoInflight = new Map>(); + export type ConnectorApiErrorSender = ( res: Response, status: number, @@ -56,6 +74,164 @@ function isLoopbackHostname(hostname: string): boolean { return net.isIP(normalized) === 4 && (normalized === '127.0.0.1' || normalized.startsWith('127.')); } +function parseConnectorToolUseCase(value: unknown): ConnectorToolUseCase | undefined { + if (value === undefined) return undefined; + if (value === 'personal_daily_digest') return value; + return undefined; +} + +function parseConnectorLogoTheme(value: unknown): 'light' | 'dark' { + return value === 'light' ? 'light' : 'dark'; +} + +function parseConnectorLogoSlug(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.toLowerCase().replace(/[^a-z0-9_]/g, ''); + const slug = COMPOSIO_LOGO_SLUG_ALIASES[normalized] ?? normalized; + return slug.length > 0 ? slug : undefined; +} + +function sendComposioLogo(res: Response, logo: CachedComposioLogo): void { + res.setHeader('Content-Type', logo.contentType); + res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800'); + if (logo.contentType === 'image/svg+xml') { + res.setHeader('Content-Security-Policy', "default-src 'none'; img-src data:; style-src 'unsafe-inline'"); + } + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.send(logo.body); +} + +function sendMissingComposioLogo(res: Response): void { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.status(404).end(); +} + +function normalizeImageContentType(value: string | null): string | null { + const contentType = value?.split(';')[0]?.trim().toLowerCase(); + if (!contentType?.startsWith('image/')) return null; + return contentType; +} + +function isAbortLikeError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); +} + +function parsePositiveIntegerHeader(value: string | null): number | null { + if (!value) return null; + if (!/^\d+$/.test(value)) return null; + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +} + +async function readComposioLogoBody(response: globalThis.Response): Promise { + const contentLength = parsePositiveIntegerHeader(response.headers.get('content-length')); + if (contentLength !== null && contentLength > COMPOSIO_LOGO_MAX_BYTES) return null; + + const reader = response.body?.getReader(); + if (!reader) { + const body = Buffer.from(await response.arrayBuffer()); + return body.byteLength <= COMPOSIO_LOGO_MAX_BYTES ? body : null; + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + totalBytes += value.byteLength; + if (totalBytes > COMPOSIO_LOGO_MAX_BYTES) return null; + chunks.push(value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes); +} + +function pruneExpiredComposioLogos(nowMs: number): void { + for (const [cacheKey, logo] of composioLogoCache) { + if (logo.expiresAtMs > nowMs) continue; + composioLogoCache.delete(cacheKey); + } +} + +function promoteComposioLogoCacheEntry(cacheKey: string, logo: CachedComposioLogo): void { + composioLogoCache.delete(cacheKey); + composioLogoCache.set(cacheKey, logo); +} + +function cacheComposioLogo(cacheKey: string, logo: CachedComposioLogo): void { + pruneExpiredComposioLogos(Date.now()); + if (composioLogoCache.has(cacheKey)) composioLogoCache.delete(cacheKey); + while (composioLogoCache.size >= COMPOSIO_LOGO_CACHE_MAX_ENTRIES) { + const oldestCacheKey = composioLogoCache.keys().next().value; + if (oldestCacheKey === undefined) break; + composioLogoCache.delete(oldestCacheKey); + } + composioLogoCache.set(cacheKey, logo); +} + +async function fetchComposioLogo(slug: string, theme: 'light' | 'dark'): Promise { + const cacheKey = `${slug}:${theme}`; + const nowMs = Date.now(); + const cached = composioLogoCache.get(cacheKey); + if (cached && cached.expiresAtMs > nowMs) { + promoteComposioLogoCacheEntry(cacheKey, cached); + return cached; + } + if (cached) composioLogoCache.delete(cacheKey); + + const inflight = composioLogoInflight.get(cacheKey); + if (inflight) return inflight; + + const promise = (async () => { + const upstream = `https://logos.composio.dev/api/${encodeURIComponent(slug)}?theme=${theme}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), COMPOSIO_LOGO_FETCH_TIMEOUT_MS); + try { + const response = await fetch(upstream, { + headers: { accept: 'image/avif,image/webp,image/apng,image/png,image/jpeg' }, + signal: controller.signal, + }); + if (!response.ok) return null; + const body = await readComposioLogoBody(response); + if (!body) return null; + const contentType = normalizeImageContentType(response.headers.get('content-type')); + if (!contentType) return null; + const logo: CachedComposioLogo = { + body, + contentType, + expiresAtMs: Date.now() + COMPOSIO_LOGO_CACHE_TTL_MS, + }; + cacheComposioLogo(cacheKey, logo); + return logo; + } catch (error) { + if (isAbortLikeError(error)) return null; + throw error; + } finally { + clearTimeout(timer); + } + })().finally(() => { + composioLogoInflight.delete(cacheKey); + }); + composioLogoInflight.set(cacheKey, promise); + return promise; +} + +async function proxyComposioLogo(req: Request, res: Response): Promise { + const slug = parseConnectorLogoSlug(req.params.slug); + if (!slug) { + res.status(400).json({ error: 'logo slug is required' }); + return; + } + const theme = parseConnectorLogoTheme(req.query.theme); + const logo = await fetchComposioLogo(slug, theme); + if (!logo) { + sendMissingComposioLogo(res); + return; + } + sendComposioLogo(res, logo); +} + function connectorCallbackUrl(req: Request): string { const host = req.get('host') ?? 'localhost'; let hostname = 'localhost'; @@ -294,17 +470,32 @@ function renderConnectorConnectedHtml(connectorId: string): string { const connectorId = ${connectorIdJson}; const connectorLabel = ${connectorLabelJson}; const message = { type: 'open-design:connector-connected', connectorId, connectorLabel }; + const closeButton = document.getElementById('close-window'); + const hint = document.getElementById('auto-close-hint'); + function showManualCloseHint() { + closeButton.textContent = 'Close this tab manually'; + hint.textContent = 'Your browser blocked automatic closing. You can close this tab and return to Open Design.'; + } + function requestClose() { + try { + window.close(); + } finally { + window.setTimeout(() => { + if (document.visibilityState === 'visible') showManualCloseHint(); + }, 250); + } + } try { if (window.opener && !window.opener.closed) { window.opener.postMessage(message, '*'); - window.setTimeout(() => window.close(), 900); + window.setTimeout(requestClose, 900); } else { - document.getElementById('auto-close-hint').textContent = 'You can close this tab and return to Open Design.'; + hint.textContent = 'You can close this tab and return to Open Design.'; } } catch { - document.getElementById('auto-close-hint').textContent = 'You can close this tab and return to Open Design.'; + hint.textContent = 'You can close this tab and return to Open Design.'; } - document.getElementById('close-window').addEventListener('click', () => window.close()); + closeButton.addEventListener('click', requestClose); })(); @@ -342,6 +533,14 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector } }); + app.get('/api/connectors/logos/:slug', async (req: Request, res: Response) => { + try { + await proxyComposioLogo(req, res); + } catch (err) { + sendConnectorRouteError(res, err, options.sendApiError); + } + }); + app.get('/api/connectors/:connectorId', async (req: Request, res: Response) => { try { const connectorId = req.params.connectorId; @@ -430,7 +629,13 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector options.sendApiError(res, 500, 'CONNECTOR_EXECUTION_FAILED', 'connector tool routes are not configured'); return; } - res.json({ connectors: await listConnectorTools({ grant, projectsRoot: options.projectsRoot, service }) }); + const rawUseCase = typeof req.query.useCase === 'string' ? req.query.useCase : undefined; + const useCase = parseConnectorToolUseCase(rawUseCase); + if (rawUseCase !== undefined && useCase === undefined) { + options.sendApiError(res, 400, 'BAD_REQUEST', 'useCase must be personal_daily_digest'); + return; + } + res.json({ connectors: await listConnectorTools({ grant, projectsRoot: options.projectsRoot, service, ...(useCase === undefined ? {} : { useCase }) }) }); } catch (err) { sendConnectorRouteError(res, err, options.sendApiError); } diff --git a/apps/daemon/src/connectors/service.ts b/apps/daemon/src/connectors/service.ts index ff0b1ac71..cfc64a142 100644 --- a/apps/daemon/src/connectors/service.ts +++ b/apps/daemon/src/connectors/service.ts @@ -525,7 +525,7 @@ export class ConnectorService { } listFastDefinitions(): ConnectorCatalogDefinition[] { - return getStaticComposioCatalogDefinitions(); + return composioConnectorProvider.getFastDefinitions(); } async getDefinition(connectorId: string, signal?: AbortSignal): Promise { @@ -554,7 +554,9 @@ export class ConnectorService { async listConnectorDiscovery(options: { refresh?: boolean; signal?: AbortSignal } = {}): Promise { if (options.refresh) composioConnectorProvider.clearDiscoveryCache(); return { - connectors: (await this.listDefinitions(options.signal)).map((definition) => this.toDetail(definition)), + connectors: ((options.refresh + ? await composioConnectorProvider.refreshCatalog(options.signal) + : await this.listDefinitions(options.signal))).map((definition) => this.toDetail(definition)), meta: { provider: 'composio', ...(options.refresh ? { refreshRequested: true } : {}), @@ -625,7 +627,12 @@ export class ConnectorService { } async execute(request: ConnectorExecuteRequest, context: ConnectorExecutionContext): Promise { - const definition = await this.getDefinition(request.connectorId, context.signal); + const fastDefinition = this.listFastDefinitions().find((candidate) => ( + candidate.id === request.connectorId && + candidate.allowedToolNames.includes(request.toolName) && + candidate.tools.some((tool) => tool.name === request.toolName) + )); + const definition = fastDefinition ?? await this.getDefinition(request.connectorId, context.signal); if (!definition) { throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); } @@ -677,7 +684,7 @@ export class ConnectorService { this.enforceRunLimits(context); - const providerOutput = await this.executeConnectorProviderTool(request, context); + const providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool); const protectedOutput = protectConnectorOutput(providerOutput); const output = protectedOutput.output; const outputSummary = summarizeConnectorOutput(output); @@ -701,9 +708,14 @@ export class ConnectorService { }; } - protected async executeConnectorProviderTool(request: ConnectorExecuteRequest, context: ConnectorExecutionContext): Promise { - const definition = await this.getDefinition(request.connectorId, context.signal); - const tool = definition?.tools.find((candidate) => candidate.name === request.toolName); + protected async executeConnectorProviderTool( + request: ConnectorExecuteRequest, + context: ConnectorExecutionContext, + resolvedDefinition?: ConnectorCatalogDefinition, + resolvedTool?: ConnectorCatalogToolDefinition, + ): Promise { + const definition = resolvedDefinition ?? await this.getDefinition(request.connectorId, context.signal); + const tool = resolvedTool ?? definition?.tools.find((candidate) => candidate.name === request.toolName); if (definition?.authentication === 'composio' && tool) { return composioConnectorProvider.execute(definition, tool, request.input, this.getCredential(request.connectorId)?.credentials, context.signal); } diff --git a/apps/daemon/src/mcp-live-artifacts-server.ts b/apps/daemon/src/mcp-live-artifacts-server.ts index 2c1f1537f..64122197c 100644 --- a/apps/daemon/src/mcp-live-artifacts-server.ts +++ b/apps/daemon/src/mcp-live-artifacts-server.ts @@ -25,6 +25,14 @@ const EMPTY_OBJECT_SCHEMA = { properties: {}, } satisfies JsonObject; +const CONNECTORS_LIST_INPUT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + useCase: { type: 'string', enum: ['personal_daily_digest'] }, + }, +} satisfies JsonObject; + const ARTIFACT_INPUT_SCHEMA = { type: 'object', additionalProperties: true, @@ -81,8 +89,8 @@ export function createLiveArtifactsMcpTools(): McpTool[] { }, { name: 'connectors_list', - description: 'List connector catalog and available read-only tools through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact`.', - inputSchema: EMPTY_OBJECT_SCHEMA, + description: 'List connector catalog and available read-only tools through the daemon tool endpoint. Use `{ "useCase": "personal_daily_digest" }` for curated daily-digest tools. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --use-case personal_daily_digest --format compact` or fallback `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact`.', + inputSchema: CONNECTORS_LIST_INPUT_SCHEMA, }, { name: 'connectors_execute', @@ -119,7 +127,9 @@ function toolToken(): string { function endpoint(baseUrl: URL, pathname: string): string { const url = new URL(baseUrl.toString()); - url.pathname = `${url.pathname}${pathname}`.replace(/\/+/gu, '/'); + const [pathPart, searchPart] = pathname.split('?'); + url.pathname = `${url.pathname}${pathPart ?? ''}`.replace(/\/+/gu, '/'); + url.search = searchPart === undefined ? '' : `?${searchPart}`; return url.toString(); } @@ -179,7 +189,8 @@ async function callTool(name: string, args: JsonObject): Promise { return await requestJson('/api/tools/live-artifacts/refresh', { method: 'POST', body: JSON.stringify({ artifactId: args.artifactId }) }); } if (name === 'connectors_list') { - return await requestJson('/api/tools/connectors/list', { method: 'GET' }); + const useCase = args.useCase === 'personal_daily_digest' ? '?useCase=personal_daily_digest' : ''; + return await requestJson(`/api/tools/connectors/list${useCase}`, { method: 'GET' }); } if (name === 'connectors_execute') { return await requestJson('/api/tools/connectors/execute', { diff --git a/apps/daemon/src/orbit.ts b/apps/daemon/src/orbit.ts new file mode 100644 index 000000000..10f84d232 --- /dev/null +++ b/apps/daemon/src/orbit.ts @@ -0,0 +1,427 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { randomBytes, randomUUID } from 'node:crypto'; +import path from 'node:path'; + +import type { OrbitConfigPrefs } from './app-config.js'; + +export interface OrbitConnectorRunResult { + connectorId: string; + connectorName: string; + accountLabel?: string; + toolName?: string; + toolTitle?: string; + status: 'succeeded' | 'skipped' | 'failed'; + summary: string; + error?: string; +} + +export interface OrbitActivitySummary { + id: string; + startedAt: string; + completedAt: string; + trigger: 'manual' | 'scheduled'; + connectorsChecked: number; + connectorsSucceeded: number; + connectorsFailed: number; + connectorsSkipped: number; + artifactId?: string; + artifactProjectId?: string; + agentRunId?: string; + markdown: string; + results: OrbitConnectorRunResult[]; +} + +export interface OrbitAgentRunResult { + agentRunId: string; + status: 'succeeded' | 'failed' | 'canceled'; + artifactId?: string; + artifactProjectId?: string; + summary?: string; +} + +export interface OrbitRunHandlerStart { + projectId: string; + agentRunId: string; + completion: Promise; +} + +export interface OrbitTemplateSelection { + id: string; + name: string; + examplePrompt: string; + dir: string; + body: string; + designSystemRequired: boolean; +} + +export type OrbitRunHandler = (request: { + runId: string; + trigger: 'manual' | 'scheduled'; + startedAt: string; + prompt: string; + systemPrompt: string; + template: OrbitTemplateSelection | null; +}) => Promise; + +export function formatLocalProjectTimestamp(iso: string): string { + const d = new Date(iso); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; +} + +function formatLocalOrbitPromptTimestamp(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mi = String(date.getMinutes()).padStart(2, '0'); + const timeZoneName = new Intl.DateTimeFormat(undefined, { timeZoneName: 'shortOffset' }) + .formatToParts(date) + .find((part) => part.type === 'timeZoneName')?.value; + return `${yyyy}-${mm}-${dd} ${hh}:${mi}${timeZoneName ? ` (${timeZoneName})` : ''}`; +} + +export type OrbitTemplateResolver = (skillId: string) => Promise; + +export interface OrbitStatus { + config: OrbitConfigPrefs; + running: boolean; + nextRunAt: string | null; + lastRun: OrbitActivitySummary | null; +} + +export const DEFAULT_ORBIT_CONFIG: OrbitConfigPrefs = { + enabled: false, + time: '08:00', + // Default to the general-purpose Orbit briefing skill so the daemon + // runs an adaptive template out of the box. Mirrors apps/web's + // DEFAULT_ORBIT — both surfaces must agree on the seed value to avoid + // a "default in UI, null on disk" drift after the first save. + templateSkillId: 'orbit-general', +}; + +const SUMMARY_FILE = 'activity-summary.json'; + +function isValidOrbitTime(time: string): boolean { + const match = /^(\d{2}):(\d{2})$/.exec(time); + if (!match) return false; + const hours = Number(match[1]); + const minutes = Number(match[2]); + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59; +} + +function normalizeOrbitConfig(config: Partial | undefined): OrbitConfigPrefs { + const time = typeof config?.time === 'string' && isValidOrbitTime(config.time) + ? config.time + : DEFAULT_ORBIT_CONFIG.time; + const hasTemplateSkillId = config !== undefined && 'templateSkillId' in config; + const defaultTemplateSkillId = DEFAULT_ORBIT_CONFIG.templateSkillId ?? null; + return { + enabled: Boolean(config?.enabled), + time, + templateSkillId: !hasTemplateSkillId + ? defaultTemplateSkillId + : typeof config?.templateSkillId === 'string' && config.templateSkillId.trim() + ? config.templateSkillId.trim() + : null, + }; +} + +function orbitDir(dataDir: string): string { + return path.join(dataDir, 'orbit'); +} + +function summaryFile(dataDir: string): string { + return path.join(orbitDir(dataDir), SUMMARY_FILE); +} + +async function readLastSummary(dataDir: string): Promise { + let raw: string; + try { + raw = await readFile(summaryFile(dataDir), 'utf8'); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return null; + throw error; + } + + try { + const parsed = JSON.parse(raw) as OrbitActivitySummary; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +async function writeLastSummary(dataDir: string, summary: OrbitActivitySummary): Promise { + const dir = orbitDir(dataDir); + await mkdir(dir, { recursive: true }); + const target = summaryFile(dataDir); + const tmp = `${target}.${randomBytes(4).toString('hex')}.tmp`; + await writeFile(tmp, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + await rename(tmp, target); +} + +function nextDailyRunAt(time: string, now = new Date()): Date { + const [hoursRaw, minutesRaw] = time.split(':'); + const hours = Number(hoursRaw); + const minutes = Number(minutesRaw); + const next = new Date(now); + next.setHours(Number.isFinite(hours) ? hours : 8, Number.isFinite(minutes) ? minutes : 0, 0, 0); + if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1); + return next; +} + +function renderMarkdown(summary: Omit): string { + const lines = [ + `# Daily Orbit Activity Summary`, + '', + `Generated: ${summary.completedAt}`, + `Trigger: ${summary.trigger}`, + '', + `Checked ${summary.connectorsChecked} connector(s): ${summary.connectorsSucceeded} succeeded, ${summary.connectorsSkipped} skipped, ${summary.connectorsFailed} failed.`, + '', + ]; + for (const result of summary.results) { + const title = result.accountLabel ? `${result.connectorName} (${result.accountLabel})` : result.connectorName; + lines.push(`## ${title}`); + lines.push(`- Status: ${result.status}`); + if (result.toolTitle || result.toolName) lines.push(`- Tool: ${result.toolTitle ?? result.toolName}`); + lines.push(`- Summary: ${result.summary}`); + if (result.error) lines.push(`- Error: ${result.error}`); + lines.push(''); + } + return lines.join('\n').trimEnd(); +} + +export function buildOrbitPrompt(now = new Date(), template?: OrbitTemplateSelection | null): string { + const end = formatLocalOrbitPromptTimestamp(now); + const start = formatLocalOrbitPromptTimestamp(new Date(now.getTime() - 24 * 60 * 60_000)); + const lines = [ + 'Create today\'s Orbit daily digest as a Live Artifact.', + '', + `Use my connected work data from ${start} through ${end}.`, + ]; + if (template) { + lines.push('', `Use the selected Orbit template: ${template.name}.`); + } + return lines.join('\n'); +} + +export function buildOrbitSystemPrompt(now = new Date(), template?: OrbitTemplateSelection | null): string { + const end = now.toISOString(); + const start = new Date(now.getTime() - 24 * 60 * 60_000).toISOString(); + const lines = [ + 'Create a Live Artifact: a polished daily digest that helps a normal person understand what changed in their connected work data during the past 24 hours and what they should do next.', + '', + `Time window: ${start} through ${end}.`, + '', + 'Work autonomously. Do not ask follow-up questions, do not emit a question form, and do not wait for user input. Use sensible defaults and proceed.', + 'Optimize for fast completion: sample at most 3 relevant data sources. DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED: first run `tools connectors list --use-case personal_daily_digest --format compact` with a 120s timeout, and if that curated list command times out or returns no output, retry it once with another 120s timeout. If the curated command is unsupported, rejected, or succeeds but returns no usable tools, immediately fall back to the unfiltered read-only list via `tools connectors list --format compact`; do not stop just because `--use-case` is unsupported. If connector discovery still fails, or if both the curated and fallback lists yield zero usable connected read-only data tools, do not create an empty-state artifact; send one concise final message explaining that data loading failed and stop. For individual source calls after discovery succeeds, if a source fails because of auth, permissions, timeout, malformed output, empty output, oversized output, or any other data-loading problem, do not get stuck trying to fix it; drop that source and continue with the others. After the artifact is registered successfully, send one concise final message with the artifact id and stop.', + '', + 'Use the live-artifact skill to author and register the artifact. Prefer the curated daily-digest connector list first: `tools connectors list --use-case personal_daily_digest --format compact`. If that command is unsupported, rejected, or returns no usable tools, fall back to the unfiltered read-only list. Then call only the tools needed for a useful digest.', + '- Prefer recent activity, search, list, updated, or changed-item tools that can be bounded to this 24-hour window.', + '- Avoid provider metadata, api_root, schema, health, status, broad fetch_all, or block-content dump tools unless they are truly necessary.', + '- When a tool needs an input file, write a small JSON file under `.daily-digest-tmp/` (create it if missing). Files at the project root show up in the user-facing Design Files panel, while dot-prefixed paths are hidden. Reuse the same path when retrying the same tool.', + '- Never persist raw responses or sensitive fields in artifact files. Exclude headers, cookies, authorization values, tokens, secrets, credentials, passwords, stack traces, and unbounded raw payloads.', + '', + 'Refresh support:', + '- If at least one read-only data call succeeds, register exactly one refresh source in `document.sourceJson` using the live-artifact schema so the manual Refresh button can update the digest later.', + '- Pick the most representative successful read-only data call, typically an activity/search/list call that represents "what changed in the last 24 hours" for the user.', + '- Prefer a relative refresh window supported by the source, such as "last 24 hours" or an equivalent relative filter. Do not persist the literal ISO timestamps from this run if that would freeze future refreshes to this exact window.', + '- Keep the refresh input bounded and free of credentials, raw payload dumps, headers, cookies, tokens, secrets, passwords, and raw response bodies.', + '- If no read-only data call succeeds, omit `document.sourceJson`. Do not fabricate a refresh source. If refresh registration fails, retry once with a smaller bounded refresh input; if it still fails, create a static artifact without `document.sourceJson` rather than failing the entire digest.', + '', + 'The artifact should include:', + '- A plain-language headline and timestamp for the reporting window.', + '- 3-5 key takeaways focused on actual changes, decisions, blockers, and opportunities.', + '- A concise section for each useful source, such as code/repository activity, documents/notes/tasks, calendars, messages, or other work data when available.', + '- Actionable recommendations for today: follow-ups, reviews, risks to check, and suggested next steps.', + '- A short "What I checked today" footnote in user-friendly language that says what categories were reviewed, what was quiet, what was unavailable, and where data was sparse. Do not expose raw errors, HTTP codes, internal ids, tool names, schemas, refresh mechanics, daemon details, or system mechanics.', + '- Links or identifiers when source data provides them.', + '- If connector discovery succeeded and at least one source was checked, but the successful source results are quiet or empty, provide a useful quiet-day briefing with clear next steps. Do not create a digest when connector discovery itself failed or no usable connected read-only data tools were available.', + '', + 'Voice and synthesis examples:', + '- Code: “open-design had 4 repositories updated. The most notable change was a daemon update that affects data refresh behavior, so review it before the next release.”', + '- Docs: “Product Notes and Launch Checklist were the only matching pages. Launch Checklist changed around onboarding and should be reviewed before sharing with the team.”', + '- Recommendation: “Today, prioritize reviewing the changed release checklist, then follow up on the two open PRs that touched user-facing refresh behavior.”', + '', + 'Keep the artifact compact: a single responsive HTML view, no more than roughly 200 lines of template/CSS, and no lengthy design critique pass. If connector discovery succeeded but checked data is sparse, empty, or partially unavailable, still create the Live Artifact and clearly state the useful human-facing outcome. If connector discovery failed or no usable connected read-only data tools are available, fail fast instead of creating an empty-state artifact. Do not invent activity. Keep the visual design polished but lightweight.', + 'Important: the user-facing artifact must not mention internal product, data plumbing, tool-running, automation terms, raw failure details, or system mechanics. Write it as a normal daily briefing for a person, not as a technical run report.', + ]; + if (template) { + lines.push( + '', + 'Selected example template:', + `- Skill id: ${template.id}`, + `- Skill name: ${template.name}`, + `- Staged root: .od-skills/${path.basename(template.dir)}/`, + '', + `Before writing the artifact, read ".od-skills/${path.basename(template.dir)}/SKILL.md" and, if present, ".od-skills/${path.basename(template.dir)}/example.html". Follow that staged template's structure, layout, tokens, domain rules, and visual language as the source of truth. The staged template is for visual/domain guidance; still use the live-artifact workflow to register the final artifact.`, + '', + 'Selected template example prompt:', + '', + template.examplePrompt.trim(), + ); + } + return lines.join('\n'); +} + +export function renderOrbitTemplateSystemPrompt(template: OrbitTemplateSelection | null): string { + if (!template) return ''; + return [ + `## Selected Orbit template skill — ${template.name}`, + '', + 'This Orbit run was explicitly steered with the selected template skill below. Treat it as authoritative for the artifact structure, visual language, tokens, layout, and domain-specific synthesis rules.', + 'The generic Orbit digest brief and the live-artifact workflow still apply for data collection and artifact registration, but they must not override the selected template\'s visual/source-of-truth rules.', + template.designSystemRequired + ? 'If an active design system is also present, follow the selected template first for structure and interaction, then apply compatible design-system tokens only where the template permits them.' + : 'This selected template opts out of external design-system injection. Do not apply the workspace design system or brand tokens; use only the template\'s own visual language.', + '', + 'Before writing files, read the staged side files referenced by this skill, especially `example.html` when present, and mirror that example as instructed by the skill.', + '', + template.body.trim(), + ].join('\n'); +} + +export class OrbitService { + private config: OrbitConfigPrefs = DEFAULT_ORBIT_CONFIG; + private timer: NodeJS.Timeout | null = null; + private nextRunAtValue: Date | null = null; + private starting: Promise<{ projectId: string; agentRunId: string }> | null = null; + private inflight: Promise | null = null; + private inflightProjectId: string | null = null; + private inflightAgentRunId: string | null = null; + private runHandler: OrbitRunHandler | null = null; + private templateResolver: OrbitTemplateResolver | null = null; + + constructor(private readonly dataDir: string) {} + + setRunHandler(handler: OrbitRunHandler): void { + this.runHandler = handler; + } + + setTemplateResolver(resolver: OrbitTemplateResolver): void { + this.templateResolver = resolver; + } + + configure(config: Partial | undefined): void { + this.config = normalizeOrbitConfig(config); + this.reschedule(); + } + + async status(): Promise { + return { + config: this.config, + running: this.starting !== null || this.inflight !== null, + nextRunAt: this.nextRunAtValue?.toISOString() ?? null, + lastRun: await readLastSummary(this.dataDir), + }; + } + + async start(trigger: 'manual' | 'scheduled'): Promise<{ projectId: string; agentRunId: string }> { + if (this.inflight && this.inflightProjectId && this.inflightAgentRunId) { + return { projectId: this.inflightProjectId, agentRunId: this.inflightAgentRunId }; + } + if (this.starting) return this.starting; + if (!this.runHandler) throw new Error('Orbit agent runner is not configured'); + + this.starting = this.startRun(trigger).finally(() => { + this.starting = null; + }); + return this.starting; + } + + private async startRun(trigger: 'manual' | 'scheduled'): Promise<{ projectId: string; agentRunId: string }> { + if (!this.runHandler) throw new Error('Orbit agent runner is not configured'); + + const startedAt = new Date().toISOString(); + const runId = `orbit-${randomUUID()}`; + const template = this.config.templateSkillId && this.templateResolver + ? await this.templateResolver(this.config.templateSkillId).catch(() => null) + : null; + const now = new Date(startedAt); + const prompt = buildOrbitPrompt(now, template); + const systemPrompt = buildOrbitSystemPrompt(now, template); + const handlerStart = await this.runHandler({ + runId, + trigger, + startedAt, + prompt, + systemPrompt, + template, + }); + + this.inflightProjectId = handlerStart.projectId; + this.inflightAgentRunId = handlerStart.agentRunId; + this.inflight = (async () => { + try { + const agentResult = await handlerStart.completion; + const completedAt = new Date().toISOString(); + const connectorsSucceeded = agentResult.status === 'succeeded' ? 1 : 0; + const connectorsFailed = agentResult.status === 'failed' ? 1 : 0; + const connectorsSkipped = agentResult.status === 'canceled' ? 1 : 0; + const base = { + id: runId, + startedAt, + completedAt, + trigger, + connectorsChecked: connectorsSucceeded + connectorsFailed + connectorsSkipped, + connectorsSucceeded, + connectorsFailed, + connectorsSkipped, + agentRunId: agentResult.agentRunId, + ...(agentResult.artifactId === undefined ? {} : { artifactId: agentResult.artifactId }), + ...(agentResult.artifactProjectId === undefined ? {} : { artifactProjectId: agentResult.artifactProjectId }), + results: [{ + connectorId: 'agent-runtime', + connectorName: 'Orbit Agent', + status: agentResult.status === 'succeeded' ? 'succeeded' : agentResult.status === 'failed' ? 'failed' : 'skipped', + summary: agentResult.summary ?? `Agent run ${agentResult.status}.`, + } satisfies OrbitConnectorRunResult], + }; + const summary: OrbitActivitySummary = { + ...base, + markdown: renderMarkdown(base), + }; + await writeLastSummary(this.dataDir, summary); + return summary; + } finally { + this.inflight = null; + this.inflightProjectId = null; + this.inflightAgentRunId = null; + this.reschedule(); + } + })(); + this.inflight.catch((error) => { + console.warn('[orbit] Run failed:', error); + }); + + return { projectId: handlerStart.projectId, agentRunId: handlerStart.agentRunId }; + } + + stop(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.nextRunAtValue = null; + } + + private reschedule(): void { + this.stop(); + if (!this.config.enabled) return; + const next = nextDailyRunAt(this.config.time); + this.nextRunAtValue = next; + this.timer = setTimeout(() => { + this.timer = null; + this.nextRunAtValue = null; + void this.start('scheduled').catch((error) => { + console.warn('[orbit] Scheduled run failed:', error); + if (!this.inflight) this.reschedule(); + }); + }, Math.max(0, next.getTime() - Date.now())); + this.timer.unref(); + } +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 8d7b43f50..5ca236e5e 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -76,6 +76,7 @@ import { } from './media-models.js'; import { readMaskedConfig, writeConfig } from './media-config.js'; import { agentCliEnvForAgent, readAppConfig, writeAppConfig } from './app-config.js'; +import { OrbitService, formatLocalProjectTimestamp, renderOrbitTemplateSystemPrompt } from './orbit.js'; import { buildMcpInstallPayload } from './mcp-install-info.js'; import { buildProjectArchive, @@ -765,6 +766,7 @@ migrateLegacyDataDirSync({ const ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'artifacts'); const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects'); fs.mkdirSync(PROJECTS_DIR, { recursive: true }); +const orbitService = new OrbitService(RUNTIME_DATA_DIR); const activeChatAgentEventSinks = new Map(); const activeProjectEventSinks = new Map(); @@ -1588,6 +1590,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST }; configureConnectorCredentialStore(new FileConnectorCredentialStore(RUNTIME_DATA_DIR)); configureComposioConfigStore(RUNTIME_DATA_DIR); + composioConnectorProvider.configureCatalogCache(RUNTIME_DATA_DIR); + composioConnectorProvider.startCatalogRefreshLoop(); let daemonUrl = `http://127.0.0.1:${port}`; // Boot reconcile: any critique_runs row left in 'running' state by a prior @@ -1608,7 +1612,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST // build advertises --include-partial-messages) so the first /api/chat // hits a populated cache even if /api/agents hasn't been called yet. void readAppConfig(RUNTIME_DATA_DIR) - .then((config) => detectAgents(config.agentCliEnv ?? {})) + .then((config) => { + orbitService.configure(config.orbit); + return detectAgents(config.agentCliEnv ?? {}); + }) .catch(() => detectAgents().catch(() => {})); await recoverStaleLiveArtifactRefreshes({ projectsRoot: PROJECTS_DIR }).catch((error) => { @@ -3727,6 +3734,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST } try { const config = await writeAppConfig(RUNTIME_DATA_DIR, req.body); + orbitService.configure(config.orbit); res.json({ config }); } catch (err) { res @@ -3735,6 +3743,32 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST } }); + app.get('/api/orbit/status', async (req, res) => { + if (!isLocalSameOrigin(req, resolvedPort)) { + return res.status(403).json({ error: 'cross-origin request rejected' }); + } + try { + res.json(await orbitService.status()); + } catch (err) { + res + .status(500) + .json({ error: String(err && err.message ? err.message : err) }); + } + }); + + app.post('/api/orbit/run', async (req, res) => { + if (!isLocalSameOrigin(req, resolvedPort)) { + return res.status(403).json({ error: 'cross-origin request rejected' }); + } + try { + res.json(await orbitService.start('manual')); + } catch (err) { + res + .status(500) + .json({ error: String(err && err.message ? err.message : err) }); + } + }); + // Native OS folder picker dialog. Returns { path: string | null }. app.post('/api/dialog/open-folder', async (req, res) => { if (!isLocalSameOrigin(req, resolvedPort)) { @@ -4906,6 +4940,155 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST } }; + orbitService.setRunHandler(async ({ + trigger, + startedAt, + prompt, + systemPrompt, + template, + }) => { + // Each Orbit run gets its own project so the conversation, messages, and + // live artifact are isolated. The handler does the synchronous prep here + // (insert project/conversation/run rows, kick off the chat run) and + // returns immediately with the new project id; the daemon endpoint + // resolves the HTTP request with that id so the client can navigate to + // the new project before the agent has finished. Anything that depends + // on the agent's final status (live artifact discovery, lastRun summary + // metadata) lives inside the `completion` promise. + const appConfig = await readAppConfig(RUNTIME_DATA_DIR); + let agentId = typeof appConfig.agentId === 'string' && appConfig.agentId + ? appConfig.agentId + : null; + if (!agentId) { + const agents = await detectAgents(appConfig.agentCliEnv ?? {}).catch(() => []); + agentId = agents.find((agent) => agent.available)?.id ?? null; + } + if (!agentId) throw new Error('No available agent is configured for Orbit. Choose an agent in Settings first.'); + + const now = Date.now(); + const projectId = `orbit-${randomUUID()}`; + const conversationId = `orbit-conv-${randomUUID()}`; + const assistantMessageId = `orbit-assistant-${randomUUID()}`; + const projectName = `Orbit · ${formatLocalProjectTimestamp(startedAt)}`; + + const orbitDesignSystemId = template?.designSystemRequired === false + ? null + : appConfig.designSystemId ?? null; + + insertProject(db, { + id: projectId, + name: projectName, + skillId: 'live-artifact', + designSystemId: orbitDesignSystemId, + pendingPrompt: null, + metadata: { kind: 'orbit', trigger }, + createdAt: now, + updatedAt: now, + }); + insertConversation(db, { + id: conversationId, + projectId, + title: projectName, + createdAt: now, + updatedAt: now, + }); + + const run = design.runs.create({ + projectId, + conversationId, + assistantMessageId, + clientRequestId: `orbit-${trigger}-${randomUUID()}`, + agentId, + }); + upsertMessage(db, conversationId, { + id: `orbit-user-${run.id}`, + role: 'user', + content: prompt, + }); + upsertMessage(db, conversationId, { + id: assistantMessageId, + role: 'assistant', + content: '', + agentId, + agentName: getAgentDef(agentId)?.name ?? agentId, + runId: run.id, + runStatus: 'queued', + startedAt: now, + }); + + if (template?.dir) { + const cwd = await ensureProject(PROJECTS_DIR, projectId); + const result = await stageActiveSkill( + cwd, + path.basename(template.dir), + template.dir, + (msg) => console.warn(msg), + ); + if (!result.staged) { + console.warn( + `[od] orbit template skill-stage skipped: ${result.reason ?? 'unknown reason'}; falling back to prompt-embedded instructions`, + ); + } + } + + const modelPrefs = appConfig.agentModels?.[agentId] ?? {}; + design.runs.start(run, () => startChatRun({ + agentId, + projectId, + conversationId: run.conversationId, + assistantMessageId: run.assistantMessageId, + clientRequestId: run.clientRequestId, + skillId: 'live-artifact', + designSystemId: orbitDesignSystemId, + model: modelPrefs.model ?? null, + reasoning: modelPrefs.reasoning ?? null, + message: prompt, + systemPrompt: [ + renderOrbitTemplateSystemPrompt(template), + systemPrompt, + 'You are Orbit, an autonomous activity-summary agent inside Open Design.', + 'You must discover connectors and connector tools yourself through the OD CLI; the daemon has not chosen tools for you.', + 'You must create and register a Live Artifact as the final deliverable. Do not merely describe what you would do.', + 'Do not ask follow-up questions, do not emit , and do not wait for user input. This run is unattended; pick reasonable defaults and complete the artifact.', + 'Keep connector credentials and OD_TOOL_TOKEN private; never print or persist secrets.', + ].join('\n'), + }, run)); + + const completion = (async () => { + const finalStatus = await design.runs.wait(run); + db.prepare( + `UPDATE messages SET run_status = ?, ended_at = ? WHERE id = ?`, + ).run(finalStatus.status, Date.now(), assistantMessageId); + const artifacts = await listLiveArtifacts({ projectsRoot: PROJECTS_DIR, projectId }); + const artifact = artifacts.find((candidate) => candidate.createdByRunId === run.id); + const status = finalStatus.status === 'succeeded' && !artifact ? 'failed' : finalStatus.status; + return { + agentRunId: run.id, + status, + ...(artifact?.id ? { artifactId: artifact.id, artifactProjectId: projectId } : {}), + summary: artifact?.id + ? `Agent ${finalStatus.status} and registered live artifact ${artifact.title}.` + : `Agent ${finalStatus.status} but did not register a live artifact for this Orbit run.`, + }; + })(); + + return { projectId, agentRunId: run.id, completion }; + }); + + orbitService.setTemplateResolver(async (skillId) => { + const skills = await listSkills(SKILLS_DIR); + const skill = findSkillById(skills, skillId); + if (!skill || skill.scenario !== 'orbit') return null; + return { + id: skill.id, + name: skill.name, + examplePrompt: skill.examplePrompt, + dir: skill.dir, + body: skill.body, + designSystemRequired: skill.designSystemRequired !== false, + }; + }); + app.post('/api/runs', (req, res) => { const run = design.runs.create(req.body || {}); /** @type {import('@open-design/contracts').ChatRunCreateResponse} */ @@ -5630,39 +5813,54 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST // - `apps/daemon/sidecar/server.ts` → expects `{ url, server }` // - `apps/daemon/tests/version-route.test.ts` → expects `{ url, server }` return await new Promise((resolve, reject) => { - const server = app.listen(port, host, () => { - const address = server.address(); - // `address()` can in theory return `string | AddressInfo | null`. For - // a TCP listener it's always `AddressInfo` with a `.port` — the guard - // is belt-and-braces so an unexpected null never silently produces a - // `http://127.0.0.1:0` URL that callers would then try to fetch. - const boundPort = - address && typeof address === 'object' ? address.port : null; - if (!boundPort) { - reject( - new Error( - `[od] daemon failed to resolve listening port (address=${JSON.stringify(address)})`, - ), - ); - return; - } - resolvedPort = boundPort; - // When binding to all interfaces report localhost for local callers; - // when binding to a specific address (e.g. a Tailscale IP) report that - // address so remote callers and the sidecar use the correct URL. - const reportHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; - const url = `http://${reportHost}:${resolvedPort}`; - if (!returnServer) { - console.log(`[od] daemon listening on ${url}`); - } - daemonUrl = url; - resolve(returnServer ? { url, server } : url); - }); + const cleanupDaemonBackgroundWork = () => { + composioConnectorProvider.stopCatalogRefreshLoop(); + orbitService.stop(); + }; + let server; + try { + server = app.listen(port, host, () => { + const address = server.address(); + // `address()` can in theory return `string | AddressInfo | null`. For + // a TCP listener it's always `AddressInfo` with a `.port` — the guard + // is belt-and-braces so an unexpected null never silently produces a + // `http://127.0.0.1:0` URL that callers would then try to fetch. + const boundPort = + address && typeof address === 'object' ? address.port : null; + if (!boundPort) { + reject( + new Error( + `[od] daemon failed to resolve listening port (address=${JSON.stringify(address)})`, + ), + ); + return; + } + resolvedPort = boundPort; + // When binding to all interfaces report localhost for local callers; + // when binding to a specific address (e.g. a Tailscale IP) report that + // address so remote callers and the sidecar use the correct URL. + const reportHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; + const url = `http://${reportHost}:${resolvedPort}`; + if (!returnServer) { + console.log(`[od] daemon listening on ${url}`); + } + daemonUrl = url; + resolve(returnServer ? { url, server } : url); + }); + } catch (error) { + cleanupDaemonBackgroundWork(); + reject(error); + return; + } + server.once('close', cleanupDaemonBackgroundWork); // `app.listen` throws synchronously when the port is already in use on // some Node versions, but emits an `error` event on others (and for // EACCES / EADDRNOTAVAIL even on the same Node). Wire the event so the // returned Promise always settles instead of hanging forever. - server.on('error', reject); + server.on('error', (error) => { + cleanupDaemonBackgroundWork(); + reject(error); + }); }); } diff --git a/apps/daemon/src/skills.ts b/apps/daemon/src/skills.ts index 0c7088970..bd23f56b9 100644 --- a/apps/daemon/src/skills.ts +++ b/apps/daemon/src/skills.ts @@ -122,16 +122,24 @@ function withSkillRootPreamble(body, dir) { const referencedFiles = collectReferencedSideFiles(body); const folder = path.basename(dir); const skillRootRel = `${SKILLS_CWD_ALIAS}/${folder}`; + const exampleFile = referencedFiles[0]; + const relativeGuidance = exampleFile + ? "> below references side files such as `" + exampleFile + "`, prefer the\n" + + "> relative form rooted at the first path above — e.g. open `" + + skillRootRel + "/" + exampleFile + "`." + : "> below references side files, prefer the relative form rooted at the\n" + + "> first path above."; + const absoluteGuidance = exampleFile + ? "> back to the absolute path: `" + path.join(dir, exampleFile) + "`." + : "> back to the absolute skill root above."; const preamble = [ "> **Skill root (relative to project):** `" + skillRootRel + "/`", "> **Skill root (absolute fallback):** `" + dir + "`", ">", "> This skill ships side files alongside `SKILL.md`. When the workflow", - "> below references relative paths such as `assets/template.html` or", - "> `references/layouts.md`, prefer the relative form rooted at the", - "> first path above — e.g. open `" + skillRootRel + "/assets/template.html`.", + relativeGuidance, "> If that path is not reachable from your working directory, fall", - "> back to the absolute path: `" + dir + "/assets/template.html`.", + absoluteGuidance, "> Either form resolves to the same file; the relative form keeps you", "> inside the project working directory, which is preferred.", ...(referencedFiles.length > 0 @@ -152,6 +160,7 @@ function collectReferencedSideFiles(body) { const files = new Set(); const matches = body.matchAll(/\b(?:assets|references)\/[A-Za-z0-9._-]+\b/g); for (const match of matches) files.add(match[0]); + if (/\bexample\.html\b/.test(body)) files.add("example.html"); return Array.from(files).sort(); } diff --git a/apps/daemon/src/tools-connectors-cli.ts b/apps/daemon/src/tools-connectors-cli.ts index c6781dbf3..131733cab 100644 --- a/apps/daemon/src/tools-connectors-cli.ts +++ b/apps/daemon/src/tools-connectors-cli.ts @@ -20,12 +20,13 @@ interface ParsedOptions { connectorId?: string; toolName?: string; inputPath?: string; + useCase?: 'personal_daily_digest'; format: 'compact' | 'json'; help: boolean; } const CONNECTORS_USAGE = `Usage: - od tools connectors list [--format compact] + od tools connectors list [--use-case personal_daily_digest] [--format compact] od tools connectors execute --connector --tool --input input.json Environment: @@ -35,7 +36,7 @@ Environment: OD_TOOL_TOKEN Bearer token injected into agent runs Agent runtime invocation: - "$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact + "$OD_NODE_BIN" "$OD_BIN" tools connectors list --use-case personal_daily_digest --format compact `; function writeJson(value: unknown, stream: NodeJS.WriteStream = process.stdout): void { @@ -73,6 +74,10 @@ function parseOptions(args: string[]): ParsedOptions | { error: string } { const value = rest[++index]; if (value !== 'compact' && value !== 'json') return { error: '--format must be compact or json' }; options.format = value; + } else if (arg === '--use-case') { + const value = rest[++index]; + if (value !== 'personal_daily_digest') return { error: '--use-case must be personal_daily_digest' }; + options.useCase = value; } else if (arg === '-h' || arg === '--help') { options.help = true; } else { @@ -105,7 +110,9 @@ function toolToken(): string | { error: string } { function endpoint(baseUrl: URL, pathname: string): string { const url = new URL(baseUrl.toString()); - url.pathname = `${url.pathname}${pathname}`.replace(/\/+/gu, '/'); + const [pathPart, searchPart] = pathname.split('?'); + url.pathname = `${url.pathname}${pathPart ?? ''}`.replace(/\/+/gu, '/'); + url.search = searchPart === undefined ? '' : `?${searchPart}`; return url.toString(); } @@ -157,6 +164,7 @@ function compactTool(value: unknown): unknown { name: tool.name, description: tool.description, safety: tool.safety, + curation: tool.curation, inputSchema: tool.inputSchemaJson ?? tool.inputSchema, }; } @@ -256,8 +264,9 @@ export async function runConnectorsToolCli(args: string[]): Promise body, ); } diff --git a/apps/daemon/src/tools/connectors.ts b/apps/daemon/src/tools/connectors.ts index fdc24ea27..4861630d9 100644 --- a/apps/daemon/src/tools/connectors.ts +++ b/apps/daemon/src/tools/connectors.ts @@ -1,6 +1,6 @@ import type { ToolTokenGrant } from '../tool-tokens.js'; -import { classifyConnectorToolSafety, type ConnectorCatalogDefinition, type ConnectorToolDetail, type ConnectorToolSafety } from '../connectors/catalog.js'; +import { classifyConnectorToolSafety, connectorDefinitionToDetail, type ConnectorCatalogDefinition, type ConnectorToolDetail, type ConnectorToolSafety, type ConnectorToolUseCase } from '../connectors/catalog.js'; import { connectorService, ConnectorService, type ConnectorExecuteRequest } from '../connectors/service.js'; export interface ConnectorToolContext { @@ -48,16 +48,49 @@ function isAgentPreviewListableTool(definition: ConnectorCatalogDefinition, tool return runtimeSafety.sideEffect === 'read' && effectiveApproval === 'auto'; } -export async function listConnectorTools(context: ConnectorToolContext): Promise>> { +function matchesConnectorToolUseCase(tool: ConnectorToolDetail, useCase: ConnectorToolUseCase | undefined): boolean { + if (useCase === undefined) return true; + return tool.curation?.useCases?.includes(useCase) ?? false; +} + +export async function listConnectorTools(context: ConnectorToolContext & { useCase?: ConnectorToolUseCase }): Promise>> { const service = context.service ?? connectorService; - const definitions = await service.listDefinitions(); - const entries = await Promise.all(definitions.map(async (definition) => ({ definition, connector: await service.getConnector(definition.id) }))); + // Agent-facing tool discovery sits on the hot path for unattended Orbit + // runs. Do not call provider discovery here: Composio toolkit discovery can + // cold-start slowly and leave the agent with no data before its shell + // timeout. Static definitions plus locally persisted connection status are + // enough to expose the approved read-only tool surface, and execution still + // validates connection state and safety again before calling providers. + const fastDefinitions = service.listFastDefinitions(); + const fastDefinitionsById = new Map(fastDefinitions.map((definition) => [definition.id, definition])); + const connectedStatusIds = Object.entries(service.listConnectorStatuses()) + .filter(([, status]) => status.status === 'connected') + .map(([connectorId]) => connectorId); + const hasConnectedConnectorNeedingDiscovery = connectedStatusIds.some((connectorId) => { + const fastDefinition = fastDefinitionsById.get(connectorId); + return !fastDefinition || fastDefinition.tools.length === 0; + }); + const definitions = hasConnectedConnectorNeedingDiscovery ? await service.listDefinitions() : fastDefinitions; + const entries = definitions.map((definition) => { + const detail = connectorDefinitionToDetail(definition); + const status = service.getStatus(definition); + return { + definition, + connector: { + ...detail, + status: status.status, + ...(status.accountLabel === undefined ? {} : { accountLabel: status.accountLabel }), + ...(status.lastError === undefined ? {} : { lastError: status.lastError }), + }, + }; + }); return entries .filter(({ connector }) => connector.status === 'connected') .map(({ definition, connector }) => ({ ...connector, tools: connector.tools .filter((tool) => isAgentPreviewListableTool(definition, tool)) + .filter((tool) => matchesConnectorToolUseCase(tool, context.useCase)) .sort((left, right) => { const leftReadOnly = left.safety.sideEffect === 'read' && left.safety.approval === 'auto'; const rightReadOnly = right.safety.sideEffect === 'read' && right.safety.approval === 'auto'; diff --git a/apps/daemon/tests/agents.test.ts b/apps/daemon/tests/agents.test.ts index 132645135..b3789b974 100644 --- a/apps/daemon/tests/agents.test.ts +++ b/apps/daemon/tests/agents.test.ts @@ -323,10 +323,34 @@ test('MCP-capable agents can discover equivalent live artifact and connector too const createTool = tools.find((tool) => tool.name === 'live_artifacts_create')!; const updateTool = tools.find((tool) => tool.name === 'live_artifacts_update')!; + const connectorsListTool = tools.find((tool) => tool.name === 'connectors_list')!; const createProperties = createTool.inputSchema.properties as Record; const updateProperties = updateTool.inputSchema.properties as Record; + const connectorsListProperties = connectorsListTool.inputSchema.properties as Record; assert.deepEqual(Object.keys(createProperties).sort(), ['input', 'provenanceJson', 'templateHtml']); assert.deepEqual(Object.keys(updateProperties).sort(), ['artifactId', 'input', 'provenanceJson', 'templateHtml']); + assert.deepEqual(Object.keys(connectorsListProperties).sort(), ['useCase']); +}); + +test('live artifact MCP connector list forwards daily digest use case to daemon tools', async () => { + process.env.OD_DAEMON_URL = 'http://127.0.0.1:17456/base'; + process.env.OD_TOOL_TOKEN = 'test-tool-token'; + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ connectors: [] }), { status: 200 }); + }; + + const response = await handleLiveArtifactsMcpRequest({ + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'connectors_list', arguments: { useCase: 'personal_daily_digest' } }, + }); + + assert.equal(response.error, undefined); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'http://127.0.0.1:17456/base/api/tools/connectors/list?useCase=personal_daily_digest'); }); test('live artifact MCP create forwards input and artifact payload fields to daemon tools', async () => { diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index 2715592ed..7e800600d 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -83,6 +83,83 @@ describe('app-config', () => { const cfg = await readAppConfig(dataDir); expect(cfg).toEqual({}); }); + + it('preserves omitted orbit.templateSkillId from legacy stored config', async () => { + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + orbit: { + enabled: true, + time: '09:30', + }, + }), + ); + + const cfg = await readAppConfig(dataDir); + + expect(cfg.orbit).toEqual({ + enabled: true, + time: '09:30', + }); + expect(cfg.orbit).not.toHaveProperty('templateSkillId'); + }); + + it('falls back to default orbit time for out-of-range stored values', async () => { + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + orbit: { + enabled: true, + time: '99:99', + }, + }), + ); + + const cfg = await readAppConfig(dataDir); + + expect(cfg.orbit).toEqual({ + enabled: true, + time: '08:00', + }); + }); + + it('preserves explicit orbit.templateSkillId null and trimmed string', async () => { + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + orbit: { + enabled: false, + time: '08:00', + templateSkillId: null, + }, + }), + ); + + let cfg = await readAppConfig(dataDir); + expect(cfg.orbit).toEqual({ + enabled: false, + time: '08:00', + templateSkillId: null, + }); + + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + orbit: { + enabled: true, + time: '10:15', + templateSkillId: ' orbit-general ', + }, + }), + ); + + cfg = await readAppConfig(dataDir); + expect(cfg.orbit).toEqual({ + enabled: true, + time: '10:15', + templateSkillId: 'orbit-general', + }); + }); }); describe('writeAppConfig', () => { diff --git a/apps/daemon/tests/composio-config.test.ts b/apps/daemon/tests/composio-config.test.ts index de4415eb9..47662f0d8 100644 --- a/apps/daemon/tests/composio-config.test.ts +++ b/apps/daemon/tests/composio-config.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { mkdtemp, readFile } from 'node:fs/promises'; +import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { tmpdir } from 'node:os'; @@ -9,7 +9,7 @@ import { readPublicComposioConfig, writeComposioConfig, } from '../src/connectors/composio-config.js'; -import { composioConnectorProvider } from '../src/connectors/composio.js'; +import { composioConnectorProvider, getStaticComposioCatalogDefinitions } from '../src/connectors/composio.js'; import type { ConnectorCatalogDefinition } from '../src/connectors/catalog.js'; async function useTempComposioStore(): Promise { @@ -83,4 +83,99 @@ describe('composio config', () => { expect(publicConfig).toEqual({ configured: false, apiKeyTail: '' }); expect(readComposioConfig()).toEqual({ apiKey: '' }); }); + + it('loads persisted Composio catalog cache into fast definitions', async () => { + const dir = await useTempComposioStore(); + await mkdir(path.join(dir, 'connectors'), { recursive: true }); + await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({ + schemaVersion: 1, + provider: 'composio', + fetchedAt: '2026-05-07T00:00:00.000Z', + definitions: [ + { + id: 'slack', + name: 'Slack', + provider: 'composio', + category: 'Communication', + providerConnectorId: 'SLACK', + authentication: 'composio', + tools: [ + { + name: 'slack.slack_list_channels', + title: 'List channels', + description: 'List Slack channels', + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only' }, + refreshEligible: true, + curation: { useCases: ['personal_daily_digest'], reason: 'Digest source' }, + requiredScopes: ['read'], + providerToolId: 'SLACK_LIST_CHANNELS', + }, + ], + allowedToolNames: ['slack.slack_list_channels'], + minimumApproval: 'auto', + }, + ], + }, null, 2)); + + composioConnectorProvider.configureCatalogCache(dir); + + expect(composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'slack')).toMatchObject({ + id: 'slack', + tools: [expect.objectContaining({ + name: 'slack.slack_list_channels', + curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }), + })], + }); + }); + + it('falls back to the static catalog when the persisted cache is empty', async () => { + const dir = await useTempComposioStore(); + await mkdir(path.join(dir, 'connectors'), { recursive: true }); + await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({ + schemaVersion: 1, + provider: 'composio', + fetchedAt: '2026-05-07T00:00:00.000Z', + definitions: [], + }, null, 2)); + + composioConnectorProvider.configureCatalogCache(dir); + + expect(composioConnectorProvider.getFastDefinitions()).toEqual(getStaticComposioCatalogDefinitions()); + }); + + it('does not hydrate persisted catalog cache before the runtime data directory is configured', async () => { + const defaultCacheDir = path.join(process.cwd(), '.od', 'connectors'); + const defaultCachePath = path.join(defaultCacheDir, 'composio-catalog-cache.json'); + const dir = await useTempComposioStore(); + await mkdir(defaultCacheDir, { recursive: true }); + await writeFile(defaultCachePath, JSON.stringify({ + schemaVersion: 1, + provider: 'composio', + fetchedAt: '2026-05-07T00:00:00.000Z', + definitions: [composioDefinition('wrong-tenant')], + }, null, 2)); + + try { + vi.resetModules(); + const composioModule = await import('../src/connectors/composio.js'); + + expect(composioModule.composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'wrong-tenant')).toBeUndefined(); + + await mkdir(path.join(dir, 'connectors'), { recursive: true }); + await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({ + schemaVersion: 1, + provider: 'composio', + fetchedAt: '2026-05-07T00:00:00.000Z', + definitions: [composioDefinition('right-tenant')], + }, null, 2)); + + composioModule.composioConnectorProvider.configureCatalogCache(dir); + + expect(composioModule.composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'right-tenant')).toMatchObject({ + id: 'right-tenant', + }); + } finally { + await rm(defaultCachePath, { force: true }); + } + }); }); diff --git a/apps/daemon/tests/connection-test.test.ts b/apps/daemon/tests/connection-test.test.ts index feacd0c74..a3d51adcb 100644 --- a/apps/daemon/tests/connection-test.test.ts +++ b/apps/daemon/tests/connection-test.test.ts @@ -1011,7 +1011,14 @@ setInterval(() => {}, 1000); agentId: 'codex', signal: controller.signal, }); - await waitForFile(pidFile); + await Promise.race([ + waitForFile(pidFile, 15_000), + pending.then((result) => { + throw new Error( + `Agent probe finished before fake agent wrote pid: ${JSON.stringify(result)}`, + ); + }), + ]); controller.abort(); await expect(pending).resolves.toMatchObject({ ok: false, diff --git a/apps/daemon/tests/connectors-routes.test.ts b/apps/daemon/tests/connectors-routes.test.ts index be031ce54..ac1b71ab4 100644 --- a/apps/daemon/tests/connectors-routes.test.ts +++ b/apps/daemon/tests/connectors-routes.test.ts @@ -3,6 +3,7 @@ import { request as httpRequest } from 'node:http'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { COMPOSIO_LOGO_CACHE_MAX_ENTRIES } from '../src/connectors/routes.js'; import { startServer } from '../src/server.js'; import { ComposioConnectorProvider, composioConnectorProvider, getStaticComposioCatalogDefinitions } from '../src/connectors/composio.js'; import { readComposioConfig, writeComposioConfig } from '../src/connectors/composio-config.js'; @@ -38,6 +39,7 @@ function mockComposioFetch(options = {}) { createAuthConfigResponse, delayFirstAuthConfigs, delayFirstToolkits, + logoFetch, linkResponse = { connected_account_id: 'ca_github', status: 'ACTIVE', account_label: 'octocat@example.com' }, } = options; composioDiscoveryRequestCounts = { authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 }; @@ -47,6 +49,13 @@ function mockComposioFetch(options = {}) { return originalFetch(input, init); } const parsed = new URL(url); + if (parsed.hostname === 'logos.composio.dev') { + if (logoFetch) return await logoFetch(parsed, init, input); + return new Response('', { + status: 200, + headers: { 'content-type': 'image/svg+xml' }, + }); + } if (parsed.pathname === '/api/v3/auth_configs') { composioDiscoveryRequestCounts.authConfigs += 1; if (delayFirstAuthConfigs && composioDiscoveryRequestCounts.authConfigs === 1) { @@ -354,10 +363,10 @@ describe('connector routes', () => { expect(connect.status).toBe(200); expect(connect.body.connector).toMatchObject({ id: 'slack', status: 'connected', auth: { configured: true } }); - expect(connect.body.connector.tools).toEqual(expect.arrayContaining([ + expect(connect.body.connector.tools).toEqual([ expect.objectContaining({ name: 'slack.slack_list_channels' }), expect.objectContaining({ name: 'slack.slack_send_message' }), - ])); + ]); expect(lastComposioAuthConfigRequest).toEqual({ toolkit: { slug: 'SLACK' }, auth_config: { type: 'use_composio_managed_auth' }, @@ -485,6 +494,8 @@ describe('connector routes', () => { expect(html).toContain('GitHub connected'); expect(html).toContain('Open Design'); expect(html).toContain('open-design:connector-connected'); + expect(html).toContain('function requestClose()'); + expect(html).toContain('Your browser blocked automatic closing. You can close this tab and return to Open Design.'); expect(html).not.toContain('

Connector connected. You can close this window.

'); }); @@ -508,9 +519,251 @@ describe('connector routes', () => { expect(lastComposioLinkRequest.callback_url).toContain(`127.0.0.2:${url.port}/api/connectors/oauth/callback`); }); + it('times out stalled Composio logo fetches and clears the inflight entry', async () => { + let upstreamRequests = 0; + let firstRequestAborted = false; + mockComposioFetch({ + logoFetch: async (_parsed, init) => { + upstreamRequests += 1; + if (upstreamRequests === 1) { + await new Promise((_, reject) => { + if (!init?.signal) { + reject(new Error('expected fetch timeout signal')); + return; + } + const abort = () => { + firstRequestAborted = true; + reject(init.signal?.reason ?? new DOMException('Aborted', 'AbortError')); + }; + if (init.signal.aborted) { + abort(); + return; + } + init.signal.addEventListener('abort', abort, { once: true }); + }); + } + return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + }, + }); + + const firstRequestPromise = fetch(`${baseUrl}/api/connectors/logos/github?theme=dark`); + const firstResponse = await firstRequestPromise; + + expect(firstRequestAborted).toBe(true); + expect(firstResponse.status).toBe(404); + + const secondResponse = await fetch(`${baseUrl}/api/connectors/logos/github?theme=dark`); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await secondResponse.arrayBuffer())).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(upstreamRequests).toBe(2); + }, 15_000); + + it('keeps the Composio logo timeout active while reading the response body', async () => { + let upstreamRequests = 0; + let firstBodyReadAborted = false; + const slug = 'body_timeout_logo'; + mockComposioFetch({ + logoFetch: async (_parsed, init) => { + upstreamRequests += 1; + if (upstreamRequests === 1) { + return { + ok: true, + headers: new Headers({ 'content-type': 'image/png' }), + arrayBuffer: async () => { + await new Promise((resolve) => setTimeout(resolve, 2_100)); + if (!init?.signal) throw new Error('expected fetch timeout signal'); + if (init.signal.aborted) { + firstBodyReadAborted = true; + throw (init.signal.reason ?? new DOMException('Aborted', 'AbortError')); + } + return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + }, + }; + } + return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + }, + }); + + const firstResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(firstBodyReadAborted).toBe(true); + expect(firstResponse.status).toBe(404); + + const secondResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await secondResponse.arrayBuffer())).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(upstreamRequests).toBe(2); + }, 15_000); + + it('rejects oversized Composio logo payloads before buffering them', async () => { + let upstreamRequests = 0; + let arrayBufferCalled = false; + const slug = 'oversized_logo'; + mockComposioFetch({ + logoFetch: async () => { + upstreamRequests += 1; + if (upstreamRequests === 1) { + return { + ok: true, + headers: new Headers({ + 'content-type': 'image/png', + 'content-length': '1048577', + }), + arrayBuffer: async () => { + arrayBufferCalled = true; + return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + }, + }; + } + return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + }, + }); + + const firstResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(firstResponse.status).toBe(404); + expect(firstResponse.headers.get('cache-control')).toBe('no-store'); + expect(arrayBufferCalled).toBe(false); + + const secondResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await secondResponse.arrayBuffer())).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(upstreamRequests).toBe(2); + }); + + it('evicts the least recently used Composio logo cache entry when the cache is full', async () => { + let upstreamRequests = 0; + mockComposioFetch({ + logoFetch: async (parsed) => { + upstreamRequests += 1; + return new Response(Buffer.from(parsed.pathname), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + }, + }); + + for (let index = 0; index < COMPOSIO_LOGO_CACHE_MAX_ENTRIES; index += 1) { + const response = await fetch(`${baseUrl}/api/connectors/logos/slug_${index}?theme=dark`); + expect(response.status).toBe(200); + } + + const warmedCount = upstreamRequests; + + const refreshedResponse = await fetch(`${baseUrl}/api/connectors/logos/slug_0?theme=dark`); + expect(refreshedResponse.status).toBe(200); + expect(upstreamRequests).toBe(warmedCount); + + const overflowResponse = await fetch(`${baseUrl}/api/connectors/logos/slug_overflow?theme=dark`); + expect(overflowResponse.status).toBe(200); + expect(upstreamRequests).toBe(warmedCount + 1); + + const stillCachedResponse = await fetch(`${baseUrl}/api/connectors/logos/slug_0?theme=dark`); + expect(stillCachedResponse.status).toBe(200); + expect(upstreamRequests).toBe(warmedCount + 1); + + const evictedResponse = await fetch(`${baseUrl}/api/connectors/logos/slug_1?theme=dark`); + expect(evictedResponse.status).toBe(200); + expect(upstreamRequests).toBe(warmedCount + 2); + }); + + it('serves raster Composio logo responses', async () => { + mockComposioFetch({ + logoFetch: async () => { + return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + }, + }); + + const response = await fetch(`${baseUrl}/api/connectors/logos/github?theme=dark`); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + }); + + it('serves SVG Composio logo responses with a restrictive CSP', async () => { + let upstreamRequests = 0; + const slug = 'svg_only_logo'; + const svg = ''; + mockComposioFetch({ + logoFetch: async () => { + upstreamRequests += 1; + return new Response(svg, { + status: 200, + headers: { 'content-type': 'image/svg+xml' }, + }); + }, + }); + + const firstResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.headers.get('content-type')).toBe('image/svg+xml'); + expect(firstResponse.headers.get('content-security-policy')).toBe("default-src 'none'; img-src data:; style-src 'unsafe-inline'"); + expect(await firstResponse.text()).toBe(svg); + + const secondResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.headers.get('content-type')).toBe('image/svg+xml'); + expect(await secondResponse.text()).toBe(svg); + expect(upstreamRequests).toBe(1); + }); + + it('rejects non-image Composio logo responses without caching them', async () => { + let upstreamRequests = 0; + const slug = 'html_only_logo'; + mockComposioFetch({ + logoFetch: async () => { + upstreamRequests += 1; + if (upstreamRequests === 1) { + return new Response('oops', { + status: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + } + return new Response(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + }, + }); + + const firstResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(firstResponse.status).toBe(404); + expect(firstResponse.headers.get('cache-control')).toBe('no-store'); + + const secondResponse = await fetch(`${baseUrl}/api/connectors/logos/${slug}?theme=dark`); + + expect(secondResponse.status).toBe(200); + expect(secondResponse.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await secondResponse.arrayBuffer())).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(upstreamRequests).toBe(2); + }); + it('lists connected Composio tools through run-scoped tool auth', async () => { await jsonFetch(`${baseUrl}/api/connectors/github/connect`, { method: 'POST' }); const token = mintConnectorToolToken(); + composioDiscoveryRequestCounts = { authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 }; const response = await jsonFetch(`${baseUrl}/api/tools/connectors/list`, { headers: { Authorization: `Bearer ${token}` }, @@ -521,6 +774,52 @@ describe('connector routes', () => { expect(response.body.connectors[0].tools).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'github.github_search_repositories', safety: expect.objectContaining({ sideEffect: 'read', approval: 'auto' }) }), ])); + expect(composioDiscoveryRequestCounts).toEqual({ authConfigs: 0, createdAuthConfigs: 0, toolkits: 0, tools: 0 }); + }); + + it('filters connected connector tools by curated use case and returns curation metadata', async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve(undefined))); + }); + mockComposioFetch({ + authConfigs: [{ id: 'ac_slack', status: 'ENABLED', toolkit: { slug: 'slack' } }], + linkResponse: { connected_account_id: 'ca_slack', status: 'ACTIVE', account_label: 'slack@example.com' }, + }); + composioConnectorProvider.clearDiscoveryCache(); + const started = await startServer({ port: 0, returnServer: true }); + server = started.server; + baseUrl = started.url; + await jsonFetch(`${baseUrl}/api/connectors/composio/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey: 'cmp_test' }), + }); + await jsonFetch(`${baseUrl}/api/connectors/slack/connect`, { method: 'POST' }); + const token = mintConnectorToolToken(); + + const response = await jsonFetch(`${baseUrl}/api/tools/connectors/list?useCase=personal_daily_digest`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + expect(response.status).toBe(200); + expect(response.body.connectors.map((connector) => connector.id)).toEqual(['slack']); + expect(response.body.connectors[0].tools).toEqual([ + expect.objectContaining({ + name: 'slack.slack_list_channels', + curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }), + }), + ]); + }); + + it('rejects invalid connector tool useCase filters', async () => { + const token = mintConnectorToolToken(); + + const response = await jsonFetch(`${baseUrl}/api/tools/connectors/list?useCase=invalid`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe('BAD_REQUEST'); }); it('executes connected Composio tools through run-scoped tool auth', async () => { diff --git a/apps/daemon/tests/connectors-service.test.ts b/apps/daemon/tests/connectors-service.test.ts index c1f4851df..3245d2606 100644 --- a/apps/daemon/tests/connectors-service.test.ts +++ b/apps/daemon/tests/connectors-service.test.ts @@ -36,17 +36,25 @@ function externalConnector(overrides: Partial = {}): } class TestConnectorService extends ConnectorService { + public listDefinitionsCallCount = 0; + constructor( private readonly definition: ConnectorCatalogDefinition, statusService: ConnectorStatusService, + private readonly includeInFastDefinitions = false, ) { super(statusService); } override async listDefinitions(): Promise { + this.listDefinitionsCallCount += 1; return [this.definition]; } + override listFastDefinitions(): ConnectorCatalogDefinition[] { + return this.includeInFastDefinitions ? [this.definition] : []; + } + override async getDefinition(connectorId: string): Promise { return connectorId === this.definition.id ? this.definition : undefined; } @@ -211,6 +219,90 @@ describe('connector read-only safety classification', () => { }); }); +describe('connector detail responses', () => { + it('keeps non-read tools in connector list/detail discovery responses', async () => { + const definition = externalConnector({ + tools: [ + { + name: 'docs.search', + title: 'Search docs', + requiredScopes: ['docs:read'], + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only docs search' }, + refreshEligible: true, + }, + { + name: 'docs.update_page', + title: 'Update page', + requiredScopes: ['docs:write'], + safety: { sideEffect: 'write', approval: 'confirm', reason: 'write-capable docs update' }, + refreshEligible: false, + }, + { + name: 'docs.delete_page', + title: 'Delete page', + requiredScopes: ['docs:write'], + safety: { sideEffect: 'destructive', approval: 'disabled', reason: 'destructive docs delete' }, + refreshEligible: false, + }, + { + name: 'docs.sync', + title: 'Sync docs', + requiredScopes: [], + safety: { sideEffect: 'unknown', approval: 'confirm', reason: 'unknown safety' }, + refreshEligible: false, + }, + ], + allowedToolNames: ['docs.search', 'docs.update_page', 'docs.delete_page', 'docs.sync'], + featuredToolNames: ['docs.search', 'docs.update_page', 'docs.delete_page', 'docs.sync'], + minimumApproval: 'auto', + }); + const service = new TestConnectorService(definition, new ConnectorStatusService(), true); + + await expect(service.listConnectors()).resolves.toEqual([ + expect.objectContaining({ + id: 'external_docs', + tools: [ + expect.objectContaining({ name: 'docs.search' }), + expect.objectContaining({ name: 'docs.update_page' }), + expect.objectContaining({ name: 'docs.delete_page' }), + expect.objectContaining({ name: 'docs.sync' }), + ], + featuredToolNames: ['docs.search', 'docs.update_page', 'docs.delete_page', 'docs.sync'], + }), + ]); + + await expect(service.listConnectorDiscovery()).resolves.toEqual( + expect.objectContaining({ + connectors: [ + expect.objectContaining({ + id: 'external_docs', + tools: [ + expect.objectContaining({ name: 'docs.search' }), + expect.objectContaining({ name: 'docs.update_page' }), + expect.objectContaining({ name: 'docs.delete_page' }), + expect.objectContaining({ name: 'docs.sync' }), + ], + featuredToolNames: ['docs.search', 'docs.update_page', 'docs.delete_page', 'docs.sync'], + }), + ], + }), + ); + + await expect(service.getConnector('external_docs')).resolves.toEqual( + expect.objectContaining({ + id: 'external_docs', + tools: [ + expect.objectContaining({ name: 'docs.search' }), + expect.objectContaining({ name: 'docs.update_page' }), + expect.objectContaining({ name: 'docs.delete_page' }), + expect.objectContaining({ name: 'docs.sync' }), + ], + featuredToolNames: ['docs.search', 'docs.update_page', 'docs.delete_page', 'docs.sync'], + }), + ); + }); +}); + describe('connector execution policy', () => { it('omits connected allowed tools that are not auto-approved read-only from agent preview listings', async () => { const definition = externalConnector({ @@ -257,6 +349,88 @@ describe('connector execution policy', () => { ]); }); + it('filters connector tools by curated use case when requested', async () => { + const definition = externalConnector({ + tools: [ + { + name: 'docs.recent_changes', + title: 'Recent changes', + requiredScopes: ['docs:read'], + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only recent changes' }, + refreshEligible: true, + curation: { useCases: ['personal_daily_digest'], reason: 'Recent changes fit a daily digest.' }, + }, + { + name: 'docs.search', + title: 'Search docs', + requiredScopes: ['docs:read'], + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only docs search' }, + refreshEligible: true, + }, + ], + allowedToolNames: ['docs.recent_changes', 'docs.search'], + minimumApproval: 'auto', + }); + const statusService = new ConnectorStatusService(); + statusService.connect(definition, 'docs@example.com'); + const service = new TestConnectorService(definition, statusService, true); + + await expect(listConnectorTools({ + grant: { + token: 'test-token', + projectId: 'project-a', + runId: 'run-a', + allowedEndpoints: [], + allowedOperations: [], + issuedAt: '2026-04-30T00:00:00.000Z', + expiresAt: '2026-04-30T00:15:00.000Z', + }, + projectsRoot: '/tmp/open-design-test', + service, + useCase: 'personal_daily_digest', + })).resolves.toEqual([ + expect.objectContaining({ + id: 'external_docs', + tools: [expect.objectContaining({ name: 'docs.recent_changes', curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }) })], + }), + ]); + }); + + it('does not force dynamic definitions when curated fast definitions are available', async () => { + const definition = externalConnector({ + tools: [{ + name: 'docs.recent_changes', + title: 'Recent changes', + requiredScopes: ['docs:read'], + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only recent changes' }, + refreshEligible: true, + curation: { useCases: ['personal_daily_digest'] }, + }], + allowedToolNames: ['docs.recent_changes'], + minimumApproval: 'auto', + }); + const statusService = new ConnectorStatusService(); + statusService.connect(definition, 'docs@example.com'); + const service = new TestConnectorService(definition, statusService, true); + + await listConnectorTools({ + grant: { + token: 'test-token', + projectId: 'project-a', + runId: 'run-a', + allowedEndpoints: [], + allowedOperations: [], + issuedAt: '2026-04-30T00:00:00.000Z', + expiresAt: '2026-04-30T00:15:00.000Z', + }, + projectsRoot: '/tmp/open-design-test', + service, + useCase: 'personal_daily_digest', + }); + + expect(service.listDefinitionsCallCount).toBe(0); + }); + it('rejects connector inputs that no longer match the current tool schema', async () => { const definition = externalConnector({ tools: [{ diff --git a/apps/daemon/tests/orbit.test.ts b/apps/daemon/tests/orbit.test.ts new file mode 100644 index 000000000..356e261ad --- /dev/null +++ b/apps/daemon/tests/orbit.test.ts @@ -0,0 +1,279 @@ +import path from 'node:path'; +import os from 'node:os'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + buildOrbitPrompt, + buildOrbitSystemPrompt, + OrbitService, + renderOrbitTemplateSystemPrompt, + type OrbitRunHandler, + type OrbitTemplateSelection, +} from '../src/orbit.js'; + +function formatExpectedLocalOrbitPromptTimestamp(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mi = String(date.getMinutes()).padStart(2, '0'); + const timeZoneName = new Intl.DateTimeFormat(undefined, { timeZoneName: 'shortOffset' }) + .formatToParts(date) + .find((part) => part.type === 'timeZoneName')?.value; + return `${yyyy}-${mm}-${dd} ${hh}:${mi}${timeZoneName ? ` (${timeZoneName})` : ''}`; +} + +describe('buildOrbitPrompt', () => { + it('keeps the user-visible Orbit prompt concise', () => { + const template: OrbitTemplateSelection = { + id: 'orbit-general', + name: 'orbit-general', + examplePrompt: 'Render the editorial bento dashboard.', + dir: path.join('/repo', 'skills', 'orbit-general'), + body: 'Open and mirror the shipped `example.html` before writing output. Use exclusively the canvas tokens.', + designSystemRequired: false, + }; + + const now = new Date('2026-05-06T15:32:52.361Z'); + const start = new Date(now.getTime() - 24 * 60 * 60_000); + const prompt = buildOrbitPrompt(now, template); + + expect(prompt).toContain('Create today\'s Orbit daily digest as a Live Artifact.'); + expect(prompt).toContain( + `Use my connected work data from ${formatExpectedLocalOrbitPromptTimestamp(start)} through ${formatExpectedLocalOrbitPromptTimestamp(now)}.`, + ); + expect(prompt).not.toContain('2026-05-05T15:32:52.361Z'); + expect(prompt).toContain('Use the selected Orbit template: orbit-general.'); + expect(prompt).not.toContain('DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED'); + expect(prompt).not.toContain('Selected template example prompt:'); + expect(prompt).not.toContain('Render the editorial bento dashboard.'); + }); +}); + +describe('buildOrbitSystemPrompt', () => { + it('embeds selected Orbit template instructions and staged side-file guidance', () => { + const template: OrbitTemplateSelection = { + id: 'orbit-general', + name: 'orbit-general', + examplePrompt: 'Render the editorial bento dashboard.', + dir: path.join('/repo', 'skills', 'orbit-general'), + body: 'Open and mirror the shipped `example.html` before writing output. Use exclusively the canvas tokens.', + designSystemRequired: false, + }; + + const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template); + + expect(prompt).toContain('Skill id: orbit-general'); + expect(prompt).toContain('Staged root: .od-skills/orbit-general/'); + expect(prompt).toContain('read ".od-skills/orbit-general/SKILL.md"'); + expect(prompt).toContain('".od-skills/orbit-general/example.html"'); + expect(prompt).toContain('visual/domain guidance'); + expect(prompt).not.toContain('Selected template skill instructions:'); + expect(prompt).toContain('Selected template example prompt:'); + expect(prompt).toContain('Render the editorial bento dashboard.'); + }); + + it('prioritizes curated daily digest connector discovery before fallback listing', () => { + const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z')); + + expect(prompt).toContain('DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED'); + expect(prompt).toContain('tools connectors list --use-case personal_daily_digest --format compact'); + expect(prompt).toContain('do not stop just because `--use-case` is unsupported'); + }); + + it('renders the selected template skill body as authoritative run instructions', () => { + const template: OrbitTemplateSelection = { + id: 'orbit-general', + name: 'orbit-general', + examplePrompt: 'Render the editorial bento dashboard.', + dir: path.join('/repo', 'skills', 'orbit-general'), + body: 'Open and mirror the shipped `example.html` before writing output. Use exclusively the canvas tokens.', + designSystemRequired: false, + }; + + const prompt = renderOrbitTemplateSystemPrompt(template); + + expect(prompt).toContain('Selected Orbit template skill — orbit-general'); + expect(prompt).toContain('Treat it as authoritative'); + expect(prompt).toContain('must not override the selected template'); + expect(prompt).toContain('opts out of external design-system injection'); + expect(prompt).toContain('Do not apply the workspace design system'); + expect(prompt).toContain('Open and mirror the shipped `example.html`'); + expect(prompt).toContain('Use exclusively the canvas tokens.'); + }); +}); + +describe('OrbitService', () => { + it('passes concise user prompt and detailed system prompt to the run handler', async () => { + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const service = new OrbitService(dataDir); + const captured: { request?: Parameters[0] } = {}; + service.setRunHandler(async (request) => { + captured.request = request; + return { + projectId: 'project-1', + agentRunId: 'agent-1', + completion: Promise.resolve({ + agentRunId: 'agent-1', + status: 'succeeded', + }), + }; + }); + + await service.start('manual'); + + expect(captured.request?.prompt).toContain( + 'Create today\'s Orbit daily digest as a Live Artifact.', + ); + expect(captured.request?.prompt).not.toContain( + 'DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED', + ); + expect(captured.request?.systemPrompt).toContain( + 'DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED', + ); + let status = await service.status(); + for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + status = await service.status(); + } + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('preserves the default template when config omits the field', async () => { + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const service = new OrbitService(dataDir); + + service.configure({ enabled: true, time: '08:00' }); + + await expect(service.status()).resolves.toMatchObject({ + config: { templateSkillId: 'orbit-general' }, + }); + service.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('falls back to the default time when config has an out-of-range time', async () => { + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const service = new OrbitService(dataDir); + + service.configure({ enabled: true, time: '24:99' }); + + await expect(service.status()).resolves.toMatchObject({ + config: { time: '08:00' }, + }); + service.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('treats a malformed activity summary file as missing state', async () => { + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + await mkdir(path.join(dataDir, 'orbit'), { recursive: true }); + await writeFile(path.join(dataDir, 'orbit', 'activity-summary.json'), '{not json', 'utf8'); + + const service = new OrbitService(dataDir); + + await expect(service.status()).resolves.toMatchObject({ + lastRun: null, + }); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('reschedules after an early scheduled start rejection', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 6, 7, 59, 0, 0)); + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const service = new OrbitService(dataDir); + service.setRunHandler(async () => { + throw new Error('agent unavailable'); + }); + service.configure({ enabled: true, time: '08:00' }); + const firstNextRunAt = (await service.status()).nextRunAt; + expect(firstNextRunAt).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(Date.parse(firstNextRunAt!) - Date.now()); + + const secondNextRunAt = (await service.status()).nextRunAt; + expect(secondNextRunAt).not.toBeNull(); + expect(secondNextRunAt).not.toBe(firstNextRunAt); + expect(Date.parse(secondNextRunAt!)).toBeGreaterThan(Date.parse(firstNextRunAt!)); + service.stop(); + } finally { + vi.useRealTimers(); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('does not report the fired schedule time as nextRunAt while a scheduled run is in flight', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 6, 7, 59, 0, 0)); + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const completion = new Promise(() => {}); + const service = new OrbitService(dataDir); + service.setRunHandler(async () => ({ + projectId: 'project-1', + agentRunId: 'agent-1', + completion, + })); + service.configure({ enabled: true, time: '08:00' }); + const firstNextRunAt = (await service.status()).nextRunAt; + expect(firstNextRunAt).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(Date.parse(firstNextRunAt!) - Date.now()); + + await expect(service.status()).resolves.toMatchObject({ + running: true, + nextRunAt: null, + }); + service.stop(); + } finally { + vi.useRealTimers(); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('sets connectorsChecked to the summed connector outcomes', async () => { + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const service = new OrbitService(dataDir); + service.setRunHandler(async () => ({ + projectId: 'project-1', + agentRunId: 'agent-1', + completion: Promise.resolve({ + agentRunId: 'agent-1', + status: 'succeeded', + }), + })); + + await service.start('manual'); + let status = await service.status(); + for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + status = await service.status(); + } + + expect(status.lastRun).not.toBeNull(); + expect(status.lastRun?.connectorsSucceeded).toBe(1); + expect(status.lastRun?.connectorsFailed).toBe(0); + expect(status.lastRun?.connectorsSkipped).toBe(0); + expect(status.lastRun?.connectorsChecked).toBe(1); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/daemon/tests/project-watchers.test.ts b/apps/daemon/tests/project-watchers.test.ts index cb652ab8f..3bc7640cc 100644 --- a/apps/daemon/tests/project-watchers.test.ts +++ b/apps/daemon/tests/project-watchers.test.ts @@ -22,6 +22,8 @@ function fakeFactory() { let factoryCloses = 0; +const FAST_WATCH_OPTIONS = { awaitWriteFinish: false }; + afterEach(async () => { await _resetForTests(); factoryCloses = 0; @@ -108,7 +110,7 @@ describe('project-watchers (real chokidar)', () => { it('emits file-changed events on add / change / unlink', async () => { const { root, projectId } = await makeProjectsRoot(); const events = []; - const sub = subscribe(root, projectId, (e) => events.push(e)); + const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS); await sub.ready; try { @@ -140,7 +142,7 @@ describe('project-watchers (real chokidar)', () => { await mkdir(path.join(projectsRoot, projectId, 'prototype'), { recursive: true }); const events = []; - const sub = subscribe(projectsRoot, projectId, (e) => events.push(e)); + const sub = subscribe(projectsRoot, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS); await sub.ready; try { @@ -159,7 +161,7 @@ describe('project-watchers (real chokidar)', () => { it('ignores files inside .od/ and node_modules/', async () => { const { root, projectId } = await makeProjectsRoot(); const events = []; - const sub = subscribe(root, projectId, (e) => events.push(e)); + const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS); await sub.ready; try { @@ -184,7 +186,7 @@ describe('project-watchers (real chokidar)', () => { it('ignores files inside Python venv and cache dirs', async () => { const { root, projectId } = await makeProjectsRoot(); const events = []; - const sub = subscribe(root, projectId, (e) => events.push(e)); + const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS); await sub.ready; const ignoredDirs = ['.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache', '.tox', '.ruff_cache']; @@ -215,7 +217,7 @@ describe('project-watchers (real chokidar)', () => { const { _internalWatcherForTests } = await import('../src/project-watchers.js'); const { root, projectId } = await makeProjectsRoot(); const events = []; - const sub = subscribe(root, projectId, (e) => events.push(e)); + const sub = subscribe(root, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS); await sub.ready; try { @@ -267,7 +269,7 @@ describe('project-watchers (chokidar options)', () => { } const events = []; - const sub = subscribe(dataRoot, projectId, (e) => events.push(e)); + const sub = subscribe(dataRoot, projectId, (e) => events.push(e), FAST_WATCH_OPTIONS); await sub.ready; try { diff --git a/apps/daemon/tests/prompts/system.test.ts b/apps/daemon/tests/prompts/system.test.ts index c4bc90eef..17e9b4ec0 100644 --- a/apps/daemon/tests/prompts/system.test.ts +++ b/apps/daemon/tests/prompts/system.test.ts @@ -16,9 +16,8 @@ const liveArtifactSkillBody = [ `> **Skill root (absolute):** \`${liveArtifactRoot}\``, '>', '> This skill ships side files alongside `SKILL.md`. When the workflow', - '> below references relative paths such as `assets/template.html` or', - '> `references/layouts.md`, resolve them against the skill root above and', - '> open them via their full absolute path.', + '> below references side files such as `references/artifact-schema.md`, resolve', + '> them against the skill root above and open them via their full absolute path.', '>', '> Known side files in this skill: `references/artifact-schema.md`, `references/connector-policy.md`, `references/refresh-contract.md`.', '', @@ -40,7 +39,9 @@ describe('composeSystemPrompt', () => { expect(prompt).toContain('## Active skill — live-artifact'); expect(prompt).toContain(`> **Skill root (absolute):** \`${liveArtifactRoot}\``); - expect(prompt).toContain('**Pre-flight (do this before any other tool):**'); + expect(prompt).not.toContain('**Pre-flight (do this before any other tool):** Read `assets/template.html`'); + expect(prompt).not.toContain('live-artifact/references/layouts.md'); + expect(prompt).not.toContain('live-artifact/assets/template.html'); expect(prompt).toContain('`references/artifact-schema.md`'); expect(prompt).toContain('`references/connector-policy.md`'); expect(prompt).toContain('`references/refresh-contract.md`'); diff --git a/apps/daemon/tests/skills.test.ts b/apps/daemon/tests/skills.test.ts index 8d434979f..c3afdfedf 100644 --- a/apps/daemon/tests/skills.test.ts +++ b/apps/daemon/tests/skills.test.ts @@ -76,6 +76,9 @@ describe('listSkills', () => { expect(skill.body).toContain('references/artifact-schema.md'); expect(skill.body).toContain('references/connector-policy.md'); expect(skill.body).toContain('references/refresh-contract.md'); + expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/live-artifact/references/artifact-schema.md`); + expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/live-artifact/assets/template.html`); + expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/live-artifact/references/layouts.md`); expect(skill.body).toContain('"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json'); expect(skill.body).toContain('do not ask “where should the data come from?” before checking daemon connector tools'); expect(skill.body).toContain('notion.notion_search'); @@ -172,6 +175,23 @@ describe('listSkills preamble', () => { expect(skill.body).toMatch(/Skill root \(relative to project\)/); }); + it('mentions root-level example.html side files in the preamble', async () => { + const root = fresh(); + writeSkill(root, 'orbit-style', { + withAttachments: false, + body: 'Open and mirror the shipped `example.html` before writing output.', + }); + writeFileSync(path.join(root, 'orbit-style', 'example.html'), '
example
'); + + const skills = await listSkills(root); + expect(skills).toHaveLength(1); + const [skill] = skills; + + expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/`); + expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/example.html`); + expect(skill.body).toContain('Known side files in this skill: `example.html`.'); + }); + it('uses the on-disk folder name in the alias path even when `name` differs', async () => { const root = fresh(); writeSkill(root, 'guizang-ppt', { diff --git a/apps/daemon/tests/tools-connectors-cli.test.ts b/apps/daemon/tests/tools-connectors-cli.test.ts new file mode 100644 index 000000000..9024f2312 --- /dev/null +++ b/apps/daemon/tests/tools-connectors-cli.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runConnectorsToolCli } from '../src/tools-connectors-cli.js'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('connectors tool CLI', () => { + let stdoutWrite: { mockRestore: () => void }; + let stderrWrite: { mockRestore: () => void }; + let stdoutOutput: string[]; + let stderrOutput: string[]; + let fetchMock: ReturnType; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + stdoutOutput = []; + stderrOutput = []; + stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutOutput.push(String(chunk)); + return true; + }); + stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + fetchMock = vi.fn(async () => new Response(JSON.stringify({ connectors: [] }), { headers: { 'Content-Type': 'application/json' }, status: 200 })); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + stdoutWrite.mockRestore(); + stderrWrite.mockRestore(); + process.env = ORIGINAL_ENV; + }); + + it('appends curated useCase query params for connector listing', async () => { + process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456/base/'; + process.env.OD_TOOL_TOKEN = 'agent-run-token'; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ connectors: [] }), { headers: { 'Content-Type': 'application/json' }, status: 200 })); + + const result = await runConnectorsToolCli(['list', '--use-case', 'personal_daily_digest']); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:7456/base/api/tools/connectors/list?useCase=personal_daily_digest', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: 'Bearer agent-run-token' }), + }), + ); + }); + + it('includes curation in compact connector output', async () => { + process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456'; + process.env.OD_TOOL_TOKEN = 'agent-run-token'; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + connectors: [{ + id: 'slack', + name: 'Slack', + provider: 'composio', + category: 'Communication', + status: 'connected', + tools: [{ + name: 'slack.slack_list_channels', + description: 'List Slack channels', + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only' }, + curation: { useCases: ['personal_daily_digest'], reason: 'Digest source' }, + }], + }], + }), { headers: { 'Content-Type': 'application/json' }, status: 200 })); + + const result = await runConnectorsToolCli(['list']); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(stdoutOutput.join(''))).toEqual({ + ok: true, + connectors: [{ + id: 'slack', + name: 'Slack', + provider: 'composio', + category: 'Communication', + status: 'connected', + accountLabel: undefined, + tools: [{ + name: 'slack.slack_list_channels', + description: 'List Slack channels', + safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only' }, + curation: { useCases: ['personal_daily_digest'], reason: 'Digest source' }, + inputSchema: undefined, + }], + }], + }); + expect(stderrOutput.join('')).toBe(''); + }); +}); diff --git a/apps/web/app/[[...slug]]/page.tsx b/apps/web/app/[[...slug]]/page.tsx index 15e48ec47..0cf4832ba 100644 --- a/apps/web/app/[[...slug]]/page.tsx +++ b/apps/web/app/[[...slug]]/page.tsx @@ -7,11 +7,10 @@ import { ClientApp } from './client-app'; // // For `output: 'export'` we return a single empty `slug` so Next.js emits // one shell HTML at out/index.html; the daemon's SPA fallback (see -// apps/daemon/src/server.ts) serves it for any unknown non-API path so deep links -// still hydrate to the right view. In dev we leave `dynamicParams` at its -// default (true) so `next dev` happily renders /projects/ directly. +// apps/daemon/src/server.ts) serves it for any unknown non-API path so deep +// links still hydrate to the right view. export function generateStaticParams() { - return [{ slug: [] as string[] }]; + return [{ slug: [] }]; } export default function Page() { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fedfe5130..611838dae 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { EntryView } from './components/EntryView'; import type { CreateInput } from './components/NewProjectPanel'; import { PetOverlay } from './components/pet/PetOverlay'; @@ -51,6 +51,13 @@ import type { SkillSummary, } from './types'; +export function shouldSyncMediaProvidersOnSave( + mediaProviders: AppConfig['mediaProviders'], + options?: { force?: boolean }, +): boolean { + return Boolean(options?.force) || hasAnyConfiguredProvider(mediaProviders); +} + function normalizeSavedComposioConfig(config: AppConfig['composio']): AppConfig['composio'] { const apiKey = config?.apiKey?.trim() ?? ''; if (apiKey) { @@ -64,8 +71,47 @@ function normalizeSavedComposioConfig(config: AppConfig['composio']): AppConfig[ return { ...(config ?? {}) }; } +export async function persistComposioConfigChange( + current: AppConfig, + composio: AppConfig['composio'], + sync: (config: AppConfig['composio']) => Promise = syncComposioConfigToDaemon, +): Promise { + const saved = await sync(composio); + if (!saved) throw new Error('Composio config save failed'); + return { + ...current, + composio: normalizeSavedComposioConfig(composio), + }; +} + +export function buildPersistedConfig(next: AppConfig, current: AppConfig): AppConfig { + return { + ...next, + onboardingCompleted: current.onboardingCompleted ? true : next.onboardingCompleted, + composio: next.composio + ? { + apiKey: '', + apiKeyConfigured: Boolean(next.composio.apiKeyConfigured), + apiKeyTail: next.composio.apiKeyTail ?? '', + } + : next.composio, + }; +} + +export function resolveSettingsCloseConfig( + rendered: AppConfig, + latestPersisted: AppConfig, +): AppConfig { + const base = latestPersisted === rendered ? rendered : latestPersisted; + return base.onboardingCompleted ? base : { ...base, onboardingCompleted: true }; +} + export function App() { const [config, setConfig] = useState(() => loadConfig()); + const configRef = useRef(config); + configRef.current = config; + const latestPersistedConfigRef = useRef(config); + latestPersistedConfigRef.current = config; const [settingsOpen, setSettingsOpen] = useState(false); const [settingsWelcome, setSettingsWelcome] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState('execution'); @@ -85,6 +131,15 @@ export function App() { // fetches. The entry view uses this to show shimmer / skeleton states // instead of an "empty" page that flickers before data lands. const [bootstrapping, setBootstrapping] = useState(true); + // Narrower flag dedicated to the Composio API key hydration. The key is + // persisted by the daemon (and only reflected back via apiKeyConfigured + // + apiKeyTail), so after a dev-server restart there is a window where + // the dialog can render an empty Composio input even though a saved key + // exists. Settings → Connectors uses this to render a skeleton over the + // input + buttons instead of an empty input that the user might + // mistake for "no key saved" — and to disable Save/Clear so a misclick + // can't overwrite the saved state with `''` before hydration lands. + const [composioConfigLoading, setComposioConfigLoading] = useState(true); const route = useRoute(); // Sync theme preference to the element so CSS variables pick it up. @@ -200,6 +255,12 @@ export function App() { return next; }); setBootstrapping(false); + // Composio hydration is part of the same Promise.all above — by the + // time we land here either the daemon returned the saved-key shape + // (apiKeyConfigured + tail) or the daemon was offline and we kept + // whatever localStorage held. Either way it is safe to drop the + // skeleton: the input now reflects the source of truth. + setComposioConfigLoading(false); })(); return () => { cancelled = true; @@ -244,30 +305,58 @@ export function App() { setTemplates(list); }, []); - const handleConfigSave = useCallback(async (next: AppConfig, closeModal: boolean = true) => { - // Only sync Composio key to the daemon when it actually changed, - // so unrelated saves (theme, model, etc.) are never blocked. - const composioChanged = - next.composio?.apiKey !== config.composio?.apiKey || - next.composio?.apiKeyConfigured !== config.composio?.apiKeyConfigured; - if (composioChanged) { - const ok = await syncComposioConfigToDaemon(next.composio); - if (!ok) return { success: false }; - } - const withOnboarding: AppConfig = { - ...next, - composio: normalizeSavedComposioConfig(next.composio), - onboardingCompleted: true, - }; - saveConfig(withOnboarding); - void syncMediaProvidersToDaemon(withOnboarding.mediaProviders, { - force: true, - }); - void syncConfigToDaemon(withOnboarding); - setConfig(withOnboarding); - if (closeModal) setSettingsOpen(false); - return { success: true }; - }, [config]); + /** + * Autosave-driven persistence path. The settings dialog calls this on + * every committed edit (via a debounced effect) so localStorage and + * the daemon stay in lock-step with the user's draft. We deliberately + * do NOT touch the Composio secret here — it has its own gesture + * (handleConfigPersistComposioKey) so partial keys never leave the + * browser. Onboarding is also left alone; the dialog's close path + * is the canonical "I'm done" signal. + */ + const handleConfigPersist = useCallback(async ( + next: AppConfig, + options?: { forceMediaProviderSync?: boolean }, + ) => { + // Strip the in-flight Composio secret before anything hits disk so + // a half-typed key can't survive in localStorage. If the dialog is + // closing, preserve any onboarding completion that the close gesture + // already committed so an unmount autosave cannot re-open the welcome flow. + const persisted = buildPersistedConfig(next, configRef.current); + latestPersistedConfigRef.current = persisted; + saveConfig(persisted); + setConfig(persisted); + await Promise.all([ + shouldSyncMediaProvidersOnSave(persisted.mediaProviders, { + force: options?.forceMediaProviderSync, + }) + ? syncMediaProvidersToDaemon(persisted.mediaProviders, { + force: options?.forceMediaProviderSync, + throwOnError: options?.forceMediaProviderSync, + }) + : Promise.resolve(), + syncConfigToDaemon(persisted), + ]); + }, []); + + /** + * Explicit Composio API-key save. Called from the section-local + * "Save key" button so secrets never ride the autosave keystroke + * loop. Once the daemon confirms, we normalize the saved config + * (strip the secret, store apiKeyConfigured + apiKeyTail) and feed + * it back into local state so the saved-key badge appears. + */ + const handleConfigPersistComposioKey = useCallback( + async (composio: AppConfig['composio']) => { + const next = await persistComposioConfigChange(config, composio); + setConfig((curr) => { + const merged: AppConfig = { ...curr, composio: next.composio }; + saveConfig(merged); + return merged; + }); + }, + [config], + ); const handleModeChange = useCallback( (mode: AppConfig['mode']) => { @@ -596,14 +685,18 @@ export function App() { appVersionInfo={appVersionInfo} welcome={settingsWelcome} initialSection={settingsInitialSection} - onSave={handleConfigSave} + composioConfigLoading={composioConfigLoading} + onPersist={handleConfigPersist} + onPersistComposioKey={handleConfigPersistComposioKey} onClose={() => { - // Dismissing the welcome modal (Skip for now / backdrop click) - // also counts as onboarding-done; we don't want to keep - // re-prompting on every refresh just because the user opted - // not to save. - if (settingsWelcome && !config.onboardingCompleted) { - const next: AppConfig = { ...config, onboardingCompleted: true }; + // Closing the dialog is the canonical "I'm done" gesture + // now that there is no global Save button. We mark + // onboardingCompleted on close so the welcome modal stops + // re-prompting on every refresh, regardless of whether + // the user changed anything during the session. + const next = resolveSettingsCloseConfig(config, latestPersistedConfigRef.current); + if (!next.onboardingCompleted || !config.onboardingCompleted) { + latestPersistedConfigRef.current = next; saveConfig(next); void syncConfigToDaemon(next); setConfig(next); diff --git a/apps/web/src/components/ConnectorsBrowser.tsx b/apps/web/src/components/ConnectorsBrowser.tsx new file mode 100644 index 000000000..3c850c137 --- /dev/null +++ b/apps/web/src/components/ConnectorsBrowser.tsx @@ -0,0 +1,1080 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type SyntheticEvent, +} from 'react'; +import type { ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts'; +import { useT } from '../i18n'; +import type { Dict } from '../i18n/types'; +import { + connectConnector, + disconnectConnector, + fetchConnectorDiscovery, + fetchConnectors, + fetchConnectorStatuses, +} from '../providers/registry'; +import { + isTrustedConnectorCallbackOrigin, + sortConnectorsForSearch, +} from './EntryView'; +import { Icon } from './Icon'; +import { CenteredLoader } from './Loading'; + +const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected'; + +const COMPOSIO_LOGO_SLUG_OVERRIDES: Record = { + google_drive: 'googledrive', +}; + +/** + * Composio publishes per-toolkit logos at `logos.composio.dev`, keyed by the + * lowercased toolkit slug (`AIRTABLE` → `airtable`, `ZOHO_BOOKS` → + * `zoho_books`). Our connector ids are mostly already that shape. A small + * override map handles CDN exceptions such as Google Drive, whose logo slug + * is `googledrive` even though the toolkit id remains `google_drive`. + */ +function composioLogoSlug(connector: ConnectorDetail): string { + const normalized = connector.id.toLowerCase().replace(/[^a-z0-9_]/g, ''); + return COMPOSIO_LOGO_SLUG_OVERRIDES[normalized] ?? normalized; +} + +/** + * Build the Composio logo URL for a given connector + theme. Returns `null` + * when the slug normalizes to empty so the fallback tile renders without a + * pointless 404 round trip. + */ +function composioLogoUrl( + connector: ConnectorDetail, + theme: 'light' | 'dark', +): string | null { + const slug = composioLogoSlug(connector); + if (!slug) return null; + return `/api/connectors/logos/${encodeURIComponent(slug)}?theme=${theme}`; +} + +/** + * Resolve the live theme from ``, falling back to the OS + * preference when the user is on the implicit "system" mode (no attribute + * set). Lightweight on purpose — the color of an icon doesn't deserve a + * full theme provider/context here. The hook listens for both the data + * attribute changing and the OS-level `prefers-color-scheme` toggling so + * the logo stays in lockstep with the rest of the chrome. + */ +function useResolvedTheme(): 'light' | 'dark' { + const read = (): 'light' | 'dark' => { + if (typeof document === 'undefined') return 'dark'; + const attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'light' || attr === 'dark') return attr; + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'dark'; + }; + const [theme, setTheme] = useState<'light' | 'dark'>(read); + useEffect(() => { + const update = () => setTheme(read()); + update(); + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + const media = window.matchMedia?.('(prefers-color-scheme: dark)'); + media?.addEventListener?.('change', update); + return () => { + observer.disconnect(); + media?.removeEventListener?.('change', update); + }; + }, []); + return theme; +} + +/** + * Tiny hash → palette index. Stable across reloads so a connector's + * fallback tile keeps the same hue, which makes the catalog feel coherent + * even when many logos are missing (e.g. dev fixtures, network blocked). + */ +function fallbackPaletteIndex(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 31 + seed.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 6; +} + +function fallbackInitials(name: string): string { + const cleaned = name.trim(); + if (!cleaned) return '?'; + const parts = cleaned.split(/\s+/u); + if (parts.length === 1) { + const single = parts[0]!; + return (single[0] ?? '').toUpperCase() + (single[1] ?? '').toLowerCase(); + } + const first = parts[0]?.[0] ?? ''; + const second = parts[1]?.[0] ?? ''; + return (first + second).toUpperCase(); +} + +/** + * Connector brand mark. Tries the Composio logo CDN first (theme-aware) and + * gracefully degrades to a colored initials tile if the request fails or no + * slug is derivable. Decorative by default — the surrounding caption (card + * title / drawer heading) is the accessible label, so the image carries an + * empty alt and `aria-hidden="true"`. + */ +function ConnectorLogo({ + connector, + theme, + size = 'sm', +}: { + connector: ConnectorDetail; + theme: 'light' | 'dark'; + /** `sm` for catalog cards (compact 28px), `lg` for the detail drawer mark (44px). */ + size?: 'sm' | 'lg'; +}) { + const url = composioLogoUrl(connector, theme); + const imageRef = useRef(null); + // Track load state per (connector, theme, size) instance. Resetting on + // url change means switching themes mid-session retries the new URL + // instead of being stuck on a previously-failed request. + const [state, setState] = useState<'pending' | 'loaded' | 'error'>( + url ? 'pending' : 'error', + ); + useEffect(() => { + if (!url) { + setState('error'); + return; + } + setState('pending'); + const image = imageRef.current; + // Some browsers can complete tiny cached SVGs before React's onLoad + // listener observes the event. The image is visually available, but the + // wrapper stays in `state-pending`, leaving the neutral fallback over it. + // Reconcile against the DOM image state after mount/theme changes so + // cached logos still promote to the visible loaded state. + if (image?.complete) { + setState(image.naturalWidth > 0 ? 'loaded' : 'error'); + } + }, [url]); + const initials = fallbackInitials(connector.name); + const palette = fallbackPaletteIndex(connector.id || connector.name); + const showImage = url !== null && state !== 'error'; + return ( + + ); +} + +function mergeConnectors(current: ConnectorDetail[], incoming: ConnectorDetail[]): ConnectorDetail[] { + if (current.length === 0) return incoming; + const incomingById = new Map(incoming.map((connector) => [connector.id, connector])); + const merged = current.map((connector) => { + const next = incomingById.get(connector.id); + if (!next) return connector; + return { + ...connector, + ...next, + tools: next.tools.length > 0 ? next.tools : connector.tools, + }; + }); + const currentIds = new Set(current.map((connector) => connector.id)); + for (const connector of incoming) { + if (!currentIds.has(connector.id)) merged.push(connector); + } + return merged; +} + +function applyConnectorStatuses( + current: ConnectorDetail[], + statuses: ConnectorStatusResponse['statuses'], +): ConnectorDetail[] { + if (Object.keys(statuses).length === 0) return current; + return current.map((connector) => { + const next = statuses[connector.id]; + if (!next) return connector; + const { accountLabel: _accountLabel, lastError: _lastError, ...base } = connector; + return { ...base, ...next }; + }); +} + +interface ConnectorsBrowserProps { + composioConfigured: boolean; + catalogRefreshKey?: string | number; +} + +/** + * Connector cards + search, lifted out of the entry-view top tab so it can + * live under Settings → Connectors. Owns its own data lifecycle: fetches the + * catalog on mount, lazily enriches with Composio discovery when the user + * actually opens the surface, and rehydrates statuses on window focus and + * OAuth callback messages. + */ +/** + * Provider tab definition. Today this is just Composio, but the surface is + * structured as a list-of-tabs because the next provider integration (e.g. + * a self-hosted MCP registry) is expected to drop in here without rework. + * + * `match` decides whether a given catalog entry belongs to this provider: + * the entry's `auth.provider` is the source of truth, falling back to the + * lowercased display `provider` for catalog rows that don't carry an auth + * payload yet. + */ +const PROVIDER_TABS: ReadonlyArray<{ + id: string; + label: string; + match: (connector: ConnectorDetail) => boolean; +}> = [ + { + id: 'composio', + label: 'Composio', + match: (connector) => { + const provider = connector.auth?.provider ?? connector.provider.toLowerCase(); + return provider === 'composio'; + }, + }, +]; + +const DEFAULT_PROVIDER_TAB_ID = 'composio'; + +const CONNECTOR_CATEGORY_KEYS = { + 'accounting': 'connectors.category.accounting', + 'admin': 'connectors.category.admin', + 'ads & conversion': 'connectors.category.advertising', + 'advertising': 'connectors.category.advertising', + 'ai agents': 'connectors.category.aiAgents', + 'ai chatbots': 'connectors.category.aiAgents', + 'ai infrastructure': 'connectors.category.aiInfrastructure', + 'ai meeting assistants': 'connectors.category.meetings', + 'analytics': 'connectors.category.analytics', + 'artificial intelligence': 'connectors.category.aiAgents', + 'automation': 'connectors.category.automation', + 'bookmark managers': 'connectors.category.personal', + 'calendar': 'connectors.category.calendar', + 'cms': 'connectors.category.cms', + 'code': 'connectors.category.developer', + 'commerce': 'connectors.category.commerce', + 'communication': 'connectors.category.communication', + 'connectors': 'connectors.category.integration', + 'contacts': 'connectors.category.contacts', + 'crm': 'connectors.category.crm', + 'customer support': 'connectors.category.support', + 'data platform': 'connectors.category.dataPlatform', + 'database': 'connectors.category.database', + 'databases': 'connectors.category.database', + 'design': 'connectors.category.design', + 'developer': 'connectors.category.developer', + 'developer tools': 'connectors.category.developer', + 'documents': 'connectors.category.documentation', + 'documentation': 'connectors.category.documentation', + 'ecommerce': 'connectors.category.commerce', + 'education': 'connectors.category.education', + 'email': 'connectors.category.email', + 'email newsletters': 'connectors.category.email', + 'erp': 'connectors.category.erp', + 'electronics': 'connectors.category.commerce', + 'events': 'connectors.category.events', + 'event management': 'connectors.category.events', + 'example': 'connectors.category.integration', + 'feedback': 'connectors.category.surveys', + 'field service': 'connectors.category.fieldService', + 'file management & storage': 'connectors.category.storage', + 'finance': 'connectors.category.finance', + 'fitness': 'connectors.category.fitness', + 'forms': 'connectors.category.forms', + 'forms & surveys': 'connectors.category.forms', + 'fundraising': 'connectors.category.nonprofit', + 'gaming': 'connectors.category.gaming', + 'hospitality': 'connectors.category.hospitality', + 'hr': 'connectors.category.hr', + 'hr talent & recruitment': 'connectors.category.recruiting', + 'human resources': 'connectors.category.hr', + 'images & design': 'connectors.category.design', + 'important': 'connectors.category.integration', + 'integration': 'connectors.category.integration', + 'itsm': 'connectors.category.itsm', + 'it operations': 'connectors.category.itsm', + 'localization': 'connectors.category.localization', + 'logistics': 'connectors.category.logistics', + 'maps': 'connectors.category.maps', + 'marketing': 'connectors.category.marketing', + 'marketing automation': 'connectors.category.marketing', + 'media': 'connectors.category.media', + 'meetings': 'connectors.category.meetings', + 'model context protocol': 'connectors.category.developer', + 'news & lifestyle': 'connectors.category.media', + 'nonprofit': 'connectors.category.nonprofit', + 'notes': 'connectors.category.documentation', + 'notifications': 'connectors.category.communication', + 'observability': 'connectors.category.observability', + 'online courses': 'connectors.category.education', + 'payments': 'connectors.category.payments', + 'payment processing': 'connectors.category.payments', + 'personal': 'connectors.category.personal', + 'phone & sms': 'connectors.category.communication', + 'presentations': 'connectors.category.presentations', + 'premium': 'connectors.category.integration', + 'procurement': 'connectors.category.procurement', + 'product': 'connectors.category.product', + 'product management': 'connectors.category.product', + 'productivity': 'connectors.category.productivity', + 'productivity & project management': 'connectors.category.projectManagement', + 'project management': 'connectors.category.projectManagement', + 'proposal & invoice management': 'connectors.category.accounting', + 'recruiting': 'connectors.category.recruiting', + 'research': 'connectors.category.research', + 'sales': 'connectors.category.salesIntelligence', + 'sales intelligence': 'connectors.category.salesIntelligence', + 'scheduling': 'connectors.category.scheduling', + 'scheduling & booking': 'connectors.category.scheduling', + 'search': 'connectors.category.search', + 'security': 'connectors.category.security', + 'security & identity tools': 'connectors.category.security', + 'server monitoring': 'connectors.category.observability', + 'signing': 'connectors.category.signing', + 'signatures': 'connectors.category.signing', + 'social': 'connectors.category.social', + 'social media accounts': 'connectors.category.social', + 'social media marketing': 'connectors.category.marketing', + 'spreadsheets': 'connectors.category.spreadsheets', + 'storage': 'connectors.category.storage', + 'support': 'connectors.category.support', + 'surveys': 'connectors.category.surveys', + 'task management': 'connectors.category.tasks', + 'tasks': 'connectors.category.tasks', + 'team chat': 'connectors.category.communication', + 'team collaboration': 'connectors.category.communication', + 'time tracking': 'connectors.category.timeTracking', + 'time tracking software': 'connectors.category.timeTracking', + 'url shortener': 'connectors.category.marketing', + 'video': 'connectors.category.video', + 'video & audio': 'connectors.category.video', + 'video conferencing': 'connectors.category.meetings', + 'website builders': 'connectors.category.cms', + 'whiteboard': 'connectors.category.whiteboard', +} as const satisfies Record; + +export function ConnectorsBrowser({ + composioConfigured, + catalogRefreshKey = 0, +}: ConnectorsBrowserProps) { + const t = useT(); + const [connectors, setConnectors] = useState([]); + const [loading, setLoading] = useState(true); + const [toolsLoading, setToolsLoading] = useState(false); + const [toolsLoaded, setToolsLoaded] = useState(false); + const [pendingConnectorAction, setPendingConnectorAction] = useState<{ + connectorId: string; + action: 'connect' | 'disconnect'; + } | null>(null); + const [detailConnectorId, setDetailConnectorId] = useState(null); + const [filter, setFilter] = useState(''); + const [selectedProvider, setSelectedProvider] = useState(DEFAULT_PROVIDER_TAB_ID); + const searchInputRef = useRef(null); + const logoTheme = useResolvedTheme(); + + const reloadConnectorStatuses = useCallback(async () => { + const statuses = await fetchConnectorStatuses(); + setConnectors((curr) => applyConnectorStatuses(curr, statuses)); + }, []); + + // Initial catalog fetch — always loads the lightweight registry payload so + // already-configured connectors render immediately. + useEffect(() => { + let cancelled = false; + setLoading(true); + setToolsLoaded(false); + (async () => { + const next = await fetchConnectors(); + if (cancelled) return; + setConnectors((curr) => mergeConnectors(curr, next)); + setLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [composioConfigured, catalogRefreshKey]); + + // Lazy Composio discovery — enriched toolkit metadata + auth configuration. + // Heavier round trip; only worth it once a Composio API key is actually + // saved. Before that, discovery returns no live tools and the web-side + // provider cache can otherwise keep those empty tool lists after Save key. + useEffect(() => { + if (!composioConfigured) { + setToolsLoaded(false); + setToolsLoading(false); + return; + } + if (toolsLoaded) return; + + let cancelled = false; + setToolsLoading(true); + (async () => { + const next = await fetchConnectorDiscovery({ refresh: true }); + if (cancelled) return; + setConnectors((curr) => mergeConnectors(curr, next)); + setToolsLoaded(true); + setToolsLoading(false); + })(); + return () => { + cancelled = true; + setToolsLoading(false); + }; + }, [composioConfigured, catalogRefreshKey, toolsLoaded]); + + // OAuth callback: a popup or system-browser tab postMessages back when an + // auth flow completes. Trust same-origin + localhost-loopback so packaged + // dev URLs (different ports) keep working. + useEffect(() => { + function onMessage(event: MessageEvent) { + const data = event.data; + if ( + !data || + typeof data !== 'object' || + (data as { type?: unknown }).type !== CONNECTOR_CALLBACK_MESSAGE_TYPE + ) + return; + if (!isTrustedConnectorCallbackOrigin(event.origin)) return; + void reloadConnectorStatuses(); + } + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [reloadConnectorStatuses]); + + // System-browser auth flows have no opener to post back to; refresh + // whenever the window regains focus so the UI catches up silently. + useEffect(() => { + function onFocus() { + void reloadConnectorStatuses(); + } + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [reloadConnectorStatuses]); + + // The local Composio API-key state is authoritative for masking. Cached + // connector auth can be stale immediately after the user clears the key. + const needsComposioKey = !composioConfigured; + + // Filter and rank connectors by user-visible fields. Exact/prefix matches + // on connector name/provider are strongest; broad description matches stay + // searchable but are down-ranked. The provider tab restricts the catalog + // to a single backing provider before search runs so result rankings stay + // tab-local. + const providerScopedConnectors = useMemo(() => { + const tab = + PROVIDER_TABS.find((p) => p.id === selectedProvider) ?? + PROVIDER_TABS.find((p) => p.id === DEFAULT_PROVIDER_TAB_ID); + if (!tab) return connectors; + return connectors.filter((connector) => tab.match(connector)); + }, [connectors, selectedProvider]); + + const filteredConnectors = useMemo(() => { + return sortConnectorsForSearch(providerScopedConnectors, filter); + }, [providerScopedConnectors, filter]); + + const hasQuery = filter.trim().length > 0; + const hasNoResults = hasQuery && filteredConnectors.length === 0; + + function updateConnector(next: ConnectorDetail | null) { + if (!next) return; + setConnectors((curr) => curr.map((connector) => (connector.id === next.id ? next : connector))); + } + + async function runConnectorAction(connectorId: string, action: 'connect' | 'disconnect') { + if (pendingConnectorAction) return; + setPendingConnectorAction({ connectorId, action }); + try { + if (action === 'connect') { + const result = await connectConnector(connectorId); + updateConnector(result.connector); + } else { + updateConnector(await disconnectConnector(connectorId)); + } + } finally { + setPendingConnectorAction(null); + } + } + + const detailConnector = useMemo( + () => (detailConnectorId ? connectors.find((c) => c.id === detailConnectorId) ?? null : null), + [detailConnectorId, connectors], + ); + + return ( +
+
+
+
+

{t('connectors.title')}

+

{t('connectors.subtitle')}

+
+
+
+
+ {PROVIDER_TABS.map((provider) => { + const active = provider.id === selectedProvider; + return ( + + ); + })} +
+
+ + + + setFilter(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape' && filter) { + event.preventDefault(); + event.stopPropagation(); + setFilter(''); + } + }} + placeholder={t('connectors.searchPlaceholder')} + aria-label={t('connectors.searchAriaLabel')} + disabled={needsComposioKey} + data-testid="connectors-search-input" + /> + {hasQuery ? ( + + ) : null} +
+
+
+ {loading ? ( + + ) : ( +
+ {hasNoResults && !needsComposioKey ? ( +
+

+ {t('connectors.emptyNoMatchTitle', { query: filter.trim() })} +

+

{t('connectors.emptyNoMatchBody')}

+ +
+ ) : ( +
+ {filteredConnectors.map((connector) => ( + runConnectorAction(connectorId, 'connect')} + onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')} + onOpenDetails={(connectorId) => setDetailConnectorId(connectorId)} + /> + ))} +
+ )} + {needsComposioKey ? ( +
+
+
+ +
+

{t('connectors.gateTitle')}

+

{t('connectors.gateBody')}

+
+
+ ) : null} +
+ )} + {detailConnector ? ( + setDetailConnectorId(null)} + onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')} + onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')} + /> + ) : null} +
+ ); +} + +function ConnectorCard({ + connector, + disabled = false, + pendingAction, + toolsLoading: _toolsLoading, + toolsLoaded, + logoTheme, + onConnect, + onDisconnect, + onOpenDetails, +}: { + connector: ConnectorDetail; + disabled?: boolean; + pendingAction: 'connect' | 'disconnect' | null; + toolsLoading: boolean; + toolsLoaded: boolean; + logoTheme: 'light' | 'dark'; + onConnect: (connectorId: string) => Promise | void; + onDisconnect: (connectorId: string) => Promise | void; + onOpenDetails: (connectorId: string) => void; +}) { + const t = useT(); + const isConnecting = pendingAction === 'connect'; + const isDisconnecting = pendingAction === 'disconnect'; + const isPending = pendingAction !== null; + const isConnected = connector.status === 'connected'; + const canConnect = !disabled && !isPending && connector.status === 'available'; + const canDisconnect = !disabled && !isPending && isConnected; + const toolCount = connector.tools.length; + const showToolsBadge = toolsLoaded && toolCount > 0; + const toolsBadgeLabel = formatToolsBadge(toolCount, t); + const categoryLabel = connectorCategoryLabel(connector.category, t); + + function openDetails() { + if (disabled) return; + onOpenDetails(connector.id); + } + + function onKeyActivate(event: ReactKeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') return; + if (event.target !== event.currentTarget) return; + event.preventDefault(); + openDetails(); + } + + function stop(event: SyntheticEvent) { + event.stopPropagation(); + } + + return ( +
+
+ +
+ {/* Title row composes the connector name with an inline + connection dot when applicable, instead of putting the + dot in the action column. The dot now reads as a small + "live status" indicator anchored to the brand label, + while the action column is reserved purely for the + connect/disconnect controls and any error/disabled + status chips. The name span carries the ellipsis so a + long brand never crowds the dot out of the row. */} +

+ {connector.name} + {isConnected ? ( + + ) : null} +

+ {/* Two-row meta block. Splitting category and tools-badge onto + their own rows keeps card heights deterministic — long + category labels no longer push the badge to a new line in + an unpredictable way, and the tools-badge slot reserves + its row even before the async discovery resolves so the + card doesn't grow when the badge appears. The category + row truncates with ellipsis (one line); the badge row is + a fixed-height anchor that the badge animates into. */} +
+ + {categoryLabel} + + + {showToolsBadge ? ( + + {toolsBadgeLabel} + + ) : null} + +
+
+
+ {isConnected ? ( + + ) : ( + + )} + {connector.status === 'error' || connector.status === 'disabled' ? ( + + {statusLabel(connector.status, t)} + + ) : null} +
+
+
+ ); +} + +function statusLabel(status: ConnectorDetail['status'], t: ReturnType): string { + switch (status) { + case 'available': + return t('connectors.statusAvailable'); + case 'connected': + return t('connectors.statusConnected'); + case 'error': + return t('connectors.statusError'); + case 'disabled': + return t('connectors.statusDisabled'); + } +} + +function connectorCategoryLabel(category: string, t: ReturnType): string { + const normalized = category.trim().toLowerCase(); + const key = CONNECTOR_CATEGORY_KEYS[normalized as keyof typeof CONNECTOR_CATEGORY_KEYS]; + return key ? t(key) : category; +} + +function formatToolsBadge(count: number, t: ReturnType): string { + if (count === 0) return t('connectors.toolsBadgeNone'); + if (count === 1) return t('connectors.toolsBadgeOne', { n: count }); + return t('connectors.toolsBadgeMany', { n: count }); +} + +function ConnectorDetailDrawer({ + connector, + disabled, + pendingAction, + toolsLoading, + toolsLoaded, + logoTheme, + onClose, + onConnect, + onDisconnect, +}: { + connector: ConnectorDetail; + disabled: boolean; + pendingAction: 'connect' | 'disconnect' | null; + toolsLoading: boolean; + toolsLoaded: boolean; + logoTheme: 'light' | 'dark'; + onClose: () => void; + onConnect: (connectorId: string) => Promise | void; + onDisconnect: (connectorId: string) => Promise | void; +}) { + const t = useT(); + const isConnected = connector.status === 'connected'; + const isConnecting = pendingAction === 'connect'; + const isDisconnecting = pendingAction === 'disconnect'; + const isPending = pendingAction !== null; + const canConnect = !disabled && !isPending && connector.status === 'available'; + const canDisconnect = !disabled && !isPending && isConnected; + const accountLabel = getDisplayableConnectorAccountLabel(connector); + const toolCount = connector.tools.length; + const isLoadingTools = !toolsLoaded || (toolsLoading && toolCount === 0); + const showToolsBadge = toolsLoaded && toolCount > 0; + const closeBtnRef = useRef(null); + const categoryLabel = connectorCategoryLabel(connector.category, t); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + } + document.addEventListener('keydown', onKey); + closeBtnRef.current?.focus(); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = previousOverflow; + }; + }, [onClose]); + + const statusTone = connector.status; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + +
+ ); +} + +function getDisplayableConnectorAccountLabel(connector: ConnectorDetail): string | undefined { + if (!connector.accountLabel) return undefined; + const provider = connector.auth?.provider ?? connector.provider.toLowerCase(); + if (provider === 'composio') return undefined; + return connector.accountLabel; +} diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index 6ca003d41..2e6cdc828 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -304,7 +304,7 @@ export function DesignFilesPanel({ key={artifact.artifactId} type="button" data-testid={`design-file-row-${artifact.tabId}`} - className="df-row" + className="df-row df-row-live-artifact" onDoubleClick={() => onOpenLiveArtifact(artifact.tabId)} onClick={() => onOpenLiveArtifact(artifact.tabId)} > diff --git a/apps/web/src/components/DesignsTab.tsx b/apps/web/src/components/DesignsTab.tsx index 83847681a..432d55b78 100644 --- a/apps/web/src/components/DesignsTab.tsx +++ b/apps/web/src/components/DesignsTab.tsx @@ -114,12 +114,20 @@ export function DesignsTab({ const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); - let list: DesignListItem[] = projects.map((project) => ({ - type: "project", - project, - updatedAt: project.updatedAt, - createdAt: project.createdAt, - })); + let list: DesignListItem[] = projects + .filter( + (project) => + !shouldHideProjectCard( + project, + liveArtifactsByProject[project.id] ?? [], + ), + ) + .map((project) => ({ + type: "project", + project, + updatedAt: project.updatedAt, + createdAt: project.createdAt, + })); const liveItems = projects.flatMap((project) => (liveArtifactsByProject[project.id] ?? []).map((liveArtifact) => ({ @@ -257,6 +265,8 @@ export function DesignsTab({ const ds = dsName(p.designSystemId); if (item.type === "live-artifact") { const artifact = item.liveArtifact; + const title = liveArtifactCardTitle(p, artifact); + const metaLead = liveArtifactCardMetaLead(p, artifact); return (
-
- {artifact.title} +
+ {title}
- {p.name} + {metaLead} {" · "} {artifactStatusLabel( artifact.status, @@ -506,3 +516,25 @@ function artifactStatusLabel( if (refreshStatus === "succeeded") return t("designs.statusRefreshed"); return t("designs.statusLive"); } + +function shouldHideProjectCard(project: Project, liveArtifacts: LiveArtifactSummary[]): boolean { + if (liveArtifacts.length === 0) return false; + return project.skillId === 'live-artifact' && isOrbitProject(project); +} + +function liveArtifactCardTitle(project: Project, liveArtifact: LiveArtifactSummary): string { + return isCollapsedOrbitArtifactProject(project) ? project.name : liveArtifact.title; +} + +function liveArtifactCardMetaLead(project: Project, liveArtifact: LiveArtifactSummary): string { + return isCollapsedOrbitArtifactProject(project) ? liveArtifact.title : project.name; +} + +function isCollapsedOrbitArtifactProject(project: Project): boolean { + return project.skillId === 'live-artifact' && isOrbitProject(project); +} + +function isOrbitProject(project: Project): boolean { + const metadata = project.metadata as { kind?: unknown } | undefined; + return metadata?.kind === 'orbit'; +} diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index b7929aba8..e1bce0a61 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type SyntheticEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts'; import { useT } from '../i18n'; import { @@ -27,9 +27,6 @@ import { LanguageMenu } from './LanguageMenu'; import { CenteredLoader } from './Loading'; import { NewProjectPanel, type CreateInput } from './NewProjectPanel'; import { - connectConnector, - disconnectConnector, - fetchConnectorDiscovery, fetchConnectors, fetchConnectorStatuses, } from '../providers/registry'; @@ -38,7 +35,7 @@ import { PromptTemplatePreviewModal } from './PromptTemplatePreviewModal'; import { PromptTemplatesTab } from './PromptTemplatesTab'; import { apiProtocolLabel } from '../utils/apiProtocol'; -type TopTab = 'designs' | 'examples' | 'design-systems' | 'connectors' | 'image-templates' | 'video-templates'; +type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates'; interface Props { skills: SkillSummary[]; @@ -99,17 +96,6 @@ function loadSidebarWidth(): number { } } -function mergeConnectors(current: ConnectorDetail[], incoming: ConnectorDetail[]): ConnectorDetail[] { - if (!incoming.length) return current; - const incomingById = new Map(incoming.map((connector) => [connector.id, connector])); - const merged = current.map((connector) => incomingById.get(connector.id) ?? connector); - const currentIds = new Set(current.map((connector) => connector.id)); - for (const connector of incoming) { - if (!currentIds.has(connector.id)) merged.push(connector); - } - return merged; -} - function applyConnectorStatuses( current: ConnectorDetail[], statuses: ConnectorStatusResponse['statuses'], @@ -249,8 +235,6 @@ export function EntryView({ const [resizing, setResizing] = useState(false); const [connectors, setConnectors] = useState([]); const [connectorsLoading, setConnectorsLoading] = useState(false); - const [connectorDiscoveryLoading, setConnectorDiscoveryLoading] = useState(false); - const [connectorDiscoveryLoaded, setConnectorDiscoveryLoaded] = useState(false); const [petRailHidden, setPetRailHiddenState] = useState(() => loadPetRailHidden()); const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); const avatarMenuRef = useRef(null); @@ -342,13 +326,6 @@ export function EntryView({ } }, [sidebarWidth]); - const reloadConnectors = useCallback(async (options: { showLoading?: boolean } = {}) => { - if (options.showLoading ?? true) setConnectorsLoading(true); - const next = await fetchConnectors(); - setConnectors(next); - setConnectorsLoading(false); - }, []); - const reloadConnectorStatuses = useCallback(async () => { const statuses = await fetchConnectorStatuses(); setConnectors((curr) => applyConnectorStatuses(curr, statuses)); @@ -358,7 +335,7 @@ export function EntryView({ let cancelled = false; // Fetch connectors on mount so the New project panel can show // already-configured connectors on the live-artifact tab without - // waiting for the user to open the Connectors tab. + // waiting for the user to open the Settings → Connectors surface. setConnectorsLoading(true); (async () => { const next = await fetchConnectors(); @@ -371,26 +348,6 @@ export function EntryView({ }; }, []); - useEffect(() => { - if (topTab !== 'connectors') return; - if (connectorDiscoveryLoaded) return; - let cancelled = false; - // Slow Composio discovery is only needed for enriched toolkit metadata - // and auth configuration. Keep the initial catalog/status path fast. - setConnectorDiscoveryLoading(true); - (async () => { - const next = await fetchConnectorDiscovery(); - if (cancelled) return; - setConnectors((curr) => mergeConnectors(curr, next)); - setConnectorDiscoveryLoaded(true); - setConnectorDiscoveryLoading(false); - })(); - return () => { - cancelled = true; - setConnectorDiscoveryLoading(false); - }; - }, [connectorDiscoveryLoaded, topTab]); - useEffect(() => { function onMessage(event: MessageEvent) { const data = event.data; @@ -415,11 +372,6 @@ export function EntryView({ return () => window.removeEventListener('focus', onFocus); }, [reloadConnectorStatuses]); - function updateConnector(next: ConnectorDetail | null) { - if (!next) return; - setConnectors((curr) => curr.map((connector) => (connector.id === next.id ? next : connector))); - } - // Dismiss the avatar dropdown on outside-click / Escape so it behaves // like the project-view AvatarMenu (which uses the same shell CSS). useEffect(() => { @@ -516,7 +468,7 @@ export function EntryView({ mediaProviders={config.mediaProviders} connectors={connectors} connectorsLoading={connectorsLoading} - onOpenConnectorsTab={() => setTopTab('connectors')} + onOpenConnectorsTab={() => onOpenSettings('composio')} loading={loading} />
@@ -548,6 +500,7 @@ export function EntryView({ type="button" className="foot-pill" onClick={() => onOpenSettings()} + aria-label={t('settings.envConfigure')} title={t('settings.envConfigure')} > @@ -597,7 +550,6 @@ export function EntryView({ label={t('entry.tabDesignSystems')} onClick={setTopTab} /> - ) : null} - {topTab === 'connectors' ? ( - { - const result = await connectConnector(connectorId); - updateConnector(result.connector); - return result; - }} - onDisconnect={async (connectorId) => updateConnector(await disconnectConnector(connectorId))} - /> - ) : null} {topTab === 'image-templates' ? ( void; - onConnect: (connectorId: string) => Promise<{ error?: string } | void> | { error?: string } | void; - onDisconnect: (connectorId: string) => Promise | void; -}) { - const t = useT(); - const [pendingConnectorAction, setPendingConnectorAction] = useState<{ - connectorId: string; - action: 'connect' | 'disconnect'; - } | null>(null); - const [detailConnectorId, setDetailConnectorId] = useState(null); - const [filter, setFilter] = useState(''); - const [actionError, setActionError] = useState(null); - const searchInputRef = useRef(null); - - // Mask the grid whenever no Composio-backed connector has its auth - // configured. We also honor the local config.composio flag so the mask - // appears immediately when the key is cleared, before the next list fetch. - const anyComposioAuthConfigured = useMemo( - () => - connectors.some( - (connector) => connector.auth?.provider === 'composio' && connector.auth.configured, - ), - [connectors], - ); - const needsComposioKey = !composioConfigured && !anyComposioAuthConfigured; - - // Filter and rank connectors by user-visible fields. Exact/prefix matches - // on connector name/provider are strongest; broad description matches stay - // searchable but are down-ranked. - const filteredConnectors = useMemo(() => { - return sortConnectorsForSearch(connectors, filter); - }, [connectors, filter]); - - const hasQuery = filter.trim().length > 0; - const hasNoResults = hasQuery && filteredConnectors.length === 0; - - async function runConnectorAction(connectorId: string, action: 'connect' | 'disconnect') { - if (pendingConnectorAction) return; - setActionError(null); - setPendingConnectorAction({ connectorId, action }); - try { - if (action === 'connect') { - const result = await onConnect(connectorId); - if (result && typeof result === 'object' && 'error' in result && result.error) { - setActionError(result.error); - } - } else { - await onDisconnect(connectorId); - } - } finally { - setPendingConnectorAction(null); - } - } - - const detailConnector = useMemo( - () => (detailConnectorId ? connectors.find((c) => c.id === detailConnectorId) ?? null : null), - [detailConnectorId, connectors], - ); - - return ( -
-
-
-
-

{t('connectors.title')}

-

{t('connectors.subtitle')}

-
-
-
-
- - - - setFilter(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape' && filter) { - event.preventDefault(); - event.stopPropagation(); - setFilter(''); - } - }} - placeholder={t('connectors.searchPlaceholder')} - aria-label={t('connectors.searchAriaLabel')} - disabled={needsComposioKey} - data-testid="connectors-search-input" - /> - {hasQuery ? ( - - ) : null} -
-
-
- {actionError ? ( -

- {actionError} -

- ) : null} - {loading ? ( - - ) : ( -
- {hasNoResults && !needsComposioKey ? ( -
-

- {t('connectors.emptyNoMatchTitle', { query: filter.trim() })} -

-

{t('connectors.emptyNoMatchBody')}

- -
- ) : ( -
- {filteredConnectors.map((connector) => ( - runConnectorAction(connectorId, 'connect')} - onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')} - onOpenDetails={(connectorId) => setDetailConnectorId(connectorId)} - /> - ))} -
- )} - {needsComposioKey ? ( -
-
-
- -
-

{t('connectors.gateTitle')}

-

{t('connectors.gateBody')}

- -
-
- ) : null} -
- )} - {detailConnector ? ( - setDetailConnectorId(null)} - onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')} - onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')} - /> - ) : null} -
- ); -} - -function ConnectorCard({ - connector, - disabled = false, - pendingAction, - toolsLoading, - toolsLoaded, - onConnect, - onDisconnect, - onOpenDetails, -}: { - connector: ConnectorDetail; - disabled?: boolean; - pendingAction: 'connect' | 'disconnect' | null; - toolsLoading: boolean; - toolsLoaded: boolean; - onConnect: (connectorId: string) => Promise | void; - onDisconnect: (connectorId: string) => Promise | void; - onOpenDetails: (connectorId: string) => void; -}) { - const t = useT(); - const isConnecting = pendingAction === 'connect'; - const isDisconnecting = pendingAction === 'disconnect'; - const isPending = pendingAction !== null; - const isConnected = connector.status === 'connected'; - const canConnect = !disabled && !isPending && connector.status === 'available'; - const canDisconnect = !disabled && !isPending && isConnected; - const toolCount = connector.tools.length; - const showToolsBadge = toolsLoaded; - const toolsBadgeLabel = formatToolsBadge(toolCount, t); - - function openDetails() { - if (disabled) return; - onOpenDetails(connector.id); - } - - function onKeyActivate(event: ReactKeyboardEvent) { - if (event.key !== 'Enter' && event.key !== ' ') return; - // Only treat the wrapper itself as a trigger — nested buttons handle - // their own activation. - if (event.target !== event.currentTarget) return; - event.preventDefault(); - openDetails(); - } - - // Any click on an interactive child (button, link) must not bubble up to - // the card-level open handler. - function stop(event: SyntheticEvent) { - event.stopPropagation(); - } - - return ( -
-
-
-

{connector.name}

-
- {connector.category} - · - {showToolsBadge ? ( - - - {toolsBadgeLabel} - - ) : null} -
-
- {isConnected ? ( - - - {statusLabel(connector.status, t)} - - ) : connector.status === 'error' || connector.status === 'disabled' ? ( - - {statusLabel(connector.status, t)} - - ) : null} -
- {connector.description ? ( -

{connector.description}

- ) : null} -
- {isConnected ? ( - - ) : ( - - )} -
-
- ); -} - -function statusLabel(status: ConnectorDetail['status'], t: ReturnType): string { - switch (status) { - case 'available': - return t('connectors.statusAvailable'); - case 'connected': - return t('connectors.statusConnected'); - case 'error': - return t('connectors.statusError'); - case 'disabled': - return t('connectors.statusDisabled'); - } -} - -function formatToolsBadge(count: number, t: ReturnType): string { - if (count === 0) return t('connectors.toolsBadgeNone'); - if (count === 1) return t('connectors.toolsBadgeOne', { n: count }); - return t('connectors.toolsBadgeMany', { n: count }); -} - -function ConnectorDetailDrawer({ - connector, - disabled, - pendingAction, - toolsLoading, - toolsLoaded, - onClose, - onConnect, - onDisconnect, -}: { - connector: ConnectorDetail; - disabled: boolean; - pendingAction: 'connect' | 'disconnect' | null; - toolsLoading: boolean; - toolsLoaded: boolean; - onClose: () => void; - onConnect: (connectorId: string) => Promise | void; - onDisconnect: (connectorId: string) => Promise | void; -}) { - const t = useT(); - const isConnected = connector.status === 'connected'; - const isConnecting = pendingAction === 'connect'; - const isDisconnecting = pendingAction === 'disconnect'; - const isPending = pendingAction !== null; - const canConnect = !disabled && !isPending && connector.status === 'available'; - const canDisconnect = !disabled && !isPending && isConnected; - const accountLabel = getDisplayableConnectorAccountLabel(connector); - const toolCount = connector.tools.length; - const isLoadingTools = !toolsLoaded || (toolsLoading && toolCount === 0); - const showToolsBadge = toolsLoaded; - const closeBtnRef = useRef(null); - - // ESC to close; focus the close button on mount for keyboard users. - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') { - e.stopPropagation(); - onClose(); - } - } - document.addEventListener('keydown', onKey); - closeBtnRef.current?.focus(); - // Lock the background scroll while the drawer is open. - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - document.removeEventListener('keydown', onKey); - document.body.style.overflow = previousOverflow; - }; - }, [onClose]); - - const statusTone = connector.status; - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > - -
- ); -} - -function getDisplayableConnectorAccountLabel(connector: ConnectorDetail): string | undefined { - if (!connector.accountLabel) return undefined; - const provider = connector.auth?.provider ?? connector.provider.toLowerCase(); - if (provider === 'composio') return undefined; - return connector.accountLabel; -} - function TopTabButton({ current, value, diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 39ef7fe24..539ba20d8 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -377,6 +377,7 @@ export function LiveArtifactViewer({ onRefreshArtifacts?: () => Promise | void; }) { const t = useT(); + const tabs = useMemo(() => liveArtifactViewerTabs(t), [t]); const [mode, setMode] = useState('preview'); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); @@ -559,7 +560,7 @@ export function LiveArtifactViewer({
- {LIVE_ARTIFACT_VIEWER_TABS.map((tab) => ( + {tabs.map((tab) => (
@@ -689,7 +690,7 @@ export function LiveArtifactViewer({ reloadKey={reloadKey} /> ) : mode === 'data' ? ( - + ) : ( = [ - { id: 'preview', label: 'Preview' }, - { id: 'code', label: 'Code' }, - { id: 'data', label: 'Data' }, - { id: 'refresh-history', label: 'Refresh history' }, -]; +function liveArtifactViewerTabs(t: TranslateFn): Array<{ id: LiveArtifactViewerTab; label: string }> { + return [ + { id: 'preview', label: t('liveArtifact.viewer.tabPreview') }, + { id: 'code', label: t('liveArtifact.viewer.tabCode') }, + { id: 'data', label: t('liveArtifact.viewer.tabData') }, + { id: 'refresh-history', label: t('liveArtifact.viewer.tabRefreshHistory') }, + ]; +} type LiveArtifactCodeVariant = 'template' | 'rendered-source'; -function LiveArtifactCodePanel({ projectId, artifactId, reloadKey }: { projectId: string; artifactId: string; reloadKey: number }) { +function LiveArtifactCodePanel({ + projectId, + artifactId, + reloadKey, +}: { + projectId: string; + artifactId: string; + reloadKey: number; +}) { + const t = useT(); const [variant, setVariant] = useState('template'); const [code, setCode] = useState(null); const [loading, setLoading] = useState(true); @@ -783,38 +795,45 @@ function LiveArtifactCodePanel({ projectId, artifactId, reloadKey }: { projectId
- {variant === 'template' ? 'Template HTML' : 'Rendered HTML'} + + {variant === 'template' + ? t('liveArtifact.viewer.code.templateHeading') + : t('liveArtifact.viewer.code.renderedHeading')} + {variant === 'template' - ? 'The editable template used with data.json to generate the preview.' - : 'The generated index.html currently loaded by Preview.'} + ? t('liveArtifact.viewer.code.templateHelp') + : t('liveArtifact.viewer.code.renderedHelp')}
-
+
{loading ? ( -
Loading code…
+
{t('liveArtifact.viewer.code.loading')}
) : failed ? ( -
Code is not available yet.
+
{t('liveArtifact.viewer.code.unavailable')}
) : code && code.trim().length > 0 ? (
{code}
) : ( -
This code file is empty.
+
{t('liveArtifact.viewer.code.empty')}
)}
); diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 91b90314e..c13914dae 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -631,7 +631,7 @@ function Tab({ const iconName = kindIconName(kind); return (
{ if (e.key === 'Enter' || e.key === ' ') { diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx index c14a539c2..888be1ff6 100644 --- a/apps/web/src/components/Icon.tsx +++ b/apps/web/src/components/Icon.tsx @@ -30,6 +30,7 @@ type IconName = | 'link' | 'mic' | 'minus' + | 'orbit' | 'pencil' | 'plus' | 'play' @@ -288,6 +289,24 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) { ); + case 'orbit': + // Tilted elliptical orbit + central body + a small satellite riding the + // path. Reads unmistakably as "orbit/automation" rather than the + // generic refresh loop, and the rotated ellipse keeps the silhouette + // distinct from `refresh` and `reload` at small sizes. + return ( + + + + + + ); case 'pencil': return ( diff --git a/apps/web/src/components/NewProjectPanel.tsx b/apps/web/src/components/NewProjectPanel.tsx index da7bff743..16d17e2f0 100644 --- a/apps/web/src/components/NewProjectPanel.tsx +++ b/apps/web/src/components/NewProjectPanel.tsx @@ -183,11 +183,12 @@ export function NewProjectPanel({ tab === 'deck' || tab === 'template' || tab === 'other'; - // Some skills (e.g. the Orbit briefings) ship their own complete visual - // language baked into example.html and explicitly opt out of DESIGN.md - // injection via `od.design_system.requires: false`. When such a skill is - // the active default for the current tab, hide the picker entirely so - // the user isn't asked to attach a brand we'll then ignore. + // Orbit briefings ship their own complete visual language baked into + // example.html and explicitly opt out of DESIGN.md injection via + // `od.design_system.requires: false`. Hide the picker only for those + // Orbit scenario skills; the general prototype creation surface should + // still honor the user's configured default design system even when a + // non-Orbit default skill does not require one. const tabDefaultSkillForcesNoDs = useMemo(() => { const tabSkillId = ((): string | null => { if (tab === 'prototype' || tab === 'live-artifact') { @@ -204,7 +205,9 @@ export function NewProjectPanel({ })(); if (!tabSkillId) return false; const s = skills.find((x) => x.id === tabSkillId); - return s ? s.designSystemRequired === false : false; + return s + ? s.scenario === 'orbit' && s.designSystemRequired === false + : false; }, [tab, skills]); const showDesignSystemPicker = tabSupportsDesignSystem && !tabDefaultSkillForcesNoDs; @@ -668,8 +671,8 @@ function FidelityPicker({ - Lists configured connectors as compact chips so the user can see at a glance what data sources this artifact can pull from. - When no connector is configured (or the list hasn't loaded yet - and ended up empty), shows a guidance card that, on click, pops - the entry-tab-connectors tab in the main view. + and ended up empty), shows a guidance card that, on click, opens + the Settings → Connectors surface (the new home of the catalog). ============================================================ */ function ConnectorsSection({ connectors, diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index f8e25d8b5..0d220fae4 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -118,7 +118,7 @@ interface Props { let liveArtifactEventSequence = 0; const CHAT_PANEL_WIDTH_STORAGE_KEY = 'open-design.project.chatPanelWidth'; const DEFAULT_CHAT_PANEL_WIDTH = 460; -const MIN_CHAT_PANEL_WIDTH = 320; +const MIN_CHAT_PANEL_WIDTH = 345; const MAX_CHAT_PANEL_WIDTH = 720; const MIN_WORKSPACE_PANEL_WIDTH = 400; const SPLIT_RESIZE_HANDLE_WIDTH = 8; @@ -379,10 +379,13 @@ export function ProjectView({ for (const textBuffer of reattachTextBuffersRef.current) textBuffer.cancel(); reattachTextBuffersRef.current.clear(); for (const controller of reattachControllersRef.current.values()) { + if (abortRef.current === controller) abortRef.current = null; controller.abort(); } for (const controller of reattachCancelControllersRef.current.values()) { - controller.abort(); + // Route changes should only detach the browser-side SSE listener. + // Aborting this signal maps to POST /cancel, so leave the daemon run alive. + if (cancelRef.current === controller) cancelRef.current = null; } reattachControllersRef.current.clear(); reattachCancelControllersRef.current.clear(); diff --git a/apps/web/src/components/QuestionForm.tsx b/apps/web/src/components/QuestionForm.tsx index d481fb697..3d38ae818 100644 --- a/apps/web/src/components/QuestionForm.tsx +++ b/apps/web/src/components/QuestionForm.tsx @@ -21,6 +21,7 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]); const [answers, setAnswers] = useState>(initial); const locked = !interactive || !onSubmit || submittedAnswers !== undefined; + const currentAnswers = submittedAnswers ?? answers; function update(id: string, value: string | string[]) { if (locked) return; @@ -43,7 +44,7 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit function missingRequired(): string | null { for (const q of form.questions) { if (!q.required) continue; - const v = answers[q.id]; + const v = currentAnswers[q.id]; if (Array.isArray(v) ? v.length === 0 : !(typeof v === 'string' && v.trim().length > 0)) { return q.label; } @@ -66,11 +67,11 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit const required = form.questions.filter((q) => q.required); const withinSelectionLimits = form.questions.every((q) => { if (q.type !== 'checkbox' || q.maxSelections === undefined) return true; - const v = answers[q.id]; + const v = currentAnswers[q.id]; return !Array.isArray(v) || v.length <= q.maxSelections; }); const ready = withinSelectionLimits && required.every((q) => { - const v = answers[q.id]; + const v = currentAnswers[q.id]; return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0; }); @@ -88,7 +89,7 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
{form.questions.map((q) => { - const value = answers[q.id]; + const value = currentAnswers[q.id]; return (
- -
- - -
); @@ -1799,13 +2050,26 @@ export function deriveComposioCredentialState( return 'empty'; } -function ComposioSection({ +function ConnectorSection({ cfg, setCfg, + composioConfigLoading = false, + onPersistComposioKey, }: { cfg: AppConfig; setCfg: Dispatch>; + /** True while the daemon-backed Composio config is still hydrating on + * first paint. The credentials surface renders a skeleton over the + * input + buttons so the user does not mistake the temporarily empty + * input for "no saved key", and so accidental Save/Clear clicks + * cannot overwrite the saved state with `''` before hydration lands. */ + composioConfigLoading?: boolean; + /** Persist the freshly typed Composio API key to the daemon. Returns + * once both localStorage and the daemon have caught up so the + * section-local Save button can flip from "Saving…" back to idle. */ + onPersistComposioKey: (composio: AppConfig['composio']) => Promise | void; }) { + const { t } = useI18n(); const composio = cfg.composio ?? {}; const updateComposio = (patch: NonNullable) => { @@ -1813,24 +2077,165 @@ function ComposioSection({ }; const credentialState = deriveComposioCredentialState(composio); const hasSavedKey = credentialState === 'saved' || credentialState === 'saved-pending'; + const hasPendingEdit = credentialState === 'pending-new' || credentialState === 'saved-pending'; const apiKeyConfigured = credentialState !== 'empty'; + const savedApiKeyConfigured = Boolean(composio.apiKeyConfigured || hasSavedKey); const tail = composio.apiKeyTail?.trim(); + // Section-local save state. The Composio key bypasses the dialog's + // global autosave loop because it is a secret — we don't want + // partial-typed keys leaving the browser on every keystroke. The + // user explicitly clicks "Save key" when they're ready, the request + // completes, the daemon returns a tail-only echo, and we land in + // the saved state with the same UI as a key loaded from disk. + const [keySaveStatus, setKeySaveStatus] = + useState<'idle' | 'saving' | 'error'>('idle'); + const [catalogRefreshNonce, setCatalogRefreshNonce] = useState(0); + const handleSaveKey = async () => { + if (keySaveStatus === 'saving') return; + if (!hasPendingEdit) return; + if (composioConfigLoading) return; + const pendingKey = composio.apiKey ?? ''; + setKeySaveStatus('saving'); + try { + await onPersistComposioKey(cfg.composio); + // Mirror the parent's normalization so the local draft moves + // into the saved state immediately: drop the secret from the + // input, mark configured, and store the last-4 tail for the + // status badge. The parent's setConfig won't propagate back to + // the dialog because `initial` is read once at mount. + updateComposio({ + apiKey: '', + apiKeyConfigured: true, + apiKeyTail: pendingKey.trim().slice(-4), + }); + setCatalogRefreshNonce((nonce) => nonce + 1); + setKeySaveStatus('idle'); + } catch { + setKeySaveStatus('error'); + } + }; + + // Action gating during hydration. Both Save and Clear are dangerous + // before the daemon's response lands: Save would push whatever the + // user typed (or didn't type) over the saved key, and Clear would + // unconditionally wipe it. The skeleton state below makes this + // visually obvious; the disabled flags here are the safety net. + const actionsLocked = composioConfigLoading || keySaveStatus === 'saving'; + const saveDisabled = actionsLocked || !hasPendingEdit; + const clearDisabled = actionsLocked || !apiKeyConfigured; + + // Two-stage destructive confirmation for "Clear". Clearing the saved + // Composio API key cascades into disconnecting every connector that + // depends on it, which is irreversible from the UI's standpoint — + // accounts, OAuth grants, and tool access all unwind. To stop that + // from happening on a stray click we gate the existing wipe behind + // 1. an inline warning panel (must click "Continue"), then + // 2. a final destructive confirmation panel with a brief arming + // window so the destructive button cannot be hit by reflex + // double-click, then + // 3. the original clear behavior fires. + // The panel collapses on Cancel, when the saved key disappears for + // any other reason, or when the user navigates away from the section. + const [clearStage, setClearStage] = useState<'idle' | 'confirm' | 'final'>('idle'); + const [clearArmed, setClearArmed] = useState(false); + const finalConfirmButtonRef = useRef(null); + // Reset the flow if the underlying state stops being clearable + // (e.g. the daemon reloaded and there's nothing saved anymore, or + // hydration started). This avoids a stale confirmation panel sitting + // open over a key that no longer exists. + useEffect(() => { + if (!apiKeyConfigured || composioConfigLoading) { + setClearStage('idle'); + setClearArmed(false); + } + }, [apiKeyConfigured, composioConfigLoading]); + // Arm the destructive button after a short delay once the user + // reaches the final stage. Until then the button is visually hot + // but inert — this is the "hold on a sec" moment that keeps a + // reflex Enter / double-click from blowing through both stages. + useEffect(() => { + if (clearStage !== 'final') { + setClearArmed(false); + return; + } + setClearArmed(false); + const timer = window.setTimeout(() => setClearArmed(true), 700); + // Pull focus to the final confirm button so keyboard users can + // see the arming animation finish and choose deliberately rather + // than tabbing through stale focus state. + const focusTimer = window.setTimeout(() => { + finalConfirmButtonRef.current?.focus({ preventScroll: true }); + }, 720); + return () => { + window.clearTimeout(timer); + window.clearTimeout(focusTimer); + }; + }, [clearStage]); + const handleClearRequest = () => { + if (clearDisabled) return; + setClearStage('confirm'); + }; + const handleClearAbort = () => { + setClearStage('idle'); + setClearArmed(false); + }; + const handleClearContinue = () => { + setClearStage('final'); + }; + const handleClearCommit = async () => { + if (keySaveStatus === 'saving') return; + if (!clearArmed) return; + setKeySaveStatus('saving'); + try { + const cleared = { + apiKey: '', + apiKeyConfigured: false, + apiKeyTail: '', + }; + await onPersistComposioKey(cleared); + updateComposio(cleared); + setCatalogRefreshNonce((nonce) => nonce + 1); + setClearStage('idle'); + setClearArmed(false); + setKeySaveStatus('idle'); + } catch { + setKeySaveStatus('error'); + } + }; + return ( -
+
-

Connectors

-

Manage connector and tool provider settings for this device.

+

{t('connectors.title')}

+

{t('settings.connectorsHint')}

-
+ ); +} + +interface OrbitRunSummary { + id?: string; + startedAt?: string; + completedAt: string; + trigger?: 'manual' | 'scheduled'; + connectorsChecked: number; + connectorsSucceeded: number; + connectorsFailed: number; + connectorsSkipped: number; + artifactId?: string | null; + artifactProjectId?: string | null; + /** Identifier of the daemon run that produced this summary. Useful for log correlation. */ + agentRunId?: string | null; + markdown: string; +} + +interface OrbitRunStartResponse { + projectId: string; + agentRunId: string; +} + +export async function persistConfigAndRunOrbit( + config: AppConfig, +): Promise { + await syncMediaProvidersToDaemon(config.mediaProviders); + await syncConfigToDaemon(config, { throwOnError: true }); + const response = await fetch('/api/orbit/run', { method: 'POST' }); + if (!response.ok) throw new Error('Orbit run failed'); + return await response.json() as OrbitRunStartResponse; +} + +export function configForManualOrbitRun(config: AppConfig): AppConfig { + const effectiveTemplateSkillId = config.orbit?.templateSkillId || DEFAULT_ORBIT.templateSkillId || ''; + if (!effectiveTemplateSkillId) return config; + return { + ...config, + orbit: { + ...(config.orbit ?? DEFAULT_ORBIT), + templateSkillId: effectiveTemplateSkillId, + }, + }; +} + +export function isOrbitRunDisabled(isBusy: boolean, connectedCount: number | null): boolean { + return isBusy || connectedCount === null || connectedCount === 0; +} + +interface OrbitStatusResponse { + running?: boolean; + nextRunAt?: string | null; + lastRun?: OrbitRunSummary | null; +} + +function formatRelative( + iso: string | undefined | null, + t: (key: keyof Dict, vars?: Record) => string, +): string | null { + if (!iso) return null; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return null; + const diffMs = Date.now() - then; + const absMin = Math.round(Math.abs(diffMs) / 60_000); + if (absMin < 1) return t('common.justNow'); + if (absMin < 60) return t('common.minutesAgo', { n: absMin }); + const absHr = Math.round(absMin / 60); + if (absHr < 24) return t('common.hoursAgo', { n: absHr }); + const absDay = Math.round(absHr / 24); + return t('common.daysAgo', { n: absDay }); +} + +function OrbitSection({ + cfg, + setCfg, + composioApiKeyConfigured, + onOpenComposioSection, + onLeaveForOrbitProject, +}: { + cfg: AppConfig; + setCfg: Dispatch>; + /** Whether the user has already saved a Composio API key. Drives the + * Orbit configuration gate's copy/CTA. When false the gate explains + * that Orbit needs Composio first; when true (key present, just no + * connectors yet) it nudges the user toward the connector catalog. */ + composioApiKeyConfigured: boolean; + /** Switch the parent settings dialog to the Connectors (Composio) tab. + * Used by the Orbit gate's primary CTA so the user can fix the + * prerequisite without leaving the dialog. */ + onOpenComposioSection: () => void; + /** Called right before navigating to the generated Orbit project so the + * parent dialog can persist any unsaved Orbit edits and close itself. */ + onLeaveForOrbitProject: (runConfig: AppConfig) => void; +}) { + const { t } = useI18n(); + const orbit = cfg.orbit ?? DEFAULT_ORBIT; + const [status, setStatus] = useState(null); + const [running, setRunning] = useState(false); + const [notice, setNotice] = useState<{ kind: 'success' | 'error'; message: string } | null>(null); + const [copied, setCopied] = useState(false); + // Orbit-scenario skill templates fetched from /api/skills. We fetch on mount + // and keep three states for graceful UX: `null` = still loading, `[]` = + // loaded with no orbit templates available, `SkillSummary[]` = ready. If + // the daemon is offline the call resolves with [] (see fetchSkills) so the + // section never throws — the rest of the Orbit controls keep working. + const [orbitTemplates, setOrbitTemplates] = useState(null); + // Connector presence drives the configuration gate at the top of the Orbit + // tab. We track three states: `null` = still loading (skip rendering the + // gate so it doesn't flash before data arrives), `0` = no connectors + // present (gate is shown), `>0` = at least one connected integration + // (gate is hidden). We only count connectors with `status === 'connected'` + // because the catalog itself ships hundreds of available rows — what + // matters for Orbit is whether anything has actually been wired up. + const [connectedCount, setConnectedCount] = useState(null); + // Once the user clicks Generate we close Settings and navigate away. The ref + // lets late-arriving handlers no-op without React warnings. + const isMountedRef = useRef(true); + useEffect(() => () => { + isMountedRef.current = false; + }, []); + + const updateOrbit = (patch: Partial>) => { + setCfg((curr) => ({ + ...curr, + orbit: { ...(curr.orbit ?? DEFAULT_ORBIT), ...patch }, + })); + }; + + const refreshStatus = async () => { + try { + const response = await fetch('/api/orbit/status'); + if (!response.ok) return; + if (!isMountedRef.current) return; + setStatus(await response.json() as OrbitStatusResponse); + } catch { + // Daemon may be offline in API-only development; keep local controls usable. + } + }; + + useEffect(() => { + void refreshStatus(); + }, []); + + useEffect(() => { + if (!status?.running) return undefined; + const interval = window.setInterval(() => { + void refreshStatus(); + }, 3000); + return () => window.clearInterval(interval); + }, [status?.running]); + + // Fetch the skills registry once on mount and filter to scenario === 'orbit'. + // We tolerate fetch failure: fetchSkills already swallows errors and returns + // []. The component then transitions from "loading" → "empty" and the rest + // of the Orbit panel stays fully functional. + useEffect(() => { + let alive = true; + void (async () => { + const all = await fetchSkills(); + if (!alive) return; + const filtered = all.filter((s) => s.scenario === 'orbit'); + // Stable order: featured first (higher number = more featured), then by name. + filtered.sort((a, b) => { + const af = a.featured ?? 0; + const bf = b.featured ?? 0; + if (af !== bf) return bf - af; + return a.name.localeCompare(b.name); + }); + setOrbitTemplates(filtered); + })(); + return () => { + alive = false; + }; + }, []); + + const refreshConnectedCount = useCallback(async () => { + const list = await fetchConnectors(); + if (!isMountedRef.current) return; + const connected = list.filter((c) => c.status === 'connected').length; + setConnectedCount(connected); + }, []); + + // Fetch the connector catalog on mount to determine whether the Orbit + // configuration gate should render. fetchConnectors swallows errors and + // returns []; if the daemon is offline we treat that as "0 connected" and + // surface the gate so the user has a clear path forward instead of being + // dropped into a broken Orbit configuration. + useEffect(() => { + void refreshConnectedCount(); + }, [refreshConnectedCount]); + + // Connector auth often completes in another window. Re-check when focus + // returns so the Orbit gate reflects newly connected accounts without + // requiring the user to close and reopen Settings. + useEffect(() => { + const onFocus = () => { + void refreshConnectedCount(); + }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [refreshConnectedCount]); + + // The id used to drive the prompt template — coalesces a null/empty + // saved value to the built-in default (DEFAULT_ORBIT.templateSkillId, + // currently 'orbit-general'). The select no longer offers a "no template" + // option, so legacy configs that stored null are presented as if they + // were on the default. Manual runs persist this effective value before + // launching so the daemon uses the same template the UI displays. + const effectiveTemplateSkillId = orbit.templateSkillId || DEFAULT_ORBIT.templateSkillId || ''; + + const selectedTemplate = useMemo(() => { + if (!effectiveTemplateSkillId || !orbitTemplates) return null; + return orbitTemplates.find((s) => s.id === effectiveTemplateSkillId) ?? null; + }, [effectiveTemplateSkillId, orbitTemplates]); + + const triggerNow = () => { + if (running) return; + setRunning(true); + setNotice(null); + + void (async () => { + try { + const runConfig = configForManualOrbitRun(cfg); + const payload = await persistConfigAndRunOrbit(runConfig); + if (!payload.projectId) throw new Error('Orbit run did not return a project'); + + onLeaveForOrbitProject(runConfig); + navigateRoute({ + kind: 'project', + projectId: payload.projectId, + fileName: null, + }); + } catch { + if (!isMountedRef.current) return; + setNotice({ + kind: 'error', + message: t('settings.orbit.runError'), + }); + } finally { + if (!isMountedRef.current) return; + setRunning(false); + void refreshStatus(); + } + })(); + }; + + const lastRun = status?.lastRun ?? null; + const nextRunLabel = status?.nextRunAt ? new Date(status.nextRunAt).toLocaleString() : null; + const lastRunAbs = lastRun ? new Date(lastRun.completedAt).toLocaleString() : null; + const lastRunRel = formatRelative(lastRun?.completedAt, t); + const liveArtifactHref = lastRun?.artifactId && lastRun?.artifactProjectId + ? `/api/live-artifacts/${encodeURIComponent(lastRun.artifactId)}/preview?projectId=${encodeURIComponent(lastRun.artifactProjectId)}` + : null; + const isBusy = running || Boolean(status?.running); + + const copyMarkdown = async () => { + if (!lastRun?.markdown) return; + try { + await navigator.clipboard.writeText(lastRun.markdown); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + } catch { + // Clipboard access may be denied in some browsing contexts; silently skip. + } + }; + + // Proportional widths for the run-result meter. We avoid showing 0-width + // segments by falling back to a tiny sliver when a category has hits but + // rounds to 0% — the visual "something happened here" cue matters more + // than exact proportion at low counts. + const total = lastRun + ? Math.max( + lastRun.connectorsSucceeded + lastRun.connectorsSkipped + lastRun.connectorsFailed, + 1, + ) + : 1; + const segPct = (n: number) => { + if (!lastRun || n <= 0) return 0; + const pct = (n / total) * 100; + return pct < 3 ? 3 : pct; + }; + const meterSucceeded = lastRun ? segPct(lastRun.connectorsSucceeded) : 0; + const meterSkipped = lastRun ? segPct(lastRun.connectorsSkipped) : 0; + const meterFailed = lastRun ? segPct(lastRun.connectorsFailed) : 0; + + const automationState = orbit.enabled ? 'active' : 'off'; + const triggerLabel = lastRun?.trigger === 'manual' + ? t('settings.orbit.triggerManual') + : t('settings.orbit.triggerScheduled'); + + // Surface the configuration gate when we know for sure that the user has + // no connected integrations. While `connectedCount === null` we are still + // loading and intentionally hide the gate so the panel doesn't flash an + // empty-state warning before data arrives. Once resolved, `0` triggers + // the gate. The gate's copy + CTA branch on whether a Composio API key + // has been saved: missing key → push toward configuring Composio first; + // key present, no connections → push toward picking an integration. + const showConfigGate = connectedCount === 0; + const gateBodyKey = composioApiKeyConfigured + ? 'settings.orbit.gateBody' + : 'settings.orbit.gateBodyNoKey'; + const gateActionKey = composioApiKeyConfigured + ? 'settings.orbit.gateAction' + : 'settings.orbit.gateActionNoKey'; + // Disable the hero's "Run it now" CTA while the gate is visible: running + // without any connector wired up surfaces a cryptic backend error. We + // keep the button mounted so layout stays stable; a tooltip and the + // adjacent gate make the disabled reason obvious. + const runDisabled = isOrbitRunDisabled(isBusy, connectedCount); + const runDisabledTitle = showConfigGate + ? t('settings.orbit.gateTitle') + : t('settings.orbit.runTitle'); + + // When the configuration gate is visible (no connector available) we + // also lock down every secondary control on the panel — schedule + // toggle, time input, prompt template select, and the missing-template + // Reset button. Touching any of them before a connector exists either + // produces a no-op or persists state the user can't actually exercise. + // Locking them keeps the panel honest, prevents "ghost configuration", + // and reinforces the gate's CTA as the only meaningful next step. + const controlsLocked = showConfigGate; + const controlsLockedHint = controlsLocked + ? t('settings.orbit.controlsLockedHint') + : undefined; + + return ( +
+ {/* ---------- 1. HEADER ZONE ---------- */} +
+ +
+ {t('settings.orbit.eyebrow')} +

{t('settings.orbit.title')}

+

+ {t('settings.orbit.lede')} +

+
+
+ + + +
+
+ + {/* ---------- 1b. CONFIGURATION GATE ---------- + Renders when no connected integrations are present. Orbit's job is + to summarize connector activity, so without any wired-up + connector there is literally nothing for it to report on. + The gate uses the same orbit-themed accent surface as the + automation card to feel like a first-class part of the panel + rather than an inline error, and routes the user back to the + Connectors tab inside the same settings dialog (no navigation + off the page). The copy/CTA branch on whether a Composio API + key has been saved already, because the prerequisite chain is: + API key → connector connected → Orbit can run. */} + {showConfigGate ? ( +
+ +
+ + {t('settings.orbit.gateEyebrow')} + +

+ {t('settings.orbit.gateTitle')} +

+

+ {t(gateBodyKey)} +

+
+
+ +
+
+ ) : null} + + {/* ---------- 2. AUTOMATION CARD ---------- + Single unified configuration surface for Orbit: the daily-summary + switch, the run-time schedule, and the prompt-template selection + all live inside one card, separated by hairline dividers. The + template row was previously a parallel card; folding it in here + collapses the "two paired panels" pattern into one cohesive + stack so users configure Orbit in one place. */} +
+ {controlsLocked ? ( +
+ + + {t('settings.orbit.controlsLockedBadge')} + + + {t('settings.orbit.controlsLockedHint')} + +
+ ) : null} +
+
+ {t('settings.orbit.dailySummaryTitle')} + + {t('settings.orbit.dailySummarySub')} + +
+ +
+ +
); } @@ -1890,9 +3264,11 @@ function ComposioSection({ function MediaProvidersSection({ cfg, setCfg, + onChange, }: { cfg: AppConfig; setCfg: Dispatch>; + onChange: () => void; }) { const { t } = useI18n(); const [visibleApiKeys, setVisibleApiKeys] = useState>( @@ -1924,6 +3300,7 @@ function MediaProvidersSection({ provider: MediaProvider, patch: { apiKey?: string; baseUrl?: string; model?: string }, ) => { + onChange(); setCfg((curr) => { const prev = curr.mediaProviders?.[provider.id] ?? { apiKey: '', baseUrl: '', model: '' }; const next = { ...prev, ...patch }; diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 51f82890a..f6876ca1f 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -199,12 +199,73 @@ export const ar: Dict = { 'connectors.statusConnected': 'متصل', 'connectors.statusError': 'خطأ', 'connectors.statusDisabled': 'معطل', - 'connectors.gateTitle': 'قم بإعداد مفتاح Composio API أولاً', - 'connectors.gateBody': 'تتطلب الموصلات مفتاح Composio API. أضفه في الإعدادات لتفعيل التكاملات المتاحة.', - 'connectors.gateAction': 'فتح الإعدادات', + 'connectors.gateTitle': 'أضف مفتاح Composio API للمتابعة', + 'connectors.gateBody': 'الصق المفتاح أعلاه واضغط حفظ المفتاح لتحميل التكاملات المتاحة.', 'connectors.aboutLabel': 'حول', 'connectors.detailsLabel': 'التفاصيل', 'connectors.statusLabel': 'الحالة', + 'connectors.category.aiAgents': 'وكلاء AI', + 'connectors.category.aiInfrastructure': 'بنية AI التحتية', + 'connectors.category.accounting': 'المحاسبة', + 'connectors.category.admin': 'الإدارة', + 'connectors.category.advertising': 'الإعلانات', + 'connectors.category.analytics': 'التحليلات', + 'connectors.category.automation': 'الأتمتة', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'التقويم', + 'connectors.category.commerce': 'التجارة', + 'connectors.category.communication': 'التواصل', + 'connectors.category.contacts': 'جهات الاتصال', + 'connectors.category.dataPlatform': 'منصة البيانات', + 'connectors.category.database': 'قاعدة البيانات', + 'connectors.category.design': 'التصميم', + 'connectors.category.developer': 'أدوات المطورين', + 'connectors.category.documentation': 'التوثيق', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'التعليم', + 'connectors.category.email': 'البريد الإلكتروني', + 'connectors.category.events': 'الأحداث', + 'connectors.category.fieldService': 'الخدمة الميدانية', + 'connectors.category.finance': 'المالية', + 'connectors.category.fitness': 'اللياقة', + 'connectors.category.forms': 'النماذج', + 'connectors.category.gaming': 'الألعاب', + 'connectors.category.hr': 'الموارد البشرية', + 'connectors.category.hospitality': 'الضيافة', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'التكامل', + 'connectors.category.localization': 'التوطين', + 'connectors.category.logistics': 'اللوجستيات', + 'connectors.category.maps': 'الخرائط', + 'connectors.category.marketing': 'التسويق', + 'connectors.category.media': 'الوسائط', + 'connectors.category.meetings': 'الاجتماعات', + 'connectors.category.nonprofit': 'غير ربحي', + 'connectors.category.observability': 'قابلية المراقبة', + 'connectors.category.payments': 'المدفوعات', + 'connectors.category.personal': 'شخصي', + 'connectors.category.presentations': 'العروض التقديمية', + 'connectors.category.procurement': 'المشتريات', + 'connectors.category.product': 'المنتج', + 'connectors.category.productivity': 'الإنتاجية', + 'connectors.category.projectManagement': 'إدارة المشاريع', + 'connectors.category.recruiting': 'التوظيف', + 'connectors.category.research': 'البحث', + 'connectors.category.salesIntelligence': 'ذكاء المبيعات', + 'connectors.category.scheduling': 'الجدولة', + 'connectors.category.search': 'البحث', + 'connectors.category.security': 'الأمان', + 'connectors.category.signing': 'التوقيع', + 'connectors.category.social': 'اجتماعي', + 'connectors.category.spreadsheets': 'جداول البيانات', + 'connectors.category.storage': 'التخزين', + 'connectors.category.support': 'الدعم', + 'connectors.category.surveys': 'الاستبيانات', + 'connectors.category.tasks': 'المهام', + 'connectors.category.timeTracking': 'تتبع الوقت', + 'connectors.category.video': 'الفيديو', + 'connectors.category.whiteboard': 'السبورة', 'connectors.categoryLabel': 'الفئة', 'connectors.providerLabel': 'المزوّد', 'connectors.toolsSection': 'الأدوات', @@ -1002,9 +1063,115 @@ export const ar: Dict = { 'settings.libraryNoResults': 'لا توجد عناصر تطابق بحثك.', 'settings.libraryEnabled': 'مفعّل', 'settings.libraryDisabled': 'معطّل', + 'settings.connectorsNavHint': 'اتصالات الأنظمة الخارجية', + 'settings.connectorsHint': 'إدارة إعدادات الموصّلات ومزوّدي الأدوات لهذا الجهاز.', + 'settings.connectorsComposioApiKey': 'مفتاح API لـ Composio', + 'settings.connectorsSavedTitle': 'محفوظ في daemon المحلي', + 'settings.connectorsSavedWithTail': 'محفوظ · ••••{tail}', + 'settings.connectorsSaved': 'محفوظ', + 'settings.connectorsGetApiKey': 'الحصول على مفتاح API', + 'settings.connectorsReplaceKeyPlaceholder': 'الصق مفتاحًا جديدًا لاستبدال المحفوظ', + 'settings.connectorsApiKeyPlaceholder': 'الصق مفتاح API لـ Composio', + 'settings.connectorsClear': 'مسح', + 'settings.connectorsClearConfirmTitle': 'مسح مفتاح Composio API المحفوظ؟', + 'settings.connectorsClearConfirmBody': 'إزالة المفتاح ستفصل جميع موصلات Composio المرتبطة بمساحة العمل هذه. سيُحذف كل من الحسابات المتصلة وأذونات OAuth والوصول إلى الأدوات.', + 'settings.connectorsClearConfirmContinue': 'متابعة', + 'settings.connectorsClearFinalTitle': 'سيؤدي هذا إلى فصل جميع الموصلات', + 'settings.connectorsClearFinalBody': 'لا يمكن التراجع عن هذا الإجراء. ستحتاج إلى إعادة ربط كل تكامل من البداية بعد لصق مفتاح جديد.', + 'settings.connectorsClearFinalConfirm': 'حذف المفتاح وفصل التكاملات', + 'settings.connectorsClearArming': 'لحظة\u2026', + 'settings.connectorsClearCancel': 'إلغاء', + 'settings.connectorsSaveKey': "حفظ المفتاح", + 'settings.connectorsSaveKeyTitle': "إرسال هذا المفتاح إلى الـ daemon المحلي", + 'settings.connectorsKeySaving': "جارٍ الحفظ…", + 'settings.connectorsKeyError': "تعذّر حفظ المفتاح. تأكد أن الـ daemon المحلي يعمل ثم حاول مرة أخرى.", + 'settings.connectorsHelpSaved': 'يفتح مفتاحك الكتالوج أدناه ويبقى في daemon المحلي. الصق مفتاحًا جديدًا لاستبداله أو امسحه لإزالته.', + 'settings.connectorsHelpUnsaved': "تغييرات غير محفوظة — اضغط على \"حفظ المفتاح\" لتخزين بيانات الاعتماد في الـ daemon المحلي وفتح الكتالوج أدناه.", + 'settings.connectorsHelpEmpty': 'أضف مفتاحًا لفتح الكتالوج أدناه. تُخزّن المفاتيح محليًا في daemon ولا تُرسل أبدًا عبر متغيرات البيئة.', + 'settings.connectorsLoadingSavedKey': 'جارٍ التحقق من مفتاح محفوظ في daemon المحلي…', + 'settings.autosaveSaving': "جارٍ الحفظ…", + 'settings.autosaveSaved': "تم حفظ جميع التغييرات", + 'settings.autosaveError': "تعذّر حفظ التغييرات. قد يكون الـ daemon المحلي غير متاح.", 'settings.libraryToggleLabel': 'تبديل', 'notify.successTitle': 'اكتملت المهمة', 'notify.failureTitle': 'فشلت المهمة', 'notify.successBody': 'انتهت جولة.', 'notify.failureBody': 'انتهت المهمة بخطأ.', + 'settings.orbit.eyebrow': 'الأتمتة', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'ملخص يومي للموصّلات', + 'settings.orbit.lede': 'اجمع نشاط الموصّلات وفق جدول وانشر النتيجة كـ live artifact قابل للتحديث.', + 'settings.orbit.statusOnTitle': 'التشغيلات اليومية المجدولة مفعّلة', + 'settings.orbit.statusOffTitle': 'التشغيلات اليومية المجدولة متوقفة', + 'settings.orbit.statusActive': 'نشط', + 'settings.orbit.statusOff': 'متوقف', + 'settings.orbit.runTitle': 'ابدأ تشغيل Orbit وافتح المحادثة الحية', + 'settings.orbit.running': 'قيد التشغيل…', + 'settings.orbit.runOpen': 'شغّله الآن', + 'settings.orbit.dailySummaryTitle': 'ملخص يومي', + 'settings.orbit.dailySummarySub': 'يعمل مرة يوميًا في الوقت المحلي المجدول.', + 'settings.orbit.on': 'تشغيل', + 'settings.orbit.off': 'متوقف', + 'settings.orbit.runTimeTitle': 'وقت التشغيل', + 'settings.orbit.runTimeSub': 'الافتراضي 08:00. احفظ لتطبيق جدول daemon.', + 'settings.orbit.runTimeAria': 'وقت تشغيل Orbit اليومي', + 'settings.orbit.nextRun': 'التشغيل التالي', + 'settings.orbit.nextRunScheduledAfterSave': 'سيُجدول بعد الحفظ', + 'settings.orbit.schedule': 'الجدول', + 'settings.orbit.pausedManualOnly': 'متوقف مؤقتًا — تشغيل يدوي فقط', + 'settings.orbit.templateTitle': 'قالب prompt', + 'settings.orbit.templateMissing': 'القالب {id} غير مثبت.', + 'settings.orbit.templateMissingOption': '{id} (مفقود)', + 'settings.orbit.templateMissingInstall': 'ثبّت skill لـ Orbit لتوجيه prompt.', + 'settings.orbit.templateMissingPickAnother': 'اختر قالبًا آخر من القائمة.', + 'settings.orbit.templateResetTitle': 'إعادة إلى {id}', + 'settings.orbit.templateReset': 'إعادة ضبط', + 'settings.orbit.templateHelp': 'وجّه Orbit باستخدام skill — يُحقن prompt المثال للقالب المحدد في كل تشغيل Orbit كي تتبع الملخصات شكل ذلك القالب.', + 'settings.orbit.templateAria': 'قالب prompt لـ Orbit', + 'settings.orbit.templatesLoading': 'جارٍ تحميل القوالب…', + 'settings.orbit.templatesOptgroup': 'قوالب skills لـ Orbit', + 'settings.orbit.lastRun': 'آخر تشغيل', + 'settings.orbit.triggerManual': 'يدوي', + 'settings.orbit.triggerScheduled': 'مجدول', + 'settings.orbit.meterAria': '{succeeded} نجح، {skipped} تم تخطيه، {failed} فشل من أصل {checked} تم فحصه', + 'settings.orbit.countChecked': 'تم الفحص', + 'settings.orbit.countSucceeded': 'نجح', + 'settings.orbit.countSkipped': 'تم التخطي', + 'settings.orbit.countFailed': 'فشل', + 'settings.orbit.runError': 'تعذر تشغيل Orbit. تأكد من أن daemon المحلي يعمل وأن الموصّلات مهيأة.', + 'settings.orbit.gateAriaLabel': "يلزم وجود موصلات لاستخدام Orbit", + 'settings.orbit.gateEyebrow': "الإعداد مطلوب", + 'settings.orbit.gateTitle': "اربط أداة لتشغيل Orbit", + 'settings.orbit.gateBody': "يلخّص Orbit نشاط الموصلات الخاصة بك. لم تربط أيّ موصل بعد — أضف تكاملًا واحدًا على الأقل ليعمل Orbit.", + 'settings.orbit.gateBodyNoKey': "يلخّص Orbit نشاط موصلاتك، وتعمل الموصلات عبر Composio. أضِف مفتاح Composio API في «الموصلات» لفتح الكتالوج واختيار أوّل تكامل لك.", + 'settings.orbit.gateAction': "فتح الموصلات", + 'settings.orbit.gateActionNoKey': "تهيئة Composio", + 'settings.orbit.gateLoading': "جارٍ فحص الموصلات…", + 'settings.orbit.controlsLockedBadge': "مقفل", + 'settings.orbit.controlsLockedHint': "اربط أداة لإلغاء قفل جدولة Orbit وقالبه.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'ملخص قديم', + 'settings.orbit.artifactTitle': 'ملخص نشاط Orbit اليومي', + 'settings.orbit.artifactMetaLive': 'artifact HTML قابل للتحديث تم إنشاؤه من نشاط الموصّلات.', + 'settings.orbit.artifactMetaLegacy': 'تم إنشاؤه قبل تفعيل دعم live artifact — شغّل Orbit مرة أخرى لنشر واحد.', + 'settings.orbit.copyMarkdownTitle': 'نسخ ملخص Markdown إلى الحافظة', + 'settings.orbit.copied': 'تم النسخ', + 'settings.orbit.copy': 'نسخ', + 'settings.orbit.openArtifact': 'فتح artifact', + 'settings.orbit.sourceMarkdown': 'Markdown المصدر', + 'liveArtifact.viewer.tabPreview': 'معاينة', + 'liveArtifact.viewer.tabCode': 'الكود', + 'liveArtifact.viewer.tabData': 'البيانات', + 'liveArtifact.viewer.tabRefreshHistory': 'سجل التحديث', + 'liveArtifact.viewer.dataEmpty': 'لا توجد ذاكرة data.json مؤقتة متاحة.', + 'liveArtifact.viewer.code.templateHeading': 'HTML القالب', + 'liveArtifact.viewer.code.renderedHeading': 'HTML المعروض', + 'liveArtifact.viewer.code.templateHelp': 'القالب القابل للتحرير المستخدم مع data.json لإنشاء المعاينة.', + 'liveArtifact.viewer.code.renderedHelp': 'ملف index.html المُنشأ الذي تحمّله المعاينة حاليًا.', + 'liveArtifact.viewer.code.variantAria': 'متغير الكود', + 'liveArtifact.viewer.code.variantTemplate': 'القالب', + 'liveArtifact.viewer.code.variantRendered': 'معروض', + 'liveArtifact.viewer.code.loading': 'جارٍ تحميل الكود…', + 'liveArtifact.viewer.code.unavailable': 'الكود غير متاح بعد.', + 'liveArtifact.viewer.code.empty': 'ملف الكود هذا فارغ.', }; diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index c716e7037..d4d942576 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -956,9 +956,115 @@ export const de: Dict = { 'settings.libraryNoResults': 'Keine Elemente entsprechen Ihrer Suche.', 'settings.libraryEnabled': 'Aktiviert', 'settings.libraryDisabled': 'Deaktiviert', + 'settings.connectorsNavHint': 'Externe Systemverbindungen', + 'settings.connectorsHint': 'Verwalte Connector- und Tool-Anbieter-Einstellungen für dieses Gerät.', + 'settings.connectorsComposioApiKey': 'Composio-API-Schlüssel', + 'settings.connectorsSavedTitle': 'Im lokalen Daemon gespeichert', + 'settings.connectorsSavedWithTail': 'Gespeichert · ••••{tail}', + 'settings.connectorsSaved': 'Gespeichert', + 'settings.connectorsGetApiKey': 'API-Schlüssel abrufen', + 'settings.connectorsReplaceKeyPlaceholder': 'Neuen Schlüssel einfügen, um den gespeicherten zu ersetzen', + 'settings.connectorsApiKeyPlaceholder': 'Composio-API-Schlüssel einfügen', + 'settings.connectorsClear': 'Löschen', + 'settings.connectorsClearConfirmTitle': 'Gespeicherten Composio-API-Schlüssel löschen?', + 'settings.connectorsClearConfirmBody': 'Beim Entfernen des Schlüssels werden alle Composio-Connectors dieses Workspaces getrennt. Verknüpfte Konten, OAuth-Freigaben und Tool-Zugriffe werden allesamt entfernt.', + 'settings.connectorsClearConfirmContinue': 'Weiter', + 'settings.connectorsClearFinalTitle': 'Damit werden alle Connectors getrennt', + 'settings.connectorsClearFinalBody': 'Dieser Schritt lässt sich nicht rückgängig machen. Nach dem Einfügen eines neuen Schlüssels musst du jede Integration neu verbinden.', + 'settings.connectorsClearFinalConfirm': 'Schlüssel löschen & trennen', + 'settings.connectorsClearArming': 'Einen Moment\u2026', + 'settings.connectorsClearCancel': 'Abbrechen', + 'settings.connectorsSaveKey': "Key speichern", + 'settings.connectorsSaveKeyTitle': "Diesen Key an den lokalen Daemon senden", + 'settings.connectorsKeySaving': "Speichere…", + 'settings.connectorsKeyError': "Key konnte nicht gespeichert werden. Prüfe, ob der lokale Daemon läuft, und versuche es erneut.", + 'settings.connectorsHelpSaved': 'Dein Schlüssel entsperrt den Katalog unten und bleibt im lokalen Daemon. Füge einen neuen Schlüssel ein, um ihn zu ersetzen, oder lösche ihn.', + 'settings.connectorsHelpUnsaved': "Ungespeicherte Änderungen — klicke auf „Key speichern“, um diesen Schlüssel im lokalen Daemon abzulegen und den Katalog unten freizuschalten.", + 'settings.connectorsHelpEmpty': 'Füge einen Schlüssel hinzu, um den Katalog unten zu entsperren. Schlüssel werden lokal im Daemon gespeichert und nie über Umgebungsvariablen gesendet.', + 'settings.connectorsLoadingSavedKey': 'Im lokalen Daemon wird nach einem gespeicherten Schlüssel gesucht…', + 'settings.autosaveSaving': "Speichere…", + 'settings.autosaveSaved': "Alle Änderungen gespeichert", + 'settings.autosaveError': "Änderungen konnten nicht gespeichert werden. Der lokale Daemon ist möglicherweise offline.", 'settings.libraryToggleLabel': 'Umschalten', 'notify.successTitle': 'Aufgabe abgeschlossen', 'notify.failureTitle': 'Aufgabe fehlgeschlagen', 'notify.successBody': 'Eine Runde ist abgeschlossen.', 'notify.failureBody': 'Die Aufgabe wurde mit einem Fehler beendet.', + 'settings.orbit.eyebrow': 'Automatisierung', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Tägliche Connector-Zusammenfassung', + 'settings.orbit.lede': 'Connector-Aktivität nach Zeitplan sammeln und das Ergebnis als aktualisierbares live artifact veröffentlichen.', + 'settings.orbit.statusOnTitle': 'Geplante tägliche Läufe sind aktiv', + 'settings.orbit.statusOffTitle': 'Geplante tägliche Läufe sind deaktiviert', + 'settings.orbit.statusActive': 'Aktiv', + 'settings.orbit.statusOff': 'Aus', + 'settings.orbit.runTitle': 'Einen Orbit-Lauf starten und die Live-Konversation öffnen', + 'settings.orbit.running': 'Läuft…', + 'settings.orbit.runOpen': 'Jetzt ausführen', + 'settings.orbit.dailySummaryTitle': 'Tägliche Zusammenfassung', + 'settings.orbit.dailySummarySub': 'Wird einmal pro Tag zur geplanten lokalen Zeit ausgeführt.', + 'settings.orbit.on': 'Ein', + 'settings.orbit.off': 'Aus', + 'settings.orbit.runTimeTitle': 'Ausführungszeit', + 'settings.orbit.runTimeSub': 'Standard 08:00. Speichern, um den Daemon-Zeitplan anzuwenden.', + 'settings.orbit.runTimeAria': 'Tägliche Orbit-Ausführungszeit', + 'settings.orbit.nextRun': 'Nächster Lauf', + 'settings.orbit.nextRunScheduledAfterSave': 'Nach dem Speichern geplant', + 'settings.orbit.schedule': 'Zeitplan', + 'settings.orbit.pausedManualOnly': 'Pausiert — nur manuelle Läufe', + 'settings.orbit.templateTitle': 'Prompt-Vorlage', + 'settings.orbit.templateMissing': 'Vorlage {id} ist nicht installiert.', + 'settings.orbit.templateMissingOption': '{id} (fehlt)', + 'settings.orbit.templateMissingInstall': 'Installiere eine Orbit-Skill, um den Prompt zu steuern.', + 'settings.orbit.templateMissingPickAnother': 'Wähle eine andere Vorlage aus dem Dropdown.', + 'settings.orbit.templateResetTitle': 'Auf {id} zurücksetzen', + 'settings.orbit.templateReset': 'Zurücksetzen', + 'settings.orbit.templateHelp': 'Steuere Orbit mit einer Skill — der Beispiel-Prompt der ausgewählten Vorlage wird in jeden Orbit-Lauf eingefügt, damit Zusammenfassungen dieser Vorlagenform folgen.', + 'settings.orbit.templateAria': 'Orbit-Prompt-Vorlage', + 'settings.orbit.templatesLoading': 'Vorlagen werden geladen…', + 'settings.orbit.templatesOptgroup': 'Orbit-Skill-Vorlagen', + 'settings.orbit.lastRun': 'Letzter Lauf', + 'settings.orbit.triggerManual': 'Manuell', + 'settings.orbit.triggerScheduled': 'Geplant', + 'settings.orbit.meterAria': '{succeeded} erfolgreich, {skipped} übersprungen, {failed} fehlgeschlagen von {checked} geprüft', + 'settings.orbit.countChecked': 'Geprüft', + 'settings.orbit.countSucceeded': 'Erfolgreich', + 'settings.orbit.countSkipped': 'Übersprungen', + 'settings.orbit.countFailed': 'Fehlgeschlagen', + 'settings.orbit.runError': 'Orbit konnte nicht ausgeführt werden. Stelle sicher, dass der lokale Daemon läuft und Connectors konfiguriert sind.', + 'settings.orbit.gateAriaLabel': "Connectors werden für Orbit benötigt", + 'settings.orbit.gateEyebrow': "Einrichtung erforderlich", + 'settings.orbit.gateTitle': "Verbinde ein Tool, um Orbit zu nutzen", + 'settings.orbit.gateBody': "Orbit fasst die Aktivität deiner Connectors zusammen. Du hast noch nichts verbunden — füge mindestens eine Integration hinzu, damit Orbit etwas zu berichten hat.", + 'settings.orbit.gateBodyNoKey': "Orbit fasst die Aktivität deiner Connectors zusammen, und Connectors laufen über Composio. Trage einen Composio-API-Key unter Connectors ein, um den Katalog freizuschalten und deine erste Integration zu wählen.", + 'settings.orbit.gateAction': "Connectors öffnen", + 'settings.orbit.gateActionNoKey': "Composio konfigurieren", + 'settings.orbit.gateLoading': "Connectors werden geprüft…", + 'settings.orbit.controlsLockedBadge': "Gesperrt", + 'settings.orbit.controlsLockedHint': "Verbinde ein Tool, um Zeitplan und Vorlage von Orbit freizuschalten.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Legacy-Zusammenfassung', + 'settings.orbit.artifactTitle': 'Tägliche Orbit-Aktivitätszusammenfassung', + 'settings.orbit.artifactMetaLive': 'Aktualisierbares HTML-Artefakt, erzeugt aus Connector-Aktivität.', + 'settings.orbit.artifactMetaLegacy': 'Erzeugt, bevor live artifact-Unterstützung aktiviert wurde — führe Orbit erneut aus, um eines zu veröffentlichen.', + 'settings.orbit.copyMarkdownTitle': 'Markdown-Zusammenfassung in die Zwischenablage kopieren', + 'settings.orbit.copied': 'Kopiert', + 'settings.orbit.copy': 'Kopieren', + 'settings.orbit.openArtifact': 'Artefakt öffnen', + 'settings.orbit.sourceMarkdown': 'Quell-Markdown', + 'liveArtifact.viewer.tabPreview': 'Vorschau', + 'liveArtifact.viewer.tabCode': 'Code', + 'liveArtifact.viewer.tabData': 'Daten', + 'liveArtifact.viewer.tabRefreshHistory': 'Aktualisierungsverlauf', + 'liveArtifact.viewer.dataEmpty': 'Kein data.json-Cache verfügbar.', + 'liveArtifact.viewer.code.templateHeading': 'Vorlagen-HTML', + 'liveArtifact.viewer.code.renderedHeading': 'Gerendertes HTML', + 'liveArtifact.viewer.code.templateHelp': 'Die bearbeitbare Vorlage, die mit data.json zum Erzeugen der Vorschau verwendet wird.', + 'liveArtifact.viewer.code.renderedHelp': 'Die erzeugte index.html, die derzeit von der Vorschau geladen wird.', + 'liveArtifact.viewer.code.variantAria': 'Code-Variante', + 'liveArtifact.viewer.code.variantTemplate': 'Vorlage', + 'liveArtifact.viewer.code.variantRendered': 'Gerendert', + 'liveArtifact.viewer.code.loading': 'Code wird geladen…', + 'liveArtifact.viewer.code.unavailable': 'Code ist noch nicht verfügbar.', + 'liveArtifact.viewer.code.empty': 'Diese Codedatei ist leer.', }; diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 68bd7551d..21e20a917 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -197,12 +197,73 @@ export const en: Dict = { 'connectors.statusConnected': 'Connected', 'connectors.statusError': 'Error', 'connectors.statusDisabled': 'Disabled', - 'connectors.gateTitle': 'Configure Composio API key', - 'connectors.gateBody': 'Connectors require a Composio API key. Add it in Settings to unlock available integrations.', - 'connectors.gateAction': 'Open settings', + 'connectors.gateTitle': 'Add your Composio API key to continue', + 'connectors.gateBody': 'Paste your key above and click Save key to load available integrations.', 'connectors.aboutLabel': 'About', 'connectors.detailsLabel': 'Details', 'connectors.statusLabel': 'Status', + 'connectors.category.aiAgents': 'AI agents', + 'connectors.category.aiInfrastructure': 'AI infrastructure', + 'connectors.category.accounting': 'Accounting', + 'connectors.category.admin': 'Admin', + 'connectors.category.advertising': 'Advertising', + 'connectors.category.analytics': 'Analytics', + 'connectors.category.automation': 'Automation', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Calendar', + 'connectors.category.commerce': 'Commerce', + 'connectors.category.communication': 'Communication', + 'connectors.category.contacts': 'Contacts', + 'connectors.category.dataPlatform': 'Data platform', + 'connectors.category.database': 'Database', + 'connectors.category.design': 'Design', + 'connectors.category.developer': 'Developer', + 'connectors.category.documentation': 'Documentation', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Education', + 'connectors.category.email': 'Email', + 'connectors.category.events': 'Events', + 'connectors.category.fieldService': 'Field service', + 'connectors.category.finance': 'Finance', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Forms', + 'connectors.category.gaming': 'Gaming', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': 'Hospitality', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Integration', + 'connectors.category.localization': 'Localization', + 'connectors.category.logistics': 'Logistics', + 'connectors.category.maps': 'Maps', + 'connectors.category.marketing': 'Marketing', + 'connectors.category.media': 'Media', + 'connectors.category.meetings': 'Meetings', + 'connectors.category.nonprofit': 'Nonprofit', + 'connectors.category.observability': 'Observability', + 'connectors.category.payments': 'Payments', + 'connectors.category.personal': 'Personal', + 'connectors.category.presentations': 'Presentations', + 'connectors.category.procurement': 'Procurement', + 'connectors.category.product': 'Product', + 'connectors.category.productivity': 'Productivity', + 'connectors.category.projectManagement': 'Project management', + 'connectors.category.recruiting': 'Recruiting', + 'connectors.category.research': 'Research', + 'connectors.category.salesIntelligence': 'Sales intelligence', + 'connectors.category.scheduling': 'Scheduling', + 'connectors.category.search': 'Search', + 'connectors.category.security': 'Security', + 'connectors.category.signing': 'Signing', + 'connectors.category.social': 'Social', + 'connectors.category.spreadsheets': 'Spreadsheets', + 'connectors.category.storage': 'Storage', + 'connectors.category.support': 'Support', + 'connectors.category.surveys': 'Surveys', + 'connectors.category.tasks': 'Tasks', + 'connectors.category.timeTracking': 'Time tracking', + 'connectors.category.video': 'Video', + 'connectors.category.whiteboard': 'Whiteboard', 'connectors.categoryLabel': 'Category', 'connectors.providerLabel': 'Provider', 'connectors.toolsSection': 'Tools', @@ -726,6 +787,21 @@ export const en: Dict = { 'liveArtifact.refresh.statusReady': 'Ready to refresh', 'liveArtifact.refresh.statusSucceeded': 'Up to date', 'liveArtifact.refresh.statusFailed': 'Refresh failed', + 'liveArtifact.viewer.tabPreview': 'Preview', + 'liveArtifact.viewer.tabCode': 'Code', + 'liveArtifact.viewer.tabData': 'Data', + 'liveArtifact.viewer.tabRefreshHistory': 'Refresh history', + 'liveArtifact.viewer.dataEmpty': 'No data.json cache available.', + 'liveArtifact.viewer.code.templateHeading': 'Template HTML', + 'liveArtifact.viewer.code.renderedHeading': 'Rendered HTML', + 'liveArtifact.viewer.code.templateHelp': 'The editable template used with data.json to generate the preview.', + 'liveArtifact.viewer.code.renderedHelp': 'The generated index.html currently loaded by Preview.', + 'liveArtifact.viewer.code.variantAria': 'Code variant', + 'liveArtifact.viewer.code.variantTemplate': 'Template', + 'liveArtifact.viewer.code.variantRendered': 'Rendered', + 'liveArtifact.viewer.code.loading': 'Loading code…', + 'liveArtifact.viewer.code.unavailable': 'Code is not available yet.', + 'liveArtifact.viewer.code.empty': 'This code file is empty.', 'fileViewer.deployToVercel': 'Deploy to Vercel', 'fileViewer.redeployToVercel': 'Redeploy', 'fileViewer.deployingToVercel': 'Deploying to Vercel…', @@ -1033,7 +1109,98 @@ export const en: Dict = { 'settings.libraryNoResults': 'No items match your search.', 'settings.libraryEnabled': 'Enabled', 'settings.libraryDisabled': 'Disabled', + 'settings.connectorsNavHint': 'External system connections', + 'settings.connectorsHint': 'Manage connector and tool provider settings for this device.', + 'settings.connectorsComposioApiKey': 'Composio API Key', + 'settings.connectorsSavedTitle': 'Saved to local daemon', + 'settings.connectorsSavedWithTail': 'Saved · ••••{tail}', + 'settings.connectorsSaved': 'Saved', + 'settings.connectorsGetApiKey': 'Get API Key', + 'settings.connectorsReplaceKeyPlaceholder': 'Paste a new key to replace the saved one', + 'settings.connectorsApiKeyPlaceholder': 'Paste Composio API key', + 'settings.connectorsClear': 'Clear', + 'settings.connectorsClearConfirmTitle': 'Clear the saved Composio API key?', + 'settings.connectorsClearConfirmBody': 'Removing the key disconnects every Composio connector linked to this workspace. Connected accounts, OAuth grants, and tool access will all be removed.', + 'settings.connectorsClearConfirmContinue': 'Continue', + 'settings.connectorsClearFinalTitle': 'This will disconnect all connectors', + 'settings.connectorsClearFinalBody': 'There is no undo. You will need to reconnect each integration from scratch after pasting a new key.', + 'settings.connectorsClearFinalConfirm': 'Delete key & disconnect', + 'settings.connectorsClearArming': 'Hold on\u2026', + 'settings.connectorsClearCancel': 'Cancel', + 'settings.connectorsSaveKey': 'Save key', + 'settings.connectorsSaveKeyTitle': 'Send this key to the local daemon', + 'settings.connectorsKeySaving': 'Saving\u2026', + 'settings.connectorsKeyError': 'Couldn\u2019t save the key. Check that the local daemon is running and try again.', + 'settings.connectorsHelpSaved': 'Your key is saved in the local daemon. Paste a new key to replace it, or Clear to remove.', + 'settings.connectorsHelpUnsaved': 'Unsaved changes \u2014 click Save key to store this credential in the local daemon and refresh the catalog below.', + 'settings.connectorsHelpEmpty': 'Add a key to load the catalog below. Keys are stored locally in the daemon and never sent through environment variables.', + 'settings.connectorsLoadingSavedKey': 'Checking for a saved key in the local daemon\u2026', + 'settings.autosaveSaving': 'Saving\u2026', + 'settings.autosaveSaved': 'All changes saved', + 'settings.autosaveError': 'Couldn\u2019t save changes. The local daemon may be offline.', 'settings.libraryToggleLabel': 'Toggle', + 'settings.orbit.eyebrow': 'Automation', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Daily connector summary', + 'settings.orbit.lede': 'Collect connector activity on a schedule and publish the result as a refreshable live artifact.', + 'settings.orbit.statusOnTitle': 'Scheduled daily runs are on', + 'settings.orbit.statusOffTitle': 'Scheduled daily runs are off', + 'settings.orbit.statusActive': 'Active', + 'settings.orbit.statusOff': 'Off', + 'settings.orbit.runTitle': 'Start an Orbit run and open the live conversation', + 'settings.orbit.running': 'Running…', + 'settings.orbit.runOpen': 'Run it now', + 'settings.orbit.dailySummaryTitle': 'Daily summary', + 'settings.orbit.dailySummarySub': 'Runs once per day at the scheduled local time.', + 'settings.orbit.on': 'On', + 'settings.orbit.off': 'Off', + 'settings.orbit.runTimeTitle': 'Run time', + 'settings.orbit.runTimeSub': 'Default 08:00. Save to apply to the daemon schedule.', + 'settings.orbit.runTimeAria': 'Daily Orbit run time', + 'settings.orbit.nextRun': 'Next run', + 'settings.orbit.nextRunScheduledAfterSave': 'Scheduled after Save', + 'settings.orbit.schedule': 'Schedule', + 'settings.orbit.pausedManualOnly': 'Paused — manual runs only', + 'settings.orbit.templateTitle': 'Prompt template', + 'settings.orbit.templateMissing': 'Template {id} is not installed.', + 'settings.orbit.templateMissingOption': '{id} (missing)', + 'settings.orbit.templateMissingInstall': 'Install an Orbit skill to steer the prompt.', + 'settings.orbit.templateMissingPickAnother': 'Pick another template from the dropdown.', + 'settings.orbit.templateResetTitle': 'Reset to {id}', + 'settings.orbit.templateReset': 'Reset', + 'settings.orbit.templateHelp': 'Steer Orbit with a skill — the selected template example prompt is injected into every Orbit run so summaries follow that template shape.', + 'settings.orbit.templateAria': 'Orbit prompt template', + 'settings.orbit.templatesLoading': 'Loading templates…', + 'settings.orbit.templatesOptgroup': 'Orbit skill templates', + 'settings.orbit.lastRun': 'Last run', + 'settings.orbit.triggerManual': 'Manual', + 'settings.orbit.triggerScheduled': 'Scheduled', + 'settings.orbit.meterAria': '{succeeded} succeeded, {skipped} skipped, {failed} failed out of {checked} checked', + 'settings.orbit.countChecked': 'Checked', + 'settings.orbit.countSucceeded': 'Succeeded', + 'settings.orbit.countSkipped': 'Skipped', + 'settings.orbit.countFailed': 'Failed', + 'settings.orbit.runError': 'Could not run Orbit. Make sure the local daemon is running and connectors are configured.', + 'settings.orbit.gateAriaLabel': 'Connectors required to use Orbit', + 'settings.orbit.gateEyebrow': 'Setup required', + 'settings.orbit.gateTitle': 'Connect a tool to power Orbit', + 'settings.orbit.gateBody': 'Orbit summarizes activity across your connectors. You haven\u2019t connected anything yet \u2014 add at least one integration to give Orbit something to report on.', + 'settings.orbit.gateBodyNoKey': 'Orbit summarizes activity across your connectors, and connectors run through Composio. Add a Composio API key in Connectors to load the catalog and pick your first integration.', + 'settings.orbit.gateAction': 'Open Connectors', + 'settings.orbit.gateActionNoKey': 'Configure Composio', + 'settings.orbit.gateLoading': 'Checking your connectors\u2026', + 'settings.orbit.controlsLockedBadge': 'Locked', + 'settings.orbit.controlsLockedHint': 'Connect a tool to unlock Orbit\u2019s schedule and template controls.', + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Legacy summary', + 'settings.orbit.artifactTitle': 'Daily Orbit activity summary', + 'settings.orbit.artifactMetaLive': 'Refreshable HTML artifact generated from connector activity.', + 'settings.orbit.artifactMetaLegacy': 'Generated before live artifact support was enabled — run Orbit again to publish one.', + 'settings.orbit.copyMarkdownTitle': 'Copy markdown summary to clipboard', + 'settings.orbit.copied': 'Copied', + 'settings.orbit.copy': 'Copy', + 'settings.orbit.openArtifact': 'Open artifact', + 'settings.orbit.sourceMarkdown': 'Source markdown', 'notify.successTitle': 'Task completed', 'notify.failureTitle': 'Task failed', 'notify.successBody': 'A turn has finished.', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 827da7f3e..69a630f24 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -957,9 +957,115 @@ export const esES: Dict = { 'settings.libraryNoResults': 'Ningún elemento coincide con tu búsqueda.', 'settings.libraryEnabled': 'Activado', 'settings.libraryDisabled': 'Desactivado', + 'settings.connectorsNavHint': 'Conexiones a sistemas externos', + 'settings.connectorsHint': 'Gestiona la configuración de conectores y proveedores de herramientas para este dispositivo.', + 'settings.connectorsComposioApiKey': 'Clave API de Composio', + 'settings.connectorsSavedTitle': 'Guardada en el daemon local', + 'settings.connectorsSavedWithTail': 'Guardada · ••••{tail}', + 'settings.connectorsSaved': 'Guardada', + 'settings.connectorsGetApiKey': 'Obtener clave API', + 'settings.connectorsReplaceKeyPlaceholder': 'Pega una clave nueva para sustituir la guardada', + 'settings.connectorsApiKeyPlaceholder': 'Pega la clave API de Composio', + 'settings.connectorsClear': 'Borrar', + 'settings.connectorsClearConfirmTitle': '¿Borrar la clave de API de Composio guardada?', + 'settings.connectorsClearConfirmBody': 'Eliminar la clave desconecta todos los conectores de Composio vinculados a este espacio. Se quitarán las cuentas conectadas, los permisos OAuth y el acceso a las herramientas.', + 'settings.connectorsClearConfirmContinue': 'Continuar', + 'settings.connectorsClearFinalTitle': 'Esto desconectará todos los conectores', + 'settings.connectorsClearFinalBody': 'No hay vuelta atrás. Tendrás que volver a conectar cada integración desde cero después de pegar una clave nueva.', + 'settings.connectorsClearFinalConfirm': 'Borrar clave y desconectar', + 'settings.connectorsClearArming': 'Un momento\u2026', + 'settings.connectorsClearCancel': 'Cancelar', + 'settings.connectorsSaveKey': "Guardar clave", + 'settings.connectorsSaveKeyTitle': "Enviar esta clave al daemon local", + 'settings.connectorsKeySaving': "Guardando…", + 'settings.connectorsKeyError': "No se pudo guardar la clave. Comprueba que el daemon local está activo y vuelve a intentarlo.", + 'settings.connectorsHelpSaved': 'Tu clave desbloquea el catálogo de abajo y permanece en el daemon local. Pega una clave nueva para sustituirla o bórrala.', + 'settings.connectorsHelpUnsaved': "Cambios sin guardar — pulsa Guardar clave para almacenar esta credencial en el daemon local y desbloquear el catálogo de abajo.", + 'settings.connectorsHelpEmpty': 'Añade una clave para desbloquear el catálogo de abajo. Las claves se guardan localmente en el daemon y nunca se envían mediante variables de entorno.', + 'settings.connectorsLoadingSavedKey': 'Buscando una clave guardada en el daemon local…', + 'settings.autosaveSaving': "Guardando…", + 'settings.autosaveSaved': "Todos los cambios guardados", + 'settings.autosaveError': "No se pudieron guardar los cambios. Es posible que el daemon local esté desconectado.", 'settings.libraryToggleLabel': 'Alternar', 'notify.successTitle': 'Tarea completada', 'notify.failureTitle': 'La tarea falló', 'notify.successBody': 'Un turno ha terminado.', 'notify.failureBody': 'La tarea terminó con un error.', + 'settings.orbit.eyebrow': 'Automatización', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Resumen diario de conectores', + 'settings.orbit.lede': 'Recopila actividad de conectores según una programación y publica el resultado como un live artifact actualizable.', + 'settings.orbit.statusOnTitle': 'Las ejecuciones diarias programadas están activadas', + 'settings.orbit.statusOffTitle': 'Las ejecuciones diarias programadas están desactivadas', + 'settings.orbit.statusActive': 'Activo', + 'settings.orbit.statusOff': 'Desactivado', + 'settings.orbit.runTitle': 'Iniciar una ejecución de Orbit y abrir la conversación en vivo', + 'settings.orbit.running': 'Ejecutando…', + 'settings.orbit.runOpen': 'Ejecutar ahora', + 'settings.orbit.dailySummaryTitle': 'Resumen diario', + 'settings.orbit.dailySummarySub': 'Se ejecuta una vez al día a la hora local programada.', + 'settings.orbit.on': 'Activado', + 'settings.orbit.off': 'Desactivado', + 'settings.orbit.runTimeTitle': 'Hora de ejecución', + 'settings.orbit.runTimeSub': 'Predeterminada 08:00. Guarda para aplicar la programación del daemon.', + 'settings.orbit.runTimeAria': 'Hora de ejecución diaria de Orbit', + 'settings.orbit.nextRun': 'Próxima ejecución', + 'settings.orbit.nextRunScheduledAfterSave': 'Programada después de guardar', + 'settings.orbit.schedule': 'Programación', + 'settings.orbit.pausedManualOnly': 'Pausado — solo ejecuciones manuales', + 'settings.orbit.templateTitle': 'Plantilla de prompt', + 'settings.orbit.templateMissing': 'La plantilla {id} no está instalada.', + 'settings.orbit.templateMissingOption': '{id} (falta)', + 'settings.orbit.templateMissingInstall': 'Instala una skill de Orbit para dirigir el prompt.', + 'settings.orbit.templateMissingPickAnother': 'Elige otra plantilla en el desplegable.', + 'settings.orbit.templateResetTitle': 'Restablecer a {id}', + 'settings.orbit.templateReset': 'Restablecer', + 'settings.orbit.templateHelp': 'Dirige Orbit con una skill: el prompt de ejemplo de la plantilla seleccionada se inyecta en cada ejecución de Orbit para que los resúmenes sigan esa forma.', + 'settings.orbit.templateAria': 'Plantilla de prompt de Orbit', + 'settings.orbit.templatesLoading': 'Cargando plantillas…', + 'settings.orbit.templatesOptgroup': 'Plantillas de skills de Orbit', + 'settings.orbit.lastRun': 'Última ejecución', + 'settings.orbit.triggerManual': 'Manual', + 'settings.orbit.triggerScheduled': 'Programada', + 'settings.orbit.meterAria': '{succeeded} correctos, {skipped} omitidos, {failed} fallidos de {checked} comprobados', + 'settings.orbit.countChecked': 'Comprobados', + 'settings.orbit.countSucceeded': 'Correctos', + 'settings.orbit.countSkipped': 'Omitidos', + 'settings.orbit.countFailed': 'Fallidos', + 'settings.orbit.runError': 'No se pudo ejecutar Orbit. Asegúrate de que el daemon local esté en ejecución y los conectores estén configurados.', + 'settings.orbit.gateAriaLabel': "Se necesitan conectores para usar Orbit", + 'settings.orbit.gateEyebrow': "Configuración requerida", + 'settings.orbit.gateTitle': "Conecta una herramienta para impulsar Orbit", + 'settings.orbit.gateBody': "Orbit resume la actividad de tus conectores. Aún no has conectado ninguno — añade al menos una integración para que Orbit tenga algo que informar.", + 'settings.orbit.gateBodyNoKey': "Orbit resume la actividad de tus conectores, y los conectores funcionan a través de Composio. Añade una clave de API de Composio en Conectores para desbloquear el catálogo y elegir tu primera integración.", + 'settings.orbit.gateAction': "Abrir Conectores", + 'settings.orbit.gateActionNoKey': "Configurar Composio", + 'settings.orbit.gateLoading': "Comprobando tus conectores…", + 'settings.orbit.controlsLockedBadge': "Bloqueado", + 'settings.orbit.controlsLockedHint': "Conecta una herramienta para desbloquear la programación y la plantilla de Orbit.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Resumen heredado', + 'settings.orbit.artifactTitle': 'Resumen diario de actividad de Orbit', + 'settings.orbit.artifactMetaLive': 'Artefacto HTML actualizable generado a partir de actividad de conectores.', + 'settings.orbit.artifactMetaLegacy': 'Generado antes de activar la compatibilidad con live artifact; ejecuta Orbit de nuevo para publicar uno.', + 'settings.orbit.copyMarkdownTitle': 'Copiar resumen Markdown al portapapeles', + 'settings.orbit.copied': 'Copiado', + 'settings.orbit.copy': 'Copiar', + 'settings.orbit.openArtifact': 'Abrir artefacto', + 'settings.orbit.sourceMarkdown': 'Markdown fuente', + 'liveArtifact.viewer.tabPreview': 'Vista previa', + 'liveArtifact.viewer.tabCode': 'Código', + 'liveArtifact.viewer.tabData': 'Datos', + 'liveArtifact.viewer.tabRefreshHistory': 'Historial de actualizaciones', + 'liveArtifact.viewer.dataEmpty': 'No hay caché data.json disponible.', + 'liveArtifact.viewer.code.templateHeading': 'HTML de plantilla', + 'liveArtifact.viewer.code.renderedHeading': 'HTML renderizado', + 'liveArtifact.viewer.code.templateHelp': 'La plantilla editable usada con data.json para generar la vista previa.', + 'liveArtifact.viewer.code.renderedHelp': 'El index.html generado que Preview carga actualmente.', + 'liveArtifact.viewer.code.variantAria': 'Variante de código', + 'liveArtifact.viewer.code.variantTemplate': 'Plantilla', + 'liveArtifact.viewer.code.variantRendered': 'Renderizado', + 'liveArtifact.viewer.code.loading': 'Cargando código…', + 'liveArtifact.viewer.code.unavailable': 'El código aún no está disponible.', + 'liveArtifact.viewer.code.empty': 'Este archivo de código está vacío.', }; diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index a26f595d7..e3f7ccd76 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -198,12 +198,73 @@ export const fa: Dict = { 'connectors.statusConnected': 'متصل', 'connectors.statusError': 'خطا', 'connectors.statusDisabled': 'غیرفعال', - 'connectors.gateTitle': 'ابتدا کلید Composio API را پیکربندی کنید', - 'connectors.gateBody': 'اتصال‌دهنده‌ها به کلید Composio API نیاز دارند. برای فعال‌سازی ادغام‌های موجود، آن را در تنظیمات اضافه کنید.', - 'connectors.gateAction': 'باز کردن تنظیمات', + 'connectors.gateTitle': 'برای ادامه کلید Composio API را اضافه کنید', + 'connectors.gateBody': 'کلید را در بالا جای‌گذاری کنید و روی ذخیره کلید بزنید تا یکپارچه‌سازی‌های موجود بارگیری شوند.', 'connectors.aboutLabel': 'درباره', 'connectors.detailsLabel': 'جزئیات', 'connectors.statusLabel': 'وضعیت', + 'connectors.category.aiAgents': 'عامل‌های AI', + 'connectors.category.aiInfrastructure': 'زیرساخت AI', + 'connectors.category.accounting': 'حسابداری', + 'connectors.category.admin': 'مدیریت', + 'connectors.category.advertising': 'تبلیغات', + 'connectors.category.analytics': 'تحلیل‌ها', + 'connectors.category.automation': 'اتوماسیون', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'تقویم', + 'connectors.category.commerce': 'تجارت', + 'connectors.category.communication': 'ارتباطات', + 'connectors.category.contacts': 'مخاطبین', + 'connectors.category.dataPlatform': 'پلتفرم داده', + 'connectors.category.database': 'پایگاه داده', + 'connectors.category.design': 'طراحی', + 'connectors.category.developer': 'ابزارهای توسعه‌دهنده', + 'connectors.category.documentation': 'مستندات', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'آموزش', + 'connectors.category.email': 'ایمیل', + 'connectors.category.events': 'رویدادها', + 'connectors.category.fieldService': 'خدمات میدانی', + 'connectors.category.finance': 'مالی', + 'connectors.category.fitness': 'تناسب اندام', + 'connectors.category.forms': 'فرم‌ها', + 'connectors.category.gaming': 'بازی', + 'connectors.category.hr': 'منابع انسانی', + 'connectors.category.hospitality': 'مهمان‌داری', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'یکپارچه‌سازی', + 'connectors.category.localization': 'بومی‌سازی', + 'connectors.category.logistics': 'لجستیک', + 'connectors.category.maps': 'نقشه‌ها', + 'connectors.category.marketing': 'بازاریابی', + 'connectors.category.media': 'رسانه', + 'connectors.category.meetings': 'جلسات', + 'connectors.category.nonprofit': 'غیرانتفاعی', + 'connectors.category.observability': 'مشاهده‌پذیری', + 'connectors.category.payments': 'پرداخت‌ها', + 'connectors.category.personal': 'شخصی', + 'connectors.category.presentations': 'ارائه‌ها', + 'connectors.category.procurement': 'تدارکات', + 'connectors.category.product': 'محصول', + 'connectors.category.productivity': 'بهره‌وری', + 'connectors.category.projectManagement': 'مدیریت پروژه', + 'connectors.category.recruiting': 'استخدام', + 'connectors.category.research': 'پژوهش', + 'connectors.category.salesIntelligence': 'هوشمندی فروش', + 'connectors.category.scheduling': 'زمان‌بندی', + 'connectors.category.search': 'جستجو', + 'connectors.category.security': 'امنیت', + 'connectors.category.signing': 'امضا', + 'connectors.category.social': 'اجتماعی', + 'connectors.category.spreadsheets': 'صفحه‌گسترده‌ها', + 'connectors.category.storage': 'ذخیره‌سازی', + 'connectors.category.support': 'پشتیبانی', + 'connectors.category.surveys': 'نظرسنجی‌ها', + 'connectors.category.tasks': 'وظایف', + 'connectors.category.timeTracking': 'پیگیری زمان', + 'connectors.category.video': 'ویدیو', + 'connectors.category.whiteboard': 'وایت‌برد', 'connectors.categoryLabel': 'دسته‌بندی', 'connectors.providerLabel': 'ارائه‌دهنده', 'connectors.toolsSection': 'ابزارها', @@ -1034,9 +1095,115 @@ export const fa: Dict = { 'settings.libraryNoResults': 'هیچ موردی با جستجوی شما مطابقت ندارد.', 'settings.libraryEnabled': 'فعال', 'settings.libraryDisabled': 'غیرفعال', + 'settings.connectorsNavHint': 'اتصال‌های سیستم‌های خارجی', + 'settings.connectorsHint': 'تنظیمات کانکتورها و ارائه‌دهندگان ابزار را برای این دستگاه مدیریت کنید.', + 'settings.connectorsComposioApiKey': 'کلید API کامپوزیو', + 'settings.connectorsSavedTitle': 'در daemon محلی ذخیره شد', + 'settings.connectorsSavedWithTail': 'ذخیره شد · ••••{tail}', + 'settings.connectorsSaved': 'ذخیره شد', + 'settings.connectorsGetApiKey': 'دریافت کلید API', + 'settings.connectorsReplaceKeyPlaceholder': 'برای جایگزینی کلید ذخیره‌شده، کلید جدید را جای‌گذاری کنید', + 'settings.connectorsApiKeyPlaceholder': 'کلید API کامپوزیو را جای‌گذاری کنید', + 'settings.connectorsClear': 'پاک کردن', + 'settings.connectorsClearConfirmTitle': 'کلید API ذخیره‌شدهٔ Composio پاک شود؟', + 'settings.connectorsClearConfirmBody': 'حذف کلید همهٔ کانکتورهای Composio متصل به این فضای کاری را قطع می‌کند. حساب‌های متصل، مجوزهای OAuth و دسترسی ابزارها همگی حذف خواهند شد.', + 'settings.connectorsClearConfirmContinue': 'ادامه', + 'settings.connectorsClearFinalTitle': 'این کار همهٔ کانکتورها را قطع می‌کند', + 'settings.connectorsClearFinalBody': 'بازگشتی وجود ندارد. پس از چسباندن کلید جدید باید هر ادغام را از ابتدا دوباره متصل کنید.', + 'settings.connectorsClearFinalConfirm': 'حذف کلید و قطع اتصال', + 'settings.connectorsClearArming': 'یک لحظه\u2026', + 'settings.connectorsClearCancel': 'انصراف', + 'settings.connectorsSaveKey': "ذخیره کلید", + 'settings.connectorsSaveKeyTitle': "ارسال این کلید به daemon محلی", + 'settings.connectorsKeySaving': "در حال ذخیره…", + 'settings.connectorsKeyError': "ذخیره کلید ممکن نشد. بررسی کنید daemon محلی در حال اجراست و دوباره تلاش کنید.", + 'settings.connectorsHelpSaved': 'کلید شما کاتالوگ پایین را باز می‌کند و در daemon محلی می‌ماند. برای جایگزینی، کلید جدیدی جای‌گذاری کنید یا برای حذف، پاک کنید.', + 'settings.connectorsHelpUnsaved': "تغییرات ذخیره‌نشده — برای ذخیرهٔ این اعتبارنامه در daemon محلی و باز کردن کاتالوگ پایین، روی «ذخیره کلید» بزنید.", + 'settings.connectorsHelpEmpty': 'برای باز کردن کاتالوگ پایین، یک کلید اضافه کنید. کلیدها به‌صورت محلی در daemon ذخیره می‌شوند و هرگز از طریق متغیرهای محیطی ارسال نمی‌شوند.', + 'settings.connectorsLoadingSavedKey': 'در حال بررسی کلید ذخیره‌شده در daemon محلی…', + 'settings.autosaveSaving': "در حال ذخیره…", + 'settings.autosaveSaved': "همهٔ تغییرات ذخیره شد", + 'settings.autosaveError': "تغییرات ذخیره نشد. ممکن است daemon محلی آفلاین باشد.", 'settings.libraryToggleLabel': 'تغییر وضعیت', 'notify.successTitle': 'وظیفه تکمیل شد', 'notify.failureTitle': 'وظیفه ناموفق بود', 'notify.successBody': 'یک نوبت به پایان رسید.', 'notify.failureBody': 'وظیفه با خطا پایان یافت.', + 'settings.orbit.eyebrow': 'اتوماسیون', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'خلاصهٔ روزانهٔ کانکتورها', + 'settings.orbit.lede': 'فعالیت کانکتورها را طبق زمان‌بندی جمع‌آوری کن و نتیجه را به‌صورت یک live artifact قابل تازه‌سازی منتشر کن.', + 'settings.orbit.statusOnTitle': 'اجرای روزانهٔ زمان‌بندی‌شده روشن است', + 'settings.orbit.statusOffTitle': 'اجرای روزانهٔ زمان‌بندی‌شده خاموش است', + 'settings.orbit.statusActive': 'فعال', + 'settings.orbit.statusOff': 'خاموش', + 'settings.orbit.runTitle': 'یک اجرای Orbit را شروع کن و گفت‌وگوی زنده را باز کن', + 'settings.orbit.running': 'در حال اجرا…', + 'settings.orbit.runOpen': 'همین حالا اجرا کن', + 'settings.orbit.dailySummaryTitle': 'خلاصهٔ روزانه', + 'settings.orbit.dailySummarySub': 'روزی یک‌بار در زمان محلی زمان‌بندی‌شده اجرا می‌شود.', + 'settings.orbit.on': 'روشن', + 'settings.orbit.off': 'خاموش', + 'settings.orbit.runTimeTitle': 'زمان اجرا', + 'settings.orbit.runTimeSub': 'پیش‌فرض 08:00. برای اعمال در زمان‌بندی daemon ذخیره کنید.', + 'settings.orbit.runTimeAria': 'زمان اجرای روزانهٔ Orbit', + 'settings.orbit.nextRun': 'اجرای بعدی', + 'settings.orbit.nextRunScheduledAfterSave': 'پس از ذخیره زمان‌بندی می‌شود', + 'settings.orbit.schedule': 'زمان‌بندی', + 'settings.orbit.pausedManualOnly': 'متوقف — فقط اجرای دستی', + 'settings.orbit.templateTitle': 'قالب prompt', + 'settings.orbit.templateMissing': 'قالب {id} نصب نشده است.', + 'settings.orbit.templateMissingOption': '{id} (موجود نیست)', + 'settings.orbit.templateMissingInstall': 'برای هدایت prompt یک skill مربوط به Orbit نصب کنید.', + 'settings.orbit.templateMissingPickAnother': 'از فهرست، قالب دیگری انتخاب کنید.', + 'settings.orbit.templateResetTitle': 'بازنشانی به {id}', + 'settings.orbit.templateReset': 'بازنشانی', + 'settings.orbit.templateHelp': 'Orbit را با یک skill هدایت کنید — prompt نمونهٔ قالب انتخاب‌شده در هر اجرای Orbit وارد می‌شود تا خلاصه‌ها شکل همان قالب را دنبال کنند.', + 'settings.orbit.templateAria': 'قالب prompt مربوط به Orbit', + 'settings.orbit.templatesLoading': 'در حال بارگذاری قالب‌ها…', + 'settings.orbit.templatesOptgroup': 'قالب‌های skills مربوط به Orbit', + 'settings.orbit.lastRun': 'آخرین اجرا', + 'settings.orbit.triggerManual': 'دستی', + 'settings.orbit.triggerScheduled': 'زمان‌بندی‌شده', + 'settings.orbit.meterAria': '{succeeded} موفق، {skipped} رد شده، {failed} ناموفق از {checked} بررسی‌شده', + 'settings.orbit.countChecked': 'بررسی‌شده', + 'settings.orbit.countSucceeded': 'موفق', + 'settings.orbit.countSkipped': 'رد شده', + 'settings.orbit.countFailed': 'ناموفق', + 'settings.orbit.runError': 'اجرای Orbit ممکن نبود. مطمئن شوید daemon محلی در حال اجراست و کانکتورها پیکربندی شده‌اند.', + 'settings.orbit.gateAriaLabel': "برای استفاده از Orbit به اتصال‌دهنده نیاز است", + 'settings.orbit.gateEyebrow': "پیکربندی لازم است", + 'settings.orbit.gateTitle': "یک ابزار را متصل کنید تا Orbit کار کند", + 'settings.orbit.gateBody': "Orbit فعالیت اتصال‌دهنده‌های شما را خلاصه می‌کند. هنوز چیزی متصل نکرده‌اید — حداقل یک یکپارچه‌سازی اضافه کنید تا Orbit چیزی برای گزارش داشته باشد.", + 'settings.orbit.gateBodyNoKey': "Orbit فعالیت اتصال‌دهنده‌ها را خلاصه می‌کند و اتصال‌دهنده‌ها از طریق Composio اجرا می‌شوند. یک کلید API از Composio در بخش اتصال‌دهنده‌ها اضافه کنید تا کاتالوگ باز شود و اولین یکپارچه‌سازی خود را انتخاب کنید.", + 'settings.orbit.gateAction': "باز کردن اتصال‌دهنده‌ها", + 'settings.orbit.gateActionNoKey': "پیکربندی Composio", + 'settings.orbit.gateLoading': "در حال بررسی اتصال‌دهنده‌ها…", + 'settings.orbit.controlsLockedBadge': "قفل شده", + 'settings.orbit.controlsLockedHint': "برای باز کردن زمان‌بندی و قالب Orbit یک ابزار را متصل کنید.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'خلاصهٔ قدیمی', + 'settings.orbit.artifactTitle': 'خلاصهٔ روزانهٔ فعالیت Orbit', + 'settings.orbit.artifactMetaLive': 'artifact HTML قابل تازه‌سازی که از فعالیت کانکتورها ساخته شده است.', + 'settings.orbit.artifactMetaLegacy': 'پیش از فعال شدن پشتیبانی live artifact تولید شده — برای انتشار یکی، Orbit را دوباره اجرا کنید.', + 'settings.orbit.copyMarkdownTitle': 'کپی خلاصهٔ Markdown در کلیپ‌بورد', + 'settings.orbit.copied': 'کپی شد', + 'settings.orbit.copy': 'کپی', + 'settings.orbit.openArtifact': 'باز کردن artifact', + 'settings.orbit.sourceMarkdown': 'Markdown منبع', + 'liveArtifact.viewer.tabPreview': 'پیش‌نمایش', + 'liveArtifact.viewer.tabCode': 'کد', + 'liveArtifact.viewer.tabData': 'داده‌ها', + 'liveArtifact.viewer.tabRefreshHistory': 'تاریخچهٔ تازه‌سازی', + 'liveArtifact.viewer.dataEmpty': 'کش data.json در دسترس نیست.', + 'liveArtifact.viewer.code.templateHeading': 'HTML قالب', + 'liveArtifact.viewer.code.renderedHeading': 'HTML رندرشده', + 'liveArtifact.viewer.code.templateHelp': 'قالب قابل ویرایشی که با data.json برای تولید پیش‌نمایش استفاده می‌شود.', + 'liveArtifact.viewer.code.renderedHelp': 'index.html تولیدشده‌ای که اکنون توسط پیش‌نمایش بارگذاری شده است.', + 'liveArtifact.viewer.code.variantAria': 'گونهٔ کد', + 'liveArtifact.viewer.code.variantTemplate': 'قالب', + 'liveArtifact.viewer.code.variantRendered': 'رندرشده', + 'liveArtifact.viewer.code.loading': 'در حال بارگذاری کد…', + 'liveArtifact.viewer.code.unavailable': 'کد هنوز در دسترس نیست.', + 'liveArtifact.viewer.code.empty': 'این فایل کد خالی است.', }; diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index cc3e2cabe..f139958a6 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -199,12 +199,73 @@ export const fr: Dict = { 'connectors.statusConnected': 'Connecté', 'connectors.statusError': 'Erreur', 'connectors.statusDisabled': 'Désactivé', - 'connectors.gateTitle': 'Configurez d\'abord la clé API Composio', - 'connectors.gateBody': 'Les connecteurs nécessitent une clé API Composio. Ajoutez-la dans les paramètres pour activer les intégrations disponibles.', - 'connectors.gateAction': 'Ouvrir les paramètres', + 'connectors.gateTitle': 'Ajoutez votre clé API Composio pour continuer', + 'connectors.gateBody': 'Collez votre clé ci-dessus, puis cliquez sur Enregistrer la clé pour charger les intégrations disponibles.', 'connectors.aboutLabel': 'À propos', 'connectors.detailsLabel': 'Détails', 'connectors.statusLabel': 'Statut', + 'connectors.category.aiAgents': 'Agents IA', + 'connectors.category.aiInfrastructure': 'Infrastructure IA', + 'connectors.category.accounting': 'Comptabilité', + 'connectors.category.admin': 'Administration', + 'connectors.category.advertising': 'Publicité', + 'connectors.category.analytics': 'Analytique', + 'connectors.category.automation': 'Automatisation', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Calendrier', + 'connectors.category.commerce': 'Commerce', + 'connectors.category.communication': 'Communication', + 'connectors.category.contacts': 'Contacts', + 'connectors.category.dataPlatform': 'Plateforme de données', + 'connectors.category.database': 'Base de données', + 'connectors.category.design': 'Design', + 'connectors.category.developer': 'Outils développeur', + 'connectors.category.documentation': 'Documentation', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Éducation', + 'connectors.category.email': 'E-mail', + 'connectors.category.events': 'Événements', + 'connectors.category.fieldService': 'Service terrain', + 'connectors.category.finance': 'Finance', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Formulaires', + 'connectors.category.gaming': 'Jeux', + 'connectors.category.hr': 'RH', + 'connectors.category.hospitality': 'Hôtellerie', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Intégration', + 'connectors.category.localization': 'Localisation', + 'connectors.category.logistics': 'Logistique', + 'connectors.category.maps': 'Cartes', + 'connectors.category.marketing': 'Marketing', + 'connectors.category.media': 'Médias', + 'connectors.category.meetings': 'Réunions', + 'connectors.category.nonprofit': 'Association', + 'connectors.category.observability': 'Observabilité', + 'connectors.category.payments': 'Paiements', + 'connectors.category.personal': 'Personnel', + 'connectors.category.presentations': 'Présentations', + 'connectors.category.procurement': 'Achats', + 'connectors.category.product': 'Produit', + 'connectors.category.productivity': 'Productivité', + 'connectors.category.projectManagement': 'Gestion de projet', + 'connectors.category.recruiting': 'Recrutement', + 'connectors.category.research': 'Recherche', + 'connectors.category.salesIntelligence': 'Intelligence commerciale', + 'connectors.category.scheduling': 'Planification', + 'connectors.category.search': 'Recherche', + 'connectors.category.security': 'Sécurité', + 'connectors.category.signing': 'Signature', + 'connectors.category.social': 'Social', + 'connectors.category.spreadsheets': 'Feuilles de calcul', + 'connectors.category.storage': 'Stockage', + 'connectors.category.support': 'Support', + 'connectors.category.surveys': 'Sondages', + 'connectors.category.tasks': 'Tâches', + 'connectors.category.timeTracking': 'Suivi du temps', + 'connectors.category.video': 'Vidéo', + 'connectors.category.whiteboard': 'Tableau blanc', 'connectors.categoryLabel': 'Catégorie', 'connectors.providerLabel': 'Fournisseur', 'connectors.toolsSection': 'Outils', @@ -1002,9 +1063,115 @@ export const fr: Dict = { 'settings.libraryNoResults': 'Aucun élément ne correspond à votre recherche.', 'settings.libraryEnabled': 'Activé', 'settings.libraryDisabled': 'Désactivé', + 'settings.connectorsNavHint': 'Connexions aux systèmes externes', + 'settings.connectorsHint': 'Gérez les paramètres des connecteurs et fournisseurs d’outils pour cet appareil.', + 'settings.connectorsComposioApiKey': 'Clé API Composio', + 'settings.connectorsSavedTitle': 'Enregistrée dans le daemon local', + 'settings.connectorsSavedWithTail': 'Enregistrée · ••••{tail}', + 'settings.connectorsSaved': 'Enregistrée', + 'settings.connectorsGetApiKey': 'Obtenir une clé API', + 'settings.connectorsReplaceKeyPlaceholder': 'Collez une nouvelle clé pour remplacer celle enregistrée', + 'settings.connectorsApiKeyPlaceholder': 'Collez la clé API Composio', + 'settings.connectorsClear': 'Effacer', + 'settings.connectorsClearConfirmTitle': 'Effacer la clé API Composio enregistrée ?', + 'settings.connectorsClearConfirmBody': 'Supprimer la clé déconnecte tous les connecteurs Composio liés à cet espace. Les comptes connectés, les autorisations OAuth et les accès aux outils seront tous supprimés.', + 'settings.connectorsClearConfirmContinue': 'Continuer', + 'settings.connectorsClearFinalTitle': 'Cette action déconnectera tous les connecteurs', + 'settings.connectorsClearFinalBody': 'Action irréversible. Vous devrez reconnecter chaque intégration depuis le début après avoir collé une nouvelle clé.', + 'settings.connectorsClearFinalConfirm': 'Supprimer la clé et déconnecter', + 'settings.connectorsClearArming': 'Un instant\u2026', + 'settings.connectorsClearCancel': 'Annuler', + 'settings.connectorsSaveKey': "Enregistrer la clé", + 'settings.connectorsSaveKeyTitle': "Envoyer cette clé au daemon local", + 'settings.connectorsKeySaving': "Enregistrement…", + 'settings.connectorsKeyError': "Impossible d’enregistrer la clé. Vérifie que le daemon local est lancé puis réessaie.", + 'settings.connectorsHelpSaved': 'Votre clé déverrouille le catalogue ci-dessous et reste dans le daemon local. Collez une nouvelle clé pour la remplacer ou effacez-la.', + 'settings.connectorsHelpUnsaved': "Modifications non enregistrées — clique sur Enregistrer la clé pour stocker cette information dans le daemon local et débloquer le catalogue ci-dessous.", + 'settings.connectorsHelpEmpty': 'Ajoutez une clé pour déverrouiller le catalogue ci-dessous. Les clés sont stockées localement dans le daemon et ne sont jamais envoyées via des variables d’environnement.', + 'settings.connectorsLoadingSavedKey': 'Recherche d’une clé enregistrée dans le daemon local…', + 'settings.autosaveSaving': "Enregistrement…", + 'settings.autosaveSaved': "Toutes les modifications enregistrées", + 'settings.autosaveError': "Impossible d’enregistrer les modifications. Le daemon local est peut-être hors ligne.", 'settings.libraryToggleLabel': 'Basculer', 'notify.successTitle': 'Tâche terminée', 'notify.failureTitle': 'Tâche échouée', 'notify.successBody': 'Un tour est terminé.', 'notify.failureBody': 'La tâche s\'est terminée avec une erreur.', + 'settings.orbit.eyebrow': 'Automatisation', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Résumé quotidien des connecteurs', + 'settings.orbit.lede': 'Collecte l’activité des connecteurs selon un calendrier et publie le résultat sous forme de live artifact actualisable.', + 'settings.orbit.statusOnTitle': 'Les exécutions quotidiennes planifiées sont activées', + 'settings.orbit.statusOffTitle': 'Les exécutions quotidiennes planifiées sont désactivées', + 'settings.orbit.statusActive': 'Actif', + 'settings.orbit.statusOff': 'Désactivé', + 'settings.orbit.runTitle': 'Démarrer une exécution Orbit et ouvrir la conversation en direct', + 'settings.orbit.running': 'Exécution…', + 'settings.orbit.runOpen': 'Exécuter maintenant', + 'settings.orbit.dailySummaryTitle': 'Résumé quotidien', + 'settings.orbit.dailySummarySub': 'S’exécute une fois par jour à l’heure locale planifiée.', + 'settings.orbit.on': 'Activé', + 'settings.orbit.off': 'Désactivé', + 'settings.orbit.runTimeTitle': 'Heure d’exécution', + 'settings.orbit.runTimeSub': 'Par défaut 08:00. Enregistrez pour appliquer au planning du daemon.', + 'settings.orbit.runTimeAria': 'Heure d’exécution quotidienne de Orbit', + 'settings.orbit.nextRun': 'Prochaine exécution', + 'settings.orbit.nextRunScheduledAfterSave': 'Planifiée après enregistrement', + 'settings.orbit.schedule': 'Planning', + 'settings.orbit.pausedManualOnly': 'En pause — exécutions manuelles uniquement', + 'settings.orbit.templateTitle': 'Modèle de prompt', + 'settings.orbit.templateMissing': 'Le modèle {id} n’est pas installé.', + 'settings.orbit.templateMissingOption': '{id} (manquant)', + 'settings.orbit.templateMissingInstall': 'Installez une skill Orbit pour guider le prompt.', + 'settings.orbit.templateMissingPickAnother': 'Choisissez un autre modèle dans la liste.', + 'settings.orbit.templateResetTitle': 'Réinitialiser sur {id}', + 'settings.orbit.templateReset': 'Réinitialiser', + 'settings.orbit.templateHelp': 'Guidez Orbit avec une skill — le prompt d’exemple du modèle sélectionné est injecté dans chaque exécution Orbit afin que les résumés suivent cette forme.', + 'settings.orbit.templateAria': 'Modèle de prompt Orbit', + 'settings.orbit.templatesLoading': 'Chargement des modèles…', + 'settings.orbit.templatesOptgroup': 'Modèles de skills Orbit', + 'settings.orbit.lastRun': 'Dernière exécution', + 'settings.orbit.triggerManual': 'Manuelle', + 'settings.orbit.triggerScheduled': 'Planifiée', + 'settings.orbit.meterAria': '{succeeded} réussis, {skipped} ignorés, {failed} échoués sur {checked} vérifiés', + 'settings.orbit.countChecked': 'Vérifiés', + 'settings.orbit.countSucceeded': 'Réussis', + 'settings.orbit.countSkipped': 'Ignorés', + 'settings.orbit.countFailed': 'Échoués', + 'settings.orbit.runError': 'Impossible d’exécuter Orbit. Vérifiez que le daemon local fonctionne et que les connecteurs sont configurés.', + 'settings.orbit.gateAriaLabel': "Des connecteurs sont requis pour utiliser Orbit", + 'settings.orbit.gateEyebrow': "Configuration requise", + 'settings.orbit.gateTitle': "Connectez un outil pour alimenter Orbit", + 'settings.orbit.gateBody': "Orbit résume l’activité de vos connecteurs. Vous n’avez encore rien connecté — ajoutez au moins une intégration pour qu’Orbit ait de quoi rapporter.", + 'settings.orbit.gateBodyNoKey': "Orbit résume l’activité de vos connecteurs, et les connecteurs passent par Composio. Ajoutez une clé d’API Composio dans Connecteurs pour débloquer le catalogue et choisir votre première intégration.", + 'settings.orbit.gateAction': "Ouvrir Connecteurs", + 'settings.orbit.gateActionNoKey': "Configurer Composio", + 'settings.orbit.gateLoading': "Vérification de vos connecteurs…", + 'settings.orbit.controlsLockedBadge': "Verrouillé", + 'settings.orbit.controlsLockedHint': "Connectez un outil pour déverrouiller la planification et le modèle d'Orbit.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Résumé hérité', + 'settings.orbit.artifactTitle': 'Résumé quotidien de l’activité Orbit', + 'settings.orbit.artifactMetaLive': 'Artefact HTML actualisable généré à partir de l’activité des connecteurs.', + 'settings.orbit.artifactMetaLegacy': 'Généré avant l’activation de la prise en charge de live artifact — relancez Orbit pour en publier un.', + 'settings.orbit.copyMarkdownTitle': 'Copier le résumé Markdown dans le presse-papiers', + 'settings.orbit.copied': 'Copié', + 'settings.orbit.copy': 'Copier', + 'settings.orbit.openArtifact': 'Ouvrir l’artefact', + 'settings.orbit.sourceMarkdown': 'Markdown source', + 'liveArtifact.viewer.tabPreview': 'Aperçu', + 'liveArtifact.viewer.tabCode': 'Code', + 'liveArtifact.viewer.tabData': 'Données', + 'liveArtifact.viewer.tabRefreshHistory': 'Historique d’actualisation', + 'liveArtifact.viewer.dataEmpty': 'Aucun cache data.json disponible.', + 'liveArtifact.viewer.code.templateHeading': 'HTML du modèle', + 'liveArtifact.viewer.code.renderedHeading': 'HTML rendu', + 'liveArtifact.viewer.code.templateHelp': 'Le modèle modifiable utilisé avec data.json pour générer l’aperçu.', + 'liveArtifact.viewer.code.renderedHelp': 'Le index.html généré actuellement chargé par l’aperçu.', + 'liveArtifact.viewer.code.variantAria': 'Variante du code', + 'liveArtifact.viewer.code.variantTemplate': 'Modèle', + 'liveArtifact.viewer.code.variantRendered': 'Rendu', + 'liveArtifact.viewer.code.loading': 'Chargement du code…', + 'liveArtifact.viewer.code.unavailable': 'Le code n’est pas encore disponible.', + 'liveArtifact.viewer.code.empty': 'Ce fichier de code est vide.', }; diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index 69a5a418b..7ac48f6bd 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -199,12 +199,73 @@ export const hu: Dict = { 'connectors.statusConnected': 'Csatlakoztatva', 'connectors.statusError': 'Hiba', 'connectors.statusDisabled': 'Letiltva', - 'connectors.gateTitle': 'Először állítsd be a Composio API-kulcsot', - 'connectors.gateBody': 'A kapcsolókhoz Composio API-kulcs kell. Add meg a beállításokban az elérhető integrációk engedélyezéséhez.', - 'connectors.gateAction': 'Beállítások megnyitása', + 'connectors.gateTitle': 'Add meg a Composio API-kulcsot a folytatáshoz', + 'connectors.gateBody': 'Illeszd be fent a kulcsot, majd kattints a Kulcs mentése gombra az elérhető integrációk betöltéséhez.', 'connectors.aboutLabel': 'Névjegy', 'connectors.detailsLabel': 'Részletek', 'connectors.statusLabel': 'Állapot', + 'connectors.category.aiAgents': 'AI-ügynökök', + 'connectors.category.aiInfrastructure': 'AI-infrastruktúra', + 'connectors.category.accounting': 'Könyvelés', + 'connectors.category.admin': 'Adminisztráció', + 'connectors.category.advertising': 'Hirdetés', + 'connectors.category.analytics': 'Analitika', + 'connectors.category.automation': 'Automatizálás', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Naptár', + 'connectors.category.commerce': 'Kereskedelem', + 'connectors.category.communication': 'Kommunikáció', + 'connectors.category.contacts': 'Kapcsolatok', + 'connectors.category.dataPlatform': 'Adatplatform', + 'connectors.category.database': 'Adatbázis', + 'connectors.category.design': 'Design', + 'connectors.category.developer': 'Fejlesztői eszközök', + 'connectors.category.documentation': 'Dokumentáció', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Oktatás', + 'connectors.category.email': 'E-mail', + 'connectors.category.events': 'Események', + 'connectors.category.fieldService': 'Terepszolgálat', + 'connectors.category.finance': 'Pénzügy', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Űrlapok', + 'connectors.category.gaming': 'Játék', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': 'Vendéglátás', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Integráció', + 'connectors.category.localization': 'Lokalizáció', + 'connectors.category.logistics': 'Logisztika', + 'connectors.category.maps': 'Térképek', + 'connectors.category.marketing': 'Marketing', + 'connectors.category.media': 'Média', + 'connectors.category.meetings': 'Megbeszélések', + 'connectors.category.nonprofit': 'Nonprofit', + 'connectors.category.observability': 'Megfigyelhetőség', + 'connectors.category.payments': 'Fizetések', + 'connectors.category.personal': 'Személyes', + 'connectors.category.presentations': 'Prezentációk', + 'connectors.category.procurement': 'Beszerzés', + 'connectors.category.product': 'Termék', + 'connectors.category.productivity': 'Produktivitás', + 'connectors.category.projectManagement': 'Projektmenedzsment', + 'connectors.category.recruiting': 'Toborzás', + 'connectors.category.research': 'Kutatás', + 'connectors.category.salesIntelligence': 'Értékesítési intelligencia', + 'connectors.category.scheduling': 'Ütemezés', + 'connectors.category.search': 'Keresés', + 'connectors.category.security': 'Biztonság', + 'connectors.category.signing': 'Aláírás', + 'connectors.category.social': 'Közösségi', + 'connectors.category.spreadsheets': 'Táblázatok', + 'connectors.category.storage': 'Tárhely', + 'connectors.category.support': 'Támogatás', + 'connectors.category.surveys': 'Kérdőívek', + 'connectors.category.tasks': 'Feladatok', + 'connectors.category.timeTracking': 'Időkövetés', + 'connectors.category.video': 'Videó', + 'connectors.category.whiteboard': 'Tábla', 'connectors.categoryLabel': 'Kategória', 'connectors.providerLabel': 'Szolgáltató', 'connectors.toolsSection': 'Eszközök', @@ -1012,9 +1073,115 @@ export const hu: Dict = { 'settings.libraryNoResults': 'Nincs a keresésnek megfelelő elem.', 'settings.libraryEnabled': 'Engedélyezve', 'settings.libraryDisabled': 'Letiltva', + 'settings.connectorsNavHint': 'Külső rendszerek kapcsolatai', + 'settings.connectorsHint': 'Kezeld az eszköz csatlakozó- és eszközszolgáltató-beállításait.', + 'settings.connectorsComposioApiKey': 'Composio API-kulcs', + 'settings.connectorsSavedTitle': 'Helyi daemonban mentve', + 'settings.connectorsSavedWithTail': 'Mentve · ••••{tail}', + 'settings.connectorsSaved': 'Mentve', + 'settings.connectorsGetApiKey': 'API-kulcs beszerzése', + 'settings.connectorsReplaceKeyPlaceholder': 'Illessz be új kulcsot a mentett cseréjéhez', + 'settings.connectorsApiKeyPlaceholder': 'Composio API-kulcs beillesztése', + 'settings.connectorsClear': 'Törlés', + 'settings.connectorsClearConfirmTitle': 'Törlöd a mentett Composio API-kulcsot?', + 'settings.connectorsClearConfirmBody': 'A kulcs törlése lekapcsolja ehhez a munkaterülethez kapcsolt összes Composio csatlakozót. A csatlakoztatott fiókok, OAuth-engedélyek és eszközhozzáférések mind eltávolításra kerülnek.', + 'settings.connectorsClearConfirmContinue': 'Tovább', + 'settings.connectorsClearFinalTitle': 'Ez minden csatlakozót lekapcsol', + 'settings.connectorsClearFinalBody': 'Nincs visszavonás. Új kulcs beillesztése után minden integrációt újra kell csatlakoztatnod.', + 'settings.connectorsClearFinalConfirm': 'Kulcs törlése és lecsatlakoztatás', + 'settings.connectorsClearArming': 'Egy pillanat\u2026', + 'settings.connectorsClearCancel': 'Mégse', + 'settings.connectorsSaveKey': "Kulcs mentése", + 'settings.connectorsSaveKeyTitle': "Kulcs elküldése a helyi daemonhoz", + 'settings.connectorsKeySaving': "Mentés…", + 'settings.connectorsKeyError': "A kulcs mentése nem sikerült. Ellenőrizd, hogy a helyi daemon fut-e, majd próbáld újra.", + 'settings.connectorsHelpSaved': 'A kulcs feloldja az alábbi katalógust, és a helyi daemonban marad. Illessz be új kulcsot a cseréhez, vagy töröld az eltávolításhoz.', + 'settings.connectorsHelpUnsaved': "Mentetlen változtatások — kattints a Kulcs mentése gombra, hogy a hitelesítő adat a helyi daemonba kerüljön, és nyíljon a lenti katalógus.", + 'settings.connectorsHelpEmpty': 'Adj hozzá kulcsot az alábbi katalógus feloldásához. A kulcsok helyben, a daemonban tárolódnak, és soha nem mennek át környezeti változókon.', + 'settings.connectorsLoadingSavedKey': 'Mentett kulcs ellenőrzése a helyi daemonban…', + 'settings.autosaveSaving': "Mentés…", + 'settings.autosaveSaved': "Minden változtatás mentve", + 'settings.autosaveError': "A változtatások mentése nem sikerült. A helyi daemon offline lehet.", 'settings.libraryToggleLabel': 'Átváltás', 'notify.successTitle': 'Feladat befejezve', 'notify.failureTitle': 'A feladat meghiúsult', 'notify.successBody': 'Egy kör befejeződött.', 'notify.failureBody': 'A feladat hibával ért véget.', + 'settings.orbit.eyebrow': 'Automatizálás', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Napi csatlakozó-összefoglaló', + 'settings.orbit.lede': 'Gyűjtsd a csatlakozók aktivitását ütemezetten, és tedd közzé az eredményt frissíthető live artifact formában.', + 'settings.orbit.statusOnTitle': 'Az ütemezett napi futások be vannak kapcsolva', + 'settings.orbit.statusOffTitle': 'Az ütemezett napi futások ki vannak kapcsolva', + 'settings.orbit.statusActive': 'Aktív', + 'settings.orbit.statusOff': 'Ki', + 'settings.orbit.runTitle': 'Orbit futás indítása és az élő beszélgetés megnyitása', + 'settings.orbit.running': 'Fut…', + 'settings.orbit.runOpen': 'Futtatás most', + 'settings.orbit.dailySummaryTitle': 'Napi összefoglaló', + 'settings.orbit.dailySummarySub': 'Naponta egyszer fut a beállított helyi időben.', + 'settings.orbit.on': 'Be', + 'settings.orbit.off': 'Ki', + 'settings.orbit.runTimeTitle': 'Futási idő', + 'settings.orbit.runTimeSub': 'Alapértelmezett 08:00. Mentés után érvényesül a daemon ütemezésében.', + 'settings.orbit.runTimeAria': 'Napi Orbit futási idő', + 'settings.orbit.nextRun': 'Következő futás', + 'settings.orbit.nextRunScheduledAfterSave': 'Mentés után ütemezve', + 'settings.orbit.schedule': 'Ütemezés', + 'settings.orbit.pausedManualOnly': 'Szüneteltetve — csak kézi futások', + 'settings.orbit.templateTitle': 'Prompt sablon', + 'settings.orbit.templateMissing': 'A(z) {id} sablon nincs telepítve.', + 'settings.orbit.templateMissingOption': '{id} (hiányzik)', + 'settings.orbit.templateMissingInstall': 'Telepíts Orbit skillt a prompt irányításához.', + 'settings.orbit.templateMissingPickAnother': 'Válassz másik sablont a legördülőből.', + 'settings.orbit.templateResetTitle': 'Visszaállítás erre: {id}', + 'settings.orbit.templateReset': 'Visszaállítás', + 'settings.orbit.templateHelp': 'Irányítsd az Orbit működését skillel — a kiválasztott sablon példa promptja bekerül minden Orbit futásba, hogy az összefoglalók a sablon formáját kövessék.', + 'settings.orbit.templateAria': 'Orbit prompt sablon', + 'settings.orbit.templatesLoading': 'Sablonok betöltése…', + 'settings.orbit.templatesOptgroup': 'Orbit skill sablonok', + 'settings.orbit.lastRun': 'Utolsó futás', + 'settings.orbit.triggerManual': 'Kézi', + 'settings.orbit.triggerScheduled': 'Ütemezett', + 'settings.orbit.meterAria': '{succeeded} sikeres, {skipped} kihagyva, {failed} sikertelen, összesen {checked} ellenőrizve', + 'settings.orbit.countChecked': 'Ellenőrizve', + 'settings.orbit.countSucceeded': 'Sikeres', + 'settings.orbit.countSkipped': 'Kihagyva', + 'settings.orbit.countFailed': 'Sikertelen', + 'settings.orbit.runError': 'Nem sikerült futtatni az Orbit műveletet. Ellenőrizd, hogy a helyi daemon fut-e, és a csatlakozók be vannak-e állítva.', + 'settings.orbit.gateAriaLabel': "Az Orbit használatához csatlakozók szükségesek", + 'settings.orbit.gateEyebrow': "Beállítás szükséges", + 'settings.orbit.gateTitle': "Csatlakoztass egy eszközt az Orbit működéséhez", + 'settings.orbit.gateBody': "Az Orbit összefoglalja a csatlakozók tevékenységét. Még nem csatlakoztattál semmit — adj hozzá legalább egy integrációt, hogy az Orbitnak legyen miről jelentenie.", + 'settings.orbit.gateBodyNoKey': "Az Orbit a csatlakozóid tevékenységét összegzi, és a csatlakozók a Composio-n keresztül futnak. Adj hozzá egy Composio API-kulcsot a Csatlakozók alatt, hogy feloldd a katalógust és kiválaszthasd az első integrációd.", + 'settings.orbit.gateAction': "Csatlakozók megnyitása", + 'settings.orbit.gateActionNoKey': "Composio beállítása", + 'settings.orbit.gateLoading': "Csatlakozók ellenőrzése…", + 'settings.orbit.controlsLockedBadge': "Zárolva", + 'settings.orbit.controlsLockedHint': "Csatlakoztass egy eszközt az Orbit ütemezésének és sablonjának feloldásához.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Régi összefoglaló', + 'settings.orbit.artifactTitle': 'Napi Orbit aktivitási összefoglaló', + 'settings.orbit.artifactMetaLive': 'Csatlakozó-aktivitásból generált frissíthető HTML artifact.', + 'settings.orbit.artifactMetaLegacy': 'A live artifact támogatás engedélyezése előtt készült — futtasd újra az Orbit műveletet egy közzétételéhez.', + 'settings.orbit.copyMarkdownTitle': 'Markdown összefoglaló másolása a vágólapra', + 'settings.orbit.copied': 'Másolva', + 'settings.orbit.copy': 'Másolás', + 'settings.orbit.openArtifact': 'Artefakt megnyitása', + 'settings.orbit.sourceMarkdown': 'Forrás Markdown', + 'liveArtifact.viewer.tabPreview': 'Előnézet', + 'liveArtifact.viewer.tabCode': 'Kód', + 'liveArtifact.viewer.tabData': 'Adatok', + 'liveArtifact.viewer.tabRefreshHistory': 'Frissítési előzmények', + 'liveArtifact.viewer.dataEmpty': 'Nincs elérhető data.json gyorsítótár.', + 'liveArtifact.viewer.code.templateHeading': 'Sablon HTML', + 'liveArtifact.viewer.code.renderedHeading': 'Renderelt HTML', + 'liveArtifact.viewer.code.templateHelp': 'A data.json fájllal az előnézet létrehozásához használt szerkeszthető sablon.', + 'liveArtifact.viewer.code.renderedHelp': 'Az előnézet által jelenleg betöltött generált index.html.', + 'liveArtifact.viewer.code.variantAria': 'Kódváltozat', + 'liveArtifact.viewer.code.variantTemplate': 'Sablon', + 'liveArtifact.viewer.code.variantRendered': 'Renderelt', + 'liveArtifact.viewer.code.loading': 'Kód betöltése…', + 'liveArtifact.viewer.code.unavailable': 'A kód még nem érhető el.', + 'liveArtifact.viewer.code.empty': 'Ez a kódfájl üres.', }; diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index eda34e121..36ebcb859 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -150,6 +150,102 @@ export const id: Dict = { 'settings.runtimePackaged': 'Aplikasi paket', 'settings.runtimeDevelopment': 'Development', 'settings.versionUnavailable': 'Detail versi tidak tersedia saat daemon offline.', + 'settings.connectorsNavHint': 'Koneksi sistem eksternal', + 'settings.connectorsHint': 'Kelola pengaturan konektor dan penyedia alat untuk perangkat ini.', + 'settings.connectorsComposioApiKey': 'API key Composio', + 'settings.connectorsSavedTitle': 'Tersimpan di daemon lokal', + 'settings.connectorsSavedWithTail': 'Tersimpan · ••••{tail}', + 'settings.connectorsSaved': 'Tersimpan', + 'settings.connectorsGetApiKey': 'Dapatkan API key', + 'settings.connectorsReplaceKeyPlaceholder': 'Tempel key baru untuk mengganti yang tersimpan', + 'settings.connectorsApiKeyPlaceholder': 'Tempel API key Composio', + 'settings.connectorsClear': 'Hapus', + 'settings.connectorsClearConfirmTitle': 'Hapus API key Composio yang tersimpan?', + 'settings.connectorsClearConfirmBody': + 'Menghapus key akan memutus semua konektor Composio yang terkait dengan workspace ini. Akun terhubung, grant OAuth, dan akses alat semuanya akan dihapus.', + 'settings.connectorsClearConfirmContinue': 'Lanjutkan', + 'settings.connectorsClearFinalTitle': 'Ini akan memutus semua konektor', + 'settings.connectorsClearFinalBody': + 'Tindakan ini tidak bisa dibatalkan. Kamu harus menghubungkan ulang setiap integrasi dari awal setelah menempelkan key baru.', + 'settings.connectorsClearFinalConfirm': 'Hapus key & putuskan koneksi', + 'settings.connectorsClearArming': 'Tunggu…', + 'settings.connectorsClearCancel': 'Batal', + 'settings.connectorsSaveKey': 'Simpan key', + 'settings.connectorsSaveKeyTitle': 'Kirim key ini ke daemon lokal', + 'settings.connectorsKeySaving': 'Menyimpan…', + 'settings.connectorsKeyError': 'Tidak bisa menyimpan key. Pastikan daemon lokal berjalan lalu coba lagi.', + 'settings.connectorsHelpSaved': + 'Key kamu tersimpan di daemon lokal. Tempel key baru untuk menggantinya, atau Hapus untuk menghapusnya.', + 'settings.connectorsHelpUnsaved': + 'Ada perubahan yang belum disimpan — klik Simpan key untuk menyimpan kredensial ini di daemon lokal dan menyegarkan katalog di bawah.', + 'settings.connectorsHelpEmpty': + 'Tambahkan key untuk memuat katalog di bawah. Key disimpan secara lokal di daemon dan tidak pernah dikirim lewat environment variable.', + 'settings.connectorsLoadingSavedKey': 'Memeriksa key yang tersimpan di daemon lokal…', + 'settings.autosaveSaving': 'Menyimpan…', + 'settings.autosaveSaved': 'Semua perubahan tersimpan', + 'settings.autosaveError': 'Tidak bisa menyimpan perubahan. Daemon lokal mungkin offline.', + 'settings.orbit.eyebrow': 'Otomatisasi', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Ringkasan konektor harian', + 'settings.orbit.lede': 'Kumpulkan aktivitas konektor sesuai jadwal dan publikasikan hasilnya sebagai live artifact yang bisa diperbarui.', + 'settings.orbit.statusOnTitle': 'Jadwal jalan harian aktif', + 'settings.orbit.statusOffTitle': 'Jadwal jalan harian nonaktif', + 'settings.orbit.statusActive': 'Aktif', + 'settings.orbit.statusOff': 'Mati', + 'settings.orbit.runTitle': 'Mulai Orbit dan buka percakapan live', + 'settings.orbit.running': 'Berjalan…', + 'settings.orbit.runOpen': 'Jalankan sekarang', + 'settings.orbit.dailySummaryTitle': 'Ringkasan harian', + 'settings.orbit.dailySummarySub': 'Berjalan sekali per hari pada waktu lokal yang dijadwalkan.', + 'settings.orbit.on': 'Nyala', + 'settings.orbit.off': 'Mati', + 'settings.orbit.runTimeTitle': 'Waktu jalan', + 'settings.orbit.runTimeSub': 'Default 08.00. Simpan untuk menerapkannya ke jadwal daemon.', + 'settings.orbit.runTimeAria': 'Waktu jalan Orbit harian', + 'settings.orbit.nextRun': 'Jalan berikutnya', + 'settings.orbit.nextRunScheduledAfterSave': 'Dijadwalkan setelah Simpan', + 'settings.orbit.schedule': 'Jadwal', + 'settings.orbit.pausedManualOnly': 'Dijeda — hanya jalan manual', + 'settings.orbit.templateTitle': 'Templat prompt', + 'settings.orbit.templateMissing': 'Templat {id} belum terpasang.', + 'settings.orbit.templateMissingOption': '{id} (belum terpasang)', + 'settings.orbit.templateMissingInstall': 'Pasang skill Orbit untuk mengarahkan prompt.', + 'settings.orbit.templateMissingPickAnother': 'Pilih templat lain dari dropdown.', + 'settings.orbit.templateResetTitle': 'Reset ke {id}', + 'settings.orbit.templateReset': 'Atur ulang', + 'settings.orbit.templateHelp': 'Arahkan Orbit dengan skill — contoh prompt templat yang dipilih akan disuntikkan ke setiap jalan Orbit agar ringkasan mengikuti bentuk templat itu.', + 'settings.orbit.templateAria': 'Templat prompt Orbit', + 'settings.orbit.templatesLoading': 'Memuat templat…', + 'settings.orbit.templatesOptgroup': 'Templat skill Orbit', + 'settings.orbit.lastRun': 'Jalan terakhir', + 'settings.orbit.triggerManual': 'Manual', + 'settings.orbit.triggerScheduled': 'Terjadwal', + 'settings.orbit.meterAria': '{succeeded} berhasil, {skipped} dilewati, {failed} gagal dari {checked} yang diperiksa', + 'settings.orbit.countChecked': 'Diperiksa', + 'settings.orbit.countSucceeded': 'Berhasil', + 'settings.orbit.countSkipped': 'Dilewati', + 'settings.orbit.countFailed': 'Gagal', + 'settings.orbit.runError': 'Tidak bisa menjalankan Orbit. Pastikan daemon lokal berjalan dan konektor sudah dikonfigurasi.', + 'settings.orbit.gateAriaLabel': 'Konektor diperlukan untuk memakai Orbit', + 'settings.orbit.gateEyebrow': 'Perlu pengaturan', + 'settings.orbit.gateTitle': 'Hubungkan alat untuk menjalankan Orbit', + 'settings.orbit.gateBody': 'Orbit merangkum aktivitas di seluruh konektormu. Belum ada yang terhubung — tambahkan setidaknya satu integrasi agar Orbit punya sesuatu untuk dilaporkan.', + 'settings.orbit.gateBodyNoKey': 'Orbit merangkum aktivitas di seluruh konektormu, dan konektor berjalan melalui Composio. Tambahkan API key Composio di Connectors untuk memuat katalog dan memilih integrasi pertamamu.', + 'settings.orbit.gateAction': 'Buka Connectors', + 'settings.orbit.gateActionNoKey': 'Konfigurasikan Composio', + 'settings.orbit.gateLoading': 'Memeriksa konektor…', + 'settings.orbit.controlsLockedBadge': 'Terkunci', + 'settings.orbit.controlsLockedHint': 'Hubungkan alat untuk membuka kontrol jadwal dan templat Orbit.', + 'settings.orbit.artifactKickerLive': 'artifact live', + 'settings.orbit.artifactKickerLegacy': 'Ringkasan lama', + 'settings.orbit.artifactTitle': 'Ringkasan aktivitas Orbit harian', + 'settings.orbit.artifactMetaLive': 'Artifact HTML yang bisa diperbarui ulang dan dihasilkan dari aktivitas konektor.', + 'settings.orbit.artifactMetaLegacy': 'Dibuat sebelum dukungan live artifact diaktifkan — jalankan Orbit lagi untuk menerbitkannya.', + 'settings.orbit.copyMarkdownTitle': 'Salin ringkasan markdown ke clipboard', + 'settings.orbit.copied': 'Tersalin', + 'settings.orbit.copy': 'Salin', + 'settings.orbit.openArtifact': 'Buka artifact', + 'settings.orbit.sourceMarkdown': 'Markdown sumber', 'entry.tabDesigns': 'Desain', 'entry.tabExamples': 'Contoh', @@ -199,10 +295,74 @@ export const id: Dict = { 'connectors.gateTitle': 'Konfigurasikan API key Composio', 'connectors.gateBody': 'Konektor membutuhkan API key Composio. Tambahkan di Pengaturan untuk membuka integrasi yang tersedia.', - 'connectors.gateAction': 'Buka pengaturan', 'connectors.aboutLabel': 'Tentang', 'connectors.detailsLabel': 'Detail', 'connectors.statusLabel': 'Status', + // Keep CI-stable explicit fallbacks for keys that still use English in + // Indonesian. Some TypeScript/tsbuild combinations do not reliably + // account for the top-level `...en` spread when checking this large object. + 'connectors.category.aiAgents': 'AI agents', + 'connectors.category.aiInfrastructure': 'AI infrastructure', + 'connectors.category.accounting': 'Accounting', + 'connectors.category.admin': 'Admin', + 'connectors.category.advertising': 'Advertising', + 'connectors.category.analytics': 'Analytics', + 'connectors.category.automation': 'Automation', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Calendar', + 'connectors.category.commerce': 'Commerce', + 'connectors.category.communication': 'Communication', + 'connectors.category.contacts': 'Contacts', + 'connectors.category.dataPlatform': 'Data platform', + 'connectors.category.database': 'Database', + 'connectors.category.design': 'Design', + 'connectors.category.developer': 'Developer', + 'connectors.category.documentation': 'Documentation', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Education', + 'connectors.category.email': 'Email', + 'connectors.category.events': 'Events', + 'connectors.category.fieldService': 'Field service', + 'connectors.category.finance': 'Finance', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Forms', + 'connectors.category.gaming': 'Gaming', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': 'Hospitality', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Integration', + 'connectors.category.localization': 'Localization', + 'connectors.category.logistics': 'Logistics', + 'connectors.category.maps': 'Maps', + 'connectors.category.marketing': 'Marketing', + 'connectors.category.media': 'Media', + 'connectors.category.meetings': 'Meetings', + 'connectors.category.nonprofit': 'Nonprofit', + 'connectors.category.observability': 'Observability', + 'connectors.category.payments': 'Payments', + 'connectors.category.personal': 'Personal', + 'connectors.category.presentations': 'Presentations', + 'connectors.category.procurement': 'Procurement', + 'connectors.category.product': 'Product', + 'connectors.category.productivity': 'Productivity', + 'connectors.category.projectManagement': 'Project management', + 'connectors.category.recruiting': 'Recruiting', + 'connectors.category.research': 'Research', + 'connectors.category.salesIntelligence': 'Sales intelligence', + 'connectors.category.scheduling': 'Scheduling', + 'connectors.category.search': 'Search', + 'connectors.category.security': 'Security', + 'connectors.category.signing': 'Signing', + 'connectors.category.social': 'Social', + 'connectors.category.spreadsheets': 'Spreadsheets', + 'connectors.category.storage': 'Storage', + 'connectors.category.support': 'Support', + 'connectors.category.surveys': 'Surveys', + 'connectors.category.tasks': 'Tasks', + 'connectors.category.timeTracking': 'Time tracking', + 'connectors.category.video': 'Video', + 'connectors.category.whiteboard': 'Whiteboard', 'connectors.categoryLabel': 'Kategori', 'connectors.providerLabel': 'Provider', 'connectors.toolsSection': 'Alat', @@ -718,7 +878,21 @@ export const id: Dict = { 'liveArtifact.refresh.statusReady': 'Siap di-refresh', 'liveArtifact.refresh.statusSucceeded': 'Sudah terbaru', 'liveArtifact.refresh.statusFailed': 'Refresh gagal', - + 'liveArtifact.viewer.tabPreview': 'Preview', + 'liveArtifact.viewer.tabCode': 'Code', + 'liveArtifact.viewer.tabData': 'Data', + 'liveArtifact.viewer.tabRefreshHistory': 'Refresh history', + 'liveArtifact.viewer.dataEmpty': 'No data.json cache available.', + 'liveArtifact.viewer.code.templateHeading': 'Template HTML', + 'liveArtifact.viewer.code.renderedHeading': 'Rendered HTML', + 'liveArtifact.viewer.code.templateHelp': 'The editable template used with data.json to generate the preview.', + 'liveArtifact.viewer.code.renderedHelp': 'The generated index.html currently loaded by Preview.', + 'liveArtifact.viewer.code.variantAria': 'Code variant', + 'liveArtifact.viewer.code.variantTemplate': 'Template', + 'liveArtifact.viewer.code.variantRendered': 'Rendered', + 'liveArtifact.viewer.code.loading': 'Loading code…', + 'liveArtifact.viewer.code.unavailable': 'Code is not available yet.', + 'liveArtifact.viewer.code.empty': 'This code file is empty.', 'fileViewer.deployToVercel': 'Deploy ke Vercel', 'fileViewer.redeployToVercel': 'Deploy ulang ke Vercel', 'fileViewer.deployingToVercel': 'Deploying ke Vercel...', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index ee9a1c5dc..6128de499 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -955,9 +955,115 @@ export const ja: Dict = { 'settings.libraryNoResults': '検索条件に一致する項目がありません。', 'settings.libraryEnabled': '有効', 'settings.libraryDisabled': '無効', + 'settings.connectorsNavHint': '外部システム接続', + 'settings.connectorsHint': 'このデバイスのコネクターとツールプロバイダー設定を管理します。', + 'settings.connectorsComposioApiKey': 'Composio API キー', + 'settings.connectorsSavedTitle': 'ローカル daemon に保存済み', + 'settings.connectorsSavedWithTail': '保存済み · ••••{tail}', + 'settings.connectorsSaved': '保存済み', + 'settings.connectorsGetApiKey': 'API キーを取得', + 'settings.connectorsReplaceKeyPlaceholder': '保存済みキーを置き換えるには新しいキーを貼り付け', + 'settings.connectorsApiKeyPlaceholder': 'Composio API キーを貼り付け', + 'settings.connectorsClear': 'クリア', + 'settings.connectorsClearConfirmTitle': '保存された Composio API キーを削除しますか?', + 'settings.connectorsClearConfirmBody': 'キーを削除すると、このワークスペースに紐づくすべての Composio コネクタが切断されます。接続中のアカウント、OAuth 認可、ツールへのアクセス権はすべて取り消されます。', + 'settings.connectorsClearConfirmContinue': '続行', + 'settings.connectorsClearFinalTitle': 'すべてのコネクタが切断されます', + 'settings.connectorsClearFinalBody': '元には戻せません。新しいキーを貼り付けたあと、各インテグレーションをイチから接続し直す必要があります。', + 'settings.connectorsClearFinalConfirm': 'キーを削除して切断', + 'settings.connectorsClearArming': '少々お待ちを\u2026', + 'settings.connectorsClearCancel': 'キャンセル', + 'settings.connectorsSaveKey': "キーを保存", + 'settings.connectorsSaveKeyTitle': "このキーをローカルの daemon に送信", + 'settings.connectorsKeySaving': "保存中…", + 'settings.connectorsKeyError': "キーを保存できませんでした。ローカルの daemon が起動しているか確認して、もう一度お試しください。", + 'settings.connectorsHelpSaved': 'キーは下のカタログを有効にし、ローカル daemon に保持されます。置き換えるには新しいキーを貼り付け、削除するにはクリアしてください。', + 'settings.connectorsHelpUnsaved': "未保存の変更があります — 「キーを保存」をクリックすると、この資格情報がローカル daemon に保存され、下のカタログが解除されます。", + 'settings.connectorsHelpEmpty': '下のカタログを有効にするにはキーを追加してください。キーは daemon にローカル保存され、環境変数経由で送信されることはありません。', + 'settings.connectorsLoadingSavedKey': 'ローカル daemon に保存されたキーを確認しています…', + 'settings.autosaveSaving': "保存中…", + 'settings.autosaveSaved': "すべての変更を保存しました", + 'settings.autosaveError': "変更を保存できませんでした。ローカル daemon がオフラインの可能性があります。", 'settings.libraryToggleLabel': '切り替え', 'notify.successTitle': 'タスクが完了しました', 'notify.failureTitle': 'タスクが失敗しました', 'notify.successBody': '1ターンが終了しました。', 'notify.failureBody': 'タスクはエラーで終了しました。', + 'settings.orbit.eyebrow': '自動化', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': '日次コネクターサマリー', + 'settings.orbit.lede': 'コネクターのアクティビティをスケジュールで収集し、結果を更新可能な live artifact として公開します。', + 'settings.orbit.statusOnTitle': '毎日のスケジュール実行はオンです', + 'settings.orbit.statusOffTitle': '毎日のスケジュール実行はオフです', + 'settings.orbit.statusActive': '有効', + 'settings.orbit.statusOff': 'オフ', + 'settings.orbit.runTitle': 'Orbit 実行を開始してライブ会話を開く', + 'settings.orbit.running': '実行中…', + 'settings.orbit.runOpen': '今すぐ実行', + 'settings.orbit.dailySummaryTitle': '日次サマリー', + 'settings.orbit.dailySummarySub': 'スケジュールされたローカル時刻に 1 日 1 回実行します。', + 'settings.orbit.on': 'オン', + 'settings.orbit.off': 'オフ', + 'settings.orbit.runTimeTitle': '実行時刻', + 'settings.orbit.runTimeSub': '既定は 08:00。保存すると daemon のスケジュールに適用されます。', + 'settings.orbit.runTimeAria': '毎日の Orbit 実行時刻', + 'settings.orbit.nextRun': '次の実行', + 'settings.orbit.nextRunScheduledAfterSave': '保存後にスケジュール', + 'settings.orbit.schedule': 'スケジュール', + 'settings.orbit.pausedManualOnly': '一時停止 — 手動実行のみ', + 'settings.orbit.templateTitle': 'プロンプトテンプレート', + 'settings.orbit.templateMissing': 'テンプレート {id} はインストールされていません。', + 'settings.orbit.templateMissingOption': '{id}(不足)', + 'settings.orbit.templateMissingInstall': 'プロンプトを制御するには Orbit skill をインストールしてください。', + 'settings.orbit.templateMissingPickAnother': 'ドロップダウンから別のテンプレートを選択してください。', + 'settings.orbit.templateResetTitle': '{id} にリセット', + 'settings.orbit.templateReset': 'リセット', + 'settings.orbit.templateHelp': 'skill で Orbit を制御します — 選択したテンプレートのサンプルプロンプトが各 Orbit 実行に挿入され、サマリーがそのテンプレートの形に従います。', + 'settings.orbit.templateAria': 'Orbit プロンプトテンプレート', + 'settings.orbit.templatesLoading': 'テンプレートを読み込み中…', + 'settings.orbit.templatesOptgroup': 'Orbit skill テンプレート', + 'settings.orbit.lastRun': '前回の実行', + 'settings.orbit.triggerManual': '手動', + 'settings.orbit.triggerScheduled': 'スケジュール済み', + 'settings.orbit.meterAria': 'チェック {checked} 件中、成功 {succeeded} 件、スキップ {skipped} 件、失敗 {failed} 件', + 'settings.orbit.countChecked': 'チェック済み', + 'settings.orbit.countSucceeded': '成功', + 'settings.orbit.countSkipped': 'スキップ', + 'settings.orbit.countFailed': '失敗', + 'settings.orbit.runError': 'Orbit を実行できませんでした。ローカル daemon が実行中で、コネクターが設定されていることを確認してください。', + 'settings.orbit.gateAriaLabel': "Orbit を使うにはコネクタが必要です", + 'settings.orbit.gateEyebrow': "セットアップが必要", + 'settings.orbit.gateTitle': "ツールを接続して Orbit を動かしましょう", + 'settings.orbit.gateBody': "Orbit はコネクタの活動を要約します。まだ何も接続されていません — 少なくとも 1 つの連携を追加すると、Orbit が報告できるようになります。", + 'settings.orbit.gateBodyNoKey': "Orbit はコネクタの活動を要約し、コネクタは Composio を通して動作します。コネクタ画面で Composio API キーを追加すると、カタログが解放され最初の連携を選べます。", + 'settings.orbit.gateAction': "コネクタを開く", + 'settings.orbit.gateActionNoKey': "Composio を設定", + 'settings.orbit.gateLoading': "コネクタを確認中…", + 'settings.orbit.controlsLockedBadge': "ロック中", + 'settings.orbit.controlsLockedHint': "ツールを接続すると、Orbit のスケジュールとテンプレート設定が有効になります。", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'レガシーサマリー', + 'settings.orbit.artifactTitle': '日次 Orbit アクティビティサマリー', + 'settings.orbit.artifactMetaLive': 'コネクターアクティビティから生成された更新可能な HTML artifact。', + 'settings.orbit.artifactMetaLegacy': 'live artifact サポートが有効になる前に生成されました — 公開するには Orbit をもう一度実行してください。', + 'settings.orbit.copyMarkdownTitle': 'Markdown サマリーをクリップボードにコピー', + 'settings.orbit.copied': 'コピー済み', + 'settings.orbit.copy': 'コピー', + 'settings.orbit.openArtifact': 'artifact を開く', + 'settings.orbit.sourceMarkdown': 'ソース Markdown', + 'liveArtifact.viewer.tabPreview': 'プレビュー', + 'liveArtifact.viewer.tabCode': 'コード', + 'liveArtifact.viewer.tabData': 'データ', + 'liveArtifact.viewer.tabRefreshHistory': '更新履歴', + 'liveArtifact.viewer.dataEmpty': 'data.json キャッシュはありません。', + 'liveArtifact.viewer.code.templateHeading': 'テンプレート HTML', + 'liveArtifact.viewer.code.renderedHeading': 'レンダリング済み HTML', + 'liveArtifact.viewer.code.templateHelp': 'data.json と共にプレビュー生成に使われる編集可能なテンプレートです。', + 'liveArtifact.viewer.code.renderedHelp': 'プレビューで現在読み込まれている生成済み index.html です。', + 'liveArtifact.viewer.code.variantAria': 'コードの種類', + 'liveArtifact.viewer.code.variantTemplate': 'テンプレート', + 'liveArtifact.viewer.code.variantRendered': 'レンダリング済み', + 'liveArtifact.viewer.code.loading': 'コードを読み込み中…', + 'liveArtifact.viewer.code.unavailable': 'コードはまだ利用できません。', + 'liveArtifact.viewer.code.empty': 'このコードファイルは空です。', }; diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 907ab5d3a..46b015fc1 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -199,12 +199,73 @@ export const ko: Dict = { 'connectors.statusConnected': '연결됨', 'connectors.statusError': '오류', 'connectors.statusDisabled': '비활성화됨', - 'connectors.gateTitle': '먼저 Composio API 키를 설정하세요', - 'connectors.gateBody': '커넥터를 사용하려면 Composio API 키가 필요합니다. 설정에 추가하면 사용 가능한 통합을 활성화할 수 있습니다.', - 'connectors.gateAction': '설정 열기', + 'connectors.gateTitle': '계속하려면 Composio API 키를 추가하세요', + 'connectors.gateBody': '위에 키를 붙여넣고 키 저장을 클릭하면 사용 가능한 통합을 불러옵니다.', 'connectors.aboutLabel': '정보', 'connectors.detailsLabel': '세부 정보', 'connectors.statusLabel': '상태', + 'connectors.category.aiAgents': 'AI 에이전트', + 'connectors.category.aiInfrastructure': 'AI 인프라', + 'connectors.category.accounting': '회계', + 'connectors.category.admin': '관리', + 'connectors.category.advertising': '광고', + 'connectors.category.analytics': '분석', + 'connectors.category.automation': '자동화', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': '캘린더', + 'connectors.category.commerce': '커머스', + 'connectors.category.communication': '커뮤니케이션', + 'connectors.category.contacts': '연락처', + 'connectors.category.dataPlatform': '데이터 플랫폼', + 'connectors.category.database': '데이터베이스', + 'connectors.category.design': '디자인', + 'connectors.category.developer': '개발자 도구', + 'connectors.category.documentation': '문서', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': '교육', + 'connectors.category.email': '이메일', + 'connectors.category.events': '이벤트', + 'connectors.category.fieldService': '현장 서비스', + 'connectors.category.finance': '재무', + 'connectors.category.fitness': '피트니스', + 'connectors.category.forms': '폼', + 'connectors.category.gaming': '게임', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': '호스피탈리티', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': '통합', + 'connectors.category.localization': '현지화', + 'connectors.category.logistics': '물류', + 'connectors.category.maps': '지도', + 'connectors.category.marketing': '마케팅', + 'connectors.category.media': '미디어', + 'connectors.category.meetings': '회의', + 'connectors.category.nonprofit': '비영리', + 'connectors.category.observability': '옵저버빌리티', + 'connectors.category.payments': '결제', + 'connectors.category.personal': '개인', + 'connectors.category.presentations': '프레젠테이션', + 'connectors.category.procurement': '조달', + 'connectors.category.product': '제품', + 'connectors.category.productivity': '생산성', + 'connectors.category.projectManagement': '프로젝트 관리', + 'connectors.category.recruiting': '채용', + 'connectors.category.research': '리서치', + 'connectors.category.salesIntelligence': '영업 인텔리전스', + 'connectors.category.scheduling': '일정 예약', + 'connectors.category.search': '검색', + 'connectors.category.security': '보안', + 'connectors.category.signing': '서명', + 'connectors.category.social': '소셜', + 'connectors.category.spreadsheets': '스프레드시트', + 'connectors.category.storage': '스토리지', + 'connectors.category.support': '지원', + 'connectors.category.surveys': '설문', + 'connectors.category.tasks': '작업', + 'connectors.category.timeTracking': '시간 추적', + 'connectors.category.video': '비디오', + 'connectors.category.whiteboard': '화이트보드', 'connectors.categoryLabel': '카테고리', 'connectors.providerLabel': '제공자', 'connectors.toolsSection': '도구', @@ -1002,9 +1063,115 @@ export const ko: Dict = { 'settings.libraryNoResults': '검색어와 일치하는 항목이 없습니다.', 'settings.libraryEnabled': '활성화됨', 'settings.libraryDisabled': '비활성화됨', + 'settings.connectorsNavHint': '외부 시스템 연결', + 'settings.connectorsHint': '이 기기의 커넥터 및 도구 제공자 설정을 관리합니다.', + 'settings.connectorsComposioApiKey': 'Composio API 키', + 'settings.connectorsSavedTitle': '로컬 daemon에 저장됨', + 'settings.connectorsSavedWithTail': '저장됨 · ••••{tail}', + 'settings.connectorsSaved': '저장됨', + 'settings.connectorsGetApiKey': 'API 키 받기', + 'settings.connectorsReplaceKeyPlaceholder': '저장된 키를 교체하려면 새 키를 붙여넣으세요', + 'settings.connectorsApiKeyPlaceholder': 'Composio API 키 붙여넣기', + 'settings.connectorsClear': '지우기', + 'settings.connectorsClearConfirmTitle': '저장된 Composio API 키를 지울까요?', + 'settings.connectorsClearConfirmBody': '키를 삭제하면 이 워크스페이스에 연결된 모든 Composio 커넥터가 해제됩니다. 연결된 계정, OAuth 권한, 도구 접근 권한이 모두 제거됩니다.', + 'settings.connectorsClearConfirmContinue': '계속', + 'settings.connectorsClearFinalTitle': '모든 커넥터 연결이 해제됩니다', + 'settings.connectorsClearFinalBody': '되돌릴 수 없습니다. 새 키를 붙여 넣은 뒤에는 각 통합을 처음부터 다시 연결해야 합니다.', + 'settings.connectorsClearFinalConfirm': '키 삭제 및 연결 해제', + 'settings.connectorsClearArming': '잠시만요\u2026', + 'settings.connectorsClearCancel': '취소', + 'settings.connectorsSaveKey': "키 저장", + 'settings.connectorsSaveKeyTitle': "이 키를 로컬 데몬으로 전송", + 'settings.connectorsKeySaving': "저장 중…", + 'settings.connectorsKeyError': "키를 저장하지 못했습니다. 로컬 데몬이 실행 중인지 확인한 뒤 다시 시도하세요.", + 'settings.connectorsHelpSaved': '키가 아래 카탈로그를 열며 로컬 daemon에 유지됩니다. 교체하려면 새 키를 붙여넣고, 제거하려면 지우세요.', + 'settings.connectorsHelpUnsaved': "저장되지 않은 변경사항 — 「키 저장」을 눌러 이 자격 정보를 로컬 데몬에 저장하고 아래 카탈로그를 해제하세요.", + 'settings.connectorsHelpEmpty': '아래 카탈로그를 열려면 키를 추가하세요. 키는 daemon에 로컬로 저장되며 환경 변수로 전송되지 않습니다.', + 'settings.connectorsLoadingSavedKey': '로컬 daemon에서 저장된 키를 확인하는 중…', + 'settings.autosaveSaving': "저장 중…", + 'settings.autosaveSaved': "모든 변경사항이 저장됨", + 'settings.autosaveError': "변경사항을 저장하지 못했습니다. 로컬 데몬이 오프라인일 수 있습니다.", 'settings.libraryToggleLabel': '전환', 'notify.successTitle': '작업 완료', 'notify.failureTitle': '작업 실패', 'notify.successBody': '한 턴이 끝났습니다.', 'notify.failureBody': '작업이 오류로 종료되었습니다.', + 'settings.orbit.eyebrow': '자동화', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': '일일 커넥터 요약', + 'settings.orbit.lede': '커넥터 활동을 일정에 따라 수집하고 결과를 새로고침 가능한 live artifact로 게시합니다.', + 'settings.orbit.statusOnTitle': '예약된 일일 실행이 켜져 있습니다', + 'settings.orbit.statusOffTitle': '예약된 일일 실행이 꺼져 있습니다', + 'settings.orbit.statusActive': '활성', + 'settings.orbit.statusOff': '꺼짐', + 'settings.orbit.runTitle': 'Orbit 실행을 시작하고 라이브 대화 열기', + 'settings.orbit.running': '실행 중…', + 'settings.orbit.runOpen': '지금 실행', + 'settings.orbit.dailySummaryTitle': '일일 요약', + 'settings.orbit.dailySummarySub': '예약된 로컬 시간에 하루 한 번 실행됩니다.', + 'settings.orbit.on': '켜짐', + 'settings.orbit.off': '꺼짐', + 'settings.orbit.runTimeTitle': '실행 시간', + 'settings.orbit.runTimeSub': '기본값 08:00. 저장하면 daemon 일정에 적용됩니다.', + 'settings.orbit.runTimeAria': '일일 Orbit 실행 시간', + 'settings.orbit.nextRun': '다음 실행', + 'settings.orbit.nextRunScheduledAfterSave': '저장 후 예약됨', + 'settings.orbit.schedule': '일정', + 'settings.orbit.pausedManualOnly': '일시 중지 — 수동 실행만', + 'settings.orbit.templateTitle': '프롬프트 템플릿', + 'settings.orbit.templateMissing': '템플릿 {id}이(가) 설치되어 있지 않습니다.', + 'settings.orbit.templateMissingOption': '{id}(누락)', + 'settings.orbit.templateMissingInstall': '프롬프트를 조정하려면 Orbit skill을 설치하세요.', + 'settings.orbit.templateMissingPickAnother': '드롭다운에서 다른 템플릿을 선택하세요.', + 'settings.orbit.templateResetTitle': '{id}(으)로 재설정', + 'settings.orbit.templateReset': '재설정', + 'settings.orbit.templateHelp': 'skill로 Orbit을 조정합니다 — 선택한 템플릿의 예시 프롬프트가 모든 Orbit 실행에 주입되어 요약이 해당 템플릿 형식을 따릅니다.', + 'settings.orbit.templateAria': 'Orbit 프롬프트 템플릿', + 'settings.orbit.templatesLoading': '템플릿 로드 중…', + 'settings.orbit.templatesOptgroup': 'Orbit skill 템플릿', + 'settings.orbit.lastRun': '마지막 실행', + 'settings.orbit.triggerManual': '수동', + 'settings.orbit.triggerScheduled': '예약됨', + 'settings.orbit.meterAria': '총 {checked}개 검사 중 성공 {succeeded}개, 건너뜀 {skipped}개, 실패 {failed}개', + 'settings.orbit.countChecked': '검사됨', + 'settings.orbit.countSucceeded': '성공', + 'settings.orbit.countSkipped': '건너뜀', + 'settings.orbit.countFailed': '실패', + 'settings.orbit.runError': 'Orbit을 실행할 수 없습니다. 로컬 daemon이 실행 중이고 커넥터가 구성되어 있는지 확인하세요.', + 'settings.orbit.gateAriaLabel': "Orbit을 사용하려면 커넥터가 필요합니다", + 'settings.orbit.gateEyebrow': "설정이 필요합니다", + 'settings.orbit.gateTitle': "도구를 연결해 Orbit을 시작하세요", + 'settings.orbit.gateBody': "Orbit은 커넥터 활동을 요약합니다. 아직 연결된 것이 없어요 — Orbit이 보고할 수 있도록 최소 하나의 통합을 추가하세요.", + 'settings.orbit.gateBodyNoKey': "Orbit은 커넥터 활동을 요약하며, 커넥터는 Composio를 통해 동작합니다. 커넥터 화면에서 Composio API 키를 추가해 카탈로그를 열고 첫 번째 통합을 선택하세요.", + 'settings.orbit.gateAction': "커넥터 열기", + 'settings.orbit.gateActionNoKey': "Composio 설정", + 'settings.orbit.gateLoading': "커넥터 확인 중…", + 'settings.orbit.controlsLockedBadge': "잠김", + 'settings.orbit.controlsLockedHint': "도구를 연결하면 Orbit의 일정과 템플릿 설정이 잠금 해제됩니다.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': '레거시 요약', + 'settings.orbit.artifactTitle': '일일 Orbit 활동 요약', + 'settings.orbit.artifactMetaLive': '커넥터 활동에서 생성된 새로고침 가능한 HTML artifact입니다.', + 'settings.orbit.artifactMetaLegacy': 'live artifact 지원이 활성화되기 전에 생성됨 — 게시하려면 Orbit을 다시 실행하세요.', + 'settings.orbit.copyMarkdownTitle': 'Markdown 요약을 클립보드에 복사', + 'settings.orbit.copied': '복사됨', + 'settings.orbit.copy': '복사', + 'settings.orbit.openArtifact': 'artifact 열기', + 'settings.orbit.sourceMarkdown': '소스 Markdown', + 'liveArtifact.viewer.tabPreview': '미리보기', + 'liveArtifact.viewer.tabCode': '코드', + 'liveArtifact.viewer.tabData': '데이터', + 'liveArtifact.viewer.tabRefreshHistory': '새로고침 기록', + 'liveArtifact.viewer.dataEmpty': '사용 가능한 data.json 캐시가 없습니다.', + 'liveArtifact.viewer.code.templateHeading': '템플릿 HTML', + 'liveArtifact.viewer.code.renderedHeading': '렌더링된 HTML', + 'liveArtifact.viewer.code.templateHelp': 'data.json과 함께 미리보기를 생성하는 데 사용되는 편집 가능한 템플릿입니다.', + 'liveArtifact.viewer.code.renderedHelp': '미리보기가 현재 로드한 생성된 index.html입니다.', + 'liveArtifact.viewer.code.variantAria': '코드 변형', + 'liveArtifact.viewer.code.variantTemplate': '템플릿', + 'liveArtifact.viewer.code.variantRendered': '렌더링됨', + 'liveArtifact.viewer.code.loading': '코드 로드 중…', + 'liveArtifact.viewer.code.unavailable': '아직 코드를 사용할 수 없습니다.', + 'liveArtifact.viewer.code.empty': '이 코드 파일은 비어 있습니다.', }; diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 9d8f15d49..748394277 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -199,12 +199,73 @@ export const pl: Dict = { 'connectors.statusConnected': 'Połączone', 'connectors.statusError': 'Błąd', 'connectors.statusDisabled': 'Wyłączone', - 'connectors.gateTitle': 'Najpierw skonfiguruj klucz API Composio', - 'connectors.gateBody': 'Konektory wymagają klucza API Composio. Dodaj go w ustawieniach, aby włączyć dostępne integracje.', - 'connectors.gateAction': 'Otwórz ustawienia', + 'connectors.gateTitle': 'Dodaj klucz API Composio, aby kontynuować', + 'connectors.gateBody': 'Wklej klucz powyżej i kliknij Zapisz klucz, aby wczytać dostępne integracje.', 'connectors.aboutLabel': 'Informacje', 'connectors.detailsLabel': 'Szczegóły', 'connectors.statusLabel': 'Status', + 'connectors.category.aiAgents': 'Agenci AI', + 'connectors.category.aiInfrastructure': 'Infrastruktura AI', + 'connectors.category.accounting': 'Księgowość', + 'connectors.category.admin': 'Administracja', + 'connectors.category.advertising': 'Reklama', + 'connectors.category.analytics': 'Analityka', + 'connectors.category.automation': 'Automatyzacja', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Kalendarz', + 'connectors.category.commerce': 'Handel', + 'connectors.category.communication': 'Komunikacja', + 'connectors.category.contacts': 'Kontakty', + 'connectors.category.dataPlatform': 'Platforma danych', + 'connectors.category.database': 'Baza danych', + 'connectors.category.design': 'Design', + 'connectors.category.developer': 'Narzędzia deweloperskie', + 'connectors.category.documentation': 'Dokumentacja', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Edukacja', + 'connectors.category.email': 'E-mail', + 'connectors.category.events': 'Wydarzenia', + 'connectors.category.fieldService': 'Serwis terenowy', + 'connectors.category.finance': 'Finanse', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Formularze', + 'connectors.category.gaming': 'Gry', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': 'Hotelarstwo', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Integracja', + 'connectors.category.localization': 'Lokalizacja', + 'connectors.category.logistics': 'Logistyka', + 'connectors.category.maps': 'Mapy', + 'connectors.category.marketing': 'Marketing', + 'connectors.category.media': 'Media', + 'connectors.category.meetings': 'Spotkania', + 'connectors.category.nonprofit': 'Nonprofit', + 'connectors.category.observability': 'Obserwowalność', + 'connectors.category.payments': 'Płatności', + 'connectors.category.personal': 'Osobiste', + 'connectors.category.presentations': 'Prezentacje', + 'connectors.category.procurement': 'Zakupy', + 'connectors.category.product': 'Produkt', + 'connectors.category.productivity': 'Produktywność', + 'connectors.category.projectManagement': 'Zarządzanie projektami', + 'connectors.category.recruiting': 'Rekrutacja', + 'connectors.category.research': 'Badania', + 'connectors.category.salesIntelligence': 'Analiza sprzedaży', + 'connectors.category.scheduling': 'Planowanie', + 'connectors.category.search': 'Wyszukiwanie', + 'connectors.category.security': 'Bezpieczeństwo', + 'connectors.category.signing': 'Podpisywanie', + 'connectors.category.social': 'Social', + 'connectors.category.spreadsheets': 'Arkusze kalkulacyjne', + 'connectors.category.storage': 'Pamięć', + 'connectors.category.support': 'Wsparcie', + 'connectors.category.surveys': 'Ankiety', + 'connectors.category.tasks': 'Zadania', + 'connectors.category.timeTracking': 'Śledzenie czasu', + 'connectors.category.video': 'Wideo', + 'connectors.category.whiteboard': 'Tablica', 'connectors.categoryLabel': 'Kategoria', 'connectors.providerLabel': 'Dostawca', 'connectors.toolsSection': 'Narzędzia', @@ -1002,9 +1063,115 @@ export const pl: Dict = { 'settings.libraryNoResults': 'Brak elementów pasujących do wyszukiwania.', 'settings.libraryEnabled': 'Włączone', 'settings.libraryDisabled': 'Wyłączone', + 'settings.connectorsNavHint': 'Połączenia z systemami zewnętrznymi', + 'settings.connectorsHint': 'Zarządzaj ustawieniami konektorów i dostawców narzędzi dla tego urządzenia.', + 'settings.connectorsComposioApiKey': 'Klucz API Composio', + 'settings.connectorsSavedTitle': 'Zapisano w lokalnym daemon', + 'settings.connectorsSavedWithTail': 'Zapisano · ••••{tail}', + 'settings.connectorsSaved': 'Zapisano', + 'settings.connectorsGetApiKey': 'Pobierz klucz API', + 'settings.connectorsReplaceKeyPlaceholder': 'Wklej nowy klucz, aby zastąpić zapisany', + 'settings.connectorsApiKeyPlaceholder': 'Wklej klucz API Composio', + 'settings.connectorsClear': 'Wyczyść', + 'settings.connectorsClearConfirmTitle': 'Wyczyścić zapisany klucz API Composio?', + 'settings.connectorsClearConfirmBody': 'Usunięcie klucza odłącza wszystkie konektory Composio powiązane z tym workspace. Połączone konta, zgody OAuth oraz dostęp do narzędzi zostaną usunięte.', + 'settings.connectorsClearConfirmContinue': 'Kontynuuj', + 'settings.connectorsClearFinalTitle': 'To odłączy wszystkie konektory', + 'settings.connectorsClearFinalBody': 'Tej operacji nie da się cofnąć. Po wklejeniu nowego klucza każdą integrację trzeba podłączyć od nowa.', + 'settings.connectorsClearFinalConfirm': 'Usuń klucz i odłącz', + 'settings.connectorsClearArming': 'Chwilka\u2026', + 'settings.connectorsClearCancel': 'Anuluj', + 'settings.connectorsSaveKey': "Zapisz klucz", + 'settings.connectorsSaveKeyTitle': "Wyślij ten klucz do lokalnego demona", + 'settings.connectorsKeySaving': "Zapisywanie…", + 'settings.connectorsKeyError': "Nie udało się zapisać klucza. Sprawdź, czy lokalny demon działa, i spróbuj ponownie.", + 'settings.connectorsHelpSaved': 'Twój klucz odblokowuje katalog poniżej i pozostaje w lokalnym daemon. Wklej nowy klucz, aby go zastąpić, albo wyczyść, aby usunąć.', + 'settings.connectorsHelpUnsaved': "Niezapisane zmiany — kliknij Zapisz klucz, aby zachować te dane w lokalnym demonie i odblokować katalog poniżej.", + 'settings.connectorsHelpEmpty': 'Dodaj klucz, aby odblokować katalog poniżej. Klucze są przechowywane lokalnie w daemon i nigdy nie są wysyłane przez zmienne środowiskowe.', + 'settings.connectorsLoadingSavedKey': 'Sprawdzanie zapisanego klucza w lokalnym daemon…', + 'settings.autosaveSaving': "Zapisywanie…", + 'settings.autosaveSaved': "Wszystkie zmiany zapisane", + 'settings.autosaveError': "Nie udało się zapisać zmian. Lokalny demon może być offline.", 'settings.libraryToggleLabel': 'Przełącz', 'notify.successTitle': 'Zadanie ukończone', 'notify.failureTitle': 'Zadanie nieudane', 'notify.successBody': 'Tura zakończona.', 'notify.failureBody': 'Zadanie zakończyło się błędem.', + 'settings.orbit.eyebrow': 'Automatyzacja', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Dzienne podsumowanie konektorów', + 'settings.orbit.lede': 'Zbieraj aktywność konektorów według harmonogramu i publikuj wynik jako odświeżalny live artifact.', + 'settings.orbit.statusOnTitle': 'Codzienne uruchomienia według harmonogramu są włączone', + 'settings.orbit.statusOffTitle': 'Codzienne uruchomienia według harmonogramu są wyłączone', + 'settings.orbit.statusActive': 'Aktywne', + 'settings.orbit.statusOff': 'Wyłączone', + 'settings.orbit.runTitle': 'Uruchom Orbit i otwórz rozmowę na żywo', + 'settings.orbit.running': 'Uruchamianie…', + 'settings.orbit.runOpen': 'Uruchom teraz', + 'settings.orbit.dailySummaryTitle': 'Podsumowanie dzienne', + 'settings.orbit.dailySummarySub': 'Uruchamia się raz dziennie o zaplanowanej lokalnej godzinie.', + 'settings.orbit.on': 'Włączone', + 'settings.orbit.off': 'Wyłączone', + 'settings.orbit.runTimeTitle': 'Czas uruchomienia', + 'settings.orbit.runTimeSub': 'Domyślnie 08:00. Zapisz, aby zastosować harmonogram daemon.', + 'settings.orbit.runTimeAria': 'Codzienny czas uruchomienia Orbit', + 'settings.orbit.nextRun': 'Następne uruchomienie', + 'settings.orbit.nextRunScheduledAfterSave': 'Zaplanowane po zapisie', + 'settings.orbit.schedule': 'Harmonogram', + 'settings.orbit.pausedManualOnly': 'Wstrzymane — tylko uruchomienia ręczne', + 'settings.orbit.templateTitle': 'Szablon prompt', + 'settings.orbit.templateMissing': 'Szablon {id} nie jest zainstalowany.', + 'settings.orbit.templateMissingOption': '{id} (brak)', + 'settings.orbit.templateMissingInstall': 'Zainstaluj skill Orbit, aby sterować prompt.', + 'settings.orbit.templateMissingPickAnother': 'Wybierz inny szablon z listy.', + 'settings.orbit.templateResetTitle': 'Resetuj do {id}', + 'settings.orbit.templateReset': 'Resetuj', + 'settings.orbit.templateHelp': 'Steruj Orbit za pomocą skill — przykładowy prompt wybranego szablonu jest wstrzykiwany do każdego uruchomienia Orbit, aby podsumowania miały kształt tego szablonu.', + 'settings.orbit.templateAria': 'Szablon prompt Orbit', + 'settings.orbit.templatesLoading': 'Ładowanie szablonów…', + 'settings.orbit.templatesOptgroup': 'Szablony skills Orbit', + 'settings.orbit.lastRun': 'Ostatnie uruchomienie', + 'settings.orbit.triggerManual': 'Ręczne', + 'settings.orbit.triggerScheduled': 'Zaplanowane', + 'settings.orbit.meterAria': '{succeeded} udanych, {skipped} pominiętych, {failed} nieudanych z {checked} sprawdzonych', + 'settings.orbit.countChecked': 'Sprawdzone', + 'settings.orbit.countSucceeded': 'Udane', + 'settings.orbit.countSkipped': 'Pominięte', + 'settings.orbit.countFailed': 'Nieudane', + 'settings.orbit.runError': 'Nie można uruchomić Orbit. Upewnij się, że lokalny daemon działa, a konektory są skonfigurowane.', + 'settings.orbit.gateAriaLabel': "Do działania Orbit wymagane są łączniki", + 'settings.orbit.gateEyebrow': "Wymagana konfiguracja", + 'settings.orbit.gateTitle': "Połącz narzędzie, aby uruchomić Orbit", + 'settings.orbit.gateBody': "Orbit podsumowuje aktywność Twoich łączników. Nic jeszcze nie zostało podłączone — dodaj co najmniej jedną integrację, aby Orbit miał o czym raportować.", + 'settings.orbit.gateBodyNoKey': "Orbit podsumowuje aktywność Twoich łączników, a łączniki działają przez Composio. Dodaj klucz API Composio w sekcji Łączniki, aby odblokować katalog i wybrać pierwszą integrację.", + 'settings.orbit.gateAction': "Otwórz Łączniki", + 'settings.orbit.gateActionNoKey': "Skonfiguruj Composio", + 'settings.orbit.gateLoading': "Sprawdzanie łączników…", + 'settings.orbit.controlsLockedBadge': "Zablokowane", + 'settings.orbit.controlsLockedHint': "Podłącz narzędzie, aby odblokować harmonogram i szablon Orbit.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Starsze podsumowanie', + 'settings.orbit.artifactTitle': 'Dzienne podsumowanie aktywności Orbit', + 'settings.orbit.artifactMetaLive': 'Odświeżalny artefakt HTML wygenerowany z aktywności konektorów.', + 'settings.orbit.artifactMetaLegacy': 'Wygenerowano przed włączeniem obsługi live artifact — uruchom Orbit ponownie, aby opublikować jeden.', + 'settings.orbit.copyMarkdownTitle': 'Kopiuj podsumowanie Markdown do schowka', + 'settings.orbit.copied': 'Skopiowano', + 'settings.orbit.copy': 'Kopiuj', + 'settings.orbit.openArtifact': 'Otwórz artefakt', + 'settings.orbit.sourceMarkdown': 'Źródłowy Markdown', + 'liveArtifact.viewer.tabPreview': 'Podgląd', + 'liveArtifact.viewer.tabCode': 'Kod', + 'liveArtifact.viewer.tabData': 'Dane', + 'liveArtifact.viewer.tabRefreshHistory': 'Historia odświeżeń', + 'liveArtifact.viewer.dataEmpty': 'Brak dostępnego cache data.json.', + 'liveArtifact.viewer.code.templateHeading': 'HTML szablonu', + 'liveArtifact.viewer.code.renderedHeading': 'Wyrenderowany HTML', + 'liveArtifact.viewer.code.templateHelp': 'Edytowalny szablon używany z data.json do wygenerowania podglądu.', + 'liveArtifact.viewer.code.renderedHelp': 'Wygenerowany index.html aktualnie ładowany przez podgląd.', + 'liveArtifact.viewer.code.variantAria': 'Wariant kodu', + 'liveArtifact.viewer.code.variantTemplate': 'Szablon', + 'liveArtifact.viewer.code.variantRendered': 'Wyrenderowany', + 'liveArtifact.viewer.code.loading': 'Ładowanie kodu…', + 'liveArtifact.viewer.code.unavailable': 'Kod nie jest jeszcze dostępny.', + 'liveArtifact.viewer.code.empty': 'Ten plik kodu jest pusty.', }; diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 8ed939de9..22092b744 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -196,12 +196,73 @@ export const ptBR: Dict = { 'connectors.statusConnected': 'Conectado', 'connectors.statusError': 'Erro', 'connectors.statusDisabled': 'Desativado', - 'connectors.gateTitle': 'Configure a chave da API Composio', - 'connectors.gateBody': 'Os conectores exigem uma chave da API Composio. Adicione-a em Configurações para desbloquear as integrações disponíveis.', - 'connectors.gateAction': 'Abrir configurações', + 'connectors.gateTitle': 'Adicione sua chave de API do Composio para continuar', + 'connectors.gateBody': 'Cole sua chave acima e clique em Salvar chave para carregar as integrações disponíveis.', 'connectors.aboutLabel': 'Sobre', 'connectors.detailsLabel': 'Detalhes', 'connectors.statusLabel': 'Status', + 'connectors.category.aiAgents': 'Agentes de IA', + 'connectors.category.aiInfrastructure': 'Infraestrutura de IA', + 'connectors.category.accounting': 'Contabilidade', + 'connectors.category.admin': 'Administração', + 'connectors.category.advertising': 'Publicidade', + 'connectors.category.analytics': 'Análises', + 'connectors.category.automation': 'Automação', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Calendário', + 'connectors.category.commerce': 'Comércio', + 'connectors.category.communication': 'Comunicação', + 'connectors.category.contacts': 'Contatos', + 'connectors.category.dataPlatform': 'Plataforma de dados', + 'connectors.category.database': 'Banco de dados', + 'connectors.category.design': 'Design', + 'connectors.category.developer': 'Ferramentas de desenvolvedor', + 'connectors.category.documentation': 'Documentação', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Educação', + 'connectors.category.email': 'E-mail', + 'connectors.category.events': 'Eventos', + 'connectors.category.fieldService': 'Serviço de campo', + 'connectors.category.finance': 'Finanças', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Formulários', + 'connectors.category.gaming': 'Jogos', + 'connectors.category.hr': 'RH', + 'connectors.category.hospitality': 'Hospitalidade', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Integração', + 'connectors.category.localization': 'Localização', + 'connectors.category.logistics': 'Logística', + 'connectors.category.maps': 'Mapas', + 'connectors.category.marketing': 'Marketing', + 'connectors.category.media': 'Mídia', + 'connectors.category.meetings': 'Reuniões', + 'connectors.category.nonprofit': 'Sem fins lucrativos', + 'connectors.category.observability': 'Observabilidade', + 'connectors.category.payments': 'Pagamentos', + 'connectors.category.personal': 'Pessoal', + 'connectors.category.presentations': 'Apresentações', + 'connectors.category.procurement': 'Compras', + 'connectors.category.product': 'Produto', + 'connectors.category.productivity': 'Produtividade', + 'connectors.category.projectManagement': 'Gerenciamento de projetos', + 'connectors.category.recruiting': 'Recrutamento', + 'connectors.category.research': 'Pesquisa', + 'connectors.category.salesIntelligence': 'Inteligência de vendas', + 'connectors.category.scheduling': 'Agendamento', + 'connectors.category.search': 'Busca', + 'connectors.category.security': 'Segurança', + 'connectors.category.signing': 'Assinatura', + 'connectors.category.social': 'Social', + 'connectors.category.spreadsheets': 'Planilhas', + 'connectors.category.storage': 'Armazenamento', + 'connectors.category.support': 'Suporte', + 'connectors.category.surveys': 'Pesquisas', + 'connectors.category.tasks': 'Tarefas', + 'connectors.category.timeTracking': 'Controle de tempo', + 'connectors.category.video': 'Vídeo', + 'connectors.category.whiteboard': 'Quadro branco', 'connectors.categoryLabel': 'Categoria', 'connectors.providerLabel': 'Provedor', 'connectors.toolsSection': 'Ferramentas', @@ -1032,9 +1093,115 @@ export const ptBR: Dict = { 'settings.libraryNoResults': 'Nenhum item corresponde à sua pesquisa.', 'settings.libraryEnabled': 'Ativado', 'settings.libraryDisabled': 'Desativado', + 'settings.connectorsNavHint': 'Conexões com sistemas externos', + 'settings.connectorsHint': 'Gerencie configurações de conectores e provedores de ferramentas para este dispositivo.', + 'settings.connectorsComposioApiKey': 'Chave de API da Composio', + 'settings.connectorsSavedTitle': 'Salva no daemon local', + 'settings.connectorsSavedWithTail': 'Salva · ••••{tail}', + 'settings.connectorsSaved': 'Salva', + 'settings.connectorsGetApiKey': 'Obter chave de API', + 'settings.connectorsReplaceKeyPlaceholder': 'Cole uma nova chave para substituir a salva', + 'settings.connectorsApiKeyPlaceholder': 'Cole a chave de API da Composio', + 'settings.connectorsClear': 'Limpar', + 'settings.connectorsClearConfirmTitle': 'Limpar a chave de API do Composio salva?', + 'settings.connectorsClearConfirmBody': 'Remover a chave desconecta todos os conectores do Composio vinculados a este workspace. Contas conectadas, autorizações OAuth e acessos a ferramentas serão removidos.', + 'settings.connectorsClearConfirmContinue': 'Continuar', + 'settings.connectorsClearFinalTitle': 'Isso vai desconectar todos os conectores', + 'settings.connectorsClearFinalBody': 'Não há como desfazer. Você precisará reconectar cada integração do zero depois de colar uma nova chave.', + 'settings.connectorsClearFinalConfirm': 'Apagar chave e desconectar', + 'settings.connectorsClearArming': 'Um instante\u2026', + 'settings.connectorsClearCancel': 'Cancelar', + 'settings.connectorsSaveKey': "Salvar chave", + 'settings.connectorsSaveKeyTitle': "Enviar esta chave para o daemon local", + 'settings.connectorsKeySaving': "Salvando…", + 'settings.connectorsKeyError': "Não foi possível salvar a chave. Confirme que o daemon local está ativo e tente novamente.", + 'settings.connectorsHelpSaved': 'Sua chave desbloqueia o catálogo abaixo e permanece no daemon local. Cole uma nova chave para substituí-la ou limpe para remover.', + 'settings.connectorsHelpUnsaved': "Alterações não salvas — clique em Salvar chave para armazenar esta credencial no daemon local e desbloquear o catálogo abaixo.", + 'settings.connectorsHelpEmpty': 'Adicione uma chave para desbloquear o catálogo abaixo. As chaves são armazenadas localmente no daemon e nunca enviadas por variáveis de ambiente.', + 'settings.connectorsLoadingSavedKey': 'Procurando uma chave salva no daemon local…', + 'settings.autosaveSaving': "Salvando…", + 'settings.autosaveSaved': "Todas as alterações foram salvas", + 'settings.autosaveError': "Não foi possível salvar as alterações. O daemon local pode estar offline.", 'settings.libraryToggleLabel': 'Alternar', 'notify.successTitle': 'Tarefa concluída', 'notify.failureTitle': 'Tarefa falhou', 'notify.successBody': 'Uma rodada foi concluída.', 'notify.failureBody': 'A tarefa terminou com erro.', + 'settings.orbit.eyebrow': 'Automação', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Resumo diário de conectores', + 'settings.orbit.lede': 'Coletar atividade dos conectores em uma agenda e publicar o resultado como um live artifact atualizável.', + 'settings.orbit.statusOnTitle': 'execuções diárias agendadas estão ativadas', + 'settings.orbit.statusOffTitle': 'execuções diárias agendadas estão desativadas', + 'settings.orbit.statusActive': 'Ativo', + 'settings.orbit.statusOff': 'Desativado', + 'settings.orbit.runTitle': 'Iniciar uma execução Orbit e abrir a conversa ao vivo', + 'settings.orbit.running': 'Executando…', + 'settings.orbit.runOpen': 'Executar agora', + 'settings.orbit.dailySummaryTitle': 'Resumo diário', + 'settings.orbit.dailySummarySub': 'Executa uma vez por dia no horário local agendado.', + 'settings.orbit.on': 'Ativado', + 'settings.orbit.off': 'Desativado', + 'settings.orbit.runTimeTitle': 'Horário de execução', + 'settings.orbit.runTimeSub': 'Padrão 08:00. Salve para aplicar à agenda do daemon.', + 'settings.orbit.runTimeAria': 'Horário diário de execução Orbit', + 'settings.orbit.nextRun': 'Próxima execução', + 'settings.orbit.nextRunScheduledAfterSave': 'Agendada após salvar', + 'settings.orbit.schedule': 'Agenda', + 'settings.orbit.pausedManualOnly': 'Pausado — apenas execuções manuais', + 'settings.orbit.templateTitle': 'Modelo de prompt', + 'settings.orbit.templateMissing': 'O modelo {id} não está instalado.', + 'settings.orbit.templateMissingOption': '{id} (ausente)', + 'settings.orbit.templateMissingInstall': 'Instale uma skill Orbit para orientar o prompt.', + 'settings.orbit.templateMissingPickAnother': 'Escolha outro modelo no menu.', + 'settings.orbit.templateResetTitle': 'Redefinir para {id}', + 'settings.orbit.templateReset': 'Redefinir', + 'settings.orbit.templateHelp': 'Oriente Orbit com uma skill — o prompt de exemplo do modelo selecionado é injetado em cada execução Orbit para que os resumos sigam esse formato.', + 'settings.orbit.templateAria': 'Modelo de prompt Orbit', + 'settings.orbit.templatesLoading': 'Carregando modelos…', + 'settings.orbit.templatesOptgroup': 'Modelos de skills Orbit', + 'settings.orbit.lastRun': 'Última execução', + 'settings.orbit.triggerManual': 'Manual', + 'settings.orbit.triggerScheduled': 'Agendada', + 'settings.orbit.meterAria': '{succeeded} bem-sucedidos, {skipped} ignorados, {failed} com falha de {checked} verificados', + 'settings.orbit.countChecked': 'Verificados', + 'settings.orbit.countSucceeded': 'Bem-sucedidos', + 'settings.orbit.countSkipped': 'Ignorados', + 'settings.orbit.countFailed': 'Falharam', + 'settings.orbit.runError': 'Não foi possível executar Orbit. Verifique se o daemon local está em execução e se os conectores estão configurados.', + 'settings.orbit.gateAriaLabel': "Conectores são necessários para usar o Orbit", + 'settings.orbit.gateEyebrow': "Configuração necessária", + 'settings.orbit.gateTitle': "Conecte uma ferramenta para alimentar o Orbit", + 'settings.orbit.gateBody': "O Orbit resume a atividade dos seus conectores. Você ainda não conectou nada — adicione ao menos uma integração para o Orbit ter o que reportar.", + 'settings.orbit.gateBodyNoKey': "O Orbit resume a atividade dos conectores, e os conectores operam via Composio. Adicione uma chave de API do Composio em Conectores para desbloquear o catálogo e escolher sua primeira integração.", + 'settings.orbit.gateAction': "Abrir Conectores", + 'settings.orbit.gateActionNoKey': "Configurar Composio", + 'settings.orbit.gateLoading': "Verificando seus conectores…", + 'settings.orbit.controlsLockedBadge': "Bloqueado", + 'settings.orbit.controlsLockedHint': "Conecte uma ferramenta para desbloquear a programação e o modelo do Orbit.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Resumo legado', + 'settings.orbit.artifactTitle': 'Resumo diário de atividade Orbit', + 'settings.orbit.artifactMetaLive': 'Artefato HTML atualizável gerado da atividade dos conectores.', + 'settings.orbit.artifactMetaLegacy': 'Gerado antes de ativar o suporte a live artifact — execute Orbit novamente para publicar um.', + 'settings.orbit.copyMarkdownTitle': 'Copiar resumo Markdown para a área de transferência', + 'settings.orbit.copied': 'Copiado', + 'settings.orbit.copy': 'Copiar', + 'settings.orbit.openArtifact': 'Abrir artefato', + 'settings.orbit.sourceMarkdown': 'Markdown de origem', + 'liveArtifact.viewer.tabPreview': 'Prévia', + 'liveArtifact.viewer.tabCode': 'Código', + 'liveArtifact.viewer.tabData': 'Dados', + 'liveArtifact.viewer.tabRefreshHistory': 'Histórico de atualização', + 'liveArtifact.viewer.dataEmpty': 'Nenhum cache data.json disponível.', + 'liveArtifact.viewer.code.templateHeading': 'HTML do modelo', + 'liveArtifact.viewer.code.renderedHeading': 'HTML renderizado', + 'liveArtifact.viewer.code.templateHelp': 'O modelo editável usado com data.json para gerar a prévia.', + 'liveArtifact.viewer.code.renderedHelp': 'O index.html gerado atualmente carregado pela Prévia.', + 'liveArtifact.viewer.code.variantAria': 'Variante de código', + 'liveArtifact.viewer.code.variantTemplate': 'Modelo', + 'liveArtifact.viewer.code.variantRendered': 'Renderizado', + 'liveArtifact.viewer.code.loading': 'Carregando código…', + 'liveArtifact.viewer.code.unavailable': 'O código ainda não está disponível.', + 'liveArtifact.viewer.code.empty': 'Este arquivo de código está vazio.', }; diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 19fb58f69..5c480663f 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -196,12 +196,73 @@ export const ru: Dict = { 'connectors.statusConnected': 'Подключено', 'connectors.statusError': 'Ошибка', 'connectors.statusDisabled': 'Отключено', - 'connectors.gateTitle': 'Сначала настройте ключ Composio API', - 'connectors.gateBody': 'Коннекторам нужен ключ Composio API. Добавьте его в настройках, чтобы разблокировать доступные интеграции.', - 'connectors.gateAction': 'Открыть настройки', + 'connectors.gateTitle': 'Добавьте ключ Composio API, чтобы продолжить', + 'connectors.gateBody': 'Вставьте ключ выше и нажмите «Сохранить ключ», чтобы загрузить доступные интеграции.', 'connectors.aboutLabel': 'О коннекторе', 'connectors.detailsLabel': 'Детали', 'connectors.statusLabel': 'Статус', + 'connectors.category.aiAgents': 'AI-агенты', + 'connectors.category.aiInfrastructure': 'AI-инфраструктура', + 'connectors.category.accounting': 'Бухгалтерия', + 'connectors.category.admin': 'Администрирование', + 'connectors.category.advertising': 'Реклама', + 'connectors.category.analytics': 'Аналитика', + 'connectors.category.automation': 'Автоматизация', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Календарь', + 'connectors.category.commerce': 'Коммерция', + 'connectors.category.communication': 'Коммуникации', + 'connectors.category.contacts': 'Контакты', + 'connectors.category.dataPlatform': 'Платформа данных', + 'connectors.category.database': 'База данных', + 'connectors.category.design': 'Дизайн', + 'connectors.category.developer': 'Инструменты разработчика', + 'connectors.category.documentation': 'Документация', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Образование', + 'connectors.category.email': 'Почта', + 'connectors.category.events': 'События', + 'connectors.category.fieldService': 'Выездное обслуживание', + 'connectors.category.finance': 'Финансы', + 'connectors.category.fitness': 'Фитнес', + 'connectors.category.forms': 'Формы', + 'connectors.category.gaming': 'Игры', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': 'Гостеприимство', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Интеграция', + 'connectors.category.localization': 'Локализация', + 'connectors.category.logistics': 'Логистика', + 'connectors.category.maps': 'Карты', + 'connectors.category.marketing': 'Маркетинг', + 'connectors.category.media': 'Медиа', + 'connectors.category.meetings': 'Встречи', + 'connectors.category.nonprofit': 'НКО', + 'connectors.category.observability': 'Наблюдаемость', + 'connectors.category.payments': 'Платежи', + 'connectors.category.personal': 'Личное', + 'connectors.category.presentations': 'Презентации', + 'connectors.category.procurement': 'Закупки', + 'connectors.category.product': 'Продукт', + 'connectors.category.productivity': 'Продуктивность', + 'connectors.category.projectManagement': 'Управление проектами', + 'connectors.category.recruiting': 'Рекрутинг', + 'connectors.category.research': 'Исследования', + 'connectors.category.salesIntelligence': 'Sales intelligence', + 'connectors.category.scheduling': 'Планирование', + 'connectors.category.search': 'Поиск', + 'connectors.category.security': 'Безопасность', + 'connectors.category.signing': 'Подписание', + 'connectors.category.social': 'Социальные сети', + 'connectors.category.spreadsheets': 'Таблицы', + 'connectors.category.storage': 'Хранилище', + 'connectors.category.support': 'Поддержка', + 'connectors.category.surveys': 'Опросы', + 'connectors.category.tasks': 'Задачи', + 'connectors.category.timeTracking': 'Учёт времени', + 'connectors.category.video': 'Видео', + 'connectors.category.whiteboard': 'Доска', 'connectors.categoryLabel': 'Категория', 'connectors.providerLabel': 'Провайдер', 'connectors.toolsSection': 'Инструменты', @@ -1032,9 +1093,115 @@ export const ru: Dict = { 'settings.libraryNoResults': 'Ничего не найдено по вашему запросу.', 'settings.libraryEnabled': 'Включено', 'settings.libraryDisabled': 'Отключено', + 'settings.connectorsNavHint': 'Подключения к внешним системам', + 'settings.connectorsHint': 'Управляйте настройками коннекторов и провайдеров инструментов для этого устройства.', + 'settings.connectorsComposioApiKey': 'API-ключ Composio', + 'settings.connectorsSavedTitle': 'Сохранено в локальном daemon', + 'settings.connectorsSavedWithTail': 'Сохранено · ••••{tail}', + 'settings.connectorsSaved': 'Сохранено', + 'settings.connectorsGetApiKey': 'Получить API-ключ', + 'settings.connectorsReplaceKeyPlaceholder': 'Вставьте новый ключ, чтобы заменить сохранённый', + 'settings.connectorsApiKeyPlaceholder': 'Вставьте API-ключ Composio', + 'settings.connectorsClear': 'Очистить', + 'settings.connectorsClearConfirmTitle': 'Очистить сохранённый ключ Composio API?', + 'settings.connectorsClearConfirmBody': 'Удаление ключа отключит все коннекторы Composio, привязанные к этому рабочему пространству. Подключённые аккаунты, разрешения OAuth и доступ к инструментам будут удалены.', + 'settings.connectorsClearConfirmContinue': 'Продолжить', + 'settings.connectorsClearFinalTitle': 'Это отключит все коннекторы', + 'settings.connectorsClearFinalBody': 'Отменить нельзя. После вставки нового ключа каждую интеграцию придётся подключать заново.', + 'settings.connectorsClearFinalConfirm': 'Удалить ключ и отключить', + 'settings.connectorsClearArming': 'Минуточку\u2026', + 'settings.connectorsClearCancel': 'Отмена', + 'settings.connectorsSaveKey': "Сохранить ключ", + 'settings.connectorsSaveKeyTitle': "Отправить ключ локальному демону", + 'settings.connectorsKeySaving': "Сохранение…", + 'settings.connectorsKeyError': "Не удалось сохранить ключ. Убедитесь, что локальный демон запущен, и попробуйте снова.", + 'settings.connectorsHelpSaved': 'Ваш ключ открывает каталог ниже и остаётся в локальном daemon. Вставьте новый ключ для замены или очистите, чтобы удалить.', + 'settings.connectorsHelpUnsaved': "Несохранённые изменения — нажмите «Сохранить ключ», чтобы передать его локальному демону и открыть каталог ниже.", + 'settings.connectorsHelpEmpty': 'Добавьте ключ, чтобы открыть каталог ниже. Ключи хранятся локально в daemon и никогда не передаются через переменные окружения.', + 'settings.connectorsLoadingSavedKey': 'Проверка сохранённого ключа в локальном daemon…', + 'settings.autosaveSaving': "Сохранение…", + 'settings.autosaveSaved': "Все изменения сохранены", + 'settings.autosaveError': "Не удалось сохранить изменения. Возможно, локальный демон не в сети.", 'settings.libraryToggleLabel': 'Переключить', 'notify.successTitle': 'Задача выполнена', 'notify.failureTitle': 'Задача завершилась с ошибкой', 'notify.successBody': 'Ход завершён.', 'notify.failureBody': 'Задача завершилась с ошибкой.', + 'settings.orbit.eyebrow': 'Автоматизация', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Ежедневная сводка коннекторов', + 'settings.orbit.lede': 'Собирать активность коннекторов по расписанию и публиковать результат как обновляемый live artifact.', + 'settings.orbit.statusOnTitle': 'Ежедневные запуски по расписанию включены', + 'settings.orbit.statusOffTitle': 'Ежедневные запуски по расписанию выключены', + 'settings.orbit.statusActive': 'Активно', + 'settings.orbit.statusOff': 'Выкл.', + 'settings.orbit.runTitle': 'Запустить Orbit и открыть живой диалог', + 'settings.orbit.running': 'Выполняется…', + 'settings.orbit.runOpen': 'Запустить сейчас', + 'settings.orbit.dailySummaryTitle': 'Ежедневная сводка', + 'settings.orbit.dailySummarySub': 'Выполняется раз в день в назначенное локальное время.', + 'settings.orbit.on': 'Вкл.', + 'settings.orbit.off': 'Выкл.', + 'settings.orbit.runTimeTitle': 'Время запуска', + 'settings.orbit.runTimeSub': 'По умолчанию 08:00. Сохраните, чтобы применить расписание daemon.', + 'settings.orbit.runTimeAria': 'Время ежедневного запуска Orbit', + 'settings.orbit.nextRun': 'Следующий запуск', + 'settings.orbit.nextRunScheduledAfterSave': 'Будет запланировано после сохранения', + 'settings.orbit.schedule': 'Расписание', + 'settings.orbit.pausedManualOnly': 'Пауза — только ручные запуски', + 'settings.orbit.templateTitle': 'Шаблон prompt', + 'settings.orbit.templateMissing': 'Шаблон {id} не установлен.', + 'settings.orbit.templateMissingOption': '{id} (отсутствует)', + 'settings.orbit.templateMissingInstall': 'Установите skill Orbit, чтобы направлять prompt.', + 'settings.orbit.templateMissingPickAnother': 'Выберите другой шаблон в списке.', + 'settings.orbit.templateResetTitle': 'Сбросить на {id}', + 'settings.orbit.templateReset': 'Сбросить', + 'settings.orbit.templateHelp': 'Направляйте Orbit с помощью skill — пример prompt выбранного шаблона добавляется в каждый запуск Orbit, чтобы сводки следовали этой форме.', + 'settings.orbit.templateAria': 'Шаблон prompt Orbit', + 'settings.orbit.templatesLoading': 'Загрузка шаблонов…', + 'settings.orbit.templatesOptgroup': 'Шаблоны skills Orbit', + 'settings.orbit.lastRun': 'Последний запуск', + 'settings.orbit.triggerManual': 'Вручную', + 'settings.orbit.triggerScheduled': 'По расписанию', + 'settings.orbit.meterAria': '{succeeded} успешно, {skipped} пропущено, {failed} с ошибкой из {checked} проверено', + 'settings.orbit.countChecked': 'Проверено', + 'settings.orbit.countSucceeded': 'Успешно', + 'settings.orbit.countSkipped': 'Пропущено', + 'settings.orbit.countFailed': 'С ошибкой', + 'settings.orbit.runError': 'Не удалось запустить Orbit. Убедитесь, что локальный daemon запущен, а коннекторы настроены.', + 'settings.orbit.gateAriaLabel': "Для работы Orbit нужны коннекторы", + 'settings.orbit.gateEyebrow': "Требуется настройка", + 'settings.orbit.gateTitle': "Подключите инструмент для работы Orbit", + 'settings.orbit.gateBody': "Orbit обобщает активность ваших коннекторов. Вы ещё ничего не подключили — добавьте хотя бы одну интеграцию, чтобы Orbit мог о чём-то сообщить.", + 'settings.orbit.gateBodyNoKey': "Orbit обобщает активность коннекторов, а коннекторы работают через Composio. Добавьте ключ API Composio в разделе «Коннекторы», чтобы открыть каталог и выбрать первую интеграцию.", + 'settings.orbit.gateAction': "Открыть коннекторы", + 'settings.orbit.gateActionNoKey': "Настроить Composio", + 'settings.orbit.gateLoading': "Проверка коннекторов…", + 'settings.orbit.controlsLockedBadge': "Заблокировано", + 'settings.orbit.controlsLockedHint': "Подключите инструмент, чтобы разблокировать расписание и шаблон Orbit.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Устаревшая сводка', + 'settings.orbit.artifactTitle': 'Ежедневная сводка активности Orbit', + 'settings.orbit.artifactMetaLive': 'Обновляемый HTML-артефакт, созданный из активности коннекторов.', + 'settings.orbit.artifactMetaLegacy': 'Создано до включения поддержки live artifact — запустите Orbit снова, чтобы опубликовать один.', + 'settings.orbit.copyMarkdownTitle': 'Скопировать сводку Markdown в буфер обмена', + 'settings.orbit.copied': 'Скопировано', + 'settings.orbit.copy': 'Копировать', + 'settings.orbit.openArtifact': 'Открыть артефакт', + 'settings.orbit.sourceMarkdown': 'Исходный Markdown', + 'liveArtifact.viewer.tabPreview': 'Предпросмотр', + 'liveArtifact.viewer.tabCode': 'Код', + 'liveArtifact.viewer.tabData': 'Данные', + 'liveArtifact.viewer.tabRefreshHistory': 'История обновлений', + 'liveArtifact.viewer.dataEmpty': 'Кэш data.json недоступен.', + 'liveArtifact.viewer.code.templateHeading': 'HTML шаблона', + 'liveArtifact.viewer.code.renderedHeading': 'Сгенерированный HTML', + 'liveArtifact.viewer.code.templateHelp': 'Редактируемый шаблон, используемый с data.json для создания предпросмотра.', + 'liveArtifact.viewer.code.renderedHelp': 'Сгенерированный index.html, который сейчас загружен в предпросмотре.', + 'liveArtifact.viewer.code.variantAria': 'Вариант кода', + 'liveArtifact.viewer.code.variantTemplate': 'Шаблон', + 'liveArtifact.viewer.code.variantRendered': 'Сгенерировано', + 'liveArtifact.viewer.code.loading': 'Загрузка кода…', + 'liveArtifact.viewer.code.unavailable': 'Код пока недоступен.', + 'liveArtifact.viewer.code.empty': 'Этот файл кода пуст.', }; diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 065d32010..68a44e563 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -193,12 +193,73 @@ export const tr: Dict = { 'connectors.statusConnected': 'Bağlı', 'connectors.statusError': 'Hata', 'connectors.statusDisabled': 'Devre dışı', - 'connectors.gateTitle': 'Önce Composio API anahtarını yapılandırın', - 'connectors.gateBody': 'Bağlayıcılar bir Composio API anahtarı gerektirir. Kullanılabilir entegrasyonları etkinleştirmek için ayarlara ekleyin.', - 'connectors.gateAction': 'Ayarları aç', + 'connectors.gateTitle': 'Devam etmek için Composio API anahtarınızı ekleyin', + 'connectors.gateBody': 'Kullanılabilir entegrasyonları yüklemek için anahtarınızı yukarıya yapıştırın ve Anahtarı kaydet’e tıklayın.', 'connectors.aboutLabel': 'Hakkında', 'connectors.detailsLabel': 'Ayrıntılar', 'connectors.statusLabel': 'Durum', + 'connectors.category.aiAgents': 'AI ajanları', + 'connectors.category.aiInfrastructure': 'AI altyapısı', + 'connectors.category.accounting': 'Muhasebe', + 'connectors.category.admin': 'Yönetim', + 'connectors.category.advertising': 'Reklam', + 'connectors.category.analytics': 'Analitik', + 'connectors.category.automation': 'Otomasyon', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Takvim', + 'connectors.category.commerce': 'Ticaret', + 'connectors.category.communication': 'İletişim', + 'connectors.category.contacts': 'Kişiler', + 'connectors.category.dataPlatform': 'Veri platformu', + 'connectors.category.database': 'Veritabanı', + 'connectors.category.design': 'Tasarım', + 'connectors.category.developer': 'Geliştirici araçları', + 'connectors.category.documentation': 'Dokümantasyon', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Eğitim', + 'connectors.category.email': 'E-posta', + 'connectors.category.events': 'Etkinlikler', + 'connectors.category.fieldService': 'Saha servisi', + 'connectors.category.finance': 'Finans', + 'connectors.category.fitness': 'Fitness', + 'connectors.category.forms': 'Formlar', + 'connectors.category.gaming': 'Oyun', + 'connectors.category.hr': 'İK', + 'connectors.category.hospitality': 'Konaklama', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Entegrasyon', + 'connectors.category.localization': 'Yerelleştirme', + 'connectors.category.logistics': 'Lojistik', + 'connectors.category.maps': 'Haritalar', + 'connectors.category.marketing': 'Pazarlama', + 'connectors.category.media': 'Medya', + 'connectors.category.meetings': 'Toplantılar', + 'connectors.category.nonprofit': 'Kâr amacı gütmeyen', + 'connectors.category.observability': 'Gözlemlenebilirlik', + 'connectors.category.payments': 'Ödemeler', + 'connectors.category.personal': 'Kişisel', + 'connectors.category.presentations': 'Sunumlar', + 'connectors.category.procurement': 'Satın alma', + 'connectors.category.product': 'Ürün', + 'connectors.category.productivity': 'Verimlilik', + 'connectors.category.projectManagement': 'Proje yönetimi', + 'connectors.category.recruiting': 'İşe alım', + 'connectors.category.research': 'Araştırma', + 'connectors.category.salesIntelligence': 'Satış zekâsı', + 'connectors.category.scheduling': 'Zamanlama', + 'connectors.category.search': 'Arama', + 'connectors.category.security': 'Güvenlik', + 'connectors.category.signing': 'İmzalama', + 'connectors.category.social': 'Sosyal', + 'connectors.category.spreadsheets': 'E-tablolar', + 'connectors.category.storage': 'Depolama', + 'connectors.category.support': 'Destek', + 'connectors.category.surveys': 'Anketler', + 'connectors.category.tasks': 'Görevler', + 'connectors.category.timeTracking': 'Zaman takibi', + 'connectors.category.video': 'Video', + 'connectors.category.whiteboard': 'Beyaz tahta', 'connectors.categoryLabel': 'Kategori', 'connectors.providerLabel': 'Sağlayıcı', 'connectors.toolsSection': 'Araçlar', @@ -993,9 +1054,115 @@ export const tr: Dict = { 'settings.libraryNoResults': 'Aramanızla eşleşen öğe bulunamadı.', 'settings.libraryEnabled': 'Etkin', 'settings.libraryDisabled': 'Devre dışı', + 'settings.connectorsNavHint': 'Harici sistem bağlantıları', + 'settings.connectorsHint': 'Bu cihaz için bağlayıcı ve araç sağlayıcı ayarlarını yönetin.', + 'settings.connectorsComposioApiKey': 'Composio API anahtarı', + 'settings.connectorsSavedTitle': 'Yerel daemon’a kaydedildi', + 'settings.connectorsSavedWithTail': 'Kaydedildi · ••••{tail}', + 'settings.connectorsSaved': 'Kaydedildi', + 'settings.connectorsGetApiKey': 'API anahtarı al', + 'settings.connectorsReplaceKeyPlaceholder': 'Kaydedilmiş anahtarı değiştirmek için yeni anahtar yapıştırın', + 'settings.connectorsApiKeyPlaceholder': 'Composio API anahtarını yapıştırın', + 'settings.connectorsClear': 'Temizle', + 'settings.connectorsClearConfirmTitle': 'Kaydedilmiş Composio API anahtarı temizlensin mi?', + 'settings.connectorsClearConfirmBody': 'Anahtarı kaldırmak bu çalışma alanına bağlı tüm Composio bağlayıcılarını koparır. Bağlı hesaplar, OAuth izinleri ve araç erişimleri tamamen kaldırılır.', + 'settings.connectorsClearConfirmContinue': 'Devam et', + 'settings.connectorsClearFinalTitle': 'Bu işlem tüm bağlayıcıların bağlantısını keser', + 'settings.connectorsClearFinalBody': 'Geri alınamaz. Yeni bir anahtar yapıştırdıktan sonra her entegrasyonu sıfırdan yeniden bağlamanız gerekir.', + 'settings.connectorsClearFinalConfirm': 'Anahtarı sil ve bağlantıyı kes', + 'settings.connectorsClearArming': 'Bir saniye\u2026', + 'settings.connectorsClearCancel': 'İptal', + 'settings.connectorsSaveKey': "Anahtarı kaydet", + 'settings.connectorsSaveKeyTitle': "Bu anahtarı yerel daemon’a gönder", + 'settings.connectorsKeySaving': "Kaydediliyor…", + 'settings.connectorsKeyError': "Anahtar kaydedilemedi. Yerel daemon’ın çalıştığını doğrulayın ve tekrar deneyin.", + 'settings.connectorsHelpSaved': 'Anahtarınız aşağıdaki kataloğu açar ve yerel daemon’da kalır. Değiştirmek için yeni anahtar yapıştırın veya kaldırmak için temizleyin.', + 'settings.connectorsHelpUnsaved': "Kaydedilmemiş değişiklikler — bu kimlik bilgisini yerel daemon’a kaydetmek ve aşağıdaki kataloğu açmak için Anahtarı kaydet’e tıklayın.", + 'settings.connectorsHelpEmpty': 'Aşağıdaki kataloğu açmak için bir anahtar ekleyin. Anahtarlar daemon’da yerel olarak saklanır ve ortam değişkenleriyle gönderilmez.', + 'settings.connectorsLoadingSavedKey': 'Yerel daemon’da kayıtlı anahtar aranıyor…', + 'settings.autosaveSaving': "Kaydediliyor…", + 'settings.autosaveSaved': "Tüm değişiklikler kaydedildi", + 'settings.autosaveError': "Değişiklikler kaydedilemedi. Yerel daemon çevrimdışı olabilir.", 'settings.libraryToggleLabel': 'Değiştir', 'notify.successTitle': 'Görev tamamlandı', 'notify.failureTitle': 'Görev başarısız oldu', 'notify.successBody': 'Bir tur tamamlandı.', 'notify.failureBody': 'Görev bir hata ile sona erdi.', + 'settings.orbit.eyebrow': 'Otomasyon', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Günlük bağlayıcı özeti', + 'settings.orbit.lede': 'Bağlayıcı etkinliğini bir zamanlamaya göre topla ve sonucu yenilenebilir bir live artifact olarak yayımla.', + 'settings.orbit.statusOnTitle': 'Zamanlanmış günlük çalıştırmalar açık', + 'settings.orbit.statusOffTitle': 'Zamanlanmış günlük çalıştırmalar kapalı', + 'settings.orbit.statusActive': 'Aktif', + 'settings.orbit.statusOff': 'Kapalı', + 'settings.orbit.runTitle': 'Bir Orbit çalıştırması başlat ve canlı konuşmayı aç', + 'settings.orbit.running': 'Çalışıyor…', + 'settings.orbit.runOpen': 'Şimdi çalıştır', + 'settings.orbit.dailySummaryTitle': 'Günlük özet', + 'settings.orbit.dailySummarySub': 'Zamanlanan yerel saatte günde bir kez çalışır.', + 'settings.orbit.on': 'Açık', + 'settings.orbit.off': 'Kapalı', + 'settings.orbit.runTimeTitle': 'Çalıştırma zamanı', + 'settings.orbit.runTimeSub': 'Varsayılan 08:00. daemon zamanlamasına uygulamak için kaydet.', + 'settings.orbit.runTimeAria': 'Günlük Orbit çalıştırma zamanı', + 'settings.orbit.nextRun': 'Sonraki çalıştırma', + 'settings.orbit.nextRunScheduledAfterSave': 'Kaydettikten sonra zamanlanır', + 'settings.orbit.schedule': 'Zamanlama', + 'settings.orbit.pausedManualOnly': 'Duraklatıldı — yalnızca manuel çalıştırmalar', + 'settings.orbit.templateTitle': 'Prompt şablonu', + 'settings.orbit.templateMissing': '{id} şablonu yüklü değil.', + 'settings.orbit.templateMissingOption': '{id} (eksik)', + 'settings.orbit.templateMissingInstall': 'Promptu yönlendirmek için bir Orbit skill yükleyin.', + 'settings.orbit.templateMissingPickAnother': 'Açılır menüden başka bir şablon seçin.', + 'settings.orbit.templateResetTitle': '{id} değerine sıfırla', + 'settings.orbit.templateReset': 'Sıfırla', + 'settings.orbit.templateHelp': 'Orbit’i bir skill ile yönlendir — seçilen şablonun örnek promptu her Orbit çalıştırmasına eklenir, böylece özetler o şablonun biçimini izler.', + 'settings.orbit.templateAria': 'Orbit prompt şablonu', + 'settings.orbit.templatesLoading': 'Şablonlar yükleniyor…', + 'settings.orbit.templatesOptgroup': 'Orbit skill şablonları', + 'settings.orbit.lastRun': 'Son çalıştırma', + 'settings.orbit.triggerManual': 'Manuel', + 'settings.orbit.triggerScheduled': 'Zamanlandı', + 'settings.orbit.meterAria': '{checked} kontrolden {succeeded} başarılı, {skipped} atlandı, {failed} başarısız', + 'settings.orbit.countChecked': 'Kontrol edildi', + 'settings.orbit.countSucceeded': 'Başarılı', + 'settings.orbit.countSkipped': 'Atlandı', + 'settings.orbit.countFailed': 'Başarısız', + 'settings.orbit.runError': 'Orbit çalıştırılamadı. Yerel daemon’ın çalıştığından ve bağlayıcıların yapılandırıldığından emin olun.', + 'settings.orbit.gateAriaLabel': "Orbit’i kullanmak için bağlayıcılar gerekiyor", + 'settings.orbit.gateEyebrow': "Kurulum gerekli", + 'settings.orbit.gateTitle': "Orbit’i çalıştırmak için bir araç bağlayın", + 'settings.orbit.gateBody': "Orbit, bağlayıcılarınızın etkinliğini özetler. Henüz hiçbir şey bağlamadınız — Orbit’in raporlayacak bir şeyi olması için en az bir entegrasyon ekleyin.", + 'settings.orbit.gateBodyNoKey': "Orbit, bağlayıcılarınızın etkinliğini özetler ve bağlayıcılar Composio üzerinden çalışır. Kataloğu açmak ve ilk entegrasyonunuzu seçmek için Bağlayıcılar bölümüne bir Composio API anahtarı ekleyin.", + 'settings.orbit.gateAction': "Bağlayıcıları aç", + 'settings.orbit.gateActionNoKey': "Composio’yu yapılandır", + 'settings.orbit.gateLoading': "Bağlayıcılarınız kontrol ediliyor…", + 'settings.orbit.controlsLockedBadge': "Kilitli", + 'settings.orbit.controlsLockedHint': "Orbit'in zamanlama ve şablon kontrollerini açmak için bir araç bağlayın.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Eski özet', + 'settings.orbit.artifactTitle': 'Günlük Orbit etkinlik özeti', + 'settings.orbit.artifactMetaLive': 'Bağlayıcı etkinliğinden oluşturulan yenilenebilir HTML artifact.', + 'settings.orbit.artifactMetaLegacy': 'live artifact desteği etkinleştirilmeden önce oluşturuldu — bir tane yayımlamak için Orbit’i yeniden çalıştırın.', + 'settings.orbit.copyMarkdownTitle': 'Markdown özetini panoya kopyala', + 'settings.orbit.copied': 'Kopyalandı', + 'settings.orbit.copy': 'Kopyala', + 'settings.orbit.openArtifact': 'Artifact aç', + 'settings.orbit.sourceMarkdown': 'Kaynak Markdown', + 'liveArtifact.viewer.tabPreview': 'Önizleme', + 'liveArtifact.viewer.tabCode': 'Kod', + 'liveArtifact.viewer.tabData': 'Veri', + 'liveArtifact.viewer.tabRefreshHistory': 'Yenileme geçmişi', + 'liveArtifact.viewer.dataEmpty': 'Kullanılabilir data.json önbelleği yok.', + 'liveArtifact.viewer.code.templateHeading': 'Şablon HTML', + 'liveArtifact.viewer.code.renderedHeading': 'Oluşturulan HTML', + 'liveArtifact.viewer.code.templateHelp': 'Önizlemeyi oluşturmak için data.json ile kullanılan düzenlenebilir şablon.', + 'liveArtifact.viewer.code.renderedHelp': 'Önizleme tarafından şu anda yüklenen oluşturulmuş index.html.', + 'liveArtifact.viewer.code.variantAria': 'Kod varyantı', + 'liveArtifact.viewer.code.variantTemplate': 'Şablon', + 'liveArtifact.viewer.code.variantRendered': 'Oluşturuldu', + 'liveArtifact.viewer.code.loading': 'Kod yükleniyor…', + 'liveArtifact.viewer.code.unavailable': 'Kod henüz kullanılamıyor.', + 'liveArtifact.viewer.code.empty': 'Bu kod dosyası boş.', }; diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index f29ca9c2a..4d538ea38 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -198,12 +198,73 @@ export const uk: Dict = { 'connectors.statusConnected': 'Підключено', 'connectors.statusError': 'Помилка', 'connectors.statusDisabled': 'Вимкнено', - 'connectors.gateTitle': 'Налаштуйте API-ключ Composio', - 'connectors.gateBody': 'Для конекторів потрібен API-ключ Composio. Додайте його в Налаштуваннях, щоб розблокувати доступні інтеграції.', - 'connectors.gateAction': 'Відкрити налаштування', + 'connectors.gateTitle': 'Додайте ключ Composio API, щоб продовжити', + 'connectors.gateBody': 'Вставте ключ вище й натисніть «Зберегти ключ», щоб завантажити доступні інтеграції.', 'connectors.aboutLabel': 'Про', 'connectors.detailsLabel': 'Деталі', 'connectors.statusLabel': 'Статус', + 'connectors.category.aiAgents': 'AI-агенти', + 'connectors.category.aiInfrastructure': 'AI-інфраструктура', + 'connectors.category.accounting': 'Бухгалтерія', + 'connectors.category.admin': 'Адміністрування', + 'connectors.category.advertising': 'Реклама', + 'connectors.category.analytics': 'Аналітика', + 'connectors.category.automation': 'Автоматизація', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': 'Календар', + 'connectors.category.commerce': 'Комерція', + 'connectors.category.communication': 'Комунікації', + 'connectors.category.contacts': 'Контакти', + 'connectors.category.dataPlatform': 'Платформа даних', + 'connectors.category.database': 'База даних', + 'connectors.category.design': 'Дизайн', + 'connectors.category.developer': 'Інструменти розробника', + 'connectors.category.documentation': 'Документація', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': 'Освіта', + 'connectors.category.email': 'Пошта', + 'connectors.category.events': 'Події', + 'connectors.category.fieldService': 'Виїзне обслуговування', + 'connectors.category.finance': 'Фінанси', + 'connectors.category.fitness': 'Фітнес', + 'connectors.category.forms': 'Форми', + 'connectors.category.gaming': 'Ігри', + 'connectors.category.hr': 'HR', + 'connectors.category.hospitality': 'Гостинність', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': 'Інтеграція', + 'connectors.category.localization': 'Локалізація', + 'connectors.category.logistics': 'Логістика', + 'connectors.category.maps': 'Карти', + 'connectors.category.marketing': 'Маркетинг', + 'connectors.category.media': 'Медіа', + 'connectors.category.meetings': 'Зустрічі', + 'connectors.category.nonprofit': 'Неприбуткові', + 'connectors.category.observability': 'Спостережуваність', + 'connectors.category.payments': 'Платежі', + 'connectors.category.personal': 'Особисте', + 'connectors.category.presentations': 'Презентації', + 'connectors.category.procurement': 'Закупівлі', + 'connectors.category.product': 'Продукт', + 'connectors.category.productivity': 'Продуктивність', + 'connectors.category.projectManagement': 'Керування проєктами', + 'connectors.category.recruiting': 'Рекрутинг', + 'connectors.category.research': 'Дослідження', + 'connectors.category.salesIntelligence': 'Аналітика продажів', + 'connectors.category.scheduling': 'Планування', + 'connectors.category.search': 'Пошук', + 'connectors.category.security': 'Безпека', + 'connectors.category.signing': 'Підписання', + 'connectors.category.social': 'Соціальні мережі', + 'connectors.category.spreadsheets': 'Таблиці', + 'connectors.category.storage': 'Сховище', + 'connectors.category.support': 'Підтримка', + 'connectors.category.surveys': 'Опитування', + 'connectors.category.tasks': 'Завдання', + 'connectors.category.timeTracking': 'Облік часу', + 'connectors.category.video': 'Відео', + 'connectors.category.whiteboard': 'Дошка', 'connectors.categoryLabel': 'Категорія', 'connectors.providerLabel': 'Провайдер', 'connectors.toolsSection': 'Інструменти', @@ -1033,9 +1094,115 @@ export const uk: Dict = { 'settings.libraryNoResults': 'Нічого не знайдено за вашим запитом.', 'settings.libraryEnabled': 'Увімкнено', 'settings.libraryDisabled': 'Вимкнено', + 'settings.connectorsNavHint': 'Підключення до зовнішніх систем', + 'settings.connectorsHint': 'Керуйте налаштуваннями конекторів і постачальників інструментів для цього пристрою.', + 'settings.connectorsComposioApiKey': 'API-ключ Composio', + 'settings.connectorsSavedTitle': 'Збережено в локальному daemon', + 'settings.connectorsSavedWithTail': 'Збережено · ••••{tail}', + 'settings.connectorsSaved': 'Збережено', + 'settings.connectorsGetApiKey': 'Отримати API-ключ', + 'settings.connectorsReplaceKeyPlaceholder': 'Вставте новий ключ, щоб замінити збережений', + 'settings.connectorsApiKeyPlaceholder': 'Вставте API-ключ Composio', + 'settings.connectorsClear': 'Очистити', + 'settings.connectorsClearConfirmTitle': 'Очистити збережений ключ API Composio?', + 'settings.connectorsClearConfirmBody': 'Видалення ключа від’єднає усі конектори Composio, прив’язані до цього робочого простору. Підключені облікові записи, дозволи OAuth і доступи до інструментів буде вилучено.', + 'settings.connectorsClearConfirmContinue': 'Продовжити', + 'settings.connectorsClearFinalTitle': 'Це від’єднає всі конектори', + 'settings.connectorsClearFinalBody': 'Скасувати неможливо. Після вставлення нового ключа кожну інтеграцію доведеться під’єднувати з нуля.', + 'settings.connectorsClearFinalConfirm': 'Видалити ключ і від’єднати', + 'settings.connectorsClearArming': 'Хвилинку\u2026', + 'settings.connectorsClearCancel': 'Скасувати', + 'settings.connectorsSaveKey': "Зберегти ключ", + 'settings.connectorsSaveKeyTitle': "Надіслати цей ключ локальному демону", + 'settings.connectorsKeySaving': "Збереження…", + 'settings.connectorsKeyError': "Не вдалося зберегти ключ. Перевірте, чи запущено локальний демон, і спробуйте ще раз.", + 'settings.connectorsHelpSaved': 'Ваш ключ відкриває каталог нижче й залишається в локальному daemon. Вставте новий ключ, щоб замінити його, або очистіть, щоб видалити.', + 'settings.connectorsHelpUnsaved': "Незбережені зміни — натисніть «Зберегти ключ», щоб надіслати ці дані локальному демону і відкрити каталог нижче.", + 'settings.connectorsHelpEmpty': 'Додайте ключ, щоб відкрити каталог нижче. Ключі зберігаються локально в daemon і ніколи не надсилаються через змінні середовища.', + 'settings.connectorsLoadingSavedKey': 'Перевірка збереженого ключа в локальному daemon…', + 'settings.autosaveSaving': "Збереження…", + 'settings.autosaveSaved': "Усі зміни збережено", + 'settings.autosaveError': "Не вдалося зберегти зміни. Можливо, локальний демон офлайн.", 'settings.libraryToggleLabel': 'Перемкнути', 'notify.successTitle': 'Завдання завершено', 'notify.failureTitle': 'Завдання не вдалося', 'notify.successBody': 'Черга завершена.', 'notify.failureBody': 'Завдання завершилось помилкою.', + 'settings.orbit.eyebrow': 'Автоматизація', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': 'Щоденний підсумок конекторів', + 'settings.orbit.lede': 'Збирати активність конекторів за розкладом і публікувати результат як оновлюваний live artifact.', + 'settings.orbit.statusOnTitle': 'Щоденні запуски за розкладом увімкнені', + 'settings.orbit.statusOffTitle': 'Щоденні запуски за розкладом вимкнені', + 'settings.orbit.statusActive': 'Активно', + 'settings.orbit.statusOff': 'Вимкнено', + 'settings.orbit.runTitle': 'Запустити Orbit і відкрити живу розмову', + 'settings.orbit.running': 'Виконується…', + 'settings.orbit.runOpen': 'Запустити зараз', + 'settings.orbit.dailySummaryTitle': 'Щоденний підсумок', + 'settings.orbit.dailySummarySub': 'Виконується раз на день у запланований локальний час.', + 'settings.orbit.on': 'Увімкнено', + 'settings.orbit.off': 'Вимкнено', + 'settings.orbit.runTimeTitle': 'Час запуску', + 'settings.orbit.runTimeSub': 'За замовчуванням 08:00. Збережіть, щоб застосувати розклад daemon.', + 'settings.orbit.runTimeAria': 'Час щоденного запуску Orbit', + 'settings.orbit.nextRun': 'Наступний запуск', + 'settings.orbit.nextRunScheduledAfterSave': 'Заплановано після збереження', + 'settings.orbit.schedule': 'Розклад', + 'settings.orbit.pausedManualOnly': 'Призупинено — лише ручні запуски', + 'settings.orbit.templateTitle': 'Шаблон prompt', + 'settings.orbit.templateMissing': 'Шаблон {id} не встановлено.', + 'settings.orbit.templateMissingOption': '{id} (немає)', + 'settings.orbit.templateMissingInstall': 'Установіть skill Orbit, щоб спрямовувати prompt.', + 'settings.orbit.templateMissingPickAnother': 'Виберіть інший шаблон у списку.', + 'settings.orbit.templateResetTitle': 'Скинути до {id}', + 'settings.orbit.templateReset': 'Скинути', + 'settings.orbit.templateHelp': 'Спрямовуйте Orbit за допомогою skill — приклад prompt вибраного шаблону додається до кожного запуску Orbit, щоб підсумки мали форму цього шаблону.', + 'settings.orbit.templateAria': 'Шаблон prompt Orbit', + 'settings.orbit.templatesLoading': 'Завантаження шаблонів…', + 'settings.orbit.templatesOptgroup': 'Шаблони skills Orbit', + 'settings.orbit.lastRun': 'Останній запуск', + 'settings.orbit.triggerManual': 'Вручну', + 'settings.orbit.triggerScheduled': 'За розкладом', + 'settings.orbit.meterAria': '{succeeded} успішно, {skipped} пропущено, {failed} з помилкою з {checked} перевірено', + 'settings.orbit.countChecked': 'Перевірено', + 'settings.orbit.countSucceeded': 'Успішно', + 'settings.orbit.countSkipped': 'Пропущено', + 'settings.orbit.countFailed': 'Помилка', + 'settings.orbit.runError': 'Не вдалося запустити Orbit. Переконайтеся, що локальний daemon працює, а конектори налаштовані.', + 'settings.orbit.gateAriaLabel': "Для роботи Orbit потрібні конектори", + 'settings.orbit.gateEyebrow': "Потрібне налаштування", + 'settings.orbit.gateTitle': "Підключіть інструмент, щоб запустити Orbit", + 'settings.orbit.gateBody': "Orbit узагальнює активність ваших конекторів. Ви ще нічого не підключили — додайте хоча б одну інтеграцію, щоб Orbit мав про що повідомляти.", + 'settings.orbit.gateBodyNoKey': "Orbit узагальнює активність конекторів, а конектори працюють через Composio. Додайте ключ API Composio у розділі «Конектори», щоб відкрити каталог і обрати першу інтеграцію.", + 'settings.orbit.gateAction': "Відкрити конектори", + 'settings.orbit.gateActionNoKey': "Налаштувати Composio", + 'settings.orbit.gateLoading': "Перевірка конекторів…", + 'settings.orbit.controlsLockedBadge': "Заблоковано", + 'settings.orbit.controlsLockedHint': "Підключіть інструмент, щоб розблокувати розклад і шаблон Orbit.", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': 'Застарілий підсумок', + 'settings.orbit.artifactTitle': 'Щоденний підсумок активності Orbit', + 'settings.orbit.artifactMetaLive': 'Оновлюваний HTML-артефакт, створений з активності конекторів.', + 'settings.orbit.artifactMetaLegacy': 'Створено до ввімкнення підтримки live artifact — запустіть Orbit знову, щоб опублікувати один.', + 'settings.orbit.copyMarkdownTitle': 'Скопіювати підсумок Markdown у буфер', + 'settings.orbit.copied': 'Скопійовано', + 'settings.orbit.copy': 'Копіювати', + 'settings.orbit.openArtifact': 'Відкрити артефакт', + 'settings.orbit.sourceMarkdown': 'Вихідний Markdown', + 'liveArtifact.viewer.tabPreview': 'Попередній перегляд', + 'liveArtifact.viewer.tabCode': 'Код', + 'liveArtifact.viewer.tabData': 'Дані', + 'liveArtifact.viewer.tabRefreshHistory': 'Історія оновлень', + 'liveArtifact.viewer.dataEmpty': 'Кеш data.json недоступний.', + 'liveArtifact.viewer.code.templateHeading': 'HTML шаблону', + 'liveArtifact.viewer.code.renderedHeading': 'Згенерований HTML', + 'liveArtifact.viewer.code.templateHelp': 'Редагований шаблон, що використовується з data.json для створення попереднього перегляду.', + 'liveArtifact.viewer.code.renderedHelp': 'Згенерований index.html, який зараз завантажує попередній перегляд.', + 'liveArtifact.viewer.code.variantAria': 'Варіант коду', + 'liveArtifact.viewer.code.variantTemplate': 'Шаблон', + 'liveArtifact.viewer.code.variantRendered': 'Згенеровано', + 'liveArtifact.viewer.code.loading': 'Завантаження коду…', + 'liveArtifact.viewer.code.unavailable': 'Код ще недоступний.', + 'liveArtifact.viewer.code.empty': 'Цей файл коду порожній.', }; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 56ae4a3d7..92fe484b2 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -195,12 +195,73 @@ export const zhCN: Dict = { 'connectors.statusConnected': '已连接', 'connectors.statusError': '错误', 'connectors.statusDisabled': '已停用', - 'connectors.gateTitle': '请先配置 Composio API 密钥', - 'connectors.gateBody': '连接器需要 Composio API 密钥。请在设置中配置后即可启用可用的集成。', - 'connectors.gateAction': '打开设置', + 'connectors.gateTitle': '添加 Composio API 密钥以继续', + 'connectors.gateBody': '在上方粘贴密钥并点击“保存密钥”,即可加载可用集成。', 'connectors.aboutLabel': '简介', 'connectors.detailsLabel': '详情', 'connectors.statusLabel': '状态', + 'connectors.category.aiAgents': 'AI 代理', + 'connectors.category.aiInfrastructure': 'AI 基础设施', + 'connectors.category.accounting': '会计', + 'connectors.category.admin': '管理', + 'connectors.category.advertising': '广告', + 'connectors.category.analytics': '分析', + 'connectors.category.automation': '自动化', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': '日历', + 'connectors.category.commerce': '电商', + 'connectors.category.communication': '沟通', + 'connectors.category.contacts': '联系人', + 'connectors.category.dataPlatform': '数据平台', + 'connectors.category.database': '数据库', + 'connectors.category.design': '设计', + 'connectors.category.developer': '开发者工具', + 'connectors.category.documentation': '文档', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': '教育', + 'connectors.category.email': '邮件', + 'connectors.category.events': '活动', + 'connectors.category.fieldService': '现场服务', + 'connectors.category.finance': '财务', + 'connectors.category.fitness': '健身', + 'connectors.category.forms': '表单', + 'connectors.category.gaming': '游戏', + 'connectors.category.hr': '人力资源', + 'connectors.category.hospitality': '酒店与款待', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': '集成', + 'connectors.category.localization': '本地化', + 'connectors.category.logistics': '物流', + 'connectors.category.maps': '地图', + 'connectors.category.marketing': '营销', + 'connectors.category.media': '媒体', + 'connectors.category.meetings': '会议', + 'connectors.category.nonprofit': '非营利', + 'connectors.category.observability': '可观测性', + 'connectors.category.payments': '支付', + 'connectors.category.personal': '个人', + 'connectors.category.presentations': '演示文稿', + 'connectors.category.procurement': '采购', + 'connectors.category.product': '产品', + 'connectors.category.productivity': '生产力', + 'connectors.category.projectManagement': '项目管理', + 'connectors.category.recruiting': '招聘', + 'connectors.category.research': '研究', + 'connectors.category.salesIntelligence': '销售情报', + 'connectors.category.scheduling': '日程安排', + 'connectors.category.search': '搜索', + 'connectors.category.security': '安全', + 'connectors.category.signing': '签署', + 'connectors.category.social': '社交', + 'connectors.category.spreadsheets': '电子表格', + 'connectors.category.storage': '存储', + 'connectors.category.support': '支持', + 'connectors.category.surveys': '问卷', + 'connectors.category.tasks': '任务', + 'connectors.category.timeTracking': '时间追踪', + 'connectors.category.video': '视频', + 'connectors.category.whiteboard': '白板', 'connectors.categoryLabel': '类别', 'connectors.providerLabel': '提供方', 'connectors.toolsSection': '工具', @@ -695,8 +756,8 @@ export const zhCN: Dict = { 'fileViewer.templateNameDefault': '未命名模板', 'fileViewer.templateDescPrompt': '简短描述(可选 — 这个模板用于什么场景?)', 'liveArtifact.refresh.button': '刷新', - 'liveArtifact.refresh.buttonTitle': '刷新此实时产物', - 'liveArtifact.refresh.loadingTitle': '正在加载实时产物…', + 'liveArtifact.refresh.buttonTitle': '刷新此 live artifact', + 'liveArtifact.refresh.loadingTitle': '正在加载 live artifact…', 'liveArtifact.refresh.noSourceTitle': '暂无可刷新来源。', 'liveArtifact.refresh.running': '刷新中…', 'liveArtifact.refresh.runningMessage': '正在刷新数据和预览,可能需要一点时间。', @@ -1017,9 +1078,115 @@ export const zhCN: Dict = { 'settings.libraryNoResults': '没有匹配的项目。', 'settings.libraryEnabled': '已启用', 'settings.libraryDisabled': '已禁用', + 'settings.connectorsNavHint': '外部系统连接', + 'settings.connectorsHint': '管理此设备的连接器和工具提供商设置。', + 'settings.connectorsComposioApiKey': 'Composio API 密钥', + 'settings.connectorsSavedTitle': '已保存到本地 daemon', + 'settings.connectorsSavedWithTail': '已保存 · ••••{tail}', + 'settings.connectorsSaved': '已保存', + 'settings.connectorsGetApiKey': '获取 API 密钥', + 'settings.connectorsReplaceKeyPlaceholder': '粘贴新密钥以替换已保存的密钥', + 'settings.connectorsApiKeyPlaceholder': '粘贴 Composio API 密钥', + 'settings.connectorsClear': '清除', + 'settings.connectorsClearConfirmTitle': '清除已保存的 Composio API 密钥?', + 'settings.connectorsClearConfirmBody': '删除密钥会断开此工作区下所有的 Composio 连接器。已连接的账号、OAuth 授权和工具访问权限都将被一并移除。', + 'settings.connectorsClearConfirmContinue': '继续', + 'settings.connectorsClearFinalTitle': '此操作会断开所有连接器', + 'settings.connectorsClearFinalBody': '该操作无法撤销。粘贴新密钥后,需要从头重新连接每个集成。', + 'settings.connectorsClearFinalConfirm': '删除密钥并断开', + 'settings.connectorsClearArming': '稍等\u2026', + 'settings.connectorsClearCancel': '取消', + 'settings.connectorsSaveKey': "保存密钥", + 'settings.connectorsSaveKeyTitle': "将此密钥发送到本地 daemon", + 'settings.connectorsKeySaving': "保存中…", + 'settings.connectorsKeyError': "保存密钥失败。请确认本地 daemon 已启动后再试。", + 'settings.connectorsHelpSaved': '你的密钥会解锁下方目录,并保留在本地 daemon 中。粘贴新密钥可替换,或清除以移除。', + 'settings.connectorsHelpUnsaved': "尚未保存 — 点击「保存密钥」即可把它存入本地 daemon,并解锁下方目录。", + 'settings.connectorsHelpEmpty': '添加密钥以解锁下方目录。密钥会本地存储在 daemon 中,绝不会通过环境变量发送。', + 'settings.connectorsLoadingSavedKey': '正在从本地 daemon 中检查已保存的密钥…', + 'settings.autosaveSaving': "保存中…", + 'settings.autosaveSaved': "所有更改已保存", + 'settings.autosaveError': "保存更改失败。本地 daemon 可能不在线。", 'settings.libraryToggleLabel': '切换', 'notify.successTitle': '任务已完成', 'notify.failureTitle': '任务失败', 'notify.successBody': '一轮回答已经写完。', 'notify.failureBody': '本轮任务出错,请查看错误信息。', + 'settings.orbit.eyebrow': '自动化', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': '每日连接器摘要', + 'settings.orbit.lede': '按计划收集连接器活动,并将结果发布为可刷新的 live artifact。', + 'settings.orbit.statusOnTitle': '每日计划运行已开启', + 'settings.orbit.statusOffTitle': '每日计划运行已关闭', + 'settings.orbit.statusActive': '活跃', + 'settings.orbit.statusOff': '关闭', + 'settings.orbit.runTitle': '启动一次 Orbit 运行并打开实时对话', + 'settings.orbit.running': '运行中…', + 'settings.orbit.runOpen': '立即运行', + 'settings.orbit.dailySummaryTitle': '每日摘要', + 'settings.orbit.dailySummarySub': '每天在计划的本地时间运行一次。', + 'settings.orbit.on': '开启', + 'settings.orbit.off': '关闭', + 'settings.orbit.runTimeTitle': '运行时间', + 'settings.orbit.runTimeSub': '默认 08:00。保存后应用到 daemon 计划。', + 'settings.orbit.runTimeAria': '每日 Orbit 运行时间', + 'settings.orbit.nextRun': '下次运行', + 'settings.orbit.nextRunScheduledAfterSave': '保存后计划', + 'settings.orbit.schedule': '计划', + 'settings.orbit.pausedManualOnly': '已暂停 — 仅手动运行', + 'settings.orbit.templateTitle': '提示词模板', + 'settings.orbit.templateMissing': '模板 {id} 未安装。', + 'settings.orbit.templateMissingOption': '{id}(缺失)', + 'settings.orbit.templateMissingInstall': '安装 Orbit skill 以引导提示词。', + 'settings.orbit.templateMissingPickAnother': '从下拉菜单选择另一个模板。', + 'settings.orbit.templateResetTitle': '重置为 {id}', + 'settings.orbit.templateReset': '重置', + 'settings.orbit.templateHelp': '用 skill 引导 Orbit — 所选模板的示例提示词会注入每次 Orbit 运行,让摘要遵循该模板结构。', + 'settings.orbit.templateAria': 'Orbit 提示词模板', + 'settings.orbit.templatesLoading': '正在加载模板…', + 'settings.orbit.templatesOptgroup': 'Orbit skill 模板', + 'settings.orbit.lastRun': '上次运行', + 'settings.orbit.triggerManual': '手动', + 'settings.orbit.triggerScheduled': '计划', + 'settings.orbit.meterAria': '已成功 {succeeded},已跳过 {skipped},失败 {failed},共检查 {checked}', + 'settings.orbit.countChecked': '已检查', + 'settings.orbit.countSucceeded': '成功', + 'settings.orbit.countSkipped': '已跳过', + 'settings.orbit.countFailed': '失败', + 'settings.orbit.runError': '无法运行 Orbit。请确认本地 daemon 正在运行且连接器已配置。', + 'settings.orbit.gateAriaLabel': "使用 Orbit 需要先配置连接器", + 'settings.orbit.gateEyebrow': "需要先完成设置", + 'settings.orbit.gateTitle': "连接一个工具,让 Orbit 运行起来", + 'settings.orbit.gateBody': "Orbit 会汇总你已连接服务的活动。你目前还没有任何连接 —— 至少添加一个集成,让 Orbit 有内容可以报告。", + 'settings.orbit.gateBodyNoKey': "Orbit 会汇总连接器的活动,而连接器通过 Composio 运行。在「连接器」中填入 Composio API 密钥即可解锁目录并选择第一个集成。", + 'settings.orbit.gateAction': "打开连接器", + 'settings.orbit.gateActionNoKey': "配置 Composio", + 'settings.orbit.gateLoading': "正在检查连接器…", + 'settings.orbit.controlsLockedBadge': "已锁定", + 'settings.orbit.controlsLockedHint': "连接一个工具后即可解锁 Orbit 的排程与模板设置。", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': '旧版摘要', + 'settings.orbit.artifactTitle': '每日 Orbit 活动摘要', + 'settings.orbit.artifactMetaLive': '由连接器活动生成的可刷新 HTML artifact。', + 'settings.orbit.artifactMetaLegacy': '在启用 live artifact 支持之前生成 — 再次运行 Orbit 以发布一个。', + 'settings.orbit.copyMarkdownTitle': '复制 Markdown 摘要到剪贴板', + 'settings.orbit.copied': '已复制', + 'settings.orbit.copy': '复制', + 'settings.orbit.openArtifact': '打开 artifact', + 'settings.orbit.sourceMarkdown': '源 Markdown', + 'liveArtifact.viewer.tabPreview': '预览', + 'liveArtifact.viewer.tabCode': '代码', + 'liveArtifact.viewer.tabData': '数据', + 'liveArtifact.viewer.tabRefreshHistory': '刷新历史', + 'liveArtifact.viewer.dataEmpty': '没有可用的 data.json 缓存。', + 'liveArtifact.viewer.code.templateHeading': '模板 HTML', + 'liveArtifact.viewer.code.renderedHeading': '渲染后 HTML', + 'liveArtifact.viewer.code.templateHelp': '用于配合 data.json 生成预览的可编辑模板。', + 'liveArtifact.viewer.code.renderedHelp': 'Preview 当前加载的已生成 index.html。', + 'liveArtifact.viewer.code.variantAria': '代码版本', + 'liveArtifact.viewer.code.variantTemplate': '模板', + 'liveArtifact.viewer.code.variantRendered': '已渲染', + 'liveArtifact.viewer.code.loading': '正在加载代码…', + 'liveArtifact.viewer.code.unavailable': '代码尚不可用。', + 'liveArtifact.viewer.code.empty': '此代码文件为空。', }; diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 1f1df6d94..968a30491 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -195,12 +195,73 @@ export const zhTW: Dict = { 'connectors.statusConnected': '已連接', 'connectors.statusError': '錯誤', 'connectors.statusDisabled': '已停用', - 'connectors.gateTitle': '請先設定 Composio API 金鑰', - 'connectors.gateBody': '連接器需要 Composio API 金鑰。請在設定中配置後即可啟用可用的整合。', - 'connectors.gateAction': '開啟設定', + 'connectors.gateTitle': '新增 Composio API 金鑰以繼續', + 'connectors.gateBody': '在上方貼上金鑰並點擊「儲存金鑰」,即可載入可用的整合。', 'connectors.aboutLabel': '簡介', 'connectors.detailsLabel': '詳情', 'connectors.statusLabel': '狀態', + 'connectors.category.aiAgents': 'AI 代理', + 'connectors.category.aiInfrastructure': 'AI 基礎設施', + 'connectors.category.accounting': '會計', + 'connectors.category.admin': '管理', + 'connectors.category.advertising': '廣告', + 'connectors.category.analytics': '分析', + 'connectors.category.automation': '自動化', + 'connectors.category.cms': 'CMS', + 'connectors.category.crm': 'CRM', + 'connectors.category.calendar': '日曆', + 'connectors.category.commerce': '電商', + 'connectors.category.communication': '溝通', + 'connectors.category.contacts': '聯絡人', + 'connectors.category.dataPlatform': '資料平台', + 'connectors.category.database': '資料庫', + 'connectors.category.design': '設計', + 'connectors.category.developer': '開發者工具', + 'connectors.category.documentation': '文件', + 'connectors.category.erp': 'ERP', + 'connectors.category.education': '教育', + 'connectors.category.email': '郵件', + 'connectors.category.events': '活動', + 'connectors.category.fieldService': '現場服務', + 'connectors.category.finance': '財務', + 'connectors.category.fitness': '健身', + 'connectors.category.forms': '表單', + 'connectors.category.gaming': '遊戲', + 'connectors.category.hr': '人力資源', + 'connectors.category.hospitality': '旅宿與款待', + 'connectors.category.itsm': 'ITSM', + 'connectors.category.integration': '整合', + 'connectors.category.localization': '在地化', + 'connectors.category.logistics': '物流', + 'connectors.category.maps': '地圖', + 'connectors.category.marketing': '行銷', + 'connectors.category.media': '媒體', + 'connectors.category.meetings': '會議', + 'connectors.category.nonprofit': '非營利', + 'connectors.category.observability': '可觀測性', + 'connectors.category.payments': '付款', + 'connectors.category.personal': '個人', + 'connectors.category.presentations': '簡報', + 'connectors.category.procurement': '採購', + 'connectors.category.product': '產品', + 'connectors.category.productivity': '生產力', + 'connectors.category.projectManagement': '專案管理', + 'connectors.category.recruiting': '招募', + 'connectors.category.research': '研究', + 'connectors.category.salesIntelligence': '銷售情報', + 'connectors.category.scheduling': '排程', + 'connectors.category.search': '搜尋', + 'connectors.category.security': '安全', + 'connectors.category.signing': '簽署', + 'connectors.category.social': '社交', + 'connectors.category.spreadsheets': '試算表', + 'connectors.category.storage': '儲存', + 'connectors.category.support': '支援', + 'connectors.category.surveys': '問卷', + 'connectors.category.tasks': '任務', + 'connectors.category.timeTracking': '時間追蹤', + 'connectors.category.video': '影片', + 'connectors.category.whiteboard': '白板', 'connectors.categoryLabel': '類別', 'connectors.providerLabel': '提供方', 'connectors.toolsSection': '工具', @@ -695,8 +756,8 @@ export const zhTW: Dict = { 'fileViewer.templateNameDefault': '未命名範本', 'fileViewer.templateDescPrompt': '簡短描述(可選 — 這個範本用於什麼情境?)', 'liveArtifact.refresh.button': '重新整理', - 'liveArtifact.refresh.buttonTitle': '重新整理此即時產物', - 'liveArtifact.refresh.loadingTitle': '正在載入即時產物…', + 'liveArtifact.refresh.buttonTitle': '刷新此 live artifact', + 'liveArtifact.refresh.loadingTitle': '正在載入 live artifact…', 'liveArtifact.refresh.noSourceTitle': '尚無已核准的唯讀重新整理來源。', 'liveArtifact.refresh.running': '重新整理中…', 'liveArtifact.refresh.runningMessage': '正在重新整理資料和預覽,可能需要一點時間。', @@ -1017,9 +1078,115 @@ export const zhTW: Dict = { 'settings.libraryNoResults': '沒有符合的項目。', 'settings.libraryEnabled': '已啟用', 'settings.libraryDisabled': '已停用', + 'settings.connectorsNavHint': '外部系統連線', + 'settings.connectorsHint': '管理此裝置的連接器與工具供應商設定。', + 'settings.connectorsComposioApiKey': 'Composio API 金鑰', + 'settings.connectorsSavedTitle': '已儲存到本機 daemon', + 'settings.connectorsSavedWithTail': '已儲存 · ••••{tail}', + 'settings.connectorsSaved': '已儲存', + 'settings.connectorsGetApiKey': '取得 API 金鑰', + 'settings.connectorsReplaceKeyPlaceholder': '貼上新金鑰以取代已儲存的金鑰', + 'settings.connectorsApiKeyPlaceholder': '貼上 Composio API 金鑰', + 'settings.connectorsClear': '清除', + 'settings.connectorsClearConfirmTitle': '清除已儲存的 Composio API 金鑰?', + 'settings.connectorsClearConfirmBody': '移除金鑰會將此工作區下所有的 Composio 連接器全部斷開。已連線的帳戶、OAuth 授權與工具存取權限都將一併移除。', + 'settings.connectorsClearConfirmContinue': '繼續', + 'settings.connectorsClearFinalTitle': '此動作會斷開所有連接器', + 'settings.connectorsClearFinalBody': '無法復原。貼上新金鑰後,每個整合都必須從頭重新連線。', + 'settings.connectorsClearFinalConfirm': '刪除金鑰並斷線', + 'settings.connectorsClearArming': '稍候\u2026', + 'settings.connectorsClearCancel': '取消', + 'settings.connectorsSaveKey': "儲存金鑰", + 'settings.connectorsSaveKeyTitle': "將此金鑰送往本機 daemon", + 'settings.connectorsKeySaving': "儲存中…", + 'settings.connectorsKeyError': "儲存金鑰失敗。請確認本機 daemon 已啟動後再試。", + 'settings.connectorsHelpSaved': '你的金鑰會解鎖下方目錄,並保留在本機 daemon 中。貼上新金鑰可取代,或清除以移除。', + 'settings.connectorsHelpUnsaved': "尚未儲存 — 點擊「儲存金鑰」即可將其存入本機 daemon,並解鎖下方目錄。", + 'settings.connectorsHelpEmpty': '新增金鑰以解鎖下方目錄。金鑰會本機儲存在 daemon 中,絕不會透過環境變數傳送。', + 'settings.connectorsLoadingSavedKey': '正在從本機 daemon 檢查已儲存的金鑰…', + 'settings.autosaveSaving': "儲存中…", + 'settings.autosaveSaved': "所有變更已儲存", + 'settings.autosaveError': "無法儲存變更。本機 daemon 可能離線。", 'settings.libraryToggleLabel': '切換', 'notify.successTitle': '任務已完成', 'notify.failureTitle': '任務失敗', 'notify.successBody': '一輪回答已經寫完。', 'notify.failureBody': '本輪任務出錯,請查看錯誤訊息。', + 'settings.orbit.eyebrow': '自動化', + 'settings.orbit.title': 'Orbit', + 'settings.orbit.navHint': '每日連接器摘要', + 'settings.orbit.lede': '依排程收集連接器活動,並將結果發布為可重新整理的 live artifact。', + 'settings.orbit.statusOnTitle': '每日排程執行已開啟', + 'settings.orbit.statusOffTitle': '每日排程執行已關閉', + 'settings.orbit.statusActive': '啟用', + 'settings.orbit.statusOff': '關閉', + 'settings.orbit.runTitle': '啟動一次 Orbit 執行並開啟即時對話', + 'settings.orbit.running': '執行中…', + 'settings.orbit.runOpen': '立即執行', + 'settings.orbit.dailySummaryTitle': '每日摘要', + 'settings.orbit.dailySummarySub': '每天在排定的本地時間執行一次。', + 'settings.orbit.on': '開啟', + 'settings.orbit.off': '關閉', + 'settings.orbit.runTimeTitle': '執行時間', + 'settings.orbit.runTimeSub': '預設 08:00。儲存後套用到 daemon 排程。', + 'settings.orbit.runTimeAria': '每日 Orbit 執行時間', + 'settings.orbit.nextRun': '下次執行', + 'settings.orbit.nextRunScheduledAfterSave': '儲存後排程', + 'settings.orbit.schedule': '排程', + 'settings.orbit.pausedManualOnly': '已暫停 — 僅手動執行', + 'settings.orbit.templateTitle': '提示詞範本', + 'settings.orbit.templateMissing': '範本 {id} 尚未安裝。', + 'settings.orbit.templateMissingOption': '{id}(缺少)', + 'settings.orbit.templateMissingInstall': '安裝 Orbit skill 以引導提示詞。', + 'settings.orbit.templateMissingPickAnother': '從下拉選單選擇其他範本。', + 'settings.orbit.templateResetTitle': '重設為 {id}', + 'settings.orbit.templateReset': '重設', + 'settings.orbit.templateHelp': '用 skill 引導 Orbit — 所選範本的範例提示詞會注入每次 Orbit 執行,讓摘要遵循該範本結構。', + 'settings.orbit.templateAria': 'Orbit 提示詞範本', + 'settings.orbit.templatesLoading': '正在載入範本…', + 'settings.orbit.templatesOptgroup': 'Orbit skill 範本', + 'settings.orbit.lastRun': '上次執行', + 'settings.orbit.triggerManual': '手動', + 'settings.orbit.triggerScheduled': '排程', + 'settings.orbit.meterAria': '成功 {succeeded},略過 {skipped},失敗 {failed},共檢查 {checked}', + 'settings.orbit.countChecked': '已檢查', + 'settings.orbit.countSucceeded': '成功', + 'settings.orbit.countSkipped': '已略過', + 'settings.orbit.countFailed': '失敗', + 'settings.orbit.runError': '無法執行 Orbit。請確認本機 daemon 正在執行且連接器已設定。', + 'settings.orbit.gateAriaLabel': "使用 Orbit 需要先設定連接器", + 'settings.orbit.gateEyebrow': "需要先完成設定", + 'settings.orbit.gateTitle': "連接一個工具,讓 Orbit 開始運作", + 'settings.orbit.gateBody': "Orbit 會彙整你已連接服務的活動。你目前還沒有任何連接 —— 至少加入一個整合,讓 Orbit 有內容可回報。", + 'settings.orbit.gateBodyNoKey': "Orbit 會彙整連接器的活動,而連接器透過 Composio 執行。在「連接器」中填入 Composio API 金鑰即可解鎖目錄並挑選第一個整合。", + 'settings.orbit.gateAction': "開啟連接器", + 'settings.orbit.gateActionNoKey': "設定 Composio", + 'settings.orbit.gateLoading': "正在檢查連接器…", + 'settings.orbit.controlsLockedBadge': "已鎖定", + 'settings.orbit.controlsLockedHint': "連接一個工具後即可解鎖 Orbit 的排程與範本設定。", + 'settings.orbit.artifactKickerLive': 'live artifact', + 'settings.orbit.artifactKickerLegacy': '舊版摘要', + 'settings.orbit.artifactTitle': '每日 Orbit 活動摘要', + 'settings.orbit.artifactMetaLive': '由連接器活動產生的可重新整理 HTML artifact。', + 'settings.orbit.artifactMetaLegacy': '在啟用 live artifact 支援之前產生 — 再次執行 Orbit 以發布一個。', + 'settings.orbit.copyMarkdownTitle': '複製 Markdown 摘要到剪貼簿', + 'settings.orbit.copied': '已複製', + 'settings.orbit.copy': '複製', + 'settings.orbit.openArtifact': '開啟 artifact', + 'settings.orbit.sourceMarkdown': '來源 Markdown', + 'liveArtifact.viewer.tabPreview': '預覽', + 'liveArtifact.viewer.tabCode': '程式碼', + 'liveArtifact.viewer.tabData': '資料', + 'liveArtifact.viewer.tabRefreshHistory': '重新整理歷史', + 'liveArtifact.viewer.dataEmpty': '沒有可用的 data.json 快取。', + 'liveArtifact.viewer.code.templateHeading': '範本 HTML', + 'liveArtifact.viewer.code.renderedHeading': '渲染後 HTML', + 'liveArtifact.viewer.code.templateHelp': '與 data.json 搭配產生預覽的可編輯範本。', + 'liveArtifact.viewer.code.renderedHelp': 'Preview 目前載入的已產生 index.html。', + 'liveArtifact.viewer.code.variantAria': '程式碼版本', + 'liveArtifact.viewer.code.variantTemplate': '範本', + 'liveArtifact.viewer.code.variantRendered': '已渲染', + 'liveArtifact.viewer.code.loading': '正在載入程式碼…', + 'liveArtifact.viewer.code.unavailable': '程式碼尚不可用。', + 'liveArtifact.viewer.code.empty': '此程式碼檔案是空的。', }; diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 6e834e64d..2a5fdf26e 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -186,6 +186,97 @@ export interface Dict { 'settings.libraryEnabled': string; 'settings.libraryDisabled': string; 'settings.libraryToggleLabel': string; + 'settings.connectorsNavHint': string; + 'settings.connectorsHint': string; + 'settings.connectorsComposioApiKey': string; + 'settings.connectorsSavedTitle': string; + 'settings.connectorsSavedWithTail': string; + 'settings.connectorsSaved': string; + 'settings.connectorsGetApiKey': string; + 'settings.connectorsReplaceKeyPlaceholder': string; + 'settings.connectorsApiKeyPlaceholder': string; + 'settings.connectorsClear': string; + 'settings.connectorsClearConfirmTitle': string; + 'settings.connectorsClearConfirmBody': string; + 'settings.connectorsClearConfirmContinue': string; + 'settings.connectorsClearFinalTitle': string; + 'settings.connectorsClearFinalBody': string; + 'settings.connectorsClearFinalConfirm': string; + 'settings.connectorsClearArming': string; + 'settings.connectorsClearCancel': string; + 'settings.connectorsSaveKey': string; + 'settings.connectorsSaveKeyTitle': string; + 'settings.connectorsKeySaving': string; + 'settings.connectorsKeyError': string; + 'settings.connectorsHelpSaved': string; + 'settings.connectorsHelpUnsaved': string; + 'settings.connectorsHelpEmpty': string; + 'settings.connectorsLoadingSavedKey': string; + 'settings.autosaveSaving': string; + 'settings.autosaveSaved': string; + 'settings.autosaveError': string; + 'settings.orbit.eyebrow': string; + 'settings.orbit.title': string; + 'settings.orbit.navHint': string; + 'settings.orbit.lede': string; + 'settings.orbit.statusOnTitle': string; + 'settings.orbit.statusOffTitle': string; + 'settings.orbit.statusActive': string; + 'settings.orbit.statusOff': string; + 'settings.orbit.runTitle': string; + 'settings.orbit.running': string; + 'settings.orbit.runOpen': string; + 'settings.orbit.dailySummaryTitle': string; + 'settings.orbit.dailySummarySub': string; + 'settings.orbit.on': string; + 'settings.orbit.off': string; + 'settings.orbit.runTimeTitle': string; + 'settings.orbit.runTimeSub': string; + 'settings.orbit.runTimeAria': string; + 'settings.orbit.nextRun': string; + 'settings.orbit.nextRunScheduledAfterSave': string; + 'settings.orbit.schedule': string; + 'settings.orbit.pausedManualOnly': string; + 'settings.orbit.templateTitle': string; + 'settings.orbit.templateMissing': string; + 'settings.orbit.templateMissingOption': string; + 'settings.orbit.templateMissingInstall': string; + 'settings.orbit.templateMissingPickAnother': string; + 'settings.orbit.templateResetTitle': string; + 'settings.orbit.templateReset': string; + 'settings.orbit.templateHelp': string; + 'settings.orbit.templateAria': string; + 'settings.orbit.templatesLoading': string; + 'settings.orbit.templatesOptgroup': string; + 'settings.orbit.lastRun': string; + 'settings.orbit.triggerManual': string; + 'settings.orbit.triggerScheduled': string; + 'settings.orbit.meterAria': string; + 'settings.orbit.countChecked': string; + 'settings.orbit.countSucceeded': string; + 'settings.orbit.countSkipped': string; + 'settings.orbit.countFailed': string; + 'settings.orbit.runError': string; + 'settings.orbit.artifactKickerLive': string; + 'settings.orbit.artifactKickerLegacy': string; + 'settings.orbit.artifactTitle': string; + 'settings.orbit.artifactMetaLive': string; + 'settings.orbit.artifactMetaLegacy': string; + 'settings.orbit.copyMarkdownTitle': string; + 'settings.orbit.copied': string; + 'settings.orbit.copy': string; + 'settings.orbit.openArtifact': string; + 'settings.orbit.sourceMarkdown': string; + 'settings.orbit.gateAriaLabel': string; + 'settings.orbit.gateEyebrow': string; + 'settings.orbit.gateTitle': string; + 'settings.orbit.gateBody': string; + 'settings.orbit.gateBodyNoKey': string; + 'settings.orbit.gateAction': string; + 'settings.orbit.gateActionNoKey': string; + 'settings.orbit.gateLoading': string; + 'settings.orbit.controlsLockedBadge': string; + 'settings.orbit.controlsLockedHint': string; // Notifications (settings + system notifications) 'settings.notifications': string; @@ -242,11 +333,72 @@ export interface Dict { 'connectors.statusDisabled': string; 'connectors.gateTitle': string; 'connectors.gateBody': string; - 'connectors.gateAction': string; 'connectors.aboutLabel': string; 'connectors.detailsLabel': string; 'connectors.statusLabel': string; 'connectors.categoryLabel': string; + 'connectors.category.aiAgents': string; + 'connectors.category.aiInfrastructure': string; + 'connectors.category.accounting': string; + 'connectors.category.admin': string; + 'connectors.category.advertising': string; + 'connectors.category.analytics': string; + 'connectors.category.automation': string; + 'connectors.category.cms': string; + 'connectors.category.crm': string; + 'connectors.category.calendar': string; + 'connectors.category.commerce': string; + 'connectors.category.communication': string; + 'connectors.category.contacts': string; + 'connectors.category.dataPlatform': string; + 'connectors.category.database': string; + 'connectors.category.design': string; + 'connectors.category.developer': string; + 'connectors.category.documentation': string; + 'connectors.category.erp': string; + 'connectors.category.education': string; + 'connectors.category.email': string; + 'connectors.category.events': string; + 'connectors.category.fieldService': string; + 'connectors.category.finance': string; + 'connectors.category.fitness': string; + 'connectors.category.forms': string; + 'connectors.category.gaming': string; + 'connectors.category.hr': string; + 'connectors.category.hospitality': string; + 'connectors.category.itsm': string; + 'connectors.category.integration': string; + 'connectors.category.localization': string; + 'connectors.category.logistics': string; + 'connectors.category.maps': string; + 'connectors.category.marketing': string; + 'connectors.category.media': string; + 'connectors.category.meetings': string; + 'connectors.category.nonprofit': string; + 'connectors.category.observability': string; + 'connectors.category.payments': string; + 'connectors.category.personal': string; + 'connectors.category.presentations': string; + 'connectors.category.procurement': string; + 'connectors.category.product': string; + 'connectors.category.productivity': string; + 'connectors.category.projectManagement': string; + 'connectors.category.recruiting': string; + 'connectors.category.research': string; + 'connectors.category.salesIntelligence': string; + 'connectors.category.scheduling': string; + 'connectors.category.search': string; + 'connectors.category.security': string; + 'connectors.category.signing': string; + 'connectors.category.social': string; + 'connectors.category.spreadsheets': string; + 'connectors.category.storage': string; + 'connectors.category.support': string; + 'connectors.category.surveys': string; + 'connectors.category.tasks': string; + 'connectors.category.timeTracking': string; + 'connectors.category.video': string; + 'connectors.category.whiteboard': string; 'connectors.providerLabel': string; 'connectors.toolsSection': string; 'connectors.toolsLoading': string; @@ -787,6 +939,21 @@ export interface Dict { 'fileViewer.deployToProvider': string; 'fileViewer.redeployToProvider': string; 'fileViewer.deployingToProvider': string; + 'liveArtifact.viewer.tabPreview': string; + 'liveArtifact.viewer.tabCode': string; + 'liveArtifact.viewer.tabData': string; + 'liveArtifact.viewer.tabRefreshHistory': string; + 'liveArtifact.viewer.dataEmpty': string; + 'liveArtifact.viewer.code.templateHeading': string; + 'liveArtifact.viewer.code.renderedHeading': string; + 'liveArtifact.viewer.code.templateHelp': string; + 'liveArtifact.viewer.code.renderedHelp': string; + 'liveArtifact.viewer.code.variantAria': string; + 'liveArtifact.viewer.code.variantTemplate': string; + 'liveArtifact.viewer.code.variantRendered': string; + 'liveArtifact.viewer.code.loading': string; + 'liveArtifact.viewer.code.unavailable': string; + 'liveArtifact.viewer.code.empty': string; 'fileViewer.deployToVercel': string; 'fileViewer.redeployToVercel': string; 'fileViewer.deployingToVercel': string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6d84553bc..812041038 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1132,6 +1132,10 @@ code { padding: 0; gap: 0; max-height: calc(100vh - 64px); + /* Anchor for the absolutely-positioned `.settings-chrome` strip + (close button + autosave indicator). Without this the chrome + would escape to the viewport on long-scrolling sections. */ + position: relative; } @media (max-height: 600px) { .modal-settings { max-height: 90vh; } @@ -1188,6 +1192,392 @@ code { margin-top: 0; flex-shrink: 0; } + +/* Top-right chrome strip for the Settings dialog. Floats above the + sidebar/content rhythm so the close affordance and the autosave + indicator never compete with the title or sidebar nav. The strip + is a flex row right-anchored to the modal corner; the autosave + pill comes first so the close button keeps a stable optical + position and the user's eye returns to the same place after a + save settles. */ +.settings-chrome { + position: absolute; + top: 14px; + right: 14px; + z-index: 3; + display: flex; + align-items: center; + gap: 8px; + pointer-events: none; /* children opt back in */ +} +.settings-chrome > * { + pointer-events: auto; +} + +/* Close button. Minimal circular icon button with a hairline ring + that warms on hover and snaps to the accent on focus-visible. + Sized to read as a passive corner control rather than a primary + CTA — the autosave indicator next to it carries any system + feedback the user might need. */ +.settings-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: 999px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-panel) 90%, transparent); + color: var(--text-muted); + cursor: pointer; + transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease, transform 140ms ease; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.settings-close:hover { + color: var(--text); + border-color: color-mix(in srgb, var(--text) 22%, var(--border)); + background: var(--bg-panel); + transform: scale(1.04); +} +.settings-close:active { + transform: scale(0.96); +} +.settings-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + color: var(--text); +} +@media (prefers-reduced-motion: reduce) { + .settings-close { transition: color 80ms linear, border-color 80ms linear; transform: none !important; } +} + +/* Autosave status pill. Lives in the chrome strip now (next to the + close button) instead of a footer. Renders nothing while idle so + the chrome reads as a single close button until something is + actually saving; settles to a green check on success and red on + failure. The pill never takes focus and never blocks input — it + is a passive system message announced to assistive tech via + aria-live on the wrapper. */ +.settings-autosave { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 4px 10px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 500; + letter-spacing: 0.005em; + color: var(--text-muted); + background: color-mix(in srgb, var(--bg-panel) 90%, transparent); + border: 1px solid transparent; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + transition: opacity 160ms ease, color 160ms ease, background-color 160ms ease, border-color 160ms ease, transform 160ms ease; + pointer-events: none; + user-select: none; + white-space: nowrap; +} +.settings-autosave.is-idle { + opacity: 0; + transform: translateY(-2px); + /* Collapse the visual weight so the chrome strip is just the + close button when nothing is happening. */ + padding: 0; + border-color: transparent; + background: transparent; + min-height: 0; +} +.settings-autosave.is-pending, +.settings-autosave.is-saving { + opacity: 1; + color: var(--text-muted); + background: color-mix(in srgb, var(--text-muted) 12%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--text-muted) 18%, transparent); +} +.settings-autosave.is-saved { + opacity: 1; + color: var(--green, #1f9d55); + background: color-mix(in srgb, var(--green, #1f9d55) 14%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--green, #1f9d55) 30%, transparent); + animation: settingsAutosavePop 220ms ease-out; +} +.settings-autosave.is-error { + opacity: 1; + color: var(--red, #d23434); + background: color-mix(in srgb, var(--red, #d23434) 14%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--red, #d23434) 32%, transparent); +} +@keyframes settingsAutosavePop { + from { transform: translateY(-2px) scale(0.98); } + to { transform: translateY(0) scale(1); } +} +@media (prefers-reduced-motion: reduce) { + .settings-autosave { transition: opacity 80ms linear; animation: none !important; } +} + +/* Hide the verbose error copy on narrow viewports so the chrome + strip doesn't crowd the close button. The icon + tooltip carry + the meaning, and the screen-reader announcement still fires. */ +@media (max-width: 720px) { + .settings-autosave.is-error span, + .settings-autosave.is-saved span, + .settings-autosave.is-pending span, + .settings-autosave.is-saving span { + display: none; + } + .settings-autosave.is-idle { padding: 0; } +} + +/* Make sure header copy doesn't crash into the close button on + narrow viewports. The kicker/title/subtitle should keep their + normal width but reserve right-side gutter for the chrome. */ +.modal-settings .modal-head { + padding-right: calc(var(--modal-padding) + 56px); +} + +/* Section-local Save key button for the Composio API key field. We do + NOT autosave secrets, so this is the explicit gesture. Styled as a + primary button to stand out next to the password input + ghost + Clear, with a tighter vertical rhythm so it sits flush in the + field-row alongside the input. */ +.settings-connectors-save { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.settings-connectors-save.is-busy { + opacity: 0.85; + cursor: progress; +} +.settings-section-connectors .field-row { + /* Allow the input + Save key + Clear triplet to wrap on narrow widths + instead of crushing the input. */ + flex-wrap: wrap; +} +@media (max-width: 540px) { + .settings-section-connectors .field-row > input, + .settings-section-connectors .field-row > .field-input-skeleton-wrap { + flex: 1 1 100%; + } +} + +/* Two-stage destructive confirmation for clearing the saved Composio + API key. Step 1 ("confirm") is a soft amber-ish warning rooted in + the same red palette as other destructive surfaces so it reads as + "this will undo something" without screaming. Step 2 ("final") leans + into red with a brief arming animation on the commit button so a + reflex double-click cannot blow through both stages. The whole panel + collapses inline beneath the credentials field so the destructive + action stays visually anchored to the row that started it. */ +.settings-connectors-clear.is-arming { + border-color: var(--red-border); + color: var(--red); + background: color-mix(in srgb, var(--red-bg) 65%, transparent); +} +.settings-connectors-clear-confirm { + margin-top: 10px; + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--red-border); + background: color-mix(in srgb, var(--red-bg) 78%, transparent); + color: var(--text); + font-size: 12.5px; + line-height: 1.45; + animation: settings-connectors-clear-confirm-in 180ms ease-out; +} +.settings-connectors-clear-confirm.is-final { + background: var(--red-bg); + border-color: color-mix(in srgb, var(--red) 55%, var(--red-border)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--red) 18%, transparent), + 0 8px 24px -16px color-mix(in srgb, var(--red) 80%, transparent); +} +@keyframes settings-connectors-clear-confirm-in { + from { opacity: 0; transform: translateY(-3px); } + to { opacity: 1; transform: translateY(0); } +} +.settings-connectors-clear-confirm-icon { + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--red) 35%, var(--red-border)); + background: var(--bg-panel); + color: var(--red); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 13px; + line-height: 1; + margin-top: 2px; + flex-shrink: 0; +} +.settings-connectors-clear-confirm.is-final .settings-connectors-clear-confirm-icon { + background: var(--red); + color: var(--bg-panel); + border-color: var(--red); +} +.settings-connectors-clear-confirm-glyph { + display: inline-block; + transform: translateY(-0.5px); +} +.settings-connectors-clear-confirm-copy { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.settings-connectors-clear-confirm-copy strong { + font-size: 13px; + font-weight: 650; + color: var(--text); + line-height: 1.3; +} +.settings-connectors-clear-confirm-copy span { + color: var(--text-muted); + overflow-wrap: anywhere; +} +.settings-connectors-clear-confirm.is-final .settings-connectors-clear-confirm-copy strong { + color: var(--red); +} +.settings-connectors-clear-confirm-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + align-self: center; +} +/* "Continue" — moves to stage 2. Styled as a quiet outlined button + tinted with the red palette so it reads as "destructive but not + yet committed". */ +.settings-connectors-clear-step { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid var(--red-border); + background: var(--bg-panel); + color: var(--red); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background 120ms ease-out, + border-color 120ms ease-out, + transform 120ms ease-out; +} +.settings-connectors-clear-step:hover { + background: color-mix(in srgb, var(--red-bg) 60%, var(--bg-panel)); + border-color: var(--red); +} +.settings-connectors-clear-step:active { + transform: translateY(1px); +} +/* Final commit button. Solid red with an arming sweep that fills the + left edge for ~700ms before the click is honored. While "arming", + the button is visually hot but disabled so an Enter-spam can't get + ahead of the user's intent; once armed, the label swaps to the + destructive verb and the click commits. */ +.settings-connectors-clear-commit { + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 200px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid var(--red); + background: var(--red); + color: var(--bg-panel); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.01em; + cursor: pointer; + transition: + transform 120ms ease-out, + box-shadow 160ms ease-out, + background 160ms ease-out; +} +.settings-connectors-clear-commit:disabled, +.settings-connectors-clear-commit[aria-disabled='true'] { + cursor: progress; + background: color-mix(in srgb, var(--red) 70%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--red) 60%, var(--red-border)); +} +.settings-connectors-clear-commit.is-armed { + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--red) 22%, transparent), + 0 10px 20px -12px color-mix(in srgb, var(--red) 75%, transparent); +} +.settings-connectors-clear-commit.is-armed:hover { + background: color-mix(in srgb, var(--red) 88%, #000); +} +.settings-connectors-clear-commit.is-armed:active { + transform: translateY(1px); +} +/* The arming sweep — a translucent fill that races from left to right + over the disabled window, signaling "almost ready". CSS-only so it + renders the same in every browser without a second timer. */ +.settings-connectors-clear-commit-arm { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + 90deg, + color-mix(in srgb, #fff 28%, transparent) 0%, + color-mix(in srgb, #fff 12%, transparent) 100% + ); + transform: translateX(-100%); + opacity: 0.85; + animation: settings-connectors-clear-commit-arm 700ms ease-out forwards; +} +.settings-connectors-clear-commit.is-armed .settings-connectors-clear-commit-arm { + display: none; +} +@keyframes settings-connectors-clear-commit-arm { + from { transform: translateX(-100%); } + to { transform: translateX(0%); } +} +.settings-connectors-clear-commit-label { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + z-index: 1; +} +@media (max-width: 540px) { + .settings-connectors-clear-confirm { + grid-template-columns: 26px minmax(0, 1fr); + } + .settings-connectors-clear-confirm-actions { + grid-column: 1 / -1; + justify-content: flex-end; + flex-wrap: wrap; + } + .settings-connectors-clear-commit { + min-width: 0; + flex: 1 1 auto; + } +} +@media (prefers-reduced-motion: reduce) { + .settings-connectors-clear-confirm { + animation: none; + } + .settings-connectors-clear-commit-arm { + animation: none; + transform: translateX(0%); + opacity: 0.4; + } +} .settings-sidebar { display: flex; flex-direction: column; @@ -1349,6 +1739,17 @@ code { } .settings-section { display: flex; flex-direction: column; gap: 12px; } +.settings-section-connectors { gap: 16px; } +/* Credentials sit above the catalog now; the divider lives under the field + so the eye reads "configure key → catalog unlocks below". */ +.settings-section-connectors > .settings-section-connectors-credentials { + padding-bottom: 16px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 4px; +} +.settings-section-connectors > .connectors-panel-embedded { + margin-top: 0; +} .settings-rescan-btn { display: inline-flex; align-items: center; @@ -1405,6 +1806,1105 @@ code { align-items: center; gap: 8px; } +/* ============================================================ + Orbit settings section — redesigned layout + Hero · Automation card · Run receipt · Artifact strip + ============================================================ */ +.orbit-section { + gap: 16px; +} + +/* ---------- 1. Hero / header zone ---------- */ +.orbit-hero { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: + radial-gradient(140% 160% at 100% 0%, color-mix(in srgb, var(--accent-tint) 75%, transparent) 0%, transparent 60%), + var(--bg-panel); + box-shadow: var(--shadow-xs); +} +.orbit-hero-mark { + width: 44px; + height: 44px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 70%, #000) 100%); + color: #fff; + box-shadow: 0 6px 14px color-mix(in srgb, var(--accent) 32%, transparent); + flex-shrink: 0; +} +.orbit-hero-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.orbit-hero-eyebrow { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-strong); +} +.orbit-hero-title { + margin: 0; + font-size: 18px; + font-weight: 650; + letter-spacing: -0.015em; + color: var(--text-strong); + line-height: 1.15; +} +.orbit-hero-lede { + margin: 4px 0 0; + font-size: 12.5px; + color: var(--text-muted); + line-height: 1.5; + max-width: 52ch; +} +.orbit-hero-lede strong { + color: var(--text); + font-weight: 600; +} +.orbit-hero-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.orbit-state-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px 4px 8px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + background: var(--bg-subtle); + border: 1px solid var(--border); + color: var(--text-muted); + white-space: nowrap; +} +.orbit-state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--border-strong); +} +.orbit-state-pill.orbit-state-active { + background: color-mix(in srgb, var(--accent-tint) 80%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); + color: var(--accent-strong); +} +.orbit-state-pill.orbit-state-active .orbit-state-dot { + background: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); + animation: pulse 2.4s ease-in-out infinite; +} + +.orbit-run-cta { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.005em; + cursor: pointer; + box-shadow: 0 1px 0 color-mix(in srgb, var(--accent-strong) 22%, transparent) inset, var(--shadow-xs); + transition: background 120ms ease, border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; +} +.orbit-run-cta:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: translateY(-1px); +} +.orbit-run-cta:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.orbit-run-cta:disabled { + cursor: not-allowed; + opacity: 0.7; + transform: none; +} +.orbit-run-cta.is-busy { + background: color-mix(in srgb, var(--accent) 75%, #000); + border-color: transparent; +} +.orbit-run-cta .icon-spin { + color: #fff; +} + +@media (max-width: 620px) { + .orbit-hero { + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + row-gap: 10px; + } + .orbit-hero-actions { + grid-column: 1 / -1; + justify-content: space-between; + } +} + +/* ---------- 2. Automation card ---------- */ +.orbit-automation { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel); + box-shadow: var(--shadow-xs); + overflow: hidden; + transition: border-color 140ms ease, background 140ms ease; +} +.orbit-automation.is-on { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent-tint) 40%, var(--bg-panel)) 0%, var(--bg-panel) 40%); +} + +/* ---------- Locked state ---------- + When no Composio connector is wired up the automation card collapses + into a passive, gated surface. We desaturate the hue, tighten contrast, + and overlay a soft diagonal sheen so the card reads as "intentionally + off" rather than "broken styling". The lock banner above the rows + names the prerequisite plainly and points back to the Connectors gate + without competing with it. */ +.orbit-automation.is-locked { + border-color: color-mix(in srgb, var(--text-soft) 22%, var(--border)); + background: + repeating-linear-gradient( + 135deg, + color-mix(in srgb, var(--text-soft) 4%, transparent) 0 6px, + transparent 6px 14px + ), + var(--bg-subtle); + filter: saturate(0.55); +} +.orbit-automation.is-locked.is-on { + /* When the user previously had the schedule on but later removed the + last connector we still show the "on" gradient very faintly — but + the locked treatment wins so the panel reads as gated. */ + background: + repeating-linear-gradient( + 135deg, + color-mix(in srgb, var(--text-soft) 4%, transparent) 0 6px, + transparent 6px 14px + ), + var(--bg-subtle); +} +.orbit-automation.is-locked .orbit-automation-row { + /* Block any accidental click-through into the row's whitespace; real + controls retain their own pointer behavior via :disabled. */ + cursor: not-allowed; +} +.orbit-automation.is-locked .orbit-automation-title, +.orbit-automation.is-locked .orbit-automation-sub { + color: var(--text-soft); +} +.orbit-automation.is-locked .orbit-time-input, +.orbit-automation.is-locked .orbit-switch, +.orbit-automation.is-locked .orbit-template-select-input, +.orbit-automation.is-locked .orbit-automation-sub-action { + cursor: not-allowed; + opacity: 0.6; +} +.orbit-automation.is-locked .orbit-time-input:disabled, +.orbit-automation.is-locked .orbit-template-select-input:disabled { + /* The native :disabled state already dims; keep our own opacity tuned + so it matches the surrounding desaturated card. */ + background: color-mix(in srgb, var(--text-soft) 6%, var(--bg-subtle)); +} + +/* Lock banner — sits above the configuration rows and names the gate + reason in a single line. Compact, low-contrast accent so it reads as + metadata rather than a second hero. */ +.orbit-automation-lock-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 18px; + font-size: 11.5px; + color: var(--text-muted); + background: color-mix(in srgb, var(--accent) 5%, var(--bg-panel)); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border)); +} +.orbit-automation-lock-banner svg { + color: var(--accent-strong); + flex-shrink: 0; +} +.orbit-automation-lock-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-strong); + padding: 2px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + flex-shrink: 0; +} +.orbit-automation-lock-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +@media (max-width: 620px) { + .orbit-automation-lock-text { white-space: normal; } +} + +.orbit-automation-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 14px 18px; +} +.orbit-automation-schedule-row { + align-items: start; +} +.orbit-automation-divider { + height: 1px; + background: var(--border-soft); + margin: 0 18px; +} +.orbit-automation-label { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.orbit-automation-title { + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.005em; +} +.orbit-automation-sub { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.45; +} + +/* Custom switch control — rebuilt rather than reusing .toggle-row so the + Orbit section owns the pattern and can tune track/thumb proportions. */ +.orbit-switch { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px 12px 4px 4px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + cursor: pointer; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + transition: border-color 140ms ease, color 140ms ease, background 140ms ease; +} +.orbit-switch:hover { border-color: var(--border-strong); } +.orbit-switch.is-on { + background: color-mix(in srgb, var(--accent-tint) 70%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); + color: var(--accent-strong); +} +.orbit-switch:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.orbit-switch:disabled, +.orbit-switch.is-locked { + cursor: not-allowed; + opacity: 0.55; + border-color: var(--border); + background: transparent; + color: var(--text-soft); +} +.orbit-switch:disabled .orbit-switch-track, +.orbit-switch.is-locked .orbit-switch-track { + background: var(--border); +} +.orbit-switch:disabled.is-on .orbit-switch-track, +.orbit-switch.is-locked.is-on .orbit-switch-track { + background: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.orbit-switch-track { + position: relative; + width: 34px; + height: 20px; + border-radius: 999px; + background: var(--border-strong); + transition: background 160ms ease; +} +.orbit-switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(28, 27, 26, 0.22); + transition: transform 180ms cubic-bezier(0.2, 0, 0.2, 1); +} +.orbit-switch.is-on .orbit-switch-track { background: var(--accent); } +.orbit-switch.is-on .orbit-switch-thumb { transform: translateX(14px); } +.orbit-switch-text { + font-variant-numeric: tabular-nums; + min-width: 22px; + text-align: right; +} + +.orbit-automation-schedule-controls { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + min-width: 0; +} +.orbit-time-input { + width: 140px; + padding: 6px 10px; + font-variant-numeric: tabular-nums; + font-size: 13px; + letter-spacing: 0.02em; + text-align: center; + border-radius: var(--radius-sm); +} +.orbit-next-run { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-size: 11.5px; + color: var(--text-muted); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-next-run-label { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 10.5px; + font-weight: 600; + color: var(--text-soft); +} +.orbit-next-run-value { + color: var(--text); + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.orbit-next-run-value.muted { + color: var(--text-muted); + font-weight: 500; +} + +@media (max-width: 620px) { + .orbit-automation-row { + grid-template-columns: minmax(0, 1fr); + row-gap: 10px; + } + .orbit-automation-schedule-controls { + align-items: stretch; + } + .orbit-time-input { + width: 100%; + } + .orbit-switch { align-self: flex-start; } +} + +/* ---------- 3. Template row (folded into Automation card) ---------- + Previously this section lived in its own paired card. We folded it into + the automation card as a third row + dedicated preview slot so users + configure schedule and prompt-steering in one place, and the section + reads as one cohesive configuration surface instead of two parallel + panels competing for attention. The class names below still use the + `orbit-template-` prefix because they continue to describe template + surface elements — they just live inside the automation card now. */ + +.orbit-automation.has-template { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); +} +.orbit-automation-template-row { + /* Slightly more vertical breathing room than the switch/schedule rows + because the right column hosts a wider select control. */ + align-items: start; + padding-top: 16px; + padding-bottom: 16px; +} +.orbit-automation-template-controls { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 220px; + width: clamp(220px, 36%, 320px); +} + +/* Inline warning variant of the row sub-copy. Used by the Prompt + template row when the saved skill id is no longer in the registry — + takes the place of the standard descriptive sub-line and inlines a + small Reset action that pushes the config back to the default + (`orbit-general`). The warning lives flush inside the automation + row so we do not need a separate preview panel for the missing + state. */ +.orbit-automation-sub-warning { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 4px 8px; + margin-top: 2px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, #c47a2c 10%, var(--bg-panel)); + color: #8a5217; + font-weight: 500; +} +.orbit-automation-sub-warning svg { + color: #c47a2c; + flex-shrink: 0; +} +.orbit-automation-sub-warning strong { + color: #6c3f0f; + font-weight: 600; +} +.orbit-automation-sub-action { + appearance: none; + -webkit-appearance: none; + display: inline-flex; + align-items: center; + padding: 2px 8px; + margin-left: auto; + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: #8a5217; + background: var(--bg-panel); + border: 1px solid color-mix(in srgb, #c47a2c 32%, var(--border)); + border-radius: var(--radius-pill); + cursor: pointer; + transition: border-color 120ms ease, color 120ms ease, background 120ms ease; +} +.orbit-automation-sub-action:hover { + color: #6c3f0f; + border-color: color-mix(in srgb, #c47a2c 55%, var(--border)); + background: color-mix(in srgb, #c47a2c 8%, var(--bg-panel)); +} +.orbit-automation-sub-action:focus-visible { + outline: 2px solid color-mix(in srgb, #c47a2c 65%, var(--accent)); + outline-offset: 2px; +} + +/* Native select wrapper. Originally a labelled grid (label + select); + the row title now carries the inline label, so the select stretches + to fill its column. */ +.orbit-template-select { + display: flex; + flex: 1; + min-width: 0; +} +.orbit-template-select-wrap { + position: relative; + display: flex; + align-items: center; + width: 100%; + min-width: 0; +} +.orbit-template-select-input { + appearance: none; + -webkit-appearance: none; + width: 100%; + padding: 8px 32px 8px 12px; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--text); + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.orbit-template-select-input:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--bg-panel); +} +.orbit-template-select-input:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} +.orbit-template-select-input:disabled { + /* While the skill registry is loading we show a progress cursor; the + locked variant overrides this back to not-allowed via the parent + `.orbit-automation.is-locked` rule above. */ + cursor: progress; + opacity: 0.65; +} +.orbit-template-select-chevron { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-soft); + pointer-events: none; +} + +@media (max-width: 620px) { + /* On narrow viewports the template row should stack: title block on top, + select control full-width below it. */ + .orbit-automation-template-row { + grid-template-columns: minmax(0, 1fr); + row-gap: 10px; + } + .orbit-automation-template-controls { + width: 100%; + min-width: 0; + justify-content: stretch; + } +} + +/* ---------- 4. Run receipt ---------- */ +.orbit-receipt { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel); + box-shadow: var(--shadow-xs); +} +.orbit-receipt-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.orbit-receipt-head-left { + display: inline-flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} +.orbit-receipt-eyebrow { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} +.orbit-receipt-eyebrow svg { color: var(--text-soft); } +.orbit-receipt-timestamp { + font-size: 14px; + font-weight: 650; + color: var(--text-strong); + letter-spacing: -0.005em; + font-variant-numeric: tabular-nums; +} +.orbit-receipt-timestamp.muted { + color: var(--text-muted); + font-weight: 500; +} +.orbit-trigger-pill { + padding: 2px 10px; + border-radius: var(--radius-pill); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: var(--bg-subtle); + border: 1px solid var(--border); + color: var(--text-muted); +} +.orbit-trigger-pill.orbit-trigger-manual { + background: var(--blue-bg); + border-color: var(--blue-border); + color: var(--blue); +} +.orbit-trigger-pill.orbit-trigger-scheduled { + background: color-mix(in srgb, var(--accent-tint) 80%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); + color: var(--accent-strong); +} + +.orbit-inline-notice { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: var(--radius-sm); + font-size: 11.5px; + line-height: 1.4; + border: 1px solid var(--border); + background: var(--bg-subtle); + color: var(--text); +} +.orbit-inline-notice.is-success { + border-color: var(--green-border); + background: var(--green-bg); + color: var(--green); +} +.orbit-inline-notice.is-error { + border-color: var(--red-border); + background: var(--red-bg); + color: var(--red); +} + +/* Proportional run meter: a single bar whose segment widths reflect the + success / skip / failure ratio. Preferred over 4 equal tiles because it + communicates "mostly succeeded" or "mostly failed" at a glance. */ +.orbit-meter { + display: flex; + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: var(--bg-subtle); + border: 1px solid var(--border); +} +.orbit-meter-seg { + height: 100%; + transition: width 260ms ease; +} +.orbit-meter-seg.is-succeeded { background: var(--green); } +.orbit-meter-seg.is-skipped { background: var(--border-strong); } +.orbit-meter-seg.is-failed { background: var(--red); } +.orbit-meter-seg.is-empty { + width: 100%; + background: repeating-linear-gradient( + 45deg, + var(--bg-subtle) 0 6px, + var(--bg-muted) 6px 12px + ); +} + +.orbit-counts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} +.orbit-counts .orbit-count { + position: relative; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 12px; +} +.orbit-counts .orbit-count + .orbit-count::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 1px; + background: var(--border-soft); +} +.orbit-count dt { + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 0; +} +.orbit-count dd { + margin: 0; + font-size: 20px; + font-weight: 650; + letter-spacing: -0.015em; + color: var(--text-strong); + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.orbit-count.is-succeeded dd { color: var(--green); } +.orbit-count.is-skipped dd { color: var(--text-muted); } +.orbit-count.is-failed dd { color: var(--red); } + +@media (max-width: 620px) { + .orbit-counts { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 10px; } + .orbit-counts .orbit-count + .orbit-count::before { display: none; } +} + +/* ---------- 1b. Configuration gate --------------------------------------- + Surfaces when the user has no connected integrations. We share the + orbit-themed accent palette of the hero/firstrun panel so the gate + reads as a first-class part of the panel rather than an inline error + banner. Layout mirrors the firstrun composition (glyph · copy · action) + so the section feels rhythmically consistent regardless of which + empty-state panel is showing. The decorative ring glyph reuses the + same dashed-orbit motif as the firstrun glyph but anchors a small + "link" icon at the center to telegraph "wire up a connector". */ +.orbit-config-gate { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + padding: 16px 20px; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); + border-radius: var(--radius); + background: + radial-gradient(120% 160% at 0% 50%, color-mix(in srgb, var(--accent-tint) 90%, transparent) 0%, transparent 60%), + var(--bg-panel); + box-shadow: var(--shadow-xs); + overflow: hidden; + animation: orbitConfigGateIn 220ms ease-out; +} +.orbit-config-gate::after { + /* Soft outer ring decoration in the corner — pure visual, mirrors the + firstrun panel so the two empty states feel like siblings. */ + content: ''; + position: absolute; + right: -50px; + top: -50px; + width: 160px; + height: 160px; + border-radius: 50%; + border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + pointer-events: none; +} +@keyframes orbitConfigGateIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.orbit-config-gate-glyph { + position: relative; + width: 52px; + height: 52px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.orbit-config-gate-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 1px dashed color-mix(in srgb, var(--accent) 42%, transparent); +} +.orbit-config-gate-ring-outer { + inset: 0; + animation: orbitFirstrunSpin 22s linear infinite; +} +.orbit-config-gate-ring-inner { + inset: 9px; + border-style: solid; + border-color: color-mix(in srgb, var(--accent) 26%, transparent); + animation: orbitFirstrunSpin 16s linear infinite reverse; +} +.orbit-config-gate-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 70%, #000) 100%); + color: var(--btn-primary-fg, #fff); + box-shadow: 0 4px 10px color-mix(in srgb, var(--accent) 32%, transparent); +} +@media (prefers-reduced-motion: reduce) { + .orbit-config-gate-ring { animation: none !important; } +} + +.orbit-config-gate-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.orbit-config-gate-eyebrow { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-strong); +} +.orbit-config-gate-title { + margin: 0; + font-size: 14px; + font-weight: 650; + letter-spacing: -0.005em; + color: var(--text-strong); + line-height: 1.3; +} +.orbit-config-gate-body { + margin: 0; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; + max-width: 56ch; +} + +.orbit-config-gate-actions { + display: flex; + align-items: center; + flex-shrink: 0; +} +.orbit-config-gate-action { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.005em; + color: var(--btn-primary-fg, #fff); + background: linear-gradient(135deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 70%, #000) 100%); + border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); + border-radius: 999px; + cursor: pointer; + transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease; + box-shadow: 0 6px 14px color-mix(in srgb, var(--accent) 22%, transparent); +} +.orbit-config-gate-action:hover { + transform: translateY(-1px); + filter: brightness(1.05); + box-shadow: 0 10px 20px color-mix(in srgb, var(--accent) 28%, transparent); +} +.orbit-config-gate-action:focus-visible { + outline: 2px solid var(--accent-strong); + outline-offset: 2px; +} +.orbit-config-gate-action svg { + transition: transform 160ms ease; +} +.orbit-config-gate-action:hover svg { + transform: translateX(2px); +} + +@media (max-width: 620px) { + .orbit-config-gate { + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + row-gap: 12px; + } + .orbit-config-gate-actions { + grid-column: 1 / -1; + justify-content: flex-start; + } +} + +/* ---------- 5. Live artifact strip ---------- */ +.orbit-artifact-strip { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: auto auto; + column-gap: 14px; + row-gap: 10px; + align-items: center; + padding: 14px 16px 14px 18px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); + border-radius: var(--radius); + background: + linear-gradient(135deg, color-mix(in srgb, var(--accent-tint) 60%, var(--bg-panel)) 0%, var(--bg-panel) 55%); + box-shadow: var(--shadow-xs); + overflow: hidden; +} +.orbit-artifact-strip::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 50%, transparent) 100%); +} +.orbit-artifact-strip.is-legacy { + background: var(--bg-subtle); + border-color: var(--border); + border-style: dashed; +} +.orbit-artifact-strip.is-legacy::before { display: none; } + +.orbit-artifact-strip-icon { + grid-row: 1; + width: 38px; + height: 38px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-panel); + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); + color: var(--accent-strong); + flex-shrink: 0; +} +.orbit-artifact-strip.is-legacy .orbit-artifact-strip-icon { + border-color: var(--border); + color: var(--text-muted); +} +.orbit-artifact-strip-copy { + grid-row: 1; + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.orbit-artifact-strip-kicker { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-strong); +} +.orbit-artifact-strip.is-legacy .orbit-artifact-strip-kicker { + color: var(--text-muted); +} +.orbit-artifact-strip-title { + font-size: 13.5px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-strong); + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.orbit-artifact-strip-meta { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; +} +.orbit-artifact-strip-actions { + grid-row: 1; + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.orbit-artifact-ghost { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 10px; + font-size: 11.5px; + font-weight: 600; + color: var(--text); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} +.orbit-artifact-ghost:hover { + background: var(--bg-subtle); + border-color: var(--border-strong); +} + +.orbit-artifact-open { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 600; + text-decoration: none; + box-shadow: 0 1px 0 color-mix(in srgb, var(--accent-strong) 20%, transparent) inset, var(--shadow-xs); + transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; +} +.orbit-artifact-open:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: translateY(-1px); +} +.orbit-artifact-open:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.orbit-artifact-peek { + grid-column: 1 / -1; + grid-row: 2; + margin-top: 2px; + border-top: 1px solid color-mix(in srgb, var(--accent) 12%, var(--border-soft)); + padding-top: 8px; +} +.orbit-artifact-strip.is-legacy .orbit-artifact-peek { + border-top-color: var(--border-soft); +} +.orbit-artifact-peek summary { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 0; + cursor: pointer; + list-style: none; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + user-select: none; +} +.orbit-artifact-peek summary::-webkit-details-marker { display: none; } +.orbit-artifact-peek summary svg { + transition: transform 160ms ease; + color: var(--text-soft); +} +.orbit-artifact-peek[open] summary svg { transform: rotate(90deg); } +.orbit-artifact-peek[open] summary { color: var(--text); } +.orbit-artifact-peek pre { + margin: 8px 0 0; + max-height: 240px; + overflow: auto; + padding: 12px 14px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--bg-panel); + color: var(--text); + font: 11.5px/1.6 var(--mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 620px) { + .orbit-artifact-strip { + grid-template-columns: auto minmax(0, 1fr); + } + .orbit-artifact-strip-actions { + grid-column: 1 / -1; + grid-row: auto; + justify-content: flex-end; + } + .orbit-artifact-peek { + grid-column: 1 / -1; + } +} .settings-field-error { color: var(--red); font-size: 12px; @@ -3328,10 +4828,245 @@ code { line-height: 1.25; letter-spacing: -0.005em; color: var(--text); + /* The title is now a flex row so a connection-status dot can sit + inline next to the connector name without breaking the title's + truncation. The name span owns the ellipsis; the dot stays at a + fixed size on the trailing edge of the row. */ + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.connector-card-title-name { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Inline title-anchored connection dot. Sized + offset so it visually + aligns with the title's cap-height rather than its full line-height, + and given a soft halo on the connected variant so the live state + reads as a small "online" pulse next to the connector name. The + halo collapses to nothing in non-connected variants because today + the dot is only rendered for `connected` (error/disabled use a + separate pill in the action column). */ +.connector-card-title-dot { + flex: 0 0 auto; + /* Optical alignment: pull the dot up by ~1px so it sits on the + baseline of an uppercase letter rather than the descender line. */ + margin-top: -1px; +} + +/* Connector brand mark. Used in two sizes: a compact 28px tile inside + catalog cards (`size-sm`) and a 44px mark in the detail drawer head + (`size-lg`). The wrapper also hosts the fallback initials tile so the + image fades over a stable, themed surface — there is no flash of empty + space while the network resolves and no broken-icon chrome if the + request fails. The remote logos come from `logos.composio.dev`, keyed + by the lowercased toolkit slug stripped of underscores; the URL is + built in `ConnectorsBrowser.tsx` so the daemon's friendlier ids + (`google_drive`) still resolve to the right CDN entry (`googledrive`). */ +.connector-logo { + position: relative; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-panel); + overflow: hidden; + isolation: isolate; + /* `var(--bg-panel)` already inverts in dark mode, so the logo's + transparent corners read cleanly on either theme. The remote SVGs + ship with theme-aware fills (we request `?theme=light|dark` to + match), but the soft border still gives them a tidy frame. */ + box-shadow: var(--shadow-xs); + user-select: none; +} +.connector-logo.size-lg { + width: 44px; + height: 44px; + border-radius: 12px; +} +.connector-logo-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + /* Inset the rendered image a hair so brand marks with no built-in + padding (small monograms, square photos) don't crash into the + border on the small tile. */ + padding: 4px; + /* Fade the image in when it lands so a slow connection doesn't pop + content under the user's eye. The fallback underneath provides + instant visual presence in the meantime. */ + opacity: 0; + transform: scale(0.96); + transition: opacity 160ms ease-out, transform 160ms ease-out; + z-index: 1; +} +.connector-logo.size-lg .connector-logo-img { + padding: 6px; +} +.connector-logo.state-loaded .connector-logo-img { + opacity: 1; + transform: scale(1); +} +/* Fallback initials tile. Three jobs, in priority order: + 1. While the image is pending, the tile is a *neutral skeleton* — + no color, no letters showing through. The user shouldn't see a + bright colored placeholder flash and then morph into a totally + different brand mark when the SVG lands; that mismatch reads as + "wrong logo" before it reads as "loading". + 2. Once the image has loaded, the tile is fully hidden. Composio + SVGs frequently have transparent regions, and even a faint + colored backdrop bleeds through and tints the brand — exactly + the visual mixing we want to avoid. Hiding it (not just dimming) + guarantees the real image owns the slot. + 3. Only when the network actually fails (`state-error`) — or when + no slug was derivable in the first place (`is-fallback`) — do we + promote the tile to a quiet brand mark with stable initials. + Even then it's deliberately *muted*: a single low-saturation + neutral surface, lighter weight type, and a subtle hue accent + from a hashed palette so two adjacent fallbacks don't read as + identical. The intent is "calm placeholder", never "louder than + the real logos one row up". */ +.connector-logo-fallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: transparent; + background: var(--bg-subtle); + text-transform: uppercase; + z-index: 0; + /* Default: skeleton mode. Initials are kept in the DOM (so a paint + of the error state doesn't have to re-flow text) but rendered + transparent. The only visible thing is the soft `--bg-subtle` + surface, which sits flush with the wrapper border and reads as a + quiet placeholder, not a brand. */ + transition: + color 140ms ease-out, + background-color 140ms ease-out, + opacity 120ms ease-out; +} +.connector-logo.size-lg .connector-logo-fallback { + font-size: 14px; + letter-spacing: 0.04em; +} +/* Pending: gentle shimmer over the neutral surface so the user can + tell something is loading, without a colored tile suggesting a + particular brand. The shimmer only kicks in if the image takes + long enough to matter — short loads finish before the animation + even completes one cycle. */ +.connector-logo.state-pending .connector-logo-fallback { + background: + linear-gradient( + 90deg, + var(--bg-subtle) 0%, + color-mix(in srgb, var(--bg-subtle) 60%, var(--bg-panel)) 50%, + var(--bg-subtle) 100% + ); + background-size: 200% 100%; + animation: connector-logo-shimmer 1400ms ease-in-out infinite; +} +@keyframes connector-logo-shimmer { + from { background-position: 100% 0; } + to { background-position: -100% 0; } +} +/* Loaded: yank the fallback entirely. `visibility: hidden` keeps it + out of the paint pipeline so transparent regions in the SVG can't + composite over a colored backdrop. */ +.connector-logo.state-loaded .connector-logo-fallback { + visibility: hidden; + opacity: 0; +} +/* Error / no-slug: quiet brand mark. Initials become visible, but in + a muted neutral palette by default. The hashed palette below adds + a hint of hue so a row of fallbacks isn't monotone, but every + variant stays low-saturation and low-contrast against the card + surface so the real logos always read as the focal point. */ +.connector-logo.state-error .connector-logo-fallback, +.connector-logo.is-fallback .connector-logo-fallback { + color: var(--text-muted); + background: var(--bg-subtle); + animation: none; +} +.connector-logo.state-error[data-palette='0'] .connector-logo-fallback, +.connector-logo.is-fallback[data-palette='0'] .connector-logo-fallback { + background: color-mix(in oklab, var(--accent) 6%, var(--bg-subtle)); + color: color-mix(in oklab, var(--accent) 35%, var(--text-muted)); +} +.connector-logo.state-error[data-palette='1'] .connector-logo-fallback, +.connector-logo.is-fallback[data-palette='1'] .connector-logo-fallback { + background: color-mix(in oklab, #6b8afd 7%, var(--bg-subtle)); + color: color-mix(in oklab, #6b8afd 38%, var(--text-muted)); +} +.connector-logo.state-error[data-palette='2'] .connector-logo-fallback, +.connector-logo.is-fallback[data-palette='2'] .connector-logo-fallback { + background: color-mix(in oklab, #2dbfa8 7%, var(--bg-subtle)); + color: color-mix(in oklab, #2dbfa8 38%, var(--text-muted)); +} +.connector-logo.state-error[data-palette='3'] .connector-logo-fallback, +.connector-logo.is-fallback[data-palette='3'] .connector-logo-fallback { + background: color-mix(in oklab, #d18b3a 7%, var(--bg-subtle)); + color: color-mix(in oklab, #d18b3a 40%, var(--text-muted)); +} +.connector-logo.state-error[data-palette='4'] .connector-logo-fallback, +.connector-logo.is-fallback[data-palette='4'] .connector-logo-fallback { + background: color-mix(in oklab, #c356b3 6%, var(--bg-subtle)); + color: color-mix(in oklab, #c356b3 38%, var(--text-muted)); +} +.connector-logo.state-error[data-palette='5'] .connector-logo-fallback, +.connector-logo.is-fallback[data-palette='5'] .connector-logo-fallback { + background: color-mix(in oklab, #5d6b85 9%, var(--bg-subtle)); + color: color-mix(in oklab, #5d6b85 42%, var(--text-muted)); +} +/* The wrapper border is what visually frames the tile. When the real + image is loaded we keep it; in the fallback states we soften it a + step so the tile recedes further compared to a card with a real + logo on the same row. */ +.connector-logo.state-error, +.connector-logo.is-fallback { + border-color: var(--border-soft, var(--border)); + box-shadow: none; +} +@media (prefers-reduced-motion: reduce) { + .connector-logo-img { + transition: none; + transform: none; + } + .connector-logo.state-pending .connector-logo-fallback { + animation: none; + background: var(--bg-subtle); + } +} + +/* Embedded catalog (Settings → Connectors). Cards are tighter here so + the logo shrinks to 24px and sheds a touch of border radius so it + reads as a quiet badge next to the connector name rather than a + prominent brand mark. The card-top gap (already 8px in the embedded + variant) keeps the logo close to the head copy. */ +.connectors-panel-embedded .connector-logo.size-sm { + width: 24px; + height: 24px; + border-radius: 7px; +} +.connectors-panel-embedded .connector-logo.size-sm .connector-logo-img { + padding: 3px; +} +.connectors-panel-embedded .connector-logo.size-sm .connector-logo-fallback { + font-size: 10px; +} + .connector-meta { display: flex; align-items: center; @@ -3546,9 +5281,376 @@ button.connector-action.is-loading { .connector-card.is-locked { cursor: not-allowed; } + +/* Embedded inside Settings → Connectors. The section-head already shows the + "Connectors" heading + hint, so suppress the inner panel heading and let + the toolbar collapse to just the search input. The masked gate keeps its + absolute positioning relative to .connector-grid-wrap (already set), but + we cap its height inside the modal so the gate stays visible without + blowing the dialog out vertically. */ +.tab-panel.connectors-panel.connectors-panel-embedded { + gap: 14px; +} +.connectors-panel-embedded .tab-panel-toolbar { + justify-content: flex-end; + margin-top: -4px; +} +.connectors-panel-embedded .toolbar-left.connectors-heading { + display: none; +} +.connectors-panel-embedded .toolbar-right { + flex: 1 1 auto; + justify-content: space-between; + gap: 10px; + align-items: center; + flex-wrap: nowrap; +} +.connectors-panel-embedded .tab-panel-toolbar .toolbar-search.connectors-search { + flex: 0 1 320px; + width: min(320px, 100%); + max-width: 320px; + min-width: 180px; +} + +/* Provider tabs sit in the toolbar's left edge (right of the hidden inner + heading). Today there is only Composio, but the segmented control is + built so additional providers slot in without re-styling. */ +.connectors-provider-tabs { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 999px; + background: var(--bg-subtle); + border: 1px solid var(--border); + flex: 0 0 auto; +} +.connectors-provider-tab { + appearance: none; + border: 0; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.005em; + padding: 5px 12px; + border-radius: 999px; + cursor: pointer; + white-space: nowrap; + transition: + background 140ms ease, + color 140ms ease, + box-shadow 180ms ease; +} +.connectors-provider-tab:hover:not(.is-active) { + color: var(--text); + background: color-mix(in srgb, var(--text) 6%, transparent); +} +.connectors-provider-tab.is-active { + color: var(--text); + background: var(--bg-panel); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.06), + 0 0 0 1px color-mix(in srgb, var(--text) 12%, var(--border)); +} +.connectors-provider-tab:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--text) 14%, transparent), + 0 1px 2px rgba(0, 0, 0, 0.06); +} +.connectors-panel-embedded .connector-grid-wrap.is-masked { + /* Cap the masked grid's height so the gate's centered card stays in the + visible portion of the settings dialog without forcing the modal body + to scroll past it. The grid below is blurred and intentionally clipped. */ + min-height: clamp(320px, 45vh, 420px); + max-height: clamp(360px, 56vh, 560px); + overflow: hidden; + border-radius: var(--radius); +} +.connectors-panel-embedded .connector-grid-wrap.is-masked .connector-grid { + max-height: 100%; + overflow: hidden; +} +/* Compact catalog density inside the modal: tighter tracks, no description + row, action collapsed to an icon-only button anchored top-right. */ +.connectors-panel-embedded .connector-grid { + grid-template-columns: repeat(auto-fill, minmax(196px, 1fr)); + gap: 10px; +} +.connectors-panel-embedded .connector-card { + min-height: 0; + padding: 10px 10px 10px 12px; + gap: 6px; +} +.connectors-panel-embedded .connector-card-title { + font-size: 13px; + font-weight: 600; + letter-spacing: 0; +} +/* Two-row meta layout for the embedded catalog. The previous single + row let long category labels wrap unpredictably, leaving cards in + a 3-column grid with mismatched heights. Stacking onto its own + rows fixes the height and gives the async tools-badge a stable + anchor to animate into without resizing the card. */ +.connectors-panel-embedded .connector-meta { + flex-direction: column; + align-items: flex-start; + flex-wrap: nowrap; + font-size: 11px; + gap: 4px; + width: 100%; + min-width: 0; +} +/* Category row: single line with ellipsis. The full label is still + reachable via the `title` attribute on the span and the card's + own openDetailsAria, so we never lose information. */ +.connectors-panel-embedded .connector-meta-category { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} +/* Tools-badge slot. Reserves its own row and a fixed height even + while the discovery call is in flight (`aria-hidden` is set on the + span before the badge resolves), so the card doesn't grow when + the badge animates in. The fixed height matches the embedded + badge's pill (1px + 1px borders + 10px text + 2px padding × 2 ≈ + 18px). */ +.connectors-panel-embedded .connector-meta-tools { + display: inline-flex; + align-items: center; + min-height: 18px; + /* Hairline visual placeholder while the discovery request is in + flight — a 1px-tall faint baseline so the row reads as + intentionally reserved space rather than an empty gap. The + placeholder is dropped the moment the badge appears. */ + position: relative; +} +.connectors-panel-embedded .connector-meta-tools[aria-hidden="true"]::after { + content: ''; + display: block; + width: 32px; + height: 1px; + background: color-mix(in srgb, var(--text) 10%, transparent); + border-radius: 999px; +} +/* The dot separator was only meaningful when the meta was a single + inline row; in the stacked layout it would float orphaned at the + start of the badge row. Hide it (the embedded card no longer + renders it in JSX, but keep the rule defensive in case a future + refactor reintroduces inline separators here). */ +.connectors-panel-embedded .connector-meta .connector-meta-dot { + display: none; +} +.connectors-panel-embedded .connector-tools-badge { + padding: 1px 6px; + font-size: 10px; + border-radius: 999px; +} +/* Anchor the action column to the top now that the meta block can be + one or two rows tall — center alignment used to make the action + drift down whenever the badge appeared. Keeping the action top- + aligned matches the title baseline and stops the eye from + tracking up and down across cards. */ +.connectors-panel-embedded .connector-card-top { + align-items: flex-start; + gap: 8px; +} +.connectors-panel-embedded .connector-card-actions { + /* Nudge the action button down a touch so it optically aligns with + the title's cap-height instead of its top edge. */ + margin-top: 1px; +} + +/* Icon-only connect/disconnect action: a 26px circular control anchored + at the card's top-right edge. We keep the same `connector-action` + class so loading/disabled state styling carries over from the + shared rules above, but the visual treatment is overridden here so + the catalog grid doesn't end up with a row of high-contrast filled + squares competing for attention. The default state is a subtle + ghost — almost recedes into the card — and the action picks up + accent weight only when the card or the button itself is hovered + or focused. The whole card is also clickable (it opens the + details drawer where Connect lives at full size), so this is a + secondary affordance, not the primary CTA. */ +.connectors-panel-embedded .connector-card-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.connectors-panel-embedded button.connector-action.icon-only { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + /* Reset the min-width from the non-embedded `.is-connect` / + `.is-disconnect` pill rules above; in the compact catalog the + action collapses to a 26px circle and any `min-width` would + force it back into a wide pill that overlaps the card head + text. */ + min-width: 0; + padding: 0; + border-radius: 999px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + transform 120ms ease, + box-shadow 160ms ease, + opacity 160ms ease; +} +/* Soft default fill so the ghost button still has a visible target on + the lightest card backgrounds. We tint with `--text` rather than a + solid color so the control reads correctly in both dark and light + themes without per-theme overrides. */ +.connectors-panel-embedded button.connector-action.icon-only { + background: color-mix(in srgb, var(--text) 5%, transparent); + border-color: color-mix(in srgb, var(--text) 10%, transparent); +} +/* When the parent card is hovered, the action gains a touch more weight + so it telegraphs interactivity without ever flashing to full white. + This applies whether the user is hovering the card or the button + directly, so reaching for the action never feels like the button + moves out from under them. */ +.connectors-panel-embedded .connector-card:hover:not(.is-locked) button.connector-action.icon-only:not(:disabled), +.connectors-panel-embedded .connector-card:focus-visible button.connector-action.icon-only:not(:disabled) { + color: var(--text); + background: color-mix(in srgb, var(--text) 9%, transparent); + border-color: color-mix(in srgb, var(--text) 18%, transparent); +} +.connectors-panel-embedded button.connector-action.icon-only:hover:not(:disabled) { + color: var(--text); + background: color-mix(in srgb, var(--text) 12%, transparent); + border-color: color-mix(in srgb, var(--text) 24%, transparent); +} +.connectors-panel-embedded button.connector-action.icon-only:active:not(:disabled) { + transform: scale(0.92); +} +.connectors-panel-embedded button.connector-action.icon-only:focus-visible { + outline: none; + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); +} +.connectors-panel-embedded button.connector-action.icon-only:disabled { + opacity: 0.4; + cursor: not-allowed; +} +@media (prefers-reduced-motion: reduce) { + .connectors-panel-embedded button.connector-action.icon-only { + transition: background 80ms linear, color 80ms linear; + } + .connectors-panel-embedded button.connector-action.icon-only:active:not(:disabled) { + transform: none; + } +} + +/* Connect (the "+" affordance). Refined into an accent-tinted ghost so + the action reads as inviting rather than competing — the previous + "fill with var(--text)" rule produced a hard off-white square in + dark theme that fought every other card. Default state borrows a + whisper of the accent so the plus is visibly the accent action of + the card; hover/card-hover both lift the tint up while keeping the + button transparent enough to feel like part of the card surface, + not a stamped-on chip. */ +.connectors-panel-embedded button.connector-action.is-connect { + color: color-mix(in srgb, var(--accent) 76%, var(--text-muted)); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 22%, transparent); +} +.connectors-panel-embedded .connector-card:hover:not(.is-locked) button.connector-action.is-connect:not(:disabled), +.connectors-panel-embedded .connector-card:focus-visible button.connector-action.is-connect:not(:disabled) { + color: var(--accent-strong, var(--accent)); + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 38%, transparent); +} +.connectors-panel-embedded button.connector-action.is-connect:hover:not(:disabled) { + color: var(--accent-strong, var(--accent)); + background: color-mix(in srgb, var(--accent) 26%, transparent); + border-color: color-mix(in srgb, var(--accent) 56%, transparent); + box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--accent) 60%, transparent); +} +.connectors-panel-embedded button.connector-action.is-connect:active:not(:disabled) { + background: color-mix(in srgb, var(--accent) 32%, transparent); +} + +/* Disconnect stays neutral until the user actually points at it, then + warms to the destructive red so the "remove" intent is unambiguous. + Slightly de-emphasized at rest compared to Connect — the connected + row already carries a green status dot to communicate state, so + this control doesn't need to advertise itself. */ +.connectors-panel-embedded button.connector-action.is-disconnect { + color: var(--text-muted); + background: color-mix(in srgb, var(--text) 4%, transparent); + border-color: color-mix(in srgb, var(--text) 10%, transparent); +} +.connectors-panel-embedded .connector-card:hover:not(.is-locked) button.connector-action.is-disconnect:not(:disabled), +.connectors-panel-embedded .connector-card:focus-visible button.connector-action.is-disconnect:not(:disabled) { + color: var(--text); + background: color-mix(in srgb, var(--text) 9%, transparent); + border-color: color-mix(in srgb, var(--text) 18%, transparent); +} +.connectors-panel-embedded button.connector-action.is-disconnect:hover:not(:disabled) { + color: var(--red, var(--text)); + background: color-mix(in srgb, var(--red, var(--text)) 14%, transparent); + border-color: color-mix(in srgb, var(--red, var(--text)) 42%, transparent); +} + +/* Connection-status pip. Lives inline next to the connector name in + the embedded catalog (anchored via `.connector-card-title-dot`), + and the same dot is reused in the drawer where the rules above + handle the larger non-embedded variant. The halo is a `box-shadow` + ring rather than a `border` so the dot's optical size stays at + 7px even with the green pulse around it. */ +.connectors-panel-embedded .connector-status-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--text-muted); + flex: 0 0 auto; +} +.connectors-panel-embedded .connector-status-dot.status-connected { + background: var(--green, #22c55e); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--green, #22c55e) 22%, transparent); +} +.connectors-panel-embedded .connector-status-dot.status-error { + background: var(--red, #ef4444); +} +.connectors-panel-embedded .connector-status-dot.status-disabled { + background: var(--text-faint); +} +/* Error/disabled pills inside the compact card stay legible but small. */ +.connectors-panel-embedded .connector-card .connector-status-pill { + font-size: 10px; + padding: 1px 6px; +} + +/* On very narrow modals, keep tabs and search on one row while letting the + search input absorb the squeeze. */ +@media (max-width: 540px) { + .connectors-provider-tabs { + flex-shrink: 0; + } + .connectors-panel-embedded .tab-panel-toolbar .toolbar-search.connectors-search { + min-width: 0; + } +} .connector-gate { position: absolute; inset: 0; + z-index: 2; display: flex; align-items: center; justify-content: center; @@ -3560,6 +5662,7 @@ button.connector-action.is-loading { -webkit-backdrop-filter: blur(6px); border-radius: var(--radius); animation: connector-gate-fade 220ms ease-out; + pointer-events: auto; } @keyframes connector-gate-fade { from { opacity: 0; transform: translateY(4px); } @@ -3603,13 +5706,6 @@ button.connector-action.is-loading { font-size: 13px; line-height: 1.5; } -.connector-gate-action { - margin-top: 6px; - padding: 7px 16px; - font-size: 13px; - font-weight: 500; -} - /* ------------------------------------------------------------------ */ /* Connector detail drawer */ /* ------------------------------------------------------------------ */ @@ -4122,6 +6218,7 @@ button.connector-action.is-loading { .design-live-count { display: inline-flex; align-items: center; + flex: 0 0 auto; border-radius: 999px; border: 1px solid var(--border); padding: 2px 7px; @@ -4129,6 +6226,8 @@ button.connector-action.is-loading { font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; + white-space: nowrap; + line-height: 1.2; background: var(--bg-panel); } .live-artifact-badge.live { @@ -4657,9 +6756,13 @@ button.connector-action.is-loading { cursor: pointer; flex-shrink: 0; max-width: 220px; + min-width: 0; color: var(--text-muted); transition: background 120ms ease, color 120ms ease; } +.ws-tab.live-artifact-tab { + max-width: 320px; +} .ws-tab:hover { background: var(--bg-subtle); color: var(--text); } .ws-tab:focus-visible { outline: 2px solid var(--accent); @@ -4671,6 +6774,7 @@ button.connector-action.is-loading { font-weight: 500; } .ws-tab .tab-icon { + flex: 0 0 auto; font-size: 13px; color: var(--text-muted); width: 14px; @@ -4678,20 +6782,30 @@ button.connector-action.is-loading { } .ws-tab.active .tab-icon { color: var(--text); } .ws-tab-label { + flex: 1 1 auto; + min-width: 32px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; } +.ws-tab.live-artifact-tab .ws-tab-label { + max-width: none; +} .ws-live-artifact-badges { + flex: 0 0 auto; flex-wrap: nowrap; - max-width: 145px; - overflow: hidden; + max-width: none; + overflow: visible; } .ws-live-artifact-badges .live-artifact-badge:not(.live):not(.refreshing):not(.refresh-failed):not(.archived) { display: none; } +.ws-live-artifact-badges:has(.refreshing) .live-artifact-badge.live { + display: none; +} .ws-tab-close { + flex: 0 0 auto; border: none; background: transparent; padding: 0 2px; @@ -4890,6 +7004,9 @@ button.connector-action.is-loading { position: relative; transition: background 120ms ease; } +.df-row-live-artifact { + grid-template-columns: 36px minmax(0, 1fr) auto; +} .df-row:hover { background: var(--bg-subtle); } .df-row.active { background: var(--blue-bg); color: var(--text); } .df-row.active .df-row-name { color: var(--text-strong); } @@ -7219,7 +9336,7 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { font-size: 12px; line-height: 1.35; } -.field-label-row a { +.field-label-row a:not(.field-label-link) { color: var(--accent); font-size: 13px; white-space: nowrap; @@ -7233,8 +9350,8 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { .field-label-link { display: inline-flex; align-items: center; - gap: 4px; - font-size: 11.5px; + gap: 3px; + font-size: 10.5px; color: var(--text-muted); text-decoration: none; white-space: nowrap; @@ -7264,6 +9381,111 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { border: 1px solid color-mix(in srgb, #1f9d55 28%, var(--border)); font-variant-numeric: tabular-nums; } +/* ---------- Composio API key skeleton ---------- + The Composio config is daemon-backed, so on first paint after a + restart there is a window where the section renders empty even + though a saved key exists. Rather than show a misleading "no key + saved" state, we overlay a skeleton on the input + chip + buttons + so the user understands the field is still resolving. + We intentionally keep the real input mounted underneath the shimmer + so focus, autofill, and accessibility nodes are not torn down on + resolve — the parent label gets aria-busy and the disabled flags + on the buttons are the structural safety net. */ +.field-status-badge-skeleton { + /* Same footprint as the saved-state chip so the row geometry + doesn't shift when hydration completes. The width is a + calibrated guess for a "Saved · ••••XXXX" string; close enough + that the swap-in feels stable without depending on the actual + tail length. */ + width: 86px; + height: 18px; + border: 1px solid var(--border); + background: + linear-gradient( + 90deg, + color-mix(in srgb, var(--text-soft) 8%, var(--bg-subtle)) 0%, + color-mix(in srgb, var(--text-soft) 18%, var(--bg-subtle)) 50%, + color-mix(in srgb, var(--text-soft) 8%, var(--bg-subtle)) 100% + ); + background-size: 200% 100%; + animation: settingsSkeletonShimmer 1.4s ease-in-out infinite; + border-radius: var(--radius-pill); + padding: 0; + color: transparent; +} + +.field-input-skeleton-wrap { + position: relative; + display: flex; + flex: 1; + min-width: 0; +} +.field-input-skeleton-wrap > input { + flex: 1; + min-width: 0; +} +.field-input-skeleton-shimmer { + position: absolute; + inset: 1px; + border-radius: var(--radius); + pointer-events: none; + background: + linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--text-soft) 14%, transparent) 50%, + transparent 100% + ); + background-size: 220% 100%; + animation: settingsSkeletonShimmer 1.6s ease-in-out infinite; +} + +/* The whole credentials field gets a softened, "we're checking" feel + while loading: muted label, slightly desaturated input chrome. + Matches the broader Settings shimmer language. */ +.settings-section-connectors-credentials.is-loading .field-label { + color: var(--text-muted); +} +.settings-section-connectors-credentials.is-loading input:disabled { + cursor: progress; + background: color-mix(in srgb, var(--text-soft) 4%, var(--bg-subtle)); + /* Preserve readable placeholder color even when disabled — the + placeholder doubles as the "Checking for a saved key…" cue. */ + color: var(--text-muted); + -webkit-text-fill-color: var(--text-muted); +} +.settings-section-connectors-credentials.is-loading input:disabled::placeholder { + color: var(--text-muted); + opacity: 1; +} + +/* Inline status hint variant — the help line below the input becomes + a small spinner + status string while loading. Sits in the same + slot as the regular hint so layout doesn't jump. */ +.field-hint-loading { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); +} +.field-hint-loading svg { + color: var(--accent-strong); + flex-shrink: 0; +} + +@keyframes settingsSkeletonShimmer { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} +@media (prefers-reduced-motion: reduce) { + .field-status-badge-skeleton, + .field-input-skeleton-shimmer { + animation: none; + } + .field-input-skeleton-shimmer { + background: color-mix(in srgb, var(--text-soft) 8%, transparent); + } +} .deploy-form input, .deploy-form select { width: 100%; diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index e20381df2..5fa50489b 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -5,6 +5,7 @@ import type { AppConfig, MediaProviderCredentials, NotificationsConfig, + OrbitConfig, PetConfig, } from '../types'; import { normalizeAccentColor } from './appearance'; @@ -41,6 +42,16 @@ export const DEFAULT_PET: PetConfig = { }, }; +export const DEFAULT_ORBIT: OrbitConfig = { + enabled: false, + time: '08:00', + // Ship with the general-purpose Orbit briefing skill pre-selected so a + // fresh install runs against a real adaptive template instead of the + // bare built-in prompt. Users can clear it from Settings → Orbit to fall + // back to the built-in prompt or pick another scenario === 'orbit' skill. + templateSkillId: 'orbit-general', +}; + export const DEFAULT_CONFIG: AppConfig = { mode: 'daemon', apiKey: '', @@ -65,6 +76,7 @@ export const DEFAULT_CONFIG: AppConfig = { agentCliEnv: {}, pet: DEFAULT_PET, notifications: DEFAULT_NOTIFICATIONS, + orbit: DEFAULT_ORBIT, }; /** Well-known providers with pre-filled base URLs. */ @@ -204,6 +216,21 @@ function normalizeNotifications( return { ...DEFAULT_NOTIFICATIONS, ...(input ?? {}) }; } +function normalizeOrbit(input: Partial | undefined): OrbitConfig { + const time = typeof input?.time === 'string' && isValidOrbitTime(input.time) + ? input.time + : DEFAULT_ORBIT.time; + return { ...DEFAULT_ORBIT, ...(input ?? {}), time }; +} + +function isValidOrbitTime(time: string): boolean { + const match = /^(\d{2}):(\d{2})$/.exec(time); + if (!match) return false; + const hours = Number(match[1]); + const minutes = Number(match[2]); + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59; +} + function inferApiProtocol(model: string, baseUrl: string): ApiProtocol { try { return isOpenAICompatible(model, baseUrl) ? 'openai' : 'anthropic'; @@ -223,6 +250,7 @@ export function loadConfig(): AppConfig { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET), notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS), + orbit: normalizeOrbit(DEFAULT_ORBIT), }; } const parsed = JSON.parse(raw) as Partial; @@ -241,6 +269,7 @@ export function loadConfig(): AppConfig { accentColor: normalizeAccentColor(parsed.accentColor) ?? DEFAULT_CONFIG.accentColor, pet: normalizePet(parsed.pet), notifications: normalizeNotifications(parsed.notifications), + orbit: normalizeOrbit(parsed.orbit), }; if (parsed.configMigrationVersion !== CONFIG_MIGRATION_VERSION) { @@ -268,6 +297,7 @@ export function loadConfig(): AppConfig { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET), notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS), + orbit: normalizeOrbit(DEFAULT_ORBIT), }; } } @@ -347,6 +377,9 @@ export function mergeDaemonConfig( if (daemonConfig.disabledDesignSystems !== undefined) { next.disabledDesignSystems = daemonConfig.disabledDesignSystems; } + if (daemonConfig.orbit !== undefined) { + next.orbit = normalizeOrbit(daemonConfig.orbit); + } return next; } @@ -361,16 +394,18 @@ export function hasAnyConfiguredProvider( export async function syncMediaProvidersToDaemon( providers: Record | undefined, - options?: { force?: boolean }, + options?: { force?: boolean; throwOnError?: boolean }, ): Promise { if (!providers) return; try { - await fetch('/api/media/config', { + const response = await fetch('/api/media/config', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ providers, force: Boolean(options?.force) }), }); + if (!response.ok) throw new Error(`Failed to sync media config (${response.status})`); } catch { + if (options?.throwOnError) throw new Error('Media config save failed'); // Daemon offline; localStorage keeps the user's copy for the next save. } } @@ -386,7 +421,10 @@ export async function fetchDaemonConfig(): Promise { } } -export async function syncConfigToDaemon(config: AppConfig): Promise { +export async function syncConfigToDaemon( + config: AppConfig, + options?: { throwOnError?: boolean }, +): Promise { const prefs: AppConfigPrefs = { onboardingCompleted: config.onboardingCompleted, agentId: config.agentId, @@ -396,14 +434,17 @@ export async function syncConfigToDaemon(config: AppConfig): Promise { designSystemId: config.designSystemId, disabledSkills: config.disabledSkills, disabledDesignSystems: config.disabledDesignSystems, + orbit: normalizeOrbit(config.orbit), }; try { - await fetch('/api/app-config', { + const response = await fetch('/api/app-config', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs), }); - } catch { + if (!response.ok) throw new Error(`Failed to sync app config (${response.status})`); + } catch (error) { + if (options?.throwOnError) throw error; // Daemon offline; localStorage keeps the user's copy for the next save. } } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c0fed1272..7cd9d3302 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -232,6 +232,14 @@ export interface NotificationsConfig { desktopEnabled: boolean; } +export interface OrbitConfig { + enabled: boolean; + /** Local 24-hour clock time in HH:mm format. */ + time: string; + /** Optional skill id from the examples gallery where scenario === "orbit". */ + templateSkillId?: string | null; +} + export interface PetConfig { // True once the user has explicitly picked a pet (built-in or custom). // Until then, the entry view shows an "adopt" callout to drive discovery. @@ -287,6 +295,9 @@ export interface AppConfig { // configs that pre-date the feature land at `undefined`, which the loader // normalizes to a safe default (everything off). notifications?: NotificationsConfig; + // Daily connector activity digest. When enabled, the daemon runs this once + // per day at the configured local time; defaults to 08:00. + orbit?: OrbitConfig; // IDs of skills/design-systems the user has explicitly disabled. disabledSkills?: string[]; disabledDesignSystems?: string[]; diff --git a/apps/web/tests/App.test.ts b/apps/web/tests/App.test.ts new file mode 100644 index 000000000..2df7eb25f --- /dev/null +++ b/apps/web/tests/App.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildPersistedConfig, + persistComposioConfigChange, + resolveSettingsCloseConfig, + shouldSyncMediaProvidersOnSave, +} from '../src/App'; +import type { AppConfig } from '../src/types'; + +const baseConfig: AppConfig = { + mode: 'api', + apiKey: 'sk-test', + apiProtocol: 'anthropic', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + apiProviderBaseUrl: 'https://api.anthropic.com', + agentId: null, + skillId: null, + designSystemId: null, +}; + +describe('persistComposioConfigChange', () => { + it('does not update local saved state when the daemon save fails', async () => { + await expect( + persistComposioConfigChange( + baseConfig, + { apiKey: 'cmp_new_key', apiKeyConfigured: false }, + vi.fn(async () => false), + ), + ).rejects.toThrow('Composio config save failed'); + }); + + it('normalizes the saved Composio key after a successful daemon save', async () => { + await expect( + persistComposioConfigChange( + baseConfig, + { apiKey: 'cmp_new_key', apiKeyConfigured: false }, + vi.fn(async () => true), + ), + ).resolves.toMatchObject({ + composio: { + apiKey: '', + apiKeyConfigured: true, + apiKeyTail: '_key', + }, + }); + }); +}); + +describe('shouldSyncMediaProvidersOnSave', () => { + it('keeps bootstrap-style empty media maps from syncing by default', () => { + expect(shouldSyncMediaProvidersOnSave({})).toBe(false); + }); + + it('syncs an explicit empty media map when the user save should force a clear', () => { + expect(shouldSyncMediaProvidersOnSave({}, { force: true })).toBe(true); + }); +}); + +describe('buildPersistedConfig', () => { + it('preserves onboarding completion when a stale autosave snapshot says false', () => { + expect( + buildPersistedConfig( + { ...baseConfig, onboardingCompleted: false }, + { ...baseConfig, onboardingCompleted: true }, + ), + ).toMatchObject({ onboardingCompleted: true }); + }); +}); + +describe('resolveSettingsCloseConfig', () => { + it('marks onboarding complete without discarding the latest persisted draft', () => { + expect( + resolveSettingsCloseConfig( + { + ...baseConfig, + onboardingCompleted: false, + orbit: { enabled: false, time: '09:00', templateSkillId: 'stale-template' }, + }, + { + ...baseConfig, + onboardingCompleted: false, + orbit: { enabled: true, time: '11:30', templateSkillId: 'fresh-template' }, + }, + ), + ).toMatchObject({ + onboardingCompleted: true, + orbit: { enabled: true, time: '11:30', templateSkillId: 'fresh-template' }, + }); + }); +}); diff --git a/apps/web/tests/components/App.connectors.test.tsx b/apps/web/tests/components/App.connectors.test.tsx index c0db5ac3d..9b555244c 100644 --- a/apps/web/tests/components/App.connectors.test.tsx +++ b/apps/web/tests/components/App.connectors.test.tsx @@ -54,11 +54,11 @@ vi.mock('../../src/components/SettingsDialog', () => ({ SettingsDialog: ({ initial, initialSection, - onSave, + onPersistComposioKey, }: { initial: AppConfig; initialSection?: string; - onSave: (next: AppConfig) => void; + onPersistComposioKey: (composio: AppConfig['composio']) => void; }) => (
Section: {initialSection}
@@ -66,13 +66,10 @@ vi.mock('../../src/components/SettingsDialog', () => ({