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>
This commit is contained in:
Marc Chan 2026-05-08 14:27:46 +08:00 committed by GitHub
parent aec9428b08
commit e14b8092ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 11584 additions and 1282 deletions

View file

@ -18,6 +18,12 @@ export interface AgentModelPrefs {
export type AgentCliEnvPrefs = Record<string, Record<string, string>>;
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<keyof AppConfigPrefs> = new Set([
@ -38,6 +45,7 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = 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<string, unknown>;
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<string, unknown>): AppConfigPrefs {

View file

@ -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'),

View file

@ -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<Record<string, Readonly<Record<string, ConnectorToolCuration>>>> = {
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.' },
},
};

View file

@ -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<string, Promise<string | undefined>>();
private readonly pendingConnections = new Map<string, ComposioPendingConnection>();
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<ConnectorCatalogDefinition[]> {
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<void> {
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<ConnectorCatalogDefinition | undefined> {
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<string, ConnectorCatalogDefinition>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).useCases && Array.isArray((record.curation as Record<string, unknown>).useCases))
? { useCases: ((record.curation as Record<string, unknown>).useCases as unknown[]).filter((item): item is 'personal_daily_digest' => item === 'personal_daily_digest') }
: {}),
...(typeof (record.curation as Record<string, unknown>).reason === 'string' ? { reason: (record.curation as Record<string, unknown>).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<string, unknown>;
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, ' ')

View file

@ -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<string, string> = {
zohobooks: 'zoho_books',
};
interface CachedComposioLogo {
body: Buffer;
contentType: string;
expiresAtMs: number;
}
const composioLogoCache = new Map<string, CachedComposioLogo>();
const composioLogoInflight = new Map<string, Promise<CachedComposioLogo | null>>();
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<Buffer | null> {
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<CachedComposioLogo | null> {
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<void> {
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);
})();
</script>
</body>
@ -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);
}

View file

@ -525,7 +525,7 @@ export class ConnectorService {
}
listFastDefinitions(): ConnectorCatalogDefinition[] {
return getStaticComposioCatalogDefinitions();
return composioConnectorProvider.getFastDefinitions();
}
async getDefinition(connectorId: string, signal?: AbortSignal): Promise<ConnectorCatalogDefinition | undefined> {
@ -554,7 +554,9 @@ export class ConnectorService {
async listConnectorDiscovery(options: { refresh?: boolean; signal?: AbortSignal } = {}): Promise<ConnectorDiscoveryResult> {
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<ConnectorExecuteResponse> {
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<BoundedJsonObject> {
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<BoundedJsonObject> {
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);
}

View file

@ -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<unknown> {
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', {

427
apps/daemon/src/orbit.ts Normal file
View file

@ -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<OrbitAgentRunResult>;
}
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<OrbitRunHandlerStart>;
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<OrbitTemplateSelection | null>;
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<OrbitConfigPrefs> | 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<OrbitActivitySummary | null> {
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<void> {
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<OrbitActivitySummary, 'markdown'>): 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<OrbitActivitySummary> | 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<OrbitConfigPrefs> | undefined): void {
this.config = normalizeOrbitConfig(config);
this.reschedule();
}
async status(): Promise<OrbitStatus> {
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();
}
}

View file

@ -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 <question-form>, 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);
});
});
}

View file

@ -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();
}

View file

@ -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 <id> --tool <name> --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<ToolCliResul
try {
if (options.command === 'list') {
const listPath = options.useCase ? `/api/tools/connectors/list?useCase=${encodeURIComponent(options.useCase)}` : '/api/tools/connectors/list';
return await printApiResult(
await requestJson(baseUrl, token, '/api/tools/connectors/list', { method: 'GET' }),
await requestJson(baseUrl, token, listPath, { method: 'GET' }),
options.format === 'compact' ? compactList : (body) => body,
);
}

View file

@ -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<Awaited<ReturnType<ConnectorService['listConnectors']>>> {
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<Awaited<ReturnType<ConnectorService['listConnectors']>>> {
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';

View file

@ -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<string, unknown>;
const updateProperties = updateTool.inputSchema.properties as Record<string, unknown>;
const connectorsListProperties = connectorsListTool.inputSchema.properties as Record<string, unknown>;
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 () => {

View file

@ -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', () => {

View file

@ -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<string> {
@ -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 });
}
});
});

View file

@ -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,

View file

@ -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('<svg xmlns="http://www.w3.org/2000/svg"></svg>', {
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('<p>Connector connected. You can close this window.</p>');
});
@ -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 = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></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('<html><body>oops</body></html>', {
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 () => {

View file

@ -36,17 +36,25 @@ function externalConnector(overrides: Partial<ConnectorCatalogDefinition> = {}):
}
class TestConnectorService extends ConnectorService {
public listDefinitionsCallCount = 0;
constructor(
private readonly definition: ConnectorCatalogDefinition,
statusService: ConnectorStatusService,
private readonly includeInFastDefinitions = false,
) {
super(statusService);
}
override async listDefinitions(): Promise<ConnectorCatalogDefinition[]> {
this.listDefinitionsCallCount += 1;
return [this.definition];
}
override listFastDefinitions(): ConnectorCatalogDefinition[] {
return this.includeInFastDefinitions ? [this.definition] : [];
}
override async getDefinition(connectorId: string): Promise<ConnectorCatalogDefinition | undefined> {
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: [{

View file

@ -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<OrbitRunHandler>[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<never>(() => {});
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 });
}
});
});

View file

