diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift index 0ca4be206..e4dee9929 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift @@ -426,24 +426,17 @@ extension ChannelsSettings { } private func resolveChannelTitle(_ id: String) -> String { - if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty { - return label - } + let label = self.store.resolveChannelLabel(id) + if label != id { return label } return id.prefix(1).uppercased() + id.dropFirst() } private func resolveChannelDetailTitle(_ id: String) -> String { - if let detail = self.store.snapshot?.channelDetailLabels?[id], !detail.isEmpty { - return detail - } - return self.resolveChannelTitle(id) + return self.store.resolveChannelDetailLabel(id) } private func resolveChannelSystemImage(_ id: String) -> String { - if let symbol = self.store.snapshot?.channelSystemImages?[id], !symbol.isEmpty { - return symbol - } - return "message" + return self.store.resolveChannelSystemImage(id) } private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore.swift b/apps/macos/Sources/Clawdbot/ChannelsStore.swift index a55ac4681..34c5759cc 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsStore.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsStore.swift @@ -153,11 +153,19 @@ struct ChannelsStatusSnapshot: Codable { let application: AnyCodable? } + struct ChannelUiMetaEntry: Codable { + let id: String + let label: String + let detailLabel: String + let systemImage: String? + } + let ts: Double let channelOrder: [String] let channelLabels: [String: String] let channelDetailLabels: [String: String]? = nil let channelSystemImages: [String: String]? = nil + let channelMeta: [ChannelUiMetaEntry]? = nil let channels: [String: AnyCodable] let channelAccounts: [String: [ChannelAccountSnapshot]] let channelDefaultAccountId: [String: String] @@ -219,6 +227,47 @@ final class ChannelsStore { var configRoot: [String: Any] = [:] var configLoaded = false + func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? { + self.snapshot?.channelMeta?.first(where: { $0.id == id }) + } + + func resolveChannelLabel(_ id: String) -> String { + if let meta = self.channelMetaEntry(id), !meta.label.isEmpty { + return meta.label + } + if let label = self.snapshot?.channelLabels[id], !label.isEmpty { + return label + } + return id + } + + func resolveChannelDetailLabel(_ id: String) -> String { + if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty { + return meta.detailLabel + } + if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty { + return detail + } + return self.resolveChannelLabel(id) + } + + func resolveChannelSystemImage(_ id: String) -> String { + if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty { + return symbol + } + if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty { + return symbol + } + return "message" + } + + func orderedChannelIds() -> [String] { + if let meta = self.snapshot?.channelMeta, !meta.isEmpty { + return meta.map { $0.id } + } + return self.snapshot?.channelOrder ?? [] + } + init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { self.isPreview = isPreview } diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index 77d4c5b37..cec2b96a6 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -55,8 +55,7 @@ struct CronJobEditor: View { @State var postPrefix: String = "Cron" var channelOptions: [String] { - let snapshot = self.channelsStore.snapshot - let ordered = snapshot?.channelOrder ?? [] + let ordered = self.channelsStore.orderedChannelIds() var options = ["last"] + ordered let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, !options.contains(trimmed) { @@ -68,7 +67,7 @@ struct CronJobEditor: View { func channelLabel(for id: String) -> String { if id == "last" { return "last" } - return self.channelsStore.snapshot?.channelLabels[id] ?? id + return self.channelsStore.resolveChannelLabel(id) } var body: some View { diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index de4d960dc..85696eb6a 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -1326,6 +1326,7 @@ public struct ChannelsStatusResult: Codable, Sendable { public let channellabels: [String: AnyCodable] public let channeldetaillabels: [String: AnyCodable]? public let channelsystemimages: [String: AnyCodable]? + public let channelmeta: [[String: AnyCodable]]? public let channels: [String: AnyCodable] public let channelaccounts: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable] @@ -1336,6 +1337,7 @@ public struct ChannelsStatusResult: Codable, Sendable { channellabels: [String: AnyCodable], channeldetaillabels: [String: AnyCodable]?, channelsystemimages: [String: AnyCodable]?, + channelmeta: [[String: AnyCodable]]?, channels: [String: AnyCodable], channelaccounts: [String: AnyCodable], channeldefaultaccountid: [String: AnyCodable] @@ -1345,6 +1347,7 @@ public struct ChannelsStatusResult: Codable, Sendable { self.channellabels = channellabels self.channeldetaillabels = channeldetaillabels self.channelsystemimages = channelsystemimages + self.channelmeta = channelmeta self.channels = channels self.channelaccounts = channelaccounts self.channeldefaultaccountid = channeldefaultaccountid @@ -1355,6 +1358,7 @@ public struct ChannelsStatusResult: Codable, Sendable { case channellabels = "channelLabels" case channeldetaillabels = "channelDetailLabels" case channelsystemimages = "channelSystemImages" + case channelmeta = "channelMeta" case channels case channelaccounts = "channelAccounts" case channeldefaultaccountid = "channelDefaultAccountId" diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 4af68be2b..1e69fbfa8 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -1,4 +1,6 @@ import { + BLUEBUBBLES_ACTION_NAMES, + BLUEBUBBLES_ACTIONS, createActionGate, jsonResult, readBooleanParam, @@ -49,19 +51,7 @@ function readMessageText(params: Record): string | undefined { } /** Supported action names for BlueBubbles */ -const SUPPORTED_ACTIONS = new Set([ - "react", - "edit", - "unsend", - "reply", - "sendWithEffect", - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", - "sendAttachment", -]); +const SUPPORTED_ACTIONS = new Set(BLUEBUBBLES_ACTION_NAMES); export const bluebubblesMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { @@ -69,19 +59,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { if (!account.enabled || !account.configured) return []; const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions); const actions = new Set(); - // Check if running on macOS 26+ (edit not supported) const macOS26 = isMacOS26OrHigher(account.accountId); - if (gate("reactions")) actions.add("react"); - if (gate("edit") && !macOS26) actions.add("edit"); - if (gate("unsend")) actions.add("unsend"); - if (gate("reply")) actions.add("reply"); - if (gate("sendWithEffect")) actions.add("sendWithEffect"); - if (gate("renameGroup")) actions.add("renameGroup"); - if (gate("setGroupIcon")) actions.add("setGroupIcon"); - if (gate("addParticipant")) actions.add("addParticipant"); - if (gate("removeParticipant")) actions.add("removeParticipant"); - if (gate("leaveGroup")) actions.add("leaveGroup"); - if (gate("sendAttachment")) actions.add("sendAttachment"); + for (const action of BLUEBUBBLES_ACTION_NAMES) { + const spec = BLUEBUBBLES_ACTIONS[action]; + if (!spec?.gate) continue; + if (spec.unsupportedOnMacOS26 && macOS26) continue; + if (gate(spec.gate)) actions.add(action); + } return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 5c4387c02..36a964def 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -928,8 +928,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { warnedCryptoMissingRooms.add(roomId); - const warning = - "matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart"; + const hint = core.system.formatNativeDependencyHint({ + packageName: "@matrix-org/matrix-sdk-crypto-nodejs", + manager: "pnpm", + downloadCommand: + "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", + }); + const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; logger.warn({ roomId }, warning); } return; diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index eb4952fe7..6f1116053 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -7,6 +7,7 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../../channels/plugins/types.js"; +import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { @@ -25,14 +26,6 @@ import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; -const BLUEBUBBLES_GROUP_ACTIONS = new Set([ - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", -]); - function buildRoutingSchema() { return { channel: Type.Optional(Type.String()), diff --git a/src/channels/plugins/bluebubbles-actions.ts b/src/channels/plugins/bluebubbles-actions.ts new file mode 100644 index 000000000..651cdc827 --- /dev/null +++ b/src/channels/plugins/bluebubbles-actions.ts @@ -0,0 +1,29 @@ +import type { ChannelMessageActionName } from "./types.js"; + +export type BlueBubblesActionSpec = { + gate: string; + groupOnly?: boolean; + unsupportedOnMacOS26?: boolean; +}; + +export const BLUEBUBBLES_ACTIONS = { + react: { gate: "reactions" }, + edit: { gate: "edit", unsupportedOnMacOS26: true }, + unsend: { gate: "unsend" }, + reply: { gate: "reply" }, + sendWithEffect: { gate: "sendWithEffect" }, + renameGroup: { gate: "renameGroup", groupOnly: true }, + setGroupIcon: { gate: "setGroupIcon", groupOnly: true }, + addParticipant: { gate: "addParticipant", groupOnly: true }, + removeParticipant: { gate: "removeParticipant", groupOnly: true }, + leaveGroup: { gate: "leaveGroup", groupOnly: true }, + sendAttachment: { gate: "sendAttachment" }, +} as const satisfies Partial>; + +export const BLUEBUBBLES_ACTION_NAMES = Object.keys( + BLUEBUBBLES_ACTIONS, +) as ChannelMessageActionName[]; + +export const BLUEBUBBLES_GROUP_ACTIONS = new Set( + BLUEBUBBLES_ACTION_NAMES.filter((action) => BLUEBUBBLES_ACTIONS[action]?.groupOnly), +); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 6c65213fc..5729276d1 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -5,6 +5,22 @@ import type { PluginOrigin } from "../../plugins/types.js"; import type { ClawdbotPackageManifest } from "../../plugins/manifest.js"; import type { ChannelMeta } from "./types.js"; +export type ChannelUiMetaEntry = { + id: string; + label: string; + detailLabel: string; + systemImage?: string; +}; + +export type ChannelUiCatalog = { + entries: ChannelUiMetaEntry[]; + order: string[]; + labels: Record; + detailLabels: Record; + systemImages: Record; + byId: Record; +}; + export type ChannelPluginCatalogEntry = { id: string; meta: ChannelMeta; @@ -116,6 +132,34 @@ function buildCatalogEntry(candidate: { return { id, meta, install }; } +export function buildChannelUiCatalog( + plugins: Array<{ id: string; meta: ChannelMeta }>, +): ChannelUiCatalog { + const entries: ChannelUiMetaEntry[] = plugins.map((plugin) => { + const detailLabel = plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label; + return { + id: plugin.id, + label: plugin.meta.label, + detailLabel, + ...(plugin.meta.systemImage ? { systemImage: plugin.meta.systemImage } : {}), + }; + }); + const order = entries.map((entry) => entry.id); + const labels: Record = {}; + const detailLabels: Record = {}; + const systemImages: Record = {}; + const byId: Record = {}; + for (const entry of entries) { + labels[entry.id] = entry.label; + detailLabels[entry.id] = entry.detailLabel; + if (entry.systemImage) { + systemImages[entry.id] = entry.systemImage; + } + byId[entry.id] = entry; + } + return { entries, order, labels, detailLabels, systemImages, byId }; +} + export function listChannelPluginCatalogEntries( options: CatalogOptions = {}, ): ChannelPluginCatalogEntry[] { diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 6f02a7c86..76c6ac4d8 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -55,6 +55,16 @@ export const ChannelAccountSnapshotSchema = Type.Object( { additionalProperties: true }, ); +export const ChannelUiMetaSchema = Type.Object( + { + id: NonEmptyString, + label: NonEmptyString, + detailLabel: NonEmptyString, + systemImage: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + export const ChannelsStatusResultSchema = Type.Object( { ts: Type.Integer({ minimum: 0 }), @@ -62,6 +72,7 @@ export const ChannelsStatusResultSchema = Type.Object( channelLabels: Type.Record(NonEmptyString, NonEmptyString), channelDetailLabels: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), channelSystemImages: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), + channelMeta: Type.Optional(Type.Array(ChannelUiMetaSchema)), channels: Type.Record(NonEmptyString, Type.Unknown()), channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)), channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString), diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index 3e7806d6d..bd282c569 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -5,6 +5,7 @@ import { listChannelPlugins, normalizeChannelId, } from "../../channels/plugins/index.js"; +import { buildChannelUiCatalog } from "../../channels/plugins/catalog.js"; import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; @@ -188,24 +189,14 @@ export const channelsHandlers: GatewayRequestHandlers = { return { accounts, defaultAccountId, defaultAccount, resolvedAccounts }; }; - const channelLabels = Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.meta.label])); - const channelDetailLabels = Object.fromEntries( - plugins.map((plugin) => [ - plugin.id, - plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label, - ]), - ); - const channelSystemImages = Object.fromEntries( - plugins.flatMap((plugin) => - plugin.meta.systemImage ? [[plugin.id, plugin.meta.systemImage]] : [], - ), - ); + const uiCatalog = buildChannelUiCatalog(plugins); const payload: Record = { ts: Date.now(), - channelOrder: plugins.map((plugin) => plugin.id), - channelLabels, - channelDetailLabels, - channelSystemImages, + channelOrder: uiCatalog.order, + channelLabels: uiCatalog.labels, + channelDetailLabels: uiCatalog.detailLabels, + channelSystemImages: uiCatalog.systemImages, + channelMeta: uiCatalog.entries, channels: {} as Record, channelAccounts: {} as Record, channelDefaultAccountId: {} as Record, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 251dc0fda..934d88c51 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -1,4 +1,9 @@ export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js"; +export { + BLUEBUBBLES_ACTIONS, + BLUEBUBBLES_ACTION_NAMES, + BLUEBUBBLES_GROUP_ACTIONS, +} from "../channels/plugins/bluebubbles-actions.js"; export type { ChannelAccountSnapshot, ChannelAccountState, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 655073c12..4765c71c7 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -106,6 +106,7 @@ import { loginWeb } from "../../web/login.js"; import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { registerMemoryCli } from "../../cli/memory-cli.js"; +import { formatNativeDependencyHint } from "./native-deps.js"; import type { PluginRuntime } from "./types.js"; @@ -134,6 +135,7 @@ export function createPluginRuntime(): PluginRuntime { system: { enqueueSystemEvent, runCommandWithTimeout, + formatNativeDependencyHint, }, media: { loadWebMedia, diff --git a/src/plugins/runtime/native-deps.ts b/src/plugins/runtime/native-deps.ts new file mode 100644 index 000000000..2d4bbb273 --- /dev/null +++ b/src/plugins/runtime/native-deps.ts @@ -0,0 +1,28 @@ +export type NativeDependencyHintParams = { + packageName: string; + manager?: "pnpm" | "npm" | "yarn"; + rebuildCommand?: string; + approveBuildsCommand?: string; + downloadCommand?: string; +}; + +export function formatNativeDependencyHint(params: NativeDependencyHintParams): string { + const manager = params.manager ?? "pnpm"; + const rebuildCommand = + params.rebuildCommand ?? + (manager === "npm" + ? `npm rebuild ${params.packageName}` + : manager === "yarn" + ? `yarn rebuild ${params.packageName}` + : `pnpm rebuild ${params.packageName}`); + const approveBuildsCommand = + params.approveBuildsCommand ?? + (manager === "pnpm" ? `pnpm approve-builds (select ${params.packageName})` : undefined); + const steps = [approveBuildsCommand, rebuildCommand, params.downloadCommand].filter( + (step): step is string => Boolean(step), + ); + if (steps.length === 0) { + return `Install ${params.packageName} and rebuild its native module.`; + } + return `Install ${params.packageName} and rebuild its native module (${steps.join("; ")}).`; +} diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index f707a20bb..089e20c37 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -59,6 +59,7 @@ type RecordChannelActivity = typeof import("../../infra/channel-activity.js").re type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity; type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent; type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout; +type FormatNativeDependencyHint = typeof import("./native-deps.js").formatNativeDependencyHint; type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia; type DetectMime = typeof import("../../media/mime.js").detectMime; type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFromMime; @@ -146,6 +147,7 @@ export type PluginRuntime = { system: { enqueueSystemEvent: EnqueueSystemEvent; runCommandWithTimeout: RunCommandWithTimeout; + formatNativeDependencyHint: FormatNativeDependencyHint; }; media: { loadWebMedia: LoadWebMedia; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 02f529618..2be9d1a16 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -272,8 +272,11 @@ export function renderApp(state: AppViewState) { error: state.cronError, busy: state.cronBusy, form: state.cronForm, - channels: state.channelsSnapshot?.channelOrder ?? [], + channels: state.channelsSnapshot?.channelMeta?.length + ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) + : state.channelsSnapshot?.channelOrder ?? [], channelLabels: state.channelsSnapshot?.channelLabels ?? {}, + channelMeta: state.channelsSnapshot?.channelMeta ?? [], runsJobId: state.cronRunsJobId, runs: state.cronRuns, onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 2a492cfdc..dfd832c97 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -4,11 +4,19 @@ export type ChannelsStatusSnapshot = { channelLabels: Record; channelDetailLabels?: Record; channelSystemImages?: Record; + channelMeta?: ChannelUiMetaEntry[]; channels: Record; channelAccounts: Record; channelDefaultAccountId: Record; }; +export type ChannelUiMetaEntry = { + id: string; + label: string; + detailLabel: string; + systemImage?: string; +}; + export const CRON_CHANNEL_LAST = "last"; export type ChannelAccountSnapshot = { diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 0a0aa7b7f..4489d7000 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import type { ChannelAccountSnapshot, + ChannelUiMetaEntry, ChannelsStatusSnapshot, DiscordStatus, IMessageStatus, @@ -85,6 +86,9 @@ ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."} } function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] { + if (snapshot?.channelMeta?.length) { + return snapshot.channelMeta.map((entry) => entry.id) as ChannelKey[]; + } if (snapshot?.channelOrder?.length) { return snapshot.channelOrder; } @@ -148,7 +152,7 @@ function renderGenericChannelCard( props: ChannelsProps, channelAccounts: Record, ) { - const label = props.snapshot?.channelLabels?.[key] ?? key; + const label = resolveChannelLabel(props.snapshot, key); const status = props.snapshot?.channels?.[key] as Record | undefined; const configured = typeof status?.configured === "boolean" ? status.configured : undefined; const running = typeof status?.running === "boolean" ? status.running : undefined; @@ -197,6 +201,21 @@ function renderGenericChannelCard( `; } +function resolveChannelMetaMap( + snapshot: ChannelsStatusSnapshot | null, +): Record { + if (!snapshot?.channelMeta?.length) return {}; + return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); +} + +function resolveChannelLabel( + snapshot: ChannelsStatusSnapshot | null, + key: string, +): string { + const meta = resolveChannelMetaMap(snapshot)[key]; + return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; +} + const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes function hasRecentActivity(account: ChannelAccountSnapshot): boolean { diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index dffa865c5..d25e3eb45 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -7,7 +7,7 @@ import { formatCronState, formatNextRun, } from "../presenter"; -import type { CronJob, CronRunLogEntry, CronStatus } from "../types"; +import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types"; import type { CronFormState } from "../ui-types"; export type CronProps = { @@ -19,6 +19,7 @@ export type CronProps = { form: CronFormState; channels: string[]; channelLabels?: Record; + channelMeta?: ChannelUiMetaEntry[]; runsJobId: string | null; runs: CronRunLogEntry[]; onFormChange: (patch: Partial) => void; @@ -46,6 +47,8 @@ function buildChannelOptions(props: CronProps): string[] { function resolveChannelLabel(props: CronProps, channel: string): string { if (channel === "last") return "last"; + const meta = props.channelMeta?.find((entry) => entry.id === channel); + if (meta?.label) return meta.label; return props.channelLabels?.[channel] ?? channel; }