diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index d2c061994..612c06bf0 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -500,6 +500,7 @@ export default { "identities.agent_loading": "Loading agent file…", "identities.agent_none": "none", "identities.agent_not_found": "Agent file not found in this workspace yet.", + "identities.agent_placeholder": "Describe how the messaging agent should respond in this workspace.", "identities.agent_saved": "Saved messaging behavior.", "identities.agent_scope_status": "Active scope: workspace · status: {status} · selected agent: {agent}", "identities.agent_status_loaded": "loaded", @@ -550,6 +551,7 @@ export default { "identities.health_offline": "Offline", "identities.health_running": "Running", "identities.health_unavailable": "Unavailable", + "identities.health_unavailable_status": "Unavailable ({status})", "identities.health_unknown": "Unknown", "identities.hours_ago": "{hours}h ago", "identities.identities_label": "Identities", diff --git a/apps/app/src/react-app/domains/settings/pages/messaging-view.tsx b/apps/app/src/react-app/domains/settings/pages/messaging-view.tsx new file mode 100644 index 000000000..77eacbb29 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/messaging-view.tsx @@ -0,0 +1,1069 @@ +/** @jsxImportSource react */ +import { ArrowRight, ChevronRight, Copy, Link, RefreshCcw, Shield } from "lucide-react"; + +import { t } from "../../../../i18n"; +import type { + OpenworkOpenCodeRouterHealthSnapshot, + OpenworkOpenCodeRouterIdentityItem, + OpenworkOpenCodeRouterSendResult, + OpenworkServerStatus, +} from "../../../../app/lib/openwork-server"; +import { Button } from "../../../design-system/button"; +import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; +import { TextInput } from "../../../design-system/text-input"; + +const agentFilePath = ".opencode/agents/opencode-router.md"; + +export type MessagingViewTab = "general" | "advanced"; +export type MessagingChannel = "telegram" | "slack"; +export type MessagingViewExpandedChannel = MessagingChannel | null; + +export type MessagingViewProps = { + busy: boolean; + showHeader?: boolean; + openworkServerStatus: OpenworkServerStatus; + openworkServerUrl: string; + scopedOpenworkBaseUrl?: string; + workspaceId: string | null; + selectedWorkspaceRoot: string; + refreshing: boolean; + openworkReconnectBusy: boolean; + reconnectStatus: string | null; + reconnectError: string | null; + health: OpenworkOpenCodeRouterHealthSnapshot | null; + healthError: string | null; + messagingEnabled: boolean; + messagingSaving: boolean; + messagingStatus: string | null; + messagingError: string | null; + messagingRestartRequired: boolean; + messagingRestartBusy: boolean; + activeTab: MessagingViewTab; + expandedChannel: MessagingViewExpandedChannel; + telegram: { + identities: OpenworkOpenCodeRouterIdentityItem[]; + identitiesError: string | null; + token: string; + enabled: boolean; + saving: boolean; + status: string | null; + error: string | null; + botUsername: string | null; + pairingCode: string | null; + }; + slack: { + identities: OpenworkOpenCodeRouterIdentityItem[]; + identitiesError: string | null; + botToken: string; + appToken: string; + enabled: boolean; + saving: boolean; + status: string | null; + error: string | null; + }; + agent: { + loading: boolean; + saving: boolean; + exists: boolean; + content: string; + draft: string; + status: string | null; + error: string | null; + }; + sendTest: { + channel: MessagingChannel; + directory: string; + peerId: string; + autoBind: boolean; + text: string; + busy: boolean; + status: string | null; + error: string | null; + result: OpenworkOpenCodeRouterSendResult | null; + }; + modals: { + messagingRiskOpen: boolean; + messagingRestartPromptOpen: boolean; + messagingRestartAction: "enable" | "disable"; + messagingDisableConfirmOpen: boolean; + publicTelegramWarningOpen: boolean; + }; + onRepairAndReconnect: () => void | Promise; + onRefresh: () => void | Promise; + onSelectTab: (tab: MessagingViewTab) => void; + onToggleExpandedChannel: (channel: MessagingChannel) => void; + onOpenMessagingRisk: () => void; + onCancelMessagingRisk: () => void; + onConfirmEnableMessaging: () => void | Promise; + onOpenDisableMessagingConfirm: () => void; + onCancelDisableMessagingConfirm: () => void; + onConfirmDisableMessaging: () => void | Promise; + onCancelRestartPrompt: () => void; + onConfirmRestartMessagingWorker: () => void | Promise; + onTelegramTokenChange: (value: string) => void; + onTelegramEnabledChange: (value: boolean) => void; + onOpenPublicTelegramWarning: () => void; + onCancelPublicTelegramWarning: () => void; + onConfirmPublicTelegram: () => void | Promise; + onConnectPrivateTelegram: () => void | Promise; + onDeleteTelegram: (id: string) => void | Promise; + onCopyTelegramPairingCode: () => void | Promise; + onHideTelegramPairingCode: () => void; + onSlackBotTokenChange: (value: string) => void; + onSlackAppTokenChange: (value: string) => void; + onSlackEnabledChange: (value: boolean) => void; + onConnectSlack: () => void | Promise; + onDeleteSlack: (id: string) => void | Promise; + onLoadAgentFile: () => void | Promise; + onCreateDefaultAgentFile: () => void | Promise; + onChangeAgentDraft: (value: string) => void; + onSaveAgentFile: () => void | Promise; + onChangeSendChannel: (channel: MessagingChannel) => void; + onChangeSendPeerId: (value: string) => void; + onChangeSendDirectory: (value: string) => void; + onChangeSendAutoBind: (value: boolean) => void; + onChangeSendText: (value: string) => void; + onSendTestMessage: () => void | Promise; +}; + +function TelegramIcon({ size = 20 }: { size?: number }) { + return ( + + ); +} + +function SlackIcon({ size = 20 }: { size?: number }) { + return ( + + ); +} + +function StatusPill(props: { label: string; value: string; ok: boolean }) { + return ( +
+
{props.label}
+
+ {props.value} +
+
+ ); +} + +function formatLastActivityLabel(timestamp?: number | null) { + if (!timestamp) return "-"; + const elapsedMs = Math.max(0, Date.now() - timestamp); + if (elapsedMs < 60_000) return t("identities.just_now"); + const minutes = Math.floor(elapsedMs / 60_000); + if (minutes < 60) return t("identities.minutes_ago", undefined, { minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t("identities.hours_ago", undefined, { hours }); + const days = Math.floor(hours / 24); + return t("identities.days_ago", undefined, { days }); +} + +export function MessagingView(props: MessagingViewProps) { + const serverReady = props.openworkServerStatus === "connected"; + const scopedWorkspaceReady = Boolean(props.workspaceId?.trim()); + const workspaceScopeLabel = + props.scopedOpenworkBaseUrl?.trim() || props.openworkServerUrl.trim() || t("identities.not_set"); + const defaultRoutingDirectory = props.selectedWorkspaceRoot.trim() || t("identities.not_set"); + const telegramBotLink = props.telegram.botUsername?.trim() + ? `https://t.me/${props.telegram.botUsername.trim().replace(/^@+/, "")}` + : null; + const agentDirty = props.agent.draft !== props.agent.content; + const hasTelegramConnected = props.telegram.identities.some((item) => item.enabled); + const hasSlackConnected = props.slack.identities.some((item) => item.enabled); + const connectedChannelCount = Number(hasTelegramConnected) + Number(hasSlackConnected); + const messagesToday = props.health?.activity + ? (props.health.activity.inboundToday ?? 0) + (props.health.activity.outboundToday ?? 0) + : null; + const lastActivityAt = props.health?.activity?.lastMessageAt ?? null; + const lastActivityLabel = formatLastActivityLabel(lastActivityAt); + const isWorkerOnline = props.health?.ok === true; + const statusLabel = props.healthError + ? t("identities.health_unavailable") + : props.health + ? props.health.ok + ? t("identities.health_running") + : t("identities.health_offline") + : t("identities.health_unknown"); + + return ( +
+
+
+ {props.showHeader !== false ? ( +

{t("identities.title")}

+ ) : ( +
+ )} +
+ + +
+
+ + {props.showHeader !== false ? ( +

{t("identities.subtitle")}

+ ) : null} + +
+ {t("identities.workspace_scope_prefix")} {workspaceScopeLabel} +
+ {props.reconnectStatus ?
{props.reconnectStatus}
: null} + {props.reconnectError ?
{props.reconnectError}
: null} + {props.messagingStatus ?
{props.messagingStatus}
: null} + {props.messagingError ?
{props.messagingError}
: null} +
+ + {!serverReady ? ( +
+
{t("identities.connect_server_title")}
+
{t("identities.connect_server_desc")}
+
+ ) : null} + + {serverReady ? ( + <> + {!scopedWorkspaceReady ? ( +
+ {t("identities.workspace_id_required")} +
+ ) : null} + + {props.messagingEnabled ? ( +
+
+ + +
+ +
+ ) : null} + + {!props.messagingEnabled ? ( +
+
{t("identities.messaging_disabled_title")}
+

{t("identities.messaging_disabled_risk")}

+

{t("identities.messaging_disabled_hint")}

+
+ +
+
+ ) : null} + + {props.activeTab === "general" && props.messagingEnabled ? ( + <> + {props.messagingRestartRequired ? ( +
+ {t("identities.messaging_sidecar_not_running")} +
+ +
+
+ ) : null} + +
+
+
+ {isWorkerOnline ? ( +
+ ) : ( +
+ )} + + {isWorkerOnline + ? t("identities.worker_online") + : props.healthError + ? t("identities.worker_unavailable") + : t("identities.worker_offline")} + +
+ + {statusLabel} + +
+ + {props.healthError ? ( +
+ {props.healthError} +
+ ) : null} + +
+ 0} + /> + 0} + /> + +
+
+ +
+
+ {t("identities.available_channels")} +
+ +
+
+ + + {props.expandedChannel === "telegram" ? ( +
+ {props.telegram.identitiesError ? ( +
+ {props.telegram.identitiesError} +
+ ) : null} + + {props.telegram.identities.length > 0 ? ( + <> +
+ {props.telegram.identities.map((item) => ( +
+
+
+
+ + {item.id} + +
+
+ {item.enabled ? t("identities.enabled_label") : t("identities.disabled_label")} · {item.running ? t("identities.running_label") : t("identities.stopped_label")} · {item.access === "private" ? t("identities.private_label") : t("identities.public_label")} +
+
+ +
+ ))} +
+ +
+
+
{t("identities.status_label")}
+
+
item.running) ? "bg-emerald-9" : "bg-gray-8" + }`} + /> + item.running) + ? "text-emerald-11" + : "text-gray-10" + }`} + > + {props.telegram.identities.some((item) => item.running) + ? t("identities.status_active") + : t("identities.status_stopped")} + +
+
+
+
{t("identities.identities_label")}
+
+ {props.telegram.identities.length} {t("identities.configured_suffix")} +
+
+
+
{t("identities.channel_label")}
+
+ {props.health?.channels.telegram ? t("common.on") : t("common.off")} +
+
+
+ + {props.telegram.status ?
{props.telegram.status}
: null} + {props.telegram.error ?
{props.telegram.error}
: null} + + ) : null} + +
+ {props.telegram.identities.length === 0 ? ( +
+
{t("identities.quick_setup")}
+
    +
  1. + 1 + + {t("identities.botfather_step1_open")}{" "} + + @BotFather + {" "} + {t("identities.botfather_step1_run")}{" "} + /newbot. + +
  2. +
  3. + 2 + {t("identities.copy_bot_token_hint")} +
  4. +
  5. + 3 + + {t("identities.botfather_step3_choose")} {t("identities.botfather_step3_public")}{" "} + {t("identities.botfather_step3_or_private")} {t("identities.botfather_step3_private")}{" "} + {t("identities.botfather_step3_to_require")} /pair <code>. + +
  6. +
+
+ ) : null} + + props.onTelegramTokenChange(event.currentTarget.value)} + className="rounded-lg border-gray-4 bg-gray-1 px-3 py-2.5 text-sm text-gray-12 placeholder:text-gray-8" + /> + + + +
+ {t("identities.telegram_bot_access_desc")} +
+ +
+ + + +
+ + {props.telegram.pairingCode ? ( +
+
{t("identities.private_pairing_code")}
+
+ {props.telegram.pairingCode} +
+
+ {t("identities.pairing_code_instruction_prefix")}{" "} + + /pair {props.telegram.pairingCode} + + . +
+
+ + +
+
+ ) : null} + + {telegramBotLink ? ( + + + {t("identities.open_bot_link", undefined, { username: props.telegram.botUsername ?? "" })} + + ) : null} + + {props.telegram.identities.length === 0 && props.telegram.status ? ( +
{props.telegram.status}
+ ) : null} + {props.telegram.identities.length === 0 && props.telegram.error ? ( +
{props.telegram.error}
+ ) : null} +
+
+ ) : null} +
+ +
+ + + {props.expandedChannel === "slack" ? ( +
+ {props.slack.identitiesError ? ( +
+ {props.slack.identitiesError} +
+ ) : null} + + {props.slack.identities.length > 0 ? ( + <> +
+ {props.slack.identities.map((item) => ( +
+
+
+
+ + {item.id} + +
+
+ {item.enabled ? t("identities.enabled_label") : t("identities.disabled_label")} · {item.running ? t("identities.running_label") : t("identities.stopped_label")} +
+
+ +
+ ))} +
+ +
+
+
{t("identities.status_label")}
+
+
item.running) ? "bg-emerald-9" : "bg-gray-8" + }`} + /> + item.running) + ? "text-emerald-11" + : "text-gray-10" + }`} + > + {props.slack.identities.some((item) => item.running) + ? t("identities.status_active") + : t("identities.status_stopped")} + +
+
+
+
{t("identities.identities_label")}
+
+ {props.slack.identities.length} {t("identities.configured_suffix")} +
+
+
+
{t("identities.channel_label")}
+
+ {props.health?.channels.slack ? t("common.on") : t("common.off")} +
+
+
+ + {props.slack.status ?
{props.slack.status}
: null} + {props.slack.error ?
{props.slack.error}
: null} + + ) : null} + +
+ {props.slack.identities.length === 0 ? ( +

{t("identities.slack_intro")}

+ ) : null} + +
+ props.onSlackBotTokenChange(event.currentTarget.value)} + className="rounded-lg border-gray-4 bg-gray-1 px-3 py-2.5 text-sm text-gray-12 placeholder:text-gray-8" + /> + props.onSlackAppTokenChange(event.currentTarget.value)} + className="rounded-lg border-gray-4 bg-gray-1 px-3 py-2.5 text-sm text-gray-12 placeholder:text-gray-8" + /> +
+ + + + + + {props.slack.identities.length === 0 && props.slack.status ? ( +
{props.slack.status}
+ ) : null} + {props.slack.identities.length === 0 && props.slack.error ? ( +
{props.slack.error}
+ ) : null} +
+
+ ) : null} +
+
+
+ + ) : null} + + {props.activeTab === "advanced" && props.messagingEnabled ? ( + <> +
+
+ {t("identities.message_routing_title")} +
+

{t("identities.message_routing_desc")}

+ +
+
+ + {t("identities.default_routing")} +
+
+ + {t("identities.all_channels")} + + + + {defaultRoutingDirectory} + +
+
+ +
+ {t("identities.routing_override_prefix")}{" "} + /dir <path>{" "} + {t("identities.routing_override_suffix")} +
+
+ +
+
+
+
{t("identities.agent_behavior_title")}
+
{t("identities.agent_behavior_desc")}
+
+ + {agentFilePath} + +
+ + {props.health?.agent ? ( +
+ {t("identities.agent_scope_status", undefined, { + status: props.health.agent.loaded ? t("identities.agent_status_loaded") : t("identities.agent_status_missing"), + agent: props.health.agent.selected || t("identities.agent_none"), + })} +
+ ) : null} + + {props.agent.loading ?
{t("identities.agent_loading")}
: null} + + {!props.agent.exists && !props.agent.loading ? ( +
+ {t("identities.agent_not_found")} +
+ ) : null} + +