,
-): ConfigSchemaAnalysis | null {
- if (schema.allOf) return null;
- const variants = schema.anyOf ?? schema.oneOf;
- if (!variants) return null;
- const values: unknown[] = [];
- const nonLiteral: JsonSchema[] = [];
- let nullable = false;
- for (const variant of variants) {
- if (!variant || typeof variant !== "object") return null;
- if (Array.isArray(variant.enum)) {
- const { enumValues, nullable: enumNullable } = normalizeEnumValues(
- variant.enum,
- );
- values.push(...enumValues);
- if (enumNullable) nullable = true;
- continue;
- }
- if ("const" in variant) {
- if (variant.const === null || variant.const === undefined) {
- nullable = true;
- continue;
- }
- values.push(variant.const);
- continue;
- }
- if (schemaType(variant) === "null") {
- nullable = true;
- continue;
- }
- nonLiteral.push(variant);
- }
-
- if (values.length > 0 && nonLiteral.length === 0) {
- const unique: unknown[] = [];
- for (const value of values) {
- if (!unique.some((entry) => Object.is(entry, value))) unique.push(value);
- }
- return {
- schema: {
- ...schema,
- enum: unique,
- nullable,
- anyOf: undefined,
- oneOf: undefined,
- allOf: undefined,
- },
- unsupportedPaths: [],
- };
- }
-
- if (nonLiteral.length === 1) {
- const result = normalizeSchemaNode(nonLiteral[0], path);
- if (result.schema) {
- result.schema.nullable = nullable || result.schema.nullable;
- }
- return result;
- }
-
- const primitiveTypes = ["string", "number", "integer", "boolean"];
- const allPrimitive = nonLiteral.every(
- (v) => v.type && primitiveTypes.includes(String(v.type)),
- );
- if (allPrimitive && nonLiteral.length > 0 && values.length === 0) {
- return {
- schema: { ...schema, nullable },
- unsupportedPaths: [],
- };
- }
-
- return null;
-}
-
-function normalizeEnumValues(values: unknown[]) {
- const filtered = values.filter((value) => value !== null && value !== undefined);
- const nullable = filtered.length !== values.length;
- const unique: unknown[] = [];
- for (const value of filtered) {
- if (!unique.some((entry) => Object.is(entry, value))) unique.push(value);
- }
- return { enumValues: unique, nullable };
-}
diff --git a/ui/src/ui/views/connections.discord.actions.ts b/ui/src/ui/views/connections.discord.actions.ts
new file mode 100644
index 000000000..283f3ff19
--- /dev/null
+++ b/ui/src/ui/views/connections.discord.actions.ts
@@ -0,0 +1,31 @@
+import { html } from "lit";
+
+import type { ConnectionsProps } from "./connections.types";
+import { discordActionOptions } from "./connections.action-options";
+
+export function renderDiscordActionsSection(props: ConnectionsProps) {
+ return html`
+ Tool actions
+
+ ${discordActionOptions.map(
+ (action) => html`
+ ${action.label}
+
+ props.onDiscordChange({
+ actions: {
+ ...props.discordForm.actions,
+ [action.key]: (e.target as HTMLSelectElement).value === "yes",
+ },
+ })}
+ >
+ Enabled
+ Disabled
+
+ `,
+ )}
+
+ `;
+}
+
diff --git a/ui/src/ui/views/connections.discord.guilds.ts b/ui/src/ui/views/connections.discord.guilds.ts
new file mode 100644
index 000000000..2d4fd32d1
--- /dev/null
+++ b/ui/src/ui/views/connections.discord.guilds.ts
@@ -0,0 +1,262 @@
+import { html, nothing } from "lit";
+
+import type { ConnectionsProps } from "./connections.types";
+
+export function renderDiscordGuildsEditor(props: ConnectionsProps) {
+ return html`
+
+
Guilds
+
+ Add each guild (id or slug) and optional channel rules. Empty channel
+ entries still allow that channel.
+
+
+ ${props.discordForm.guilds.map(
+ (guild, guildIndex) => html`
+
+
+
+
+ Guild id / slug
+ {
+ const next = [...props.discordForm.guilds];
+ next[guildIndex] = {
+ ...next[guildIndex],
+ key: (e.target as HTMLInputElement).value,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ />
+
+
+ Slug
+ {
+ const next = [...props.discordForm.guilds];
+ next[guildIndex] = {
+ ...next[guildIndex],
+ slug: (e.target as HTMLInputElement).value,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ />
+
+
+ Require mention
+ {
+ const next = [...props.discordForm.guilds];
+ next[guildIndex] = {
+ ...next[guildIndex],
+ requireMention:
+ (e.target as HTMLSelectElement).value === "yes",
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Yes
+ No
+
+
+
+ Reaction notifications
+ {
+ const next = [...props.discordForm.guilds];
+ next[guildIndex] = {
+ ...next[guildIndex],
+ reactionNotifications: (e.target as HTMLSelectElement)
+ .value as "off" | "own" | "all" | "allowlist",
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Off
+ Own
+ All
+ Allowlist
+
+
+
+ Users allowlist
+ {
+ const next = [...props.discordForm.guilds];
+ next[guildIndex] = {
+ ...next[guildIndex],
+ users: (e.target as HTMLInputElement).value,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ placeholder="123456789, username#1234"
+ />
+
+
+ ${guild.channels.length
+ ? html`
+
+ ${guild.channels.map(
+ (channel, channelIndex) => html`
+
+ Channel id / slug
+ {
+ const next = [...props.discordForm.guilds];
+ const channels = [
+ ...(next[guildIndex].channels ?? []),
+ ];
+ channels[channelIndex] = {
+ ...channels[channelIndex],
+ key: (e.target as HTMLInputElement).value,
+ };
+ next[guildIndex] = {
+ ...next[guildIndex],
+ channels,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ />
+
+
+ Allow
+ {
+ const next = [...props.discordForm.guilds];
+ const channels = [
+ ...(next[guildIndex].channels ?? []),
+ ];
+ channels[channelIndex] = {
+ ...channels[channelIndex],
+ allow:
+ (e.target as HTMLSelectElement).value ===
+ "yes",
+ };
+ next[guildIndex] = {
+ ...next[guildIndex],
+ channels,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Yes
+ No
+
+
+
+ Require mention
+ {
+ const next = [...props.discordForm.guilds];
+ const channels = [
+ ...(next[guildIndex].channels ?? []),
+ ];
+ channels[channelIndex] = {
+ ...channels[channelIndex],
+ requireMention:
+ (e.target as HTMLSelectElement).value ===
+ "yes",
+ };
+ next[guildIndex] = {
+ ...next[guildIndex],
+ channels,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Yes
+ No
+
+
+
+
+ {
+ const next = [...props.discordForm.guilds];
+ const channels = [
+ ...(next[guildIndex].channels ?? []),
+ ];
+ channels.splice(channelIndex, 1);
+ next[guildIndex] = {
+ ...next[guildIndex],
+ channels,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Remove
+
+
+ `,
+ )}
+
+ `
+ : nothing}
+
+
+ Channels
+ {
+ const next = [...props.discordForm.guilds];
+ const channels = [
+ ...(next[guildIndex].channels ?? []),
+ { key: "", allow: true, requireMention: false },
+ ];
+ next[guildIndex] = {
+ ...next[guildIndex],
+ channels,
+ };
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Add channel
+
+ {
+ const next = [...props.discordForm.guilds];
+ next.splice(guildIndex, 1);
+ props.onDiscordChange({ guilds: next });
+ }}
+ >
+ Remove guild
+
+
+
+ `,
+ )}
+
+
+ props.onDiscordChange({
+ guilds: [
+ ...props.discordForm.guilds,
+ {
+ key: "",
+ slug: "",
+ requireMention: false,
+ reactionNotifications: "own",
+ users: "",
+ channels: [],
+ },
+ ],
+ })}
+ >
+ Add guild
+
+
+ `;
+}
+
diff --git a/ui/src/ui/views/connections.discord.ts b/ui/src/ui/views/connections.discord.ts
new file mode 100644
index 000000000..54a84e316
--- /dev/null
+++ b/ui/src/ui/views/connections.discord.ts
@@ -0,0 +1,261 @@
+import { html, nothing } from "lit";
+
+import { formatAgo } from "../format";
+import type { DiscordStatus } from "../types";
+import type { ConnectionsProps } from "./connections.types";
+import { renderDiscordActionsSection } from "./connections.discord.actions";
+import { renderDiscordGuildsEditor } from "./connections.discord.guilds";
+
+export function renderDiscordCard(params: {
+ props: ConnectionsProps;
+ discord: DiscordStatus | null;
+ accountCountLabel: unknown;
+}) {
+ const { props, discord, accountCountLabel } = params;
+ const botName = discord?.probe?.bot?.username;
+
+ return html`
+
+
Discord
+
Bot connection and probe status.
+ ${accountCountLabel}
+
+
+
+ Configured
+ ${discord?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${discord?.running ? "Yes" : "No"}
+
+
+ Bot
+ ${botName ? `@${botName}` : "n/a"}
+
+
+ Last start
+ ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}
+
+
+
+ ${discord?.lastError
+ ? html`
+ ${discord.lastError}
+
`
+ : nothing}
+
+ ${discord?.probe
+ ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} ·
+ ${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
+
`
+ : nothing}
+
+
+
+ Enabled
+
+ props.onDiscordChange({
+ enabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Bot token
+
+ props.onDiscordChange({
+ token: (e.target as HTMLInputElement).value,
+ })}
+ />
+
+
+ Allow DMs from
+
+ props.onDiscordChange({
+ allowFrom: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="123456789, username#1234"
+ />
+
+
+ DMs enabled
+
+ props.onDiscordChange({
+ dmEnabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Enabled
+ Disabled
+
+
+
+ Group DMs
+
+ props.onDiscordChange({
+ groupEnabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Enabled
+ Disabled
+
+
+
+ Group channels
+
+ props.onDiscordChange({
+ groupChannels: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="channelId1, channelId2"
+ />
+
+
+ Media max MB
+
+ props.onDiscordChange({
+ mediaMaxMb: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="8"
+ />
+
+
+ History limit
+
+ props.onDiscordChange({
+ historyLimit: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="20"
+ />
+
+
+ Text chunk limit
+
+ props.onDiscordChange({
+ textChunkLimit: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="2000"
+ />
+
+
+ Reply to mode
+
+ props.onDiscordChange({
+ replyToMode: (e.target as HTMLSelectElement).value as
+ | "off"
+ | "first"
+ | "all",
+ })}
+ >
+ Off
+ First
+ All
+
+
+ ${renderDiscordGuildsEditor(props)}
+
+ Slash command
+
+ props.onDiscordChange({
+ slashEnabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Enabled
+ Disabled
+
+
+
+ Slash name
+
+ props.onDiscordChange({
+ slashName: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="clawd"
+ />
+
+
+ Slash session prefix
+
+ props.onDiscordChange({
+ slashSessionPrefix: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="discord:slash"
+ />
+
+
+ Slash ephemeral
+
+ props.onDiscordChange({
+ slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+
+ ${renderDiscordActionsSection(props)}
+
+ ${props.discordTokenLocked
+ ? html`
+ DISCORD_BOT_TOKEN is set in the environment. Config edits will not
+ override it.
+
`
+ : nothing}
+
+ ${props.discordStatus
+ ? html`
+ ${props.discordStatus}
+
`
+ : nothing}
+
+
+ props.onDiscordSave()}
+ >
+ ${props.discordSaving ? "Saving…" : "Save"}
+
+ props.onRefresh(true)}>Probe
+
+
+ `;
+}
diff --git a/ui/src/ui/views/connections.imessage.ts b/ui/src/ui/views/connections.imessage.ts
new file mode 100644
index 000000000..fcdbf0b12
--- /dev/null
+++ b/ui/src/ui/views/connections.imessage.ts
@@ -0,0 +1,184 @@
+import { html, nothing } from "lit";
+
+import { formatAgo } from "../format";
+import type { IMessageStatus } from "../types";
+import type { ConnectionsProps } from "./connections.types";
+
+export function renderIMessageCard(params: {
+ props: ConnectionsProps;
+ imessage: IMessageStatus | null;
+ accountCountLabel: unknown;
+}) {
+ const { props, imessage, accountCountLabel } = params;
+
+ return html`
+
+
iMessage
+
imsg CLI and database availability.
+ ${accountCountLabel}
+
+
+
+ Configured
+ ${imessage?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${imessage?.running ? "Yes" : "No"}
+
+
+ CLI
+ ${imessage?.cliPath ?? "n/a"}
+
+
+ DB
+ ${imessage?.dbPath ?? "n/a"}
+
+
+ Last start
+
+ ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
+
+
+
+ Last probe
+
+ ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
+
+
+
+
+ ${imessage?.lastError
+ ? html`
+ ${imessage.lastError}
+
`
+ : nothing}
+
+ ${imessage?.probe && !imessage.probe.ok
+ ? html`
+ Probe failed · ${imessage.probe.error ?? "unknown error"}
+
`
+ : nothing}
+
+
+
+ Enabled
+
+ props.onIMessageChange({
+ enabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ CLI path
+
+ props.onIMessageChange({
+ cliPath: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="imsg"
+ />
+
+
+ DB path
+
+ props.onIMessageChange({
+ dbPath: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="~/Library/Messages/chat.db"
+ />
+
+
+ Service
+
+ props.onIMessageChange({
+ service: (e.target as HTMLSelectElement).value as
+ | "auto"
+ | "imessage"
+ | "sms",
+ })}
+ >
+ Auto
+ iMessage
+ SMS
+
+
+
+ Region
+
+ props.onIMessageChange({
+ region: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="US"
+ />
+
+
+ Allow from
+
+ props.onIMessageChange({
+ allowFrom: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="chat_id:101, +1555"
+ />
+
+
+ Include attachments
+
+ props.onIMessageChange({
+ includeAttachments:
+ (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Media max MB
+
+ props.onIMessageChange({
+ mediaMaxMb: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="16"
+ />
+
+
+
+ ${props.imessageStatus
+ ? html`
+ ${props.imessageStatus}
+
`
+ : nothing}
+
+
+ props.onIMessageSave()}
+ >
+ ${props.imessageSaving ? "Saving…" : "Save"}
+
+ props.onRefresh(true)}>Probe
+
+
+ `;
+}
+
diff --git a/ui/src/ui/views/connections.signal.ts b/ui/src/ui/views/connections.signal.ts
new file mode 100644
index 000000000..ca04d6bc9
--- /dev/null
+++ b/ui/src/ui/views/connections.signal.ts
@@ -0,0 +1,237 @@
+import { html, nothing } from "lit";
+
+import { formatAgo } from "../format";
+import type { SignalStatus } from "../types";
+import type { ConnectionsProps } from "./connections.types";
+
+export function renderSignalCard(params: {
+ props: ConnectionsProps;
+ signal: SignalStatus | null;
+ accountCountLabel: unknown;
+}) {
+ const { props, signal, accountCountLabel } = params;
+
+ return html`
+
+
Signal
+
REST daemon status and probe details.
+ ${accountCountLabel}
+
+
+
+ Configured
+ ${signal?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${signal?.running ? "Yes" : "No"}
+
+
+ Base URL
+ ${signal?.baseUrl ?? "n/a"}
+
+
+ Last start
+ ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}
+
+
+
+ ${signal?.lastError
+ ? html`
+ ${signal.lastError}
+
`
+ : nothing}
+
+ ${signal?.probe
+ ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} ·
+ ${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
+
`
+ : nothing}
+
+
+
+ Enabled
+
+ props.onSignalChange({
+ enabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Account
+
+ props.onSignalChange({
+ account: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="+15551234567"
+ />
+
+
+ HTTP URL
+
+ props.onSignalChange({
+ httpUrl: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="http://127.0.0.1:8080"
+ />
+
+
+ HTTP host
+
+ props.onSignalChange({
+ httpHost: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="127.0.0.1"
+ />
+
+
+ HTTP port
+
+ props.onSignalChange({
+ httpPort: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="8080"
+ />
+
+
+ CLI path
+
+ props.onSignalChange({
+ cliPath: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="signal-cli"
+ />
+
+
+ Auto start
+
+ props.onSignalChange({
+ autoStart: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Receive mode
+
+ props.onSignalChange({
+ receiveMode: (e.target as HTMLSelectElement).value as
+ | "on-start"
+ | "manual"
+ | "",
+ })}
+ >
+ Default
+ on-start
+ manual
+
+
+
+ Ignore attachments
+
+ props.onSignalChange({
+ ignoreAttachments: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Ignore stories
+
+ props.onSignalChange({
+ ignoreStories: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Send read receipts
+
+ props.onSignalChange({
+ sendReadReceipts: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Allow from
+
+ props.onSignalChange({
+ allowFrom: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="12345, +1555"
+ />
+
+
+ Media max MB
+
+ props.onSignalChange({
+ mediaMaxMb: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="8"
+ />
+
+
+
+ ${props.signalStatus
+ ? html`
+ ${props.signalStatus}
+
`
+ : nothing}
+
+
+ props.onSignalSave()}
+ >
+ ${props.signalSaving ? "Saving…" : "Save"}
+
+ props.onRefresh(true)}>Probe
+
+
+ `;
+}
+
diff --git a/ui/src/ui/views/connections.slack.ts b/ui/src/ui/views/connections.slack.ts
new file mode 100644
index 000000000..4116c649d
--- /dev/null
+++ b/ui/src/ui/views/connections.slack.ts
@@ -0,0 +1,391 @@
+import { html, nothing } from "lit";
+
+import { formatAgo } from "../format";
+import type { SlackStatus } from "../types";
+import type { ConnectionsProps } from "./connections.types";
+import { slackActionOptions } from "./connections.action-options";
+
+export function renderSlackCard(params: {
+ props: ConnectionsProps;
+ slack: SlackStatus | null;
+ accountCountLabel: unknown;
+}) {
+ const { props, slack, accountCountLabel } = params;
+ const botName = slack?.probe?.bot?.name;
+ const teamName = slack?.probe?.team?.name;
+
+ return html`
+
+
Slack
+
Socket mode status and bot details.
+ ${accountCountLabel}
+
+
+
+ Configured
+ ${slack?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${slack?.running ? "Yes" : "No"}
+
+
+ Bot
+ ${botName ? botName : "n/a"}
+
+
+ Team
+ ${teamName ? teamName : "n/a"}
+
+
+ Last start
+ ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}
+
+
+
+ ${slack?.lastError
+ ? html`
+ ${slack.lastError}
+
`
+ : nothing}
+
+ ${slack?.probe
+ ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""}
+ ${slack.probe.error ?? ""}
+
`
+ : nothing}
+
+
+
+ Enabled
+
+ props.onSlackChange({
+ enabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+ Bot token
+
+ props.onSlackChange({
+ botToken: (e.target as HTMLInputElement).value,
+ })}
+ />
+
+
+ App token
+
+ props.onSlackChange({
+ appToken: (e.target as HTMLInputElement).value,
+ })}
+ />
+
+
+ DMs enabled
+
+ props.onSlackChange({
+ dmEnabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Enabled
+ Disabled
+
+
+
+ Allow DMs from
+
+ props.onSlackChange({
+ allowFrom: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="U123, U456, *"
+ />
+
+
+ Group DMs enabled
+
+ props.onSlackChange({
+ groupEnabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Enabled
+ Disabled
+
+
+
+ Group DM channels
+
+ props.onSlackChange({
+ groupChannels: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="G123, #team"
+ />
+
+
+ Reaction notifications
+
+ props.onSlackChange({
+ reactionNotifications: (e.target as HTMLSelectElement)
+ .value as "off" | "own" | "all" | "allowlist",
+ })}
+ >
+ Off
+ Own
+ All
+ Allowlist
+
+
+
+ Reaction allowlist
+
+ props.onSlackChange({
+ reactionAllowlist: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="U123, U456"
+ />
+
+
+ Text chunk limit
+
+ props.onSlackChange({
+ textChunkLimit: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="4000"
+ />
+
+
+ Media max (MB)
+
+ props.onSlackChange({
+ mediaMaxMb: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="20"
+ />
+
+
+
+
Slash command
+
+
+ Slash enabled
+
+ props.onSlackChange({
+ slashEnabled: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Enabled
+ Disabled
+
+
+
+ Slash name
+
+ props.onSlackChange({
+ slashName: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="clawd"
+ />
+
+
+ Slash session prefix
+
+ props.onSlackChange({
+ slashSessionPrefix: (e.target as HTMLInputElement).value,
+ })}
+ placeholder="slack:slash"
+ />
+
+
+ Slash ephemeral
+
+ props.onSlackChange({
+ slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
+ })}
+ >
+ Yes
+ No
+
+
+
+
+
Channels
+
Add channel ids or #names and optionally require mentions.
+
+ ${props.slackForm.channels.map(
+ (channel, channelIndex) => html`
+
+
+
+
+ Channel id / name
+ {
+ const next = [...props.slackForm.channels];
+ next[channelIndex] = {
+ ...next[channelIndex],
+ key: (e.target as HTMLInputElement).value,
+ };
+ props.onSlackChange({ channels: next });
+ }}
+ />
+
+
+ Allow
+ {
+ const next = [...props.slackForm.channels];
+ next[channelIndex] = {
+ ...next[channelIndex],
+ allow: (e.target as HTMLSelectElement).value === "yes",
+ };
+ props.onSlackChange({ channels: next });
+ }}
+ >
+ Yes
+ No
+
+
+
+ Require mention
+ {
+ const next = [...props.slackForm.channels];
+ next[channelIndex] = {
+ ...next[channelIndex],
+ requireMention:
+ (e.target as HTMLSelectElement).value === "yes",
+ };
+ props.onSlackChange({ channels: next });
+ }}
+ >
+ Yes
+ No
+
+
+
+
+ {
+ const next = [...props.slackForm.channels];
+ next.splice(channelIndex, 1);
+ props.onSlackChange({ channels: next });
+ }}
+ >
+ Remove
+
+
+
+
+
+ `,
+ )}
+
+
+ props.onSlackChange({
+ channels: [
+ ...props.slackForm.channels,
+ { key: "", allow: true, requireMention: false },
+ ],
+ })}
+ >
+ Add channel
+
+
+
Tool actions
+
+ ${slackActionOptions.map(
+ (action) => html`
+ ${action.label}
+
+ props.onSlackChange({
+ actions: {
+ ...props.slackForm.actions,
+ [action.key]: (e.target as HTMLSelectElement).value === "yes",
+ },
+ })}
+ >
+ Enabled
+ Disabled
+
+ `,
+ )}
+
+
+ ${props.slackTokenLocked || props.slackAppTokenLocked
+ ? html`
+ ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
+ ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the
+ environment. Config edits will not override it.
+
`
+ : nothing}
+
+ ${props.slackStatus
+ ? html`
+ ${props.slackStatus}
+
`
+ : nothing}
+
+
+ props.onSlackSave()}
+ >
+ ${props.slackSaving ? "Saving…" : "Save"}
+
+ props.onRefresh(true)}>Probe
+
+
+ `;
+}
+
diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts
index 42bbaf602..2c7aef25e 100644
--- a/ui/src/ui/views/connections.ts
+++ b/ui/src/ui/views/connections.ts
@@ -2,8 +2,6 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type {
- ChannelAccountSnapshot,
- ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
SignalStatus,
@@ -11,20 +9,16 @@ import type {
TelegramStatus,
WhatsAppStatus,
} from "../types";
-import type {
- DiscordForm,
- IMessageForm,
- SlackForm,
- SignalForm,
- TelegramForm,
-} from "../ui-types";
import type {
ChannelKey,
ConnectionsChannelData,
ConnectionsProps,
} from "./connections.types";
-import { channelEnabled, formatDuration, renderChannelAccountCount } from "./connections.shared";
-import { discordActionOptions, slackActionOptions } from "./connections.action-options";
+import { channelEnabled, renderChannelAccountCount } from "./connections.shared";
+import { renderDiscordCard } from "./connections.discord";
+import { renderIMessageCard } from "./connections.imessage";
+import { renderSignalCard } from "./connections.signal";
+import { renderSlackCard } from "./connections.slack";
import { renderTelegramCard } from "./connections.telegram";
import { renderWhatsAppCard } from "./connections.whatsapp";
@@ -117,1318 +111,30 @@ function renderChannel(
telegramAccounts: data.channelAccounts?.telegram ?? [],
accountCountLabel,
});
- case "discord": {
- const discord = data.discord;
- const botName = discord?.probe?.bot?.username;
- return html`
-
-
Discord
-
Bot connection and probe status.
- ${accountCountLabel}
-
-
-
- Configured
- ${discord?.configured ? "Yes" : "No"}
-
-
- Running
- ${discord?.running ? "Yes" : "No"}
-
-
- Bot
- ${botName ? `@${botName}` : "n/a"}
-
-
- Last start
- ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}
-
-
- Last probe
- ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}
-
-
-
- ${discord?.lastError
- ? html`
- ${discord.lastError}
-
`
- : nothing}
-
- ${discord?.probe
- ? html`
- Probe ${discord.probe.ok ? "ok" : "failed"} ·
- ${discord.probe.status ?? ""}
- ${discord.probe.error ?? ""}
-
`
- : nothing}
-
-
-
-
Tool actions
-
- ${discordActionOptions.map(
- (action) => html`
- ${action.label}
-
- props.onDiscordChange({
- actions: {
- ...props.discordForm.actions,
- [action.key]: (e.target as HTMLSelectElement).value === "yes",
- },
- })}
- >
- Enabled
- Disabled
-
- `,
- )}
-
-
- ${props.discordTokenLocked
- ? html`
- DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it.
-
`
- : nothing}
-
- ${props.discordStatus
- ? html`
- ${props.discordStatus}
-
`
- : nothing}
-
-
- props.onDiscordSave()}
- >
- ${props.discordSaving ? "Saving…" : "Save"}
-
- props.onRefresh(true)}>
- Probe
-
-
-
- `;
- }
- case "slack": {
- const slack = data.slack;
- const botName = slack?.probe?.bot?.name;
- const teamName = slack?.probe?.team?.name;
- return html`
-
-
Slack
-
Socket mode status and bot details.
- ${accountCountLabel}
-
-
-
- Configured
- ${slack?.configured ? "Yes" : "No"}
-
-
- Running
- ${slack?.running ? "Yes" : "No"}
-
-
- Bot
- ${botName ? botName : "n/a"}
-
-
- Team
- ${teamName ? teamName : "n/a"}
-
-
- Last start
- ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}
-
-
- Last probe
- ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}
-
-
-
- ${slack?.lastError
- ? html`
- ${slack.lastError}
-
`
- : nothing}
-
- ${slack?.probe
- ? html`
- Probe ${slack.probe.ok ? "ok" : "failed"} ·
- ${slack.probe.status ?? ""}
- ${slack.probe.error ?? ""}
-
`
- : nothing}
-
-
-
- Enabled
-
- props.onSlackChange({
- enabled: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Bot token
-
- props.onSlackChange({
- botToken: (e.target as HTMLInputElement).value,
- })}
- />
-
-
- App token
-
- props.onSlackChange({
- appToken: (e.target as HTMLInputElement).value,
- })}
- />
-
-
- DMs enabled
-
- props.onSlackChange({
- dmEnabled: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Enabled
- Disabled
-
-
-
- Allow DMs from
-
- props.onSlackChange({
- allowFrom: (e.target as HTMLInputElement).value,
- })}
- placeholder="U123, U456, *"
- />
-
-
- Group DMs enabled
-
- props.onSlackChange({
- groupEnabled: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Enabled
- Disabled
-
-
-
- Group DM channels
-
- props.onSlackChange({
- groupChannels: (e.target as HTMLInputElement).value,
- })}
- placeholder="G123, #team"
- />
-
-
- Reaction notifications
-
- props.onSlackChange({
- reactionNotifications: (e.target as HTMLSelectElement)
- .value as "off" | "own" | "all" | "allowlist",
- })}
- >
- Off
- Own
- All
- Allowlist
-
-
-
- Reaction allowlist
-
- props.onSlackChange({
- reactionAllowlist: (e.target as HTMLInputElement).value,
- })}
- placeholder="U123, U456"
- />
-
-
- Text chunk limit
-
- props.onSlackChange({
- textChunkLimit: (e.target as HTMLInputElement).value,
- })}
- placeholder="4000"
- />
-
-
- Media max (MB)
-
- props.onSlackChange({
- mediaMaxMb: (e.target as HTMLInputElement).value,
- })}
- placeholder="20"
- />
-
-
-
-
Slash command
-
-
- Slash enabled
-
- props.onSlackChange({
- slashEnabled: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Enabled
- Disabled
-
-
-
- Slash name
-
- props.onSlackChange({
- slashName: (e.target as HTMLInputElement).value,
- })}
- placeholder="clawd"
- />
-
-
- Slash session prefix
-
- props.onSlackChange({
- slashSessionPrefix: (e.target as HTMLInputElement).value,
- })}
- placeholder="slack:slash"
- />
-
-
- Slash ephemeral
-
- props.onSlackChange({
- slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
-
-
Channels
-
- Add channel ids or #names and optionally require mentions.
-
-
- ${props.slackForm.channels.map(
- (channel, channelIndex) => html`
-
-
-
-
- Channel id / name
- {
- const next = [...props.slackForm.channels];
- next[channelIndex] = {
- ...next[channelIndex],
- key: (e.target as HTMLInputElement).value,
- };
- props.onSlackChange({ channels: next });
- }}
- />
-
-
- Allow
- {
- const next = [...props.slackForm.channels];
- next[channelIndex] = {
- ...next[channelIndex],
- allow:
- (e.target as HTMLSelectElement).value === "yes",
- };
- props.onSlackChange({ channels: next });
- }}
- >
- Yes
- No
-
-
-
- Require mention
- {
- const next = [...props.slackForm.channels];
- next[channelIndex] = {
- ...next[channelIndex],
- requireMention:
- (e.target as HTMLSelectElement).value === "yes",
- };
- props.onSlackChange({ channels: next });
- }}
- >
- Yes
- No
-
-
-
-
- {
- const next = [...props.slackForm.channels];
- next.splice(channelIndex, 1);
- props.onSlackChange({ channels: next });
- }}
- >
- Remove
-
-
-
-
-
- `,
- )}
-
-
- props.onSlackChange({
- channels: [
- ...props.slackForm.channels,
- { key: "", allow: true, requireMention: false },
- ],
- })}
- >
- Add channel
-
-
-
Tool actions
-
- ${slackActionOptions.map(
- (action) => html`
- ${action.label}
-
- props.onSlackChange({
- actions: {
- ...props.slackForm.actions,
- [action.key]: (e.target as HTMLSelectElement).value === "yes",
- },
- })}
- >
- Enabled
- Disabled
-
- `,
- )}
-
-
- ${props.slackTokenLocked || props.slackAppTokenLocked
- ? html`
- ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
- ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""}
- is set in the environment. Config edits will not override it.
-
`
- : nothing}
-
- ${props.slackStatus
- ? html`
- ${props.slackStatus}
-
`
- : nothing}
-
-
- props.onSlackSave()}
- >
- ${props.slackSaving ? "Saving…" : "Save"}
-
- props.onRefresh(true)}>
- Probe
-
-
-
- `;
- }
- case "signal": {
- const signal = data.signal;
- return html`
-
-
Signal
-
REST daemon status and probe details.
- ${accountCountLabel}
-
-
-
- Configured
- ${signal?.configured ? "Yes" : "No"}
-
-
- Running
- ${signal?.running ? "Yes" : "No"}
-
-
- Base URL
- ${signal?.baseUrl ?? "n/a"}
-
-
- Last start
- ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}
-
-
- Last probe
- ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}
-
-
-
- ${signal?.lastError
- ? html`
- ${signal.lastError}
-
`
- : nothing}
-
- ${signal?.probe
- ? html`
- Probe ${signal.probe.ok ? "ok" : "failed"} ·
- ${signal.probe.status ?? ""}
- ${signal.probe.error ?? ""}
-
`
- : nothing}
-
-
-
- Enabled
-
- props.onSignalChange({
- enabled: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Account
-
- props.onSignalChange({
- account: (e.target as HTMLInputElement).value,
- })}
- placeholder="+15551234567"
- />
-
-
- HTTP URL
-
- props.onSignalChange({
- httpUrl: (e.target as HTMLInputElement).value,
- })}
- placeholder="http://127.0.0.1:8080"
- />
-
-
- HTTP host
-
- props.onSignalChange({
- httpHost: (e.target as HTMLInputElement).value,
- })}
- placeholder="127.0.0.1"
- />
-
-
- HTTP port
-
- props.onSignalChange({
- httpPort: (e.target as HTMLInputElement).value,
- })}
- placeholder="8080"
- />
-
-
- CLI path
-
- props.onSignalChange({
- cliPath: (e.target as HTMLInputElement).value,
- })}
- placeholder="signal-cli"
- />
-
-
- Auto start
-
- props.onSignalChange({
- autoStart: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Receive mode
-
- props.onSignalChange({
- receiveMode: (e.target as HTMLSelectElement).value as
- | "on-start"
- | "manual"
- | "",
- })}
- >
- Default
- on-start
- manual
-
-
-
- Ignore attachments
-
- props.onSignalChange({
- ignoreAttachments:
- (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Ignore stories
-
- props.onSignalChange({
- ignoreStories: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Send read receipts
-
- props.onSignalChange({
- sendReadReceipts:
- (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Allow from
-
- props.onSignalChange({
- allowFrom: (e.target as HTMLInputElement).value,
- })}
- placeholder="12345, +1555"
- />
-
-
- Media max MB
-
- props.onSignalChange({
- mediaMaxMb: (e.target as HTMLInputElement).value,
- })}
- placeholder="8"
- />
-
-
-
- ${props.signalStatus
- ? html`
- ${props.signalStatus}
-
`
- : nothing}
-
-
- props.onSignalSave()}
- >
- ${props.signalSaving ? "Saving…" : "Save"}
-
- props.onRefresh(true)}>
- Probe
-
-
-
- `;
- }
- case "imessage": {
- const imessage = data.imessage;
- return html`
-
-
iMessage
-
imsg CLI and database availability.
- ${accountCountLabel}
-
-
-
- Configured
- ${imessage?.configured ? "Yes" : "No"}
-
-
- Running
- ${imessage?.running ? "Yes" : "No"}
-
-
- CLI
- ${imessage?.cliPath ?? "n/a"}
-
-
- DB
- ${imessage?.dbPath ?? "n/a"}
-
-
- Last start
-
- ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
-
-
-
- Last probe
-
- ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
-
-
-
-
- ${imessage?.lastError
- ? html`
- ${imessage.lastError}
-
`
- : nothing}
-
- ${imessage?.probe && !imessage.probe.ok
- ? html`
- Probe failed · ${imessage.probe.error ?? "unknown error"}
-
`
- : nothing}
-
-
-
- Enabled
-
- props.onIMessageChange({
- enabled: (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- CLI path
-
- props.onIMessageChange({
- cliPath: (e.target as HTMLInputElement).value,
- })}
- placeholder="imsg"
- />
-
-
- DB path
-
- props.onIMessageChange({
- dbPath: (e.target as HTMLInputElement).value,
- })}
- placeholder="~/Library/Messages/chat.db"
- />
-
-
- Service
-
- props.onIMessageChange({
- service: (e.target as HTMLSelectElement).value as
- | "auto"
- | "imessage"
- | "sms",
- })}
- >
- Auto
- iMessage
- SMS
-
-
-
- Region
-
- props.onIMessageChange({
- region: (e.target as HTMLInputElement).value,
- })}
- placeholder="US"
- />
-
-
- Allow from
-
- props.onIMessageChange({
- allowFrom: (e.target as HTMLInputElement).value,
- })}
- placeholder="chat_id:101, +1555"
- />
-
-
- Include attachments
-
- props.onIMessageChange({
- includeAttachments:
- (e.target as HTMLSelectElement).value === "yes",
- })}
- >
- Yes
- No
-
-
-
- Media max MB
-
- props.onIMessageChange({
- mediaMaxMb: (e.target as HTMLInputElement).value,
- })}
- placeholder="16"
- />
-
-
-
- ${props.imessageStatus
- ? html`
- ${props.imessageStatus}
-
`
- : nothing}
-
-
- props.onIMessageSave()}
- >
- ${props.imessageSaving ? "Saving…" : "Save"}
-
- props.onRefresh(true)}>
- Probe
-
-
-
- `;
- }
+ case "discord":
+ return renderDiscordCard({
+ props,
+ discord: data.discord,
+ accountCountLabel,
+ });
+ case "slack":
+ return renderSlackCard({
+ props,
+ slack: data.slack,
+ accountCountLabel,
+ });
+ case "signal":
+ return renderSignalCard({
+ props,
+ signal: data.signal,
+ accountCountLabel,
+ });
+ case "imessage":
+ return renderIMessageCard({
+ props,
+ imessage: data.imessage,
+ accountCountLabel,
+ });
default:
return nothing;
}
diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts
index f1284145c..35e2e1af2 100644
--- a/ui/src/ui/views/debug.ts
+++ b/ui/src/ui/views/debug.ts
@@ -1,12 +1,7 @@
import { html, nothing } from "lit";
import { formatEventPayload } from "../presenter";
-
-type EventLogEntry = {
- ts: number;
- event: string;
- payload?: unknown;
-};
+import type { EventLogEntry } from "../app-events";
export type DebugProps = {
loading: boolean;
@@ -126,4 +121,3 @@ export function renderDebug(props: DebugProps) {
`;
}
-