@ -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 {

View file

@ -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`');

View file

@ -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'), '<main>example</main>');
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', {

View file

@ -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<typeof vi.fn>;
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('');
});
});

View file

@ -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/<id> 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() {

View file

@ -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<boolean> = syncComposioConfigToDaemon,
): Promise<AppConfig> {
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<AppConfig>(() => 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<SettingsSection>('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 <html> 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);

File diff suppressed because it is too large Load diff

View file

@ -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)}
>

View file

@ -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 (
<div
key={`live:${artifact.id}`}
@ -295,11 +305,11 @@ export function DesignsTab({
status={artifact.status}
refreshStatus={artifact.refreshStatus}
/>
<div className="design-card-name" title={artifact.title}>
{artifact.title}
<div className="design-card-name" title={title}>
{title}
</div>
<div className="design-card-meta">
<span className="ds">{p.name}</span>
<span className="ds">{metaLead}</span>
{" · "}
{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';
}

View file

@ -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<ConnectorDetail[]>([]);
const [connectorsLoading, setConnectorsLoading] = useState(false);
const [connectorDiscoveryLoading, setConnectorDiscoveryLoading] = useState(false);
const [connectorDiscoveryLoaded, setConnectorDiscoveryLoaded] = useState(false);
const [petRailHidden, setPetRailHiddenState] = useState<boolean>(() => loadPetRailHidden());
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
const avatarMenuRef = useRef<HTMLDivElement | null>(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}
/>
<div className="entry-side-foot">
@ -548,6 +500,7 @@ export function EntryView({
type="button"
className="foot-pill"
onClick={() => onOpenSettings()}
aria-label={t('settings.envConfigure')}
title={t('settings.envConfigure')}
>
<Icon name="settings" size={12} />
@ -597,7 +550,6 @@ export function EntryView({
label={t('entry.tabDesignSystems')}
onClick={setTopTab}
/>
<TopTabButton current={topTab} value="connectors" label={t('entry.tabConnectors')} onClick={setTopTab} />
<TopTabButton
current={topTab}
value="image-templates"
@ -638,22 +590,6 @@ export function EntryView({
onPreview={previewDesignSystem}
/>
) : null}
{topTab === 'connectors' ? (
<ConnectorsTab
connectors={connectors}
loading={connectorsLoading}
toolsLoading={connectorDiscoveryLoading}
toolsLoaded={connectorDiscoveryLoaded}
composioConfigured={Boolean(config.composio?.apiKeyConfigured)}
onOpenSettings={onOpenSettings}
onConnect={async (connectorId) => {
const result = await connectConnector(connectorId);
updateConnector(result.connector);
return result;
}}
onDisconnect={async (connectorId) => updateConnector(await disconnectConnector(connectorId))}
/>
) : null}
{topTab === 'image-templates' ? (
<PromptTemplatesTab
surface="image"
@ -698,593 +634,6 @@ export function EntryView({
);
}
function ConnectorsTab({
connectors,
loading,
toolsLoading,
toolsLoaded,
composioConfigured,
onOpenSettings,
onConnect,
onDisconnect,
}: {
connectors: ConnectorDetail[];
loading: boolean;
toolsLoading: boolean;
toolsLoaded: boolean;
composioConfigured: boolean;
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'language' | 'about') => void;
onConnect: (connectorId: string) => Promise<{ error?: string } | void> | { error?: string } | void;
onDisconnect: (connectorId: string) => Promise<void> | void;
}) {
const t = useT();
const [pendingConnectorAction, setPendingConnectorAction] = useState<{
connectorId: string;
action: 'connect' | 'disconnect';
} | null>(null);
const [detailConnectorId, setDetailConnectorId] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(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 (
<div className="tab-panel connectors-panel">
<div className="tab-panel-toolbar">
<div className="toolbar-left connectors-heading">
<div>
<h2>{t('connectors.title')}</h2>
<p>{t('connectors.subtitle')}</p>
</div>
</div>
<div className="toolbar-right">
<div className="toolbar-search connectors-search">
<span className="search-icon" aria-hidden>
<Icon name="search" size={13} />
</span>
<input
ref={searchInputRef}
type="search"
value={filter}
onChange={(event) => 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 ? (
<button
type="button"
className="toolbar-search-clear"
aria-label={t('connectors.searchClear')}
onClick={() => {
setFilter('');
searchInputRef.current?.focus();
}}
data-testid="connectors-search-clear"
>
<Icon name="close" size={12} />
</button>
) : null}
</div>
</div>
</div>
{actionError ? (
<p className="connector-inline-error" role="alert" data-testid="connectors-action-error">
{actionError}
</p>
) : null}
{loading ? (
<CenteredLoader label={t('common.loading')} />
) : (
<div
className={`connector-grid-wrap${needsComposioKey ? ' is-masked' : ''}`}
data-testid="connector-grid-wrap"
>
{hasNoResults && !needsComposioKey ? (
<div
className="tab-empty connectors-empty"
role="status"
aria-live="polite"
data-testid="connectors-empty"
>
<p className="connectors-empty-title">
{t('connectors.emptyNoMatchTitle', { query: filter.trim() })}
</p>
<p className="connectors-empty-body">{t('connectors.emptyNoMatchBody')}</p>
<button
type="button"
className="ghost connectors-empty-action"
onClick={() => {
setFilter('');
searchInputRef.current?.focus();
}}
>
{t('connectors.emptyNoMatchAction')}
</button>
</div>
) : (
<div
className="connector-grid"
aria-hidden={needsComposioKey || undefined}
>
{filteredConnectors.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
disabled={needsComposioKey}
pendingAction={
pendingConnectorAction?.connectorId === connector.id
? pendingConnectorAction.action
: null
}
toolsLoading={toolsLoading}
toolsLoaded={toolsLoaded}
onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')}
onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')}
onOpenDetails={(connectorId) => setDetailConnectorId(connectorId)}
/>
))}
</div>
)}
{needsComposioKey ? (
<div
className="connector-gate"
role="region"
aria-label={t('connectors.gateTitle')}
data-testid="connector-gate"
>
<div className="connector-gate-card">
<div className="connector-gate-icon" aria-hidden>
<Icon name="settings" size={20} />
</div>
<h3 className="connector-gate-title">{t('connectors.gateTitle')}</h3>
<p className="connector-gate-body">{t('connectors.gateBody')}</p>
<button
type="button"
className="primary connector-gate-action"
onClick={() => onOpenSettings('composio')}
data-testid="connector-gate-action"
>
{t('connectors.gateAction')}
</button>
</div>
</div>
) : null}
</div>
)}
{detailConnector ? (
<ConnectorDetailDrawer
connector={detailConnector}
disabled={needsComposioKey}
pendingAction={
pendingConnectorAction?.connectorId === detailConnector.id
? pendingConnectorAction.action
: null
}
toolsLoading={toolsLoading}
toolsLoaded={toolsLoaded}
onClose={() => setDetailConnectorId(null)}
onConnect={(connectorId) => runConnectorAction(connectorId, 'connect')}
onDisconnect={(connectorId) => runConnectorAction(connectorId, 'disconnect')}
/>
) : null}
</div>
);
}
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> | void;
onDisconnect: (connectorId: string) => Promise<void> | 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<HTMLElement>) {
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 (
<article
className={`connector-card status-${connector.status}${disabled ? ' is-locked' : ''}`}
data-connector-id={connector.id}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
aria-label={t('connectors.openDetailsAria', { name: connector.name })}
onClick={openDetails}
onKeyDown={onKeyActivate}
>
<div className="connector-card-top">
<div className="connector-card-head">
<h3 className="connector-card-title">{connector.name}</h3>
<div className="connector-meta">
<span className="connector-meta-item">{connector.category}</span>
<span className="connector-meta-dot" aria-hidden>·</span>
{showToolsBadge ? (
<span className="connector-tools-badge is-ready" title={toolsBadgeLabel}>
<Icon name="settings" size={10} />
<span>{toolsBadgeLabel}</span>
</span>
) : null}
</div>
</div>
{isConnected ? (
<span
className={`connector-status status-${connector.status}`}
aria-label={statusLabel(connector.status, t)}
>
<span className="connector-status-dot" aria-hidden />
{statusLabel(connector.status, t)}
</span>
) : connector.status === 'error' || connector.status === 'disabled' ? (
<span className={`connector-status status-${connector.status}`}>
{statusLabel(connector.status, t)}
</span>
) : null}
</div>
{connector.description ? (
<p className="connector-description">{connector.description}</p>
) : null}
<div className="connector-actions">
{isConnected ? (
<button
type="button"
className={`ghost connector-action is-disconnect${isDisconnecting ? ' is-loading' : ''}`}
disabled={!canDisconnect}
aria-busy={isDisconnecting || undefined}
tabIndex={disabled ? -1 : undefined}
onMouseDown={stop}
onKeyDown={stop}
onClick={(e) => {
stop(e);
onDisconnect(connector.id);
}}
>
{isDisconnecting ? <Icon name="spinner" size={12} /> : null}
<span>{t('connectors.disconnect')}</span>
</button>
) : (
<button
type="button"
className={`primary connector-action is-connect${isConnecting ? ' is-loading' : ''}`}
disabled={!canConnect}
aria-busy={isConnecting || undefined}
tabIndex={disabled ? -1 : undefined}
onMouseDown={stop}
onKeyDown={stop}
onClick={(e) => {
stop(e);
onConnect(connector.id);
}}
>
{isConnecting ? <Icon name="spinner" size={12} /> : null}
<span>{t('connectors.connect')}</span>
</button>
)}
</div>
</article>
);
}
function statusLabel(status: ConnectorDetail['status'], t: ReturnType<typeof useT>): 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<typeof useT>): 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> | void;
onDisconnect: (connectorId: string) => Promise<void> | 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<HTMLButtonElement | null>(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 (
<div
className="connector-drawer-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<aside
className="connector-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="connector-drawer-title"
data-testid="connector-drawer"
onClick={(e) => e.stopPropagation()}
>
<header className="connector-drawer-head">
<div className="connector-drawer-titles">
<div className="connector-drawer-eyebrow">
<span>{connector.category}</span>
<span className="connector-meta-dot" aria-hidden>·</span>
<span>{connector.provider}</span>
</div>
<h2 id="connector-drawer-title">{connector.name}</h2>
<div className="connector-drawer-status">
<span className={`connector-status-pill status-${statusTone}`}>
<span className="connector-status-dot" aria-hidden />
{statusLabel(connector.status, t)}
</span>
{showToolsBadge ? (
<span className="connector-tools-badge is-ready" title={formatToolsBadge(toolCount, t)}>
<Icon name="settings" size={10} />
<span>{formatToolsBadge(toolCount, t)}</span>
</span>
) : null}
</div>
</div>
<button
ref={closeBtnRef}
type="button"
className="ghost connector-drawer-close"
onClick={onClose}
aria-label={t('common.close')}
data-testid="connector-drawer-close"
>
<Icon name="close" size={14} />
</button>
</header>
<div className="connector-drawer-body">
{connector.description ? (
<section className="connector-drawer-section">
<h3 className="connector-drawer-section-title">{t('connectors.aboutLabel')}</h3>
<p className="connector-drawer-description">{connector.description}</p>
</section>
) : null}
<section className="connector-drawer-section">
<h3 className="connector-drawer-section-title">{t('connectors.detailsLabel')}</h3>
<dl className="connector-drawer-details">
<div>
<dt>{t('connectors.statusLabel')}</dt>
<dd>{statusLabel(connector.status, t)}</dd>
</div>
<div>
<dt>{t('connectors.categoryLabel')}</dt>
<dd>{connector.category}</dd>
</div>
<div>
<dt>{t('connectors.providerLabel')}</dt>
<dd>{connector.provider}</dd>
</div>
{accountLabel ? (
<div>
<dt>{t('connectors.account')}</dt>
<dd>{accountLabel}</dd>
</div>
) : null}
{connector.lastError ? (
<div className="connector-drawer-details-error">
<dt>{t('connectors.statusError')}</dt>
<dd>{connector.lastError}</dd>
</div>
) : null}
</dl>
</section>
<section className="connector-drawer-section">
<h3 className="connector-drawer-section-title">
{t('connectors.toolsSection')} <span className="connector-drawer-count">{toolCount}</span>
</h3>
{isLoadingTools ? (
<p className="connector-drawer-empty"><Icon name="spinner" size={12} /> {t('connectors.toolsLoading')}</p>
) : toolCount === 0 ? (
<p className="connector-drawer-empty">{t('connectors.noToolsAvailable')}</p>
) : (
<ul className="connector-drawer-tools">
{connector.tools.map((tool) => (
<li key={tool.name} className="connector-drawer-tool">
<div className="connector-drawer-tool-head">
<span className="connector-drawer-tool-title">{tool.title || tool.name}</span>
<span
className={`connector-drawer-tool-badge side-${tool.safety.sideEffect}`}
title={tool.safety.reason}
>
{tool.safety.sideEffect}
</span>
</div>
{tool.description ? (
<p className="connector-drawer-tool-desc">{tool.description}</p>
) : null}
<code className="connector-drawer-tool-name">{tool.name}</code>
</li>
))}
</ul>
)}
</section>
</div>
<footer className="connector-drawer-foot">
{isConnected ? (
<button
type="button"
className={`ghost connector-action is-disconnect${isDisconnecting ? ' is-loading' : ''}`}
disabled={!canDisconnect}
aria-busy={isDisconnecting || undefined}
onClick={() => onDisconnect(connector.id)}
>
{isDisconnecting ? <Icon name="spinner" size={12} /> : null}
<span>{t('connectors.disconnect')}</span>
</button>
) : (
<button
type="button"
className={`primary connector-action is-connect${isConnecting ? ' is-loading' : ''}`}
disabled={!canConnect}
aria-busy={isConnecting || undefined}
onClick={() => onConnect(connector.id)}
>
{isConnecting ? <Icon name="spinner" size={12} /> : null}
<span>{t('connectors.connect')}</span>
</button>
)}
</footer>
</aside>
</div>
);
}
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,

View file

@ -377,6 +377,7 @@ export function LiveArtifactViewer({
onRefreshArtifacts?: () => Promise<void> | void;
}) {
const t = useT();
const tabs = useMemo(() => liveArtifactViewerTabs(t), [t]);
const [mode, setMode] = useState<LiveArtifactViewerTab>('preview');
const [detail, setDetail] = useState<LiveArtifact | null>(null);
const [loading, setLoading] = useState(true);
@ -559,7 +560,7 @@ export function LiveArtifactViewer({
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
{LIVE_ARTIFACT_VIEWER_TABS.map((tab) => (
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@ -676,7 +677,7 @@ export function LiveArtifactViewer({
<iframe
data-testid="live-artifact-preview-frame"
title={liveArtifact.title}
sandbox="allow-scripts"
sandbox="allow-scripts allow-popups"
src={previewUrl}
/>
</div>
@ -689,7 +690,7 @@ export function LiveArtifactViewer({
reloadKey={reloadKey}
/>
) : mode === 'data' ? (
<JsonPanel value={dataPayload} emptyLabel="No data.json cache available." />
<JsonPanel value={dataPayload} emptyLabel={t('liveArtifact.viewer.dataEmpty')} />
) : (
<LiveArtifactRefreshHistoryPanel
liveArtifact={detail}
@ -748,16 +749,27 @@ function refreshErrorMessage(error: unknown, t: TranslateFn): string {
return t('liveArtifact.refresh.genericFailure');
}
const LIVE_ARTIFACT_VIEWER_TABS: Array<{ id: LiveArtifactViewerTab; label: string }> = [
{ 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<LiveArtifactCodeVariant>('template');
const [code, setCode] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@ -783,38 +795,45 @@ function LiveArtifactCodePanel({ projectId, artifactId, reloadKey }: { projectId
<div className="live-artifact-code-panel">
<div className="live-artifact-code-header">
<div className="live-artifact-code-copy">
<strong>{variant === 'template' ? 'Template HTML' : 'Rendered HTML'}</strong>
<strong>
{variant === 'template'
? t('liveArtifact.viewer.code.templateHeading')
: t('liveArtifact.viewer.code.renderedHeading')}
</strong>
<span>
{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')}
</span>
</div>
<div className="viewer-tabs live-artifact-code-tabs" aria-label="Code variant">
<div
className="viewer-tabs live-artifact-code-tabs"
aria-label={t('liveArtifact.viewer.code.variantAria')}
>
<button
type="button"
className={`viewer-tab ${variant === 'template' ? 'active' : ''}`}
onClick={() => setVariant('template')}
>
Template
{t('liveArtifact.viewer.code.variantTemplate')}
</button>
<button
type="button"
className={`viewer-tab ${variant === 'rendered-source' ? 'active' : ''}`}
onClick={() => setVariant('rendered-source')}
>
Rendered
{t('liveArtifact.viewer.code.variantRendered')}
</button>
</div>
</div>
{loading ? (
<div className="viewer-empty">Loading code</div>
<div className="viewer-empty">{t('liveArtifact.viewer.code.loading')}</div>
) : failed ? (
<div className="viewer-empty">Code is not available yet.</div>
<div className="viewer-empty">{t('liveArtifact.viewer.code.unavailable')}</div>
) : code && code.trim().length > 0 ? (
<pre className="viewer-source">{code}</pre>
) : (
<div className="viewer-empty">This code file is empty.</div>
<div className="viewer-empty">{t('liveArtifact.viewer.code.empty')}</div>
)}
</div>
);

View file

@ -631,7 +631,7 @@ function Tab({
const iconName = kindIconName(kind);
return (
<div
className={`ws-tab ${active ? 'active' : ''}`}
className={`ws-tab${kind === 'live-artifact' ? ' live-artifact-tab' : ''} ${active ? 'active' : ''}`}
onClick={onActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {

View file

@ -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) {
<path d="M5 12h14" />
</svg>
);
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 (
<svg {...common}>
<ellipse
cx="12"
cy="12"
rx="9"
ry="3.5"
transform="rotate(-25 12 12)"
/>
<circle cx="12" cy="12" r="2.25" fill="currentColor" stroke="none" />
<circle cx="16" cy="6.8" r="1.5" fill="currentColor" stroke="none" />
</svg>
);
case 'pencil':
return (
<svg {...common}>

View file

@ -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,

View file

@ -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();

View file

@ -21,6 +21,7 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
const initial = useMemo(() => buildInitialState(form, submittedAnswers), [form, submittedAnswers]);
const [answers, setAnswers] = useState<Record<string, string | string[]>>(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
</div>
<div className="question-form-body">
{form.questions.map((q) => {
const value = answers[q.id];
const value = currentAnswers[q.id];
return (
<div key={q.id} className="qf-field">
<label className="qf-label">

File diff suppressed because it is too large Load diff

View file

@ -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': 'ملف الكود هذا فارغ.',
};

View file

@ -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.',
};

View file

@ -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.',

View file

@ -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.',
};

View file

@ -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': 'این فایل کد خالی است.',
};

View file

@ -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 doutils 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 denregistrer 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 denvironnement.',
'settings.connectorsLoadingSavedKey': 'Recherche dune clé enregistrée dans le daemon local…',
'settings.autosaveSaving': "Enregistrement…",
'settings.autosaveSaved': "Toutes les modifications enregistrées",
'settings.autosaveError': "Impossible denregistrer 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 lactivité 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': 'Sexécute une fois par jour à lheure locale planifiée.',
'settings.orbit.on': 'Activé',
'settings.orbit.off': 'Désactivé',
'settings.orbit.runTimeTitle': 'Heure dexécution',
'settings.orbit.runTimeSub': 'Par défaut 08:00. Enregistrez pour appliquer au planning du daemon.',
'settings.orbit.runTimeAria': 'Heure dexé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} nest 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 dexemple 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 dexé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 lactivité de vos connecteurs. Vous navez encore rien connecté — ajoutez au moins une intégration pour quOrbit ait de quoi rapporter.",
'settings.orbit.gateBodyNoKey': "Orbit résume lactivité de vos connecteurs, et les connecteurs passent par Composio. Ajoutez une clé dAPI 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 lactivité Orbit',
'settings.orbit.artifactMetaLive': 'Artefact HTML actualisable généré à partir de lactivité des connecteurs.',
'settings.orbit.artifactMetaLegacy': 'Généré avant lactivation 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 lartefact',
'settings.orbit.sourceMarkdown': 'Markdown source',
'liveArtifact.viewer.tabPreview': 'Aperçu',
'liveArtifact.viewer.tabCode': 'Code',
'liveArtifact.viewer.tabData': 'Données',
'liveArtifact.viewer.tabRefreshHistory': 'Historique dactualisation',
'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 laperçu.',
'liveArtifact.viewer.code.renderedHelp': 'Le index.html généré actuellement chargé par laperç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 nest pas encore disponible.',
'liveArtifact.viewer.code.empty': 'Ce fichier de code est vide.',
};

View file

@ -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.',
};

View file

@ -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...',

View file

@ -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': 'このコードファイルは空です。',
};

View file

@ -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': '이 코드 파일은 비어 있습니다.',
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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': 'Этот файл кода пуст.',
};

View file

@ -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ı kaydete 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 daemona 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 daemona 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 daemonda 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 daemona kaydetmek ve aşağıdaki kataloğu açmak için Anahtarı kaydete tıklayın.",
'settings.connectorsHelpEmpty': 'Aşağıdaki kataloğu açmak için bir anahtar ekleyin. Anahtarlar daemonda yerel olarak saklanır ve ortam değişkenleriyle gönderilmez.',
'settings.connectorsLoadingSavedKey': 'Yerel daemonda 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': 'Orbiti 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': "Orbiti kullanmak için bağlayıcılar gerekiyor",
'settings.orbit.gateEyebrow': "Kurulum gerekli",
'settings.orbit.gateTitle': "Orbiti ç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 — Orbitin 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': "Composioyu 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 Orbiti 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ş.',
};

View file

@ -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': 'Цей файл коду порожній.',
};

View file

@ -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': '此代码文件为空。',
};

View file

@ -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': '此程式碼檔案是空的。',
};

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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<OrbitConfig> | 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<AppConfig>;
@ -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<string, MediaProviderCredentials> | undefined,
options?: { force?: boolean },
options?: { force?: boolean; throwOnError?: boolean },
): Promise<void> {
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<AppConfigPrefs | null> {
}
}
export async function syncConfigToDaemon(config: AppConfig): Promise<void> {
export async function syncConfigToDaemon(
config: AppConfig,
options?: { throwOnError?: boolean },
): Promise<void> {
const prefs: AppConfigPrefs = {
onboardingCompleted: config.onboardingCompleted,
agentId: config.agentId,
@ -396,14 +434,17 @@ export async function syncConfigToDaemon(config: AppConfig): Promise<void> {
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.
}
}

View file

@ -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[];

View file

@ -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' },
});
});
});

View file

@ -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;
}) => (
<div role="dialog" aria-label="Settings dialog">
<div>Section: {initialSection}</div>
@ -66,13 +66,10 @@ vi.mock('../../src/components/SettingsDialog', () => ({
<button
type="button"
onClick={() =>
onSave({
...initial,
composio: {
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
apiKeyTail: initial.composio?.apiKeyTail ?? '',
},
onPersistComposioKey({
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
apiKeyTail: initial.composio?.apiKeyTail ?? '',
})
}
>
@ -81,13 +78,10 @@ vi.mock('../../src/components/SettingsDialog', () => ({
<button
type="button"
onClick={() =>
onSave({
...initial,
composio: {
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
},
onPersistComposioKey({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
})
}
>

View file

@ -57,12 +57,12 @@ vi.mock('../../src/components/SettingsDialog', () => ({
SettingsDialog: ({
initial,
initialSection,
onSave,
onPersist,
onClose,
}: {
initial: AppConfig;
initialSection?: string;
onSave: (next: AppConfig) => void;
onPersist: (next: AppConfig) => void;
onClose: () => void;
}) => (
<div role="dialog" aria-label="Settings dialog">
@ -70,7 +70,7 @@ vi.mock('../../src/components/SettingsDialog', () => ({
<button
type="button"
onClick={() =>
onSave({
onPersist({
...initial,
mediaProviders: {
openai: {
@ -236,13 +236,13 @@ describe('App media provider sync flows', () => {
model: '',
},
},
{ force: true },
{ force: undefined, throwOnError: undefined },
);
});
expect(mockedSaveConfig).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
onboardingCompleted: false,
mediaProviders: {
openai: {
apiKey: 'media-key',
@ -254,7 +254,7 @@ describe('App media provider sync flows', () => {
);
expect(mockedSyncConfigToDaemon).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
onboardingCompleted: false,
mediaProviders: {
openai: {
apiKey: 'media-key',

View file

@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConnectorDetail } from '@open-design/contracts';
import { ConnectorsBrowser } from '../../src/components/ConnectorsBrowser';
import {
fetchConnectorDiscovery,
fetchConnectors,
fetchConnectorStatuses,
} from '../../src/providers/registry';
vi.mock('../../src/providers/registry', () => ({
connectConnector: vi.fn(),
disconnectConnector: vi.fn(),
fetchConnectorDiscovery: vi.fn(),
fetchConnectors: vi.fn(),
fetchConnectorStatuses: vi.fn(),
}));
const configuredComposioConnector: ConnectorDetail = {
id: 'github',
name: 'GitHub',
provider: 'Composio',
category: 'Code',
status: 'connected',
auth: { provider: 'composio', configured: true },
tools: [],
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('ConnectorsBrowser', () => {
afterEach(() => {
cleanup();
vi.mocked(fetchConnectors).mockReset();
vi.mocked(fetchConnectorDiscovery).mockReset();
vi.mocked(fetchConnectorStatuses).mockReset();
});
it('masks the grid immediately when the Composio key is cleared locally', async () => {
vi.mocked(fetchConnectors).mockResolvedValue([configuredComposioConnector]);
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([configuredComposioConnector]);
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
render(<ConnectorsBrowser composioConfigured={false} />);
await waitFor(() => expect(screen.getByTestId('connector-gate')).toBeTruthy());
expect(screen.getByTestId('connector-grid-wrap').className).toContain('is-masked');
});
it('keeps discovered tools when discovery resolves before the base catalog', async () => {
const base = deferred<ConnectorDetail[]>();
const discovery = deferred<ConnectorDetail[]>();
vi.mocked(fetchConnectors).mockReturnValue(base.promise);
vi.mocked(fetchConnectorDiscovery).mockReturnValue(discovery.promise);
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
render(<ConnectorsBrowser composioConfigured />);
discovery.resolve([
{
...configuredComposioConnector,
tools: [
{
name: 'list_issues',
title: 'List issues',
safety: { sideEffect: 'read', approval: 'auto', reason: 'Reads issues.' },
refreshEligible: true,
},
],
},
]);
base.resolve([configuredComposioConnector]);
await screen.findByText('GitHub');
await screen.findAllByText('1 tool');
fireEvent.click(screen.getByRole('button', { name: 'Open GitHub details' }));
await screen.findByText('List issues');
await waitFor(() => expect(screen.getByText('List issues')).toBeTruthy());
expect(screen.getAllByText('1 tool')).toHaveLength(2);
});
it('stops showing the drawer loading state after discovery completes with zero tools', async () => {
vi.mocked(fetchConnectors).mockResolvedValue([configuredComposioConnector]);
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([configuredComposioConnector]);
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
render(<ConnectorsBrowser composioConfigured />);
await screen.findByText('GitHub');
fireEvent.click(screen.getByRole('button', { name: 'Open GitHub details' }));
await waitFor(() => {
expect(
screen.getByText(
'No tools available yet. Connect to discover what this integration exposes.',
),
).toBeTruthy();
expect(screen.queryByText('Loading tools…')).toBeNull();
});
});
it('prefers refreshed catalog statuses over stale cached connector state', async () => {
vi.mocked(fetchConnectors)
.mockResolvedValueOnce([configuredComposioConnector])
.mockResolvedValueOnce([
{
...configuredComposioConnector,
status: 'available',
auth: { provider: 'composio', configured: false },
},
]);
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([]);
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
const { rerender } = render(
<ConnectorsBrowser composioConfigured catalogRefreshKey="initial" />,
);
await screen.findByRole('button', { name: 'Disconnect' });
rerender(<ConnectorsBrowser composioConfigured catalogRefreshKey="refetched" />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Connect' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Disconnect' })).toBeNull();
});
});
});

View file

@ -74,7 +74,6 @@ beforeEach(() => {
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
Element.prototype.scrollIntoView = vi.fn();
});
describe('NewProjectPanel design system defaults', () => {
it('uses the configured default design system when it exists in the catalog', () => {
expect(defaultDesignSystemSelection('clay', designSystems)).toEqual(['clay']);
@ -109,7 +108,6 @@ describe('NewProjectPanel design system defaults', () => {
inspirations: [],
});
});
it('preserves prototype fidelity across tab switches and saves it into the create payload', () => {
const onCreate = vi.fn();
render(

View file

@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { QuestionFormView } from '../../src/components/QuestionForm';
import type { QuestionForm } from '../../src/artifacts/question-form';
const form: QuestionForm = {
id: 'discovery',
title: 'Quick brief',
questions: [
{
id: 'tone',
label: 'Visual tone (pick up to two)',
type: 'checkbox',
options: ['Editorial / magazine', 'Modern minimal', 'Soft gradients'],
maxSelections: 2,
required: true,
},
],
};
describe('QuestionFormView', () => {
afterEach(() => cleanup());
it('updates locked answers when submitted history arrives after the initial render', () => {
const onSubmit = vi.fn();
const { container, rerender } = render(
<QuestionFormView form={form} interactive submittedAnswers={undefined} onSubmit={onSubmit} />,
);
expect(container.querySelectorAll('input[type="checkbox"]:checked')).toHaveLength(0);
rerender(
<QuestionFormView
form={form}
interactive={false}
submittedAnswers={{ tone: ['Editorial / magazine', 'Modern minimal'] }}
onSubmit={onSubmit}
/>,
);
expect(screen.getByText('answered')).toBeTruthy();
expect(container.querySelectorAll('input[type="checkbox"]:checked')).toHaveLength(2);
});
});

View file

@ -182,7 +182,8 @@ function renderSettingsDialog(
appVersionInfo?: AppVersionInfo | null;
} = {},
) {
const onSave = vi.fn();
const onPersist = vi.fn();
const onPersistComposioKey = vi.fn();
const onClose = vi.fn();
const onRefreshAgents = options.onRefreshAgents ?? vi.fn();
@ -193,17 +194,18 @@ function renderSettingsDialog(
daemonLive={options.daemonLive ?? true}
appVersionInfo={options.appVersionInfo ?? null}
initialSection={options.initialSection ?? 'execution'}
onSave={onSave}
onPersist={onPersist}
onPersistComposioKey={onPersistComposioKey}
onClose={onClose}
onRefreshAgents={onRefreshAgents}
/>,
);
return { onSave, onClose, onRefreshAgents, ...view };
return { onPersist, onPersistComposioKey, onClose, onRefreshAgents, ...view };
}
function renderLanguageSettingsDialog(initialLocale: Parameters<typeof I18nProvider>[0]['initial'] = 'en') {
const onSave = vi.fn();
const onPersist = vi.fn();
const onClose = vi.fn();
render(
@ -214,14 +216,28 @@ function renderLanguageSettingsDialog(initialLocale: Parameters<typeof I18nProvi
daemonLive={true}
appVersionInfo={null}
initialSection="language"
onSave={onSave}
onPersist={onPersist}
onPersistComposioKey={vi.fn()}
onClose={onClose}
onRefreshAgents={vi.fn()}
/>
</I18nProvider>,
);
return { onSave, onClose };
return { onPersist, onClose };
}
async function waitForPersist(
onPersist: ReturnType<typeof vi.fn>,
expectedConfig: unknown,
expectedOptions: { forceMediaProviderSync?: boolean } = { forceMediaProviderSync: false },
) {
await waitFor(() => {
expect(onPersist).toHaveBeenCalledWith(
expectedConfig,
expect.objectContaining(expectedOptions),
);
});
}
function deferred<T>() {
@ -346,22 +362,18 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('openai-key');
});
it('enables Save only when BYOK required fields are valid and saves the edited config', () => {
const { onSave } = renderSettingsDialog();
it('autosaves BYOK edits once required fields are valid', async () => {
const { onPersist } = renderSettingsDialog();
const saveButton = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
const baseUrlInput = screen.getByLabelText('Base URL') as HTMLInputElement;
expect(saveButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'sk-test' },
});
expect(saveButton.disabled).toBe(false);
fireEvent.change(baseUrlInput, {
target: { value: 'http://10.0.0.5:11434/v1' },
});
expect(saveButton.disabled).toBe(true);
expect(screen.getByRole('alert').textContent).toContain(
'Enter a valid public http:// or https:// URL.',
);
@ -369,11 +381,9 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
fireEvent.change(baseUrlInput, {
target: { value: 'http://localhost:11434/v1' },
});
expect(saveButton.disabled).toBe(false);
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mode: 'api',
apiProtocol: 'anthropic',
@ -382,18 +392,17 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: null,
}),
true,
{},
);
});
it('does not save BYOK edits when cancel is used or the backdrop is clicked', () => {
it('closes BYOK via the close button or backdrop', () => {
const first = renderSettingsDialog();
fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'sk-unsaved' },
});
fireEvent.click(screen.getByRole('button', { name: /Cancel|Abbrechen/i }));
expect(first.onSave).not.toHaveBeenCalled();
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
expect(first.onClose).toHaveBeenCalledTimes(1);
cleanup();
@ -403,12 +412,11 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
target: { value: 'sk-unsaved-2' },
});
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
expect(second.onSave).not.toHaveBeenCalled();
expect(second.onClose).toHaveBeenCalledTimes(1);
});
it('shows Azure-specific fields and saves an Azure config', () => {
const { onSave } = renderSettingsDialog();
it('shows Azure-specific fields and autosaves an Azure config', async () => {
const { onPersist } = renderSettingsDialog();
fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' }));
@ -432,8 +440,8 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
target: { value: '2024-10-21' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mode: 'api',
apiProtocol: 'azure',
@ -443,12 +451,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
apiVersion: '2024-10-21',
apiProviderBaseUrl: null,
}),
true,
{},
);
});
it('supports custom model entry in BYOK mode', () => {
const { onSave } = renderSettingsDialog({ apiProtocol: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiProviderBaseUrl: 'https://api.openai.com/v1' });
it('supports custom model entry in BYOK mode', async () => {
const { onPersist } = renderSettingsDialog({ apiProtocol: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiProviderBaseUrl: 'https://api.openai.com/v1' });
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
fireEvent.change(screen.getByLabelText('API key'), {
@ -464,15 +472,15 @@ describe('SettingsDialog execution settings BYOK interactions', () => {
target: { value: 'gpt-4.1-custom' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
apiProtocol: 'openai',
apiKey: 'sk-openai',
model: 'gpt-4.1-custom',
baseUrl: 'https://api.openai.com/v1',
}),
true,
{},
);
});
});
@ -482,7 +490,7 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
cleanup();
});
it('lets users switch to Local CLI, select an installed agent, and save', () => {
it('lets users switch to Local CLI, select an installed agent, and autosave', async () => {
const installed = availableAgents[0]!;
const unavailable: AgentInfo = {
id: 'gemini',
@ -492,7 +500,7 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
version: null,
models: [],
};
const { onSave } = renderSettingsDialog(
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: null },
{ agents: [installed, unavailable] },
);
@ -500,23 +508,18 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
const localCliTab = screen.getByRole('tab', { name: /Local CLI.*1 installed/i });
fireEvent.click(localCliTab);
const saveButton = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
const codexCard = screen.getByRole('button', { name: /Codex CLI/i }) as HTMLButtonElement;
const geminiCard = screen.getByRole('button', { name: /Gemini CLI/i }) as HTMLButtonElement;
expect(geminiCard.disabled).toBe(true);
fireEvent.click(codexCard);
expect(saveButton.disabled).toBe(false);
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mode: 'daemon',
agentId: 'codex',
}),
true,
{},
);
});
@ -528,7 +531,6 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*0 installed/i }));
expect(screen.getByText(/No agents detected yet/i)).toBeTruthy();
expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(true);
});
it('shows rescan loading, avoids duplicate rescans, and renders the success notice', async () => {
@ -592,8 +594,8 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
});
});
it('saves CLI config locations from the execution form', () => {
const { onSave } = renderSettingsDialog(
it('autosaves CLI config locations from the execution form', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ agents: availableAgents },
);
@ -607,8 +609,8 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
target: { value: ' ~/.codex-team ' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mode: 'daemon',
agentId: 'codex',
@ -617,7 +619,7 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
codex: { CODEX_HOME: '~/.codex-team' },
},
}),
true,
{},
);
});
@ -672,8 +674,8 @@ describe('SettingsDialog media providers interactions', () => {
expect(bflBaseUrl.disabled).toBe(true);
});
it('clears an existing provider config and removes it from the saved payload', () => {
const { onSave } = renderSettingsDialog(
it('clears an existing provider config and removes it from the persisted payload', async () => {
const { onPersist } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
@ -690,17 +692,17 @@ describe('SettingsDialog media providers interactions', () => {
expect((screen.getByLabelText('OpenAI API key') as HTMLInputElement).value).toBe('');
expect((screen.getByLabelText('OpenAI Base URL') as HTMLInputElement).value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mediaProviders: {},
}),
true,
{ forceMediaProviderSync: true },
);
});
it('supports saving provider API key and base URL edits', () => {
const { onSave } = renderSettingsDialog(
it('supports persisting provider API key and base URL edits', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'media' },
);
@ -712,8 +714,8 @@ describe('SettingsDialog media providers interactions', () => {
target: { value: 'https://fish.example.com' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mediaProviders: expect.objectContaining({
fishaudio: {
@ -723,7 +725,7 @@ describe('SettingsDialog media providers interactions', () => {
},
}),
}),
true,
{ forceMediaProviderSync: true },
);
});
@ -755,8 +757,8 @@ describe('SettingsDialog media providers interactions', () => {
expect(apiKeyInput.type).toBe('text');
});
it('supports providers with a custom model override field', () => {
const { onSave } = renderSettingsDialog(
it('supports providers with a custom model override field', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'media' },
);
@ -771,8 +773,8 @@ describe('SettingsDialog media providers interactions', () => {
target: { value: 'gemini-3.1-flash-image-preview' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
mediaProviders: expect.objectContaining({
nanobanana: {
@ -782,11 +784,50 @@ describe('SettingsDialog media providers interactions', () => {
},
}),
}),
true,
{ forceMediaProviderSync: true },
);
});
it('does not save media provider edits when cancel is used or the backdrop is clicked', () => {
it('catches unmount flush failures for pending media-provider autosaves', async () => {
const rejection = new Error('daemon unavailable');
const handleUnhandledRejection = vi.fn((event: PromiseRejectionEvent) => {
event.preventDefault();
});
window.addEventListener('unhandledrejection', handleUnhandledRejection);
try {
const { onPersist, unmount } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'media' },
);
onPersist.mockRejectedValueOnce(rejection);
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
target: { value: 'sk-unmount-media' },
});
await waitFor(() => {
expect(screen.getByText('Saving…')).toBeTruthy();
});
unmount();
await Promise.resolve();
await Promise.resolve();
expect(onPersist).toHaveBeenCalledWith(
expect.objectContaining({
mediaProviders: expect.objectContaining({
openai: expect.objectContaining({ apiKey: 'sk-unmount-media' }),
}),
}),
expect.objectContaining({ forceMediaProviderSync: true }),
);
expect(handleUnhandledRejection).not.toHaveBeenCalled();
} finally {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
}
});
it('closes media settings via the close button or backdrop', () => {
const first = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'media' },
@ -795,8 +836,7 @@ describe('SettingsDialog media providers interactions', () => {
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
target: { value: 'sk-unsaved-media' },
});
fireEvent.click(screen.getByRole('button', { name: /Cancel|Abbrechen/i }));
expect(first.onSave).not.toHaveBeenCalled();
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
expect(first.onClose).toHaveBeenCalledTimes(1);
cleanup();
@ -809,7 +849,6 @@ describe('SettingsDialog media providers interactions', () => {
target: { value: 'sk-unsaved-media-2' },
});
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
expect(second.onSave).not.toHaveBeenCalled();
expect(second.onClose).toHaveBeenCalledTimes(1);
});
});
@ -833,18 +872,18 @@ describe('SettingsDialog connectors interactions', () => {
{ initialSection: 'composio' },
);
expect(screen.getByRole('heading', { name: 'Connectors' })).toBeTruthy();
expect(screen.getAllByRole('heading', { name: 'Connectors' }).length).toBeGreaterThan(0);
expect(screen.getByText('Saved · ••••uQEg')).toBeTruthy();
expect((screen.getByPlaceholderText('Paste a new key to replace the saved one') as HTMLInputElement).value).toBe('');
expect(screen.getByText(/Your key stays in the local daemon/i)).toBeTruthy();
expect(screen.getByText(/your key is saved in the local daemon/i)).toBeTruthy();
expect((screen.getByRole('button', { name: 'Clear' }) as HTMLButtonElement).disabled).toBe(false);
const getApiKeyLink = screen.getByRole('link', { name: /Get API Key/i }) as HTMLAnchorElement;
expect(getApiKeyLink.href).toBe('https://app.composio.dev/');
});
it('supports replacing a saved Composio key and saving the pending edit', () => {
const { onSave } = renderSettingsDialog(
it('supports replacing a saved Composio key and saving the pending edit', async () => {
const { onPersistComposioKey } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
@ -861,23 +900,18 @@ describe('SettingsDialog connectors interactions', () => {
target: { value: 'cmp_replacement_secret' },
});
expect(screen.getByText(/Unsaved replacement/i)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
composio: {
apiKey: 'cmp_replacement_secret',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
},
}),
false,
);
fireEvent.click(screen.getByRole('button', { name: 'Save key' }));
await waitFor(() => {
expect(onPersistComposioKey).toHaveBeenCalledWith({
apiKey: 'cmp_replacement_secret',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
});
});
});
it('clears a saved Composio key from the payload', () => {
const { onSave } = renderSettingsDialog(
it('clears a saved Composio key from the payload', async () => {
const { onPersistComposioKey } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
@ -890,72 +924,24 @@ describe('SettingsDialog connectors interactions', () => {
{ initialSection: 'composio' },
);
// Issue #734 added a window.confirm guard on the Clear button so a
// stray click cannot wipe a daemon-stored Composio key. Auto-accept
// the prompt here so the test still exercises the cleared-payload
// path.
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
await waitFor(() => {
expect((screen.getByRole('button', { name: /hold on|disconnect/i }) as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: /hold on|disconnect/i }));
expect(confirmSpy).toHaveBeenCalledTimes(1);
expect((screen.getByPlaceholderText('Paste Composio API key') as HTMLInputElement).value).toBe('');
expect(screen.getByText(/Keys are stored locally in the daemon/i)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
composio: {
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
},
}),
false,
);
confirmSpy.mockRestore();
await waitFor(() => {
expect(onPersistComposioKey).toHaveBeenCalledWith({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
});
});
expect(screen.getByText(/keys are stored locally in the daemon/i)).toBeTruthy();
});
it('cancels Clear when the Composio confirmation is dismissed (issue #734)', () => {
const { onSave } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
},
},
{ initialSection: 'composio' },
);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(confirmSpy).toHaveBeenCalledTimes(1);
// Saved badge survives because apiKeyConfigured is still true.
expect(screen.getByText(/Saved · ••••uQEg/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
// Without a confirmation, the saved key must remain in the payload
// so the user does not silently lose their daemon-stored credential.
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
composio: expect.objectContaining({
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
}),
}),
false,
);
confirmSpy.mockRestore();
});
it('does not save Composio edits when cancel is used or the backdrop is clicked', () => {
it('closes Composio settings via the close button or backdrop', () => {
const first = renderSettingsDialog(
{
mode: 'daemon',
@ -972,8 +958,7 @@ describe('SettingsDialog connectors interactions', () => {
fireEvent.change(screen.getByPlaceholderText('Paste a new key to replace the saved one'), {
target: { value: 'cmp_unsaved_secret' },
});
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(first.onSave).not.toHaveBeenCalled();
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
expect(first.onClose).toHaveBeenCalledTimes(1);
cleanup();
@ -994,7 +979,6 @@ describe('SettingsDialog connectors interactions', () => {
target: { value: 'cmp_unsaved_secret_2' },
});
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
expect(second.onSave).not.toHaveBeenCalled();
expect(second.onClose).toHaveBeenCalledTimes(1);
});
});
@ -1182,8 +1166,8 @@ describe('SettingsDialog language interactions', () => {
expect(document.documentElement.getAttribute('dir')).toBe('rtl');
});
it('does not route language changes through Save and Cancel does not revert an applied locale', async () => {
const { onSave, onClose } = renderLanguageSettingsDialog('en');
it('does not route language changes through autosave and closing does not revert an applied locale', async () => {
const { onPersist, onClose } = renderLanguageSettingsDialog('en');
fireEvent.click(screen.getByRole('button', { name: /English/i }));
fireEvent.click(await screen.findByRole('menuitemradio', { name: /Deutsch/i }));
@ -1191,8 +1175,8 @@ describe('SettingsDialog language interactions', () => {
expect(window.localStorage.getItem('open-design:locale')).toBe('de');
expect(document.documentElement.getAttribute('lang')).toBe('de');
fireEvent.click(screen.getByRole('button', { name: /Cancel|Abbrechen/i }));
expect(onSave).not.toHaveBeenCalled();
fireEvent.click(screen.getByTitle(/close|schließen/i));
expect(onPersist).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem('open-design:locale')).toBe('de');
expect(document.documentElement.getAttribute('lang')).toBe('de');
@ -1222,8 +1206,8 @@ describe('SettingsDialog notifications interactions', () => {
expect(screen.getByRole('group', { name: 'Failure sound' })).toBeTruthy();
});
it('updates completion success and failure sounds and saves the edited notification config', () => {
const { onSave } = renderSettingsDialog(
it('updates completion success and failure sounds and autosaves the edited notification config', async () => {
const { onPersist } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
@ -1243,8 +1227,8 @@ describe('SettingsDialog notifications interactions', () => {
expect(playSoundMock).toHaveBeenNthCalledWith(1, 'pluck');
expect(playSoundMock).toHaveBeenNthCalledWith(2, 'thud');
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
notifications: {
soundEnabled: true,
@ -1253,7 +1237,7 @@ describe('SettingsDialog notifications interactions', () => {
desktopEnabled: false,
},
}),
true,
{},
);
});
@ -1303,15 +1287,14 @@ describe('SettingsDialog notifications interactions', () => {
expect(screen.queryByRole('button', { name: 'Send test' })).toBeNull();
});
it('does not save notification edits when cancel is used or the backdrop is clicked', () => {
it('closes notification settings via the close button or backdrop', () => {
const first = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'notifications' },
);
fireEvent.click(screen.getAllByRole('button', { name: 'offline' })[0] as HTMLButtonElement);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(first.onSave).not.toHaveBeenCalled();
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
expect(first.onClose).toHaveBeenCalledTimes(1);
cleanup();
@ -1322,7 +1305,6 @@ describe('SettingsDialog notifications interactions', () => {
);
fireEvent.click(screen.getAllByRole('button', { name: 'offline' })[0] as HTMLButtonElement);
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
expect(second.onSave).not.toHaveBeenCalled();
expect(second.onClose).toHaveBeenCalledTimes(1);
});
});
@ -1374,15 +1356,15 @@ describe('SettingsDialog appearance interactions', () => {
fireEvent.click(screen.getByRole('button', { name: 'Light' }));
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
expect(first.onClose).toHaveBeenCalledTimes(1);
first.unmount();
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('saves System mode explicitly and preserves accent variables without an explicit document theme', () => {
const { onSave } = renderSettingsDialog(
it('persists System mode explicitly and preserves accent variables without an explicit document theme', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex', theme: 'dark', accentColor: '#2563eb' },
{ initialSection: 'appearance' },
);
@ -1391,13 +1373,13 @@ describe('SettingsDialog appearance interactions', () => {
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#2563eb');
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
theme: 'system',
accentColor: '#2563eb',
}),
true,
{},
);
});
});
@ -1441,8 +1423,8 @@ describe('SettingsDialog pets interactions', () => {
expect(screen.getByText('Voidling')).toBeTruthy();
});
it('supports editing and saving a custom pet', async () => {
const { onSave } = renderSettingsDialog(
it('supports editing and persisting a custom pet', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'pet' },
);
@ -1464,9 +1446,9 @@ describe('SettingsDialog pets interactions', () => {
expect(screen.getByText('Hi there, builder.')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Use my pet' }));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
pet: expect.objectContaining({
adopted: true,
@ -1480,12 +1462,12 @@ describe('SettingsDialog pets interactions', () => {
}),
}),
}),
true,
{},
);
});
it('toggles an adopted pet between tucked and awake states', async () => {
const { onSave } = renderSettingsDialog(
const { onPersist } = renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
@ -1508,15 +1490,15 @@ describe('SettingsDialog pets interactions', () => {
fireEvent.click(toggle);
expect(screen.getByRole('button', { name: 'Wake' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
pet: expect.objectContaining({
adopted: true,
enabled: false,
}),
}),
true,
{},
);
});
@ -1616,8 +1598,8 @@ describe('SettingsDialog skills and design systems interactions', () => {
expect(screen.queryByText('dashboard')).toBeNull();
});
it('opens a skill preview and saves disabled skills from toggle switches', async () => {
const { onSave } = renderSettingsDialog(
it('opens a skill preview and persists disabled skills from toggle switches', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'library' },
);
@ -1634,18 +1616,18 @@ describe('SettingsDialog skills and design systems interactions', () => {
const toggles = screen.getAllByTitle('Toggle');
fireEvent.click(toggles[0] as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
disabledSkills: ['blog-post'],
}),
true,
{},
);
});
it('switches to design systems, previews details, and saves disabled design systems', async () => {
const { onSave } = renderSettingsDialog(
it('switches to design systems, previews details, and persists disabled design systems', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'library' },
);
@ -1671,13 +1653,13 @@ describe('SettingsDialog skills and design systems interactions', () => {
});
fireEvent.click(screen.getAllByTitle('Toggle')[0] as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith(
await waitForPersist(
onPersist,
expect.objectContaining({
disabledDesignSystems: ['signal-green'],
}),
true,
{},
);
});
@ -1757,8 +1739,7 @@ describe('SettingsDialog about interactions', () => {
},
);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(first.onSave).not.toHaveBeenCalled();
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
expect(first.onClose).toHaveBeenCalledTimes(1);
cleanup();
@ -1778,7 +1759,6 @@ describe('SettingsDialog about interactions', () => {
);
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
expect(second.onSave).not.toHaveBeenCalled();
expect(second.onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConnectorDetail } from '@open-design/contracts';
import { SettingsDialog } from '../../src/components/SettingsDialog';
import { fetchConnectors, fetchSkills } from '../../src/providers/registry';
import type { AppConfig } from '../../src/types';
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
fetchConnectors: vi.fn(),
fetchSkills: vi.fn(),
};
});
const originalFetch = globalThis.fetch;
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,
composio: { apiKeyConfigured: true },
orbit: {
enabled: false,
time: '09:00',
templateSkillId: 'orbit-general',
},
};
const connectedConnector: ConnectorDetail = {
id: 'github',
name: 'GitHub',
provider: 'Composio',
category: 'Code',
status: 'connected',
auth: { provider: 'composio', configured: true },
tools: [],
};
describe('SettingsDialog Orbit connector gate refresh', () => {
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
vi.mocked(fetchConnectors).mockReset();
vi.mocked(fetchSkills).mockReset();
});
it('rechecks connected connectors when the window regains focus', async () => {
vi.mocked(fetchConnectors)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([connectedConnector]);
vi.mocked(fetchSkills).mockResolvedValue([]);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/orbit/status') {
return new Response(null, { status: 404 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
render(
<SettingsDialog
initial={baseConfig}
agents={[]}
daemonLive
appVersionInfo={null}
initialSection="orbit"
onPersist={vi.fn()}
onPersistComposioKey={vi.fn()}
onClose={vi.fn()}
onRefreshAgents={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('orbit-config-gate')).toBeTruthy();
});
expect(screen.getByRole('button', { name: 'Run it now' }).hasAttribute('disabled')).toBe(true);
fireEvent.focus(window);
await waitFor(() => {
expect(screen.queryByTestId('orbit-config-gate')).toBeNull();
expect(screen.getByRole('button', { name: 'Run it now' }).hasAttribute('disabled')).toBe(false);
});
});
});

View file

@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
agentRefreshOptionsForConfig,
canRunProviderConnectionTest,
deriveComposioCredentialState,
configForManualOrbitRun,
isOrbitRunDisabled,
isValidApiBaseUrl,
sanitizeSettingsSavePayload,
shouldEnableSettingsSave,
shouldShowCustomModelInput,
persistConfigAndRunOrbit,
switchApiProtocolConfig,
testStatusVariant,
updateAgentCliEnvValue,
@ -14,6 +17,8 @@ import {
} from '../../src/components/SettingsDialog';
import type { AppConfig, ConnectionTestResponse } from '../../src/types';
const originalFetch = globalThis.fetch;
const baseConfig: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
@ -26,6 +31,12 @@ const baseConfig: AppConfig = {
designSystemId: null,
};
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('SettingsDialog API protocol switching', () => {
it('stores the current custom protocol config while preserving custom endpoint details', () => {
const config: AppConfig = {
@ -378,6 +389,240 @@ describe('deriveComposioCredentialState', () => {
});
});
describe('SettingsDialog Orbit run behavior', () => {
it('keeps manual Orbit runs disabled while connector availability is still loading', () => {
expect(isOrbitRunDisabled(false, null)).toBe(true);
});
it('allows manual Orbit runs once loading finishes and a connector is available', () => {
expect(isOrbitRunDisabled(false, 1)).toBe(false);
});
it('persists the current orbit template config before starting the run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-1' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-1' });
expect(calls).toHaveLength(2);
expect(calls[0]).toMatchObject({
url: '/api/app-config',
method: 'PUT',
});
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
});
expect(calls[1]).toMatchObject({
url: '/api/orbit/run',
method: 'POST',
});
});
it('does not sync an unsaved Composio draft before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-3' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
composio: { apiKey: 'cmp_new_key', apiKeyConfigured: false },
mediaProviders: {
openai: { apiKey: 'media-key', baseUrl: '' },
},
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-3' });
expect(calls.map((call) => call.url)).toEqual([
'/api/media/config',
'/api/app-config',
'/api/orbit/run',
]);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({ force: false });
});
it('does not force an explicit empty media provider map before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-4' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
mediaProviders: {},
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-4' });
expect(calls.map((call) => call.url)).toEqual(['/api/media/config', '/api/app-config', '/api/orbit/run']);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
providers: {},
force: false,
});
});
it('does not start a manual Orbit run when saving app config fails', async () => {
const calls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push(url);
if (url === '/api/app-config') {
return new Response(null, { status: 500 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
composio: { apiKey: 'cmp_new_key', apiKeyConfigured: false },
}),
).rejects.toThrow('Failed to sync app config (500)');
expect(calls).toEqual(['/api/app-config']);
});
it('still starts a manual Orbit run when saving media credentials fails', async () => {
const calls: Array<{ url: string; method: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push({ url, method: init?.method ?? 'GET' });
if (url === '/api/media/config') {
return new Response(null, { status: 500 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-media-failed' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
mediaProviders: {
openai: { apiKey: 'media-key', baseUrl: '' },
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-media-failed' });
expect(calls).toEqual([
{ url: '/api/media/config', method: 'PUT' },
{ url: '/api/app-config', method: 'PUT' },
{ url: '/api/orbit/run', method: 'POST' },
]);
});
it('persists the displayed default template before starting a legacy null-template run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-2' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit(configForManualOrbitRun({
...baseConfig,
orbit: {
enabled: true,
time: '09:30',
templateSkillId: null,
},
})),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-2' });
expect(calls).toHaveLength(2);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-general',
},
});
expect(calls[1]).toMatchObject({
url: '/api/orbit/run',
method: 'POST',
});
});
});
describe('shouldEnableSettingsSave', () => {
// Issue #739: when the user toggles BYOK on the execution section without
// filling required fields and then navigates to a different sidebar section

View file

@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { ChatMessage } from '../../src/types';
@ -23,6 +23,7 @@ import type { ChatMessage } from '../../src/types';
// through this single test-controlled geometry.
type Geom = { scrollHeight: number; clientHeight: number; scrollTop: number };
let geom: Geom;
let rafCallbacks: FrameRequestCallback[] = [];
let savedDescriptors: Record<
'scrollTop' | 'scrollHeight' | 'clientHeight',
PropertyDescriptor | undefined
@ -34,6 +35,11 @@ function isChatLog(el: HTMLElement): boolean {
beforeEach(() => {
geom = { scrollHeight: 0, clientHeight: 0, scrollTop: 0 };
rafCallbacks = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
savedDescriptors = {
scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
scrollHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'),
@ -64,6 +70,8 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
rafCallbacks = [];
for (const key of ['scrollTop', 'scrollHeight', 'clientHeight'] as const) {
const original = savedDescriptors[key];
if (original) {
@ -114,9 +122,12 @@ const sampleMessages: ChatMessage[] = [
{ id: 'a2', role: 'assistant', content: 'second reply', createdAt: Date.now() },
];
function flushFrame() {
return act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
async function flushFrame() {
await act(async () => {
const callbacks = rafCallbacks;
rafCallbacks = [];
callbacks.forEach((callback) => callback(performance.now()));
await Promise.resolve();
});
}

View file

@ -1,5 +1,7 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { en } from '../../src/i18n/locales/en';
import { id } from '../../src/i18n/locales/id';
import { LOCALES, LOCALE_LABEL, type Dict, type Locale } from '../../src/i18n/types';
const EXPECTED_LOCALES = ['en', 'id', 'de', 'zh-CN', 'zh-TW', 'pt-BR', 'es-ES', 'ru', 'fa', 'ar', 'ja', 'ko', 'pl', 'hu', 'fr', 'uk', 'tr'];
@ -25,6 +27,11 @@ async function loadDict(locale: Locale): Promise<Dict> {
return dict;
}
function explicitLocaleKeys(locale: Locale): string[] {
const source = readFileSync(new URL(`../../src/i18n/locales/${locale}.ts`, import.meta.url), 'utf8');
return Array.from(source.matchAll(/'([^']+)':/g), (match) => match[1] ?? '').filter(Boolean);
}
describe('i18n locales', () => {
it('registers every supported locale in the language menu', () => {
expect(LOCALES).toEqual(EXPECTED_LOCALES);
@ -48,4 +55,76 @@ describe('i18n locales', () => {
}
}
});
it('keeps Indonesian connector settings copy translated instead of falling back to English', () => {
const translatedKeys: Array<keyof Dict> = [
'settings.connectorsNavHint',
'settings.connectorsHint',
'settings.connectorsComposioApiKey',
'settings.connectorsSavedTitle',
'settings.connectorsSaved',
'settings.connectorsGetApiKey',
'settings.connectorsApiKeyPlaceholder',
'settings.connectorsClear',
'settings.connectorsSaveKey',
'settings.connectorsKeyError',
'settings.connectorsHelpEmpty',
'settings.connectorsLoadingSavedKey',
'settings.autosaveSaving',
'settings.autosaveSaved',
'settings.autosaveError',
'settings.orbit.eyebrow',
'settings.orbit.navHint',
'settings.orbit.lede',
'settings.orbit.statusOnTitle',
'settings.orbit.statusOffTitle',
'settings.orbit.runTitle',
'settings.orbit.running',
'settings.orbit.runOpen',
'settings.orbit.dailySummaryTitle',
'settings.orbit.dailySummarySub',
'settings.orbit.runTimeTitle',
'settings.orbit.runTimeSub',
'settings.orbit.nextRun',
'settings.orbit.nextRunScheduledAfterSave',
'settings.orbit.schedule',
'settings.orbit.pausedManualOnly',
'settings.orbit.templateTitle',
'settings.orbit.templateMissing',
'settings.orbit.templateMissingOption',
'settings.orbit.templateMissingInstall',
'settings.orbit.templateMissingPickAnother',
'settings.orbit.templateResetTitle',
'settings.orbit.templateReset',
'settings.orbit.templateHelp',
'settings.orbit.templatesLoading',
'settings.orbit.templatesOptgroup',
'settings.orbit.lastRun',
'settings.orbit.countChecked',
'settings.orbit.countSucceeded',
'settings.orbit.countSkipped',
'settings.orbit.countFailed',
'settings.orbit.runError',
'settings.orbit.artifactKickerLive',
];
for (const key of translatedKeys) {
expect(id[key], key).not.toBe(en[key]);
}
});
it('declares CI-sensitive Indonesian fallback keys explicitly', () => {
const explicitKeys = new Set(explicitLocaleKeys('id'));
const requiredExplicitKeys = Object.keys(en).filter((key) => {
return key.startsWith('connectors.category.') || key.startsWith('liveArtifact.viewer.');
});
expect(requiredExplicitKeys.filter((key) => !explicitKeys.has(key))).toEqual([]);
});
it('avoids brittle per-key English lookups in the Indonesian locale source', () => {
const source = readFileSync(new URL('../../src/i18n/locales/id.ts', import.meta.url), 'utf8');
expect(source).not.toMatch(/en\['(?:connectors\.category\.|liveArtifact\.viewer\.)/);
});
});

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import nextConfig from '../../next.config';
import * as spaShellRoute from '../../app/[[...slug]]/page';
describe('SPA shell export route', () => {
it('stays compatible with static export builds', () => {
expect(nextConfig.output).toBe('export');
expect('dynamicParams' in spaShellRoute).toBe(false);
expect(spaShellRoute.generateStaticParams()).toEqual([{ slug: [] }]);
});
});

View file

@ -5,6 +5,7 @@ import {
mergeDaemonConfig,
syncComposioConfigToDaemon,
syncConfigToDaemon,
syncMediaProvidersToDaemon,
} from '../../src/state/config';
import type { AppConfig } from '../../src/types';
@ -97,6 +98,21 @@ describe('syncConfigToDaemon', () => {
});
});
describe('syncMediaProvidersToDaemon', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.stubGlobal('fetch', originalFetch);
});
it('throws when a forced media sync fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 503 })));
await expect(
syncMediaProvidersToDaemon({}, { force: true, throwOnError: true }),
).rejects.toThrow('Media config save failed');
});
});
describe('mergeDaemonConfig', () => {
it('clears stale local CLI env prefs when the daemon has none', () => {
const merged = mergeDaemonConfig(
@ -259,6 +275,19 @@ describe('loadConfig', () => {
expect(loadConfig().accentColor).toBe(DEFAULT_CONFIG.accentColor);
});
it('falls back to the default Orbit time for out-of-range saved times', () => {
const savedConfig: Partial<AppConfig> = {
orbit: {
enabled: true,
time: '99:99',
templateSkillId: 'orbit-general',
},
};
store.set('open-design:config', JSON.stringify(savedConfig));
expect(loadConfig().orbit?.time).toBe(DEFAULT_CONFIG.orbit?.time);
});
it('returns defaults for malformed localStorage JSON', () => {
store.set('open-design:config', '{broken-json');

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
@ -55,6 +55,13 @@ const IMAGE_TEMPLATE = {
},
};
async function readSavedConfig(page: Page) {
return page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
}
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
@ -143,42 +150,72 @@ test('prompt template retry preserves the edited body in project metadata', asyn
test('live artifact empty connector CTA opens the gated connector setup path', async ({ page }) => {
await routeConnectors(page, []);
await routeComposioConfig(page, { configured: false, apiKeyTail: '' });
await gotoEntryHome(page);
await page.getByTestId('new-project-tab-live-artifact').click();
await expect(page.getByTestId('new-project-connectors')).toBeVisible();
// The empty CTA now opens Settings → Connectors directly. The Composio API
// key field sits at the top of the section; the catalog (and its gate)
// sits below it.
await page.getByTestId('new-project-connectors-empty').click();
await expect(page.getByTestId('entry-tab-connectors')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('connector-gate')).toBeVisible();
await page.getByTestId('connector-gate-action').click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await expect(settingsDialog.getByRole('heading', { name: 'Connectors' })).toBeVisible();
await expect(settingsDialog.getByPlaceholder('Paste Composio API key')).toBeVisible();
await expect(settingsDialog.getByTestId('connector-gate')).toBeVisible();
await expect(settingsDialog.getByTestId('connectors-search-input')).toBeDisabled();
});
test('connectors search supports empty results and keyboard-closeable details', async ({ page }) => {
await routeConnectors(page, CONNECTORS);
await routeComposioConfig(page, { configured: true, apiKeyTail: '1234' });
await page.addInitScript((key) => {
const next = {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
};
window.localStorage.setItem(key, JSON.stringify(next));
}, STORAGE_KEY);
await gotoEntryHome(page);
await page.getByTestId('entry-tab-connectors').click();
await expect(page.getByTestId('connector-grid-wrap')).toBeVisible();
await page.goto('/');
// Connector cards + search now live under Settings → Connectors. Open the
// settings dialog via the entry sidebar's "Configure execution mode" pill
// and switch to the Connectors section before exercising the
// search/empty/details flow.
await page.getByRole('button', { name: 'Configure execution mode' }).click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await settingsDialog.getByRole('button', { name: /^Connectors\b/ }).click();
await expect(settingsDialog.getByTestId('connector-grid-wrap')).toBeVisible();
const search = page.getByTestId('connectors-search-input');
const search = settingsDialog.getByTestId('connectors-search-input');
await search.fill('git');
await expect(connectorCard(page, 'github')).toBeVisible();
await expect(connectorCard(page, 'slack')).toHaveCount(0);
await expect(connectorCard(settingsDialog, 'github')).toBeVisible();
await expect(connectorCard(settingsDialog, 'slack')).toHaveCount(0);
await search.fill('missing connector');
await expect(page.getByTestId('connectors-empty')).toBeVisible();
await search.press('Escape');
await expect(page.getByTestId('connectors-empty')).toHaveCount(0);
await expect(connectorCard(page, 'github')).toBeVisible();
await expect(connectorCard(page, 'slack')).toBeVisible();
await expect(settingsDialog.getByTestId('connectors-empty')).toBeVisible();
await settingsDialog.getByTestId('connectors-search-clear').click();
await expect(settingsDialog.getByTestId('connectors-empty')).toHaveCount(0);
await expect(connectorCard(settingsDialog, 'github')).toBeVisible();
await expect(connectorCard(settingsDialog, 'slack')).toBeVisible();
await connectorCard(page, 'github').click();
await connectorCard(settingsDialog, 'github').focus();
await connectorCard(settingsDialog, 'github').press('Enter');
await expect(page.getByTestId('connector-drawer')).toBeVisible();
await expect(page.getByTestId('connector-drawer')).toContainText('List issues');
await page.keyboard.press('Escape');
@ -214,25 +251,27 @@ test('saving a Composio key from Settings unlocks the connectors gate immediatel
});
await gotoEntryHome(page);
await page.getByTestId('entry-tab-connectors').click();
await expect(page.getByTestId('connector-gate')).toBeVisible();
await expect(page.getByTestId('connectors-search-input')).toBeDisabled();
await page.getByTestId('connector-gate-action').click();
await page.getByRole('button', { name: 'Configure execution mode' }).click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await settingsDialog.getByRole('button', { name: /^Connectors\b/ }).click();
await expect(settingsDialog.getByTestId('connectors-search-input')).toBeDisabled();
await settingsDialog.getByPlaceholder('Paste Composio API key').fill('cmp-secret-1234');
await settingsDialog.getByRole('button', { name: 'Save', exact: true }).click();
await settingsDialog.getByRole('button', { name: 'Save key', exact: true }).click();
expect(savedComposioBody).toEqual({ apiKey: 'cmp-secret-1234' });
await expect(page.getByTestId('connector-gate')).toHaveCount(0);
await expect(page.getByTestId('connectors-search-input')).toBeEnabled();
await expect(connectorCard(page, 'github')).toBeVisible();
await expect(settingsDialog.getByTestId('connectors-search-input')).toBeEnabled();
await expect(connectorCard(settingsDialog, 'github')).toBeVisible();
const savedConfig = await page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
},
});
const savedConfig = await readSavedConfig(page);
expect(savedConfig?.composio).toMatchObject({
apiKey: '',
apiKeyConfigured: true,
@ -271,8 +310,22 @@ async function gotoEntryHome(page: Page) {
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
function connectorCard(page: Page, id: string) {
return page.locator(`article.connector-card[data-connector-id="${id}"]`);
async function routeComposioConfig(
page: Page,
config: { configured: boolean; apiKeyTail?: string },
) {
await page.route('**/api/connectors/composio/config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: config });
return;
}
await route.fulfill({ json: { ok: true } });
});
}
function connectorCard(scope: Page | Locator, id: string) {
return scope.locator(`article.connector-card[data-connector-id="${id}"]`);
}
async function fetchCurrentProject(page: Page) {

View file

@ -52,6 +52,21 @@ test.beforeEach(async ({ page }) => {
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
agentCliEnv: {},
},
},
});
});
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
@ -125,10 +140,12 @@ test('design system multi-select stores primary and inspiration metadata', async
await page.goto('/');
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system multi select metadata');
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await page.getByTestId('design-system-trigger').click();
await page.getByRole('tab', { name: /multi/i }).click();
await page.getByRole('option', { name: /Nexu Soft Tech/i }).click();
const multiTab = page.getByRole('tab', { name: /multi/i });
await multiTab.click();
await expect(multiTab).toHaveAttribute('aria-selected', 'true');
await page.getByRole('option', { name: /Editorial Noir/i }).click();
await page.getByRole('option', { name: /Data Mist/i }).click();
@ -316,7 +333,7 @@ test('home designs search filters projects and recovers from no results', async
await expect(homeDesignCard(page, betaName)).toBeVisible();
});
test('change pet opens pet settings and saves a custom companion', async ({ page }) => {
test('change pet opens pet settings and updates the custom companion draft', async ({ page }) => {
await seedAdoptedPet(page);
await page.route('**/api/codex-pets', async (route) => {
await route.fulfill({ json: { pets: [], rootDir: '' } });
@ -341,26 +358,11 @@ test('change pet opens pet settings and saves a custom companion', async ({ page
await customPanel.getByLabel('Name').fill('QA Turtle');
await customPanel.getByLabel('Glyph').fill('🐢');
await customPanel.getByLabel('Greeting').fill('Shell yeah, tests are green.');
await expect(customPanel.getByRole('button', { name: /adopted/i })).toBeVisible();
await expect(customPanel.getByText('QA Turtle')).toBeVisible();
await expect(customPanel.getByText('Shell yeah, tests are green.')).toBeVisible();
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.locator('.pet-overlay .pet-sprite')).toHaveAttribute(
'aria-label',
/QA Turtle/i,
);
const petConfig = await readPetConfig(page);
expect(petConfig).toMatchObject({
adopted: true,
enabled: true,
petId: 'custom',
custom: {
name: 'QA Turtle',
glyph: '🐢',
greeting: 'Shell yeah, tests are green.',
},
});
});
async function createProject(
@ -464,22 +466,6 @@ async function seedAdoptedPet(page: Page) {
}, STORAGE_KEY);
}
async function readPetConfig(page: Page) {
return page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw).pet : null;
}, STORAGE_KEY) as Promise<{
adopted: boolean;
enabled: boolean;
petId: string;
custom: {
name: string;
glyph: string;
greeting: string;
};
} | null>;
}
async function fetchCurrentProject(page: Page) {
const { projectId } = getProjectContextFromUrl(page);
const response = await page.request.get(`/api/projects/${projectId}`);

View file

@ -23,6 +23,13 @@ async function openExecutionSettings(
await expect(page.getByRole('dialog')).toBeVisible();
}
async function readSavedConfig(page: Page) {
return page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
}
async function openExecutionSettingsWithAgents(
page: Page,
config: Record<string, unknown>,
@ -68,21 +75,22 @@ test('legacy known OpenAI provider switches to the matching Anthropic preset', a
agentModels: {},
});
const protocolTabs = page.getByRole('tablist', { name: 'API protocol' });
const dialog = page.getByRole('dialog');
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = page.getByLabel('Base URL');
const modelSelect = page.getByLabel('Model');
const baseUrlInput = dialog.getByLabel('Base URL');
const modelSelect = dialog.getByLabel('Model');
await expect(openAiTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.deepseek.com');
await expect(modelSelect).toHaveValue('deepseek-chat');
await anthropicTab.click();
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.deepseek.com/anthropic');
await expect(modelSelect).toHaveValue('deepseek-chat');
});
@ -101,21 +109,22 @@ test('legacy custom provider preserves custom baseUrl and model when switching p
agentModels: {},
});
const protocolTabs = page.getByRole('tablist', { name: 'API protocol' });
const dialog = page.getByRole('dialog');
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = page.getByLabel('Base URL');
const customModelInput = page.getByLabel(/Custom model id/i);
const baseUrlInput = dialog.getByLabel('Base URL');
const customModelInput = dialog.getByLabel(/Custom model id/i);
await expect(openAiTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1');
await expect(customModelInput).toHaveValue('my-custom-model');
await anthropicTab.click();
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1');
await expect(customModelInput).toHaveValue('my-custom-model');
});
@ -138,25 +147,33 @@ test('BYOK quick fill provider updates fields and saved settings persist after c
agentCliEnv: {},
});
await page.getByRole('tab', { name: 'OpenAI', exact: true }).click();
await page.getByLabel('Quick fill provider').selectOption('1');
await expect(page.getByLabel('Model')).toHaveValue('deepseek-chat');
await expect(page.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
const dialog = page.getByRole('dialog');
await page.getByRole('button', { name: 'Show' }).click();
const apiKeyInput = page.getByLabel('API key');
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
await dialog.getByLabel('Quick fill provider').selectOption('1');
await expect(dialog.getByLabel('Model')).toHaveValue('deepseek-chat');
await expect(dialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await dialog.getByRole('button', { name: 'Show' }).click();
const apiKeyInput = dialog.getByLabel('API key');
await expect(apiKeyInput).toHaveAttribute('type', 'text');
await apiKeyInput.fill('sk-openai-test');
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect
.poll(async () => readSavedConfig(page))
.toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'sk-openai-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
const savedConfig = await page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
const savedConfig = await readSavedConfig(page);
expect(savedConfig).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
@ -168,11 +185,12 @@ test('BYOK quick fill provider updates fields and saved settings persist after c
await page.getByTitle('Configure execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByLabel('Quick fill provider')).toHaveValue('1');
await expect(page.getByLabel('Model')).toHaveValue('deepseek-chat');
await expect(page.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await expect(page.getByLabel('API key')).toHaveValue('sk-openai-test');
const reopenedDialog = page.getByRole('dialog');
await expect(reopenedDialog.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(reopenedDialog.getByLabel('Quick fill provider')).toHaveValue('1');
await expect(reopenedDialog.getByLabel('Model')).toHaveValue('deepseek-chat');
await expect(reopenedDialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await expect(reopenedDialog.getByLabel('API key')).toHaveValue('sk-openai-test');
});
test('BYOK save stays disabled until required fields are valid', async ({ page }) => {
@ -193,18 +211,21 @@ test('BYOK save stays disabled until required fields are valid', async ({ page }
agentCliEnv: {},
});
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
await expect(saveButton).toBeDisabled();
const dialog = page.getByRole('dialog');
const closeButton = dialog.getByRole('button', { name: 'Close', exact: true });
await expect(closeButton).toBeEnabled();
await page.getByLabel('API key').fill('sk-ant-test');
await expect(saveButton).toBeEnabled();
await dialog.getByLabel('API key').fill('sk-ant-test');
await expect.poll(async () => readSavedConfig(page)).toMatchObject({ apiKey: 'sk-ant-test' });
await page.getByLabel('Base URL').fill('http://10.0.0.5:11434/v1');
await expect(saveButton).toBeDisabled();
await expect(page.locator('#settings-base-url-error')).toContainText('valid public');
await dialog.getByLabel('Base URL').fill('http://10.0.0.5:11434/v1');
await expect(dialog.locator('#settings-base-url-error')).toContainText('valid public');
await page.getByLabel('Base URL').fill('http://localhost:11434/v1');
await expect(saveButton).toBeEnabled();
await dialog.getByLabel('Base URL').fill('http://localhost:11434/v1');
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiKey: 'sk-ant-test',
baseUrl: 'http://localhost:11434/v1',
});
});
test('saving Local CLI updates the entry status pill with the selected agent', async ({ page }) => {
@ -246,9 +267,15 @@ test('saving Local CLI updates the entry status pill with the selected agent', a
],
);
await page.getByRole('tab', { name: /Local CLI.*1 installed/i }).click();
await page.getByRole('button', { name: /Codex CLI/i }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: /Local CLI.*1 installed/i }).click();
await dialog.getByRole('button', { name: /Codex CLI/i }).click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
mode: 'daemon',
agentId: 'codex',
});
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
const executionPill = page.getByTitle('Configure execution mode');

View file

@ -5,6 +5,14 @@ export interface AgentModelPrefs {
export type AgentCliEnvPrefs = Record<string, Record<string, string>>;
export interface OrbitConfigPrefs {
enabled: boolean;
/** Local 24-hour clock time in HH:mm format. Defaults to 08:00. */
time: string;
/** Optional skill id from the examples gallery where scenario === "orbit". */
templateSkillId?: string | null;
}
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
@ -14,6 +22,7 @@ export interface AppConfigPrefs {
designSystemId?: string | null;
disabledSkills?: string[];
disabledDesignSystems?: string[];
orbit?: OrbitConfigPrefs;
}
export interface AppConfigResponse {

View file

@ -6,12 +6,19 @@ export type ConnectorToolSideEffect = 'read' | 'write' | 'destructive' | 'unknow
export type ConnectorToolApproval = 'auto' | 'confirm' | 'disabled';
export type ConnectorToolUseCase = 'personal_daily_digest';
export interface ConnectorToolSafety {
sideEffect: ConnectorToolSideEffect;
approval: ConnectorToolApproval;
reason: string;
}
export interface ConnectorToolCuration {
useCases?: ConnectorToolUseCase[];
reason?: string;
}
export interface ConnectorToolDetail {
name: string;
title: string;
@ -20,6 +27,7 @@ export interface ConnectorToolDetail {
outputSchemaJson?: BoundedJsonObject;
safety: ConnectorToolSafety;
refreshEligible: boolean;
curation?: ConnectorToolCuration;
}
export interface ConnectorDetail {

View file

@ -139,6 +139,10 @@ export const exampleConnectorDetail: ConnectorDetail = {
reason: 'Tool name, scope, or description indicates explicit read-only behavior.',
},
refreshEligible: true,
curation: {
useCases: ['personal_daily_digest'],
reason: 'Curated for recent personal GitHub activity in a daily digest.',
},
},
],
auth: { provider: 'composio', configured: false },

View file

@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const desktopPackageRoot = join(repoRoot, "apps", "desktop");
function readDesktopPackageJson(): {
exports?: Record<string, { default?: string; types?: string }>;
files?: string[];
} {
return JSON.parse(readFileSync(join(desktopPackageRoot, "package.json"), "utf8"));
}
describe("desktop package runtime shape", () => {
it("keeps exported desktop types inside the published dist allowlist", () => {
const pkg = readDesktopPackageJson();
expect(pkg.files).toEqual(["dist"]);
expect(pkg.exports?.["./main"]?.default).toBe("./dist/main/index.js");
expect(pkg.exports?.["./main"]?.types).toBe("./dist/main/index.d.ts");
});
});