Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,8 @@ const messages: TranslationMap = {
'channels.status.error': 'خطأ',
'channels.status.configuring': 'جارٍ الضبط',
'channels.defaultMessaging': 'قناة المراسلة الافتراضية',
'channels.setAsDefault': 'تعيين كافتراضي',
'channels.defaultBadge': 'افتراضي',
'webhooks.title': 'الـ Webhooks',
'webhooks.create': 'إنشاء Webhook',
'webhooks.noWebhooks': 'لا توجد Webhooks مضبوطة',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,8 @@ const messages: TranslationMap = {
'channels.status.error': 'ত্রুটি',
'channels.status.configuring': 'কনফিগার হচ্ছে',
'channels.defaultMessaging': 'ডিফল্ট মেসেজিং চ্যানেল',
'channels.setAsDefault': 'ডিফল্ট হিসেবে সেট করুন',
'channels.defaultBadge': 'ডিফল্ট',
'webhooks.title': 'ওয়েবহুক',
'webhooks.create': 'Webhook তৈরি করুন',
'webhooks.noWebhooks': 'কোনো webhook কনফিগার করা হয়নি',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Fehler',
'channels.status.configuring': 'Konfigurieren',
'channels.defaultMessaging': 'Standard-Messaging-Kanal',
'channels.setAsDefault': 'Als Standard festlegen',
'channels.defaultBadge': 'Standard',
'webhooks.title': 'Webhooks',
'webhooks.create': 'Erstelle einen Webhook',
'webhooks.noWebhooks': 'Keine Webhooks konfiguriert',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,8 @@ const en: TranslationMap = {
'channels.status.error': 'Error',
'channels.status.configuring': 'Configuring',
'channels.defaultMessaging': 'Default Messaging Channel',
'channels.setAsDefault': 'Set as default',
'channels.defaultBadge': 'Default',

// Webhooks
'webhooks.title': 'Webhooks',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,8 @@ const messages: TranslationMap = {
'channels.status.error': 'error',
'channels.status.configuring': 'Configurando',
'channels.defaultMessaging': 'Canal de mensajería predeterminado',
'channels.setAsDefault': 'Establecer como predeterminado',
'channels.defaultBadge': 'Predeterminado',
'webhooks.title': 'Ganchos web',
'webhooks.create': 'Crear webhook',
'webhooks.noWebhooks': 'Sin webhooks configurados',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Erreur',
'channels.status.configuring': 'Configuration en cours',
'channels.defaultMessaging': 'Canal de messagerie par défaut',
'channels.setAsDefault': 'Définir par défaut',
'channels.defaultBadge': 'Par défaut',
'webhooks.title': 'Webhooks',
'webhooks.create': 'Créer un webhook',
'webhooks.noWebhooks': 'Aucun webhook configuré',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,8 @@ const messages: TranslationMap = {
'channels.status.error': 'एरर',
'channels.status.configuring': 'कॉन्फिगर हो रहा है',
'channels.defaultMessaging': 'डिफ़ॉल्ट मैसेजिंग चैनल',
'channels.setAsDefault': 'डिफ़ॉल्ट के रूप में सेट करें',
'channels.defaultBadge': 'डिफ़ॉल्ट',
'webhooks.title': 'वेबहुक्स',
'webhooks.create': 'Webhook बनाएं',
'webhooks.noWebhooks': 'कोई webhook कॉन्फिगर नहीं है',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Kesalahan',
'channels.status.configuring': 'Mengonfigurasi',
'channels.defaultMessaging': 'Kanal Pesan Default',
'channels.setAsDefault': 'Jadikan bawaan',
'channels.defaultBadge': 'Bawaan',
'webhooks.title': 'Webhook',
'webhooks.create': 'Buat Webhook',
'webhooks.noWebhooks': 'Belum ada webhook yang dikonfigurasi',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Errore',
'channels.status.configuring': 'Configurazione',
'channels.defaultMessaging': 'Canale di messaggistica predefinito',
'channels.setAsDefault': 'Imposta come predefinito',
'channels.defaultBadge': 'Predefinito',
'webhooks.title': 'Webhook',
'webhooks.create': 'Crea webhook',
'webhooks.noWebhooks': 'Nessun webhook configurato',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,8 @@ const messages: TranslationMap = {
'channels.status.error': '오류',
'channels.status.configuring': '구성 중',
'channels.defaultMessaging': '기본 메시징 채널',
'channels.setAsDefault': '기본값으로 설정',
'channels.defaultBadge': '기본값',
'webhooks.title': '웹훅',
'webhooks.create': '웹훅 생성',
'webhooks.noWebhooks': '구성된 웹훅이 없습니다',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Błąd',
'channels.status.configuring': 'Konfigurowanie',
'channels.defaultMessaging': 'Domyślny kanał wiadomości',
'channels.setAsDefault': 'Ustaw jako domyślny',
'channels.defaultBadge': 'Domyślny',
'webhooks.title': 'Webhooki',
'webhooks.create': 'Utwórz webhook',
'webhooks.noWebhooks': 'Brak skonfigurowanych webhooków',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Erro',
'channels.status.configuring': 'Configurando',
'channels.defaultMessaging': 'Canal de Mensagens Padrão',
'channels.setAsDefault': 'Definir como padrão',
'channels.defaultBadge': 'Padrão',
'webhooks.title': 'Webhooks',
'webhooks.create': 'Criar Webhook',
'webhooks.noWebhooks': 'Nenhum webhook configurado',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,8 @@ const messages: TranslationMap = {
'channels.status.error': 'Ошибка',
'channels.status.configuring': 'Настройка',
'channels.defaultMessaging': 'Канал по умолчанию',
'channels.setAsDefault': 'Сделать по умолчанию',
'channels.defaultBadge': 'По умолчанию',
'webhooks.title': 'Вебхуки',
'webhooks.create': 'Создать вебхук',
'webhooks.noWebhooks': 'Вебхуки не настроены',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,8 @@ const messages: TranslationMap = {
'channels.status.error': '错误',
'channels.status.configuring': '配置中',
'channels.defaultMessaging': '默认消息渠道',
'channels.setAsDefault': '设为默认',
'channels.defaultBadge': '默认',
'webhooks.title': 'Webhook',
'webhooks.create': '创建 Webhook',
'webhooks.noWebhooks': '未配置任何 Webhook',
Expand Down
203 changes: 137 additions & 66 deletions app/src/pages/Skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,44 +256,109 @@ interface ChannelTileProps {
icon: React.ReactNode;
testId?: string;
onOpen: () => void;
/** Whether this channel is the current default messaging channel. */
isDefault: boolean;
/** Set this channel as the default. */
onSetDefault: () => void;
/** Test id for the "set as default" control (kept stable for E2E). */
setDefaultTestId?: string;
/** Disable the default control while a write is in flight. */
setDefaultBusy?: boolean;
}

function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
function ChannelTile({
def,
status,
icon,
testId,
onOpen,
isDefault,
onSetDefault,
setDefaultTestId,
setDefaultBusy,
}: ChannelTileProps) {
const { t } = useT();
const isConnected = status === 'connected';
const isPending = status === 'connecting';
const isError = status === 'error';
const statusLabel = channelStatusLabel(status, t);
const ctaLabel = isConnected ? t('skills.configure') : t('channels.setup');

// Horizontal tile: icon on the left; name → status → default control stacked
// on the right. The tile is a container (not one button) so "configure" (the
// icon + name row) and "set as default" stay distinct, focusable controls —
// collapsing the old two-selector layout (connect grid + separate picker)
// into one place. "Set as default" only appears for channels you can actually
// route through (connected); the default badge still shows on whichever
// channel is persisted as default, connected or not.
const showDefaultControl = isDefault || isConnected;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep backend-only channels selectable as defaults

In the macOS iMessage path, core still advertises imessage from channels_list (src/openhuman/channels/controllers/definitions.rs:310-318), but the frontend ChannelType/KNOWN_CHANNEL_TYPES omit it (app/src/types/channels.ts:1-15), and useChannelDefinitions skips status entries that fail that guard (app/src/hooks/useChannelDefinitions.ts:92-97). That means even a configured iMessage channel reaches this line with isConnected === false; because it is normally not already the default, the unified grid no longer renders any channel-select-imessage control. The removed selector rendered a button for every channelDefs entry, so this regresses users who want to make configured iMessage the default.

Useful? React with 👍 / 👎.


return (
<button
type="button"
data-testid={testId}
onClick={onOpen}
title={`${def.display_name} — ${def.description}`}
aria-label={`${def.display_name}, ${statusLabel}. ${ctaLabel}.`}
className={`group flex flex-col items-center gap-2 rounded-2xl border p-3 pb-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
<div
className={`group flex flex-col gap-2 rounded-2xl border p-3 transition-colors ${
isConnected
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50 dark:border-sage-500/30 dark:bg-sage-500/10 dark:hover:bg-sage-500/15'
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] dark:border-sage-500/30 dark:bg-sage-500/10'
: isPending
? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15'
? 'border-amber-200 bg-amber-50/40 dark:border-amber-500/30 dark:bg-amber-500/10'
: isError
? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50 dark:border-coral-500/30 dark:bg-coral-500/10 dark:hover:bg-coral-500/15'
: 'border-stone-200 bg-white hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60'
? 'border-coral-200 bg-coral-50/30 dark:border-coral-500/30 dark:bg-coral-500/10'
: 'border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900'
} ${
// The default channel keeps its connection-status colour but gains a
// primary ring so "which one is the default" reads at a glance without
// masking whether it is connected.
isDefault
? 'ring-2 ring-primary-400 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900'
: ''
}`}>
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center text-stone-700 dark:text-neutral-200 [&>span]:h-12 [&>span]:w-12 [&>span]:rounded-2xl [&_svg]:h-7 [&_svg]:w-7">
{icon}
</div>
<div className="flex min-h-[2.5rem] w-full min-w-0 flex-col items-center justify-start gap-0.5">
<span className="line-clamp-2 text-[11px] font-semibold leading-tight text-stone-900 dark:text-neutral-100">
{def.display_name}
</span>
<span className={`line-clamp-1 text-[10px] font-medium ${channelStatusColor(status)}`}>
{statusLabel}
</span>
</div>
</button>
<button
type="button"
data-testid={testId}
onClick={onOpen}
title={`${def.display_name} — ${def.description}`}
aria-label={`${def.display_name}, ${statusLabel}. ${ctaLabel}.`}
className="flex w-full items-center gap-3 rounded-xl text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40">
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center text-stone-700 dark:text-neutral-200 [&>span]:h-10 [&>span]:w-10 [&>span]:rounded-2xl [&_svg]:h-6 [&_svg]:w-6">
{icon}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="line-clamp-2 text-xs font-semibold leading-tight text-stone-900 dark:text-neutral-100">
{def.display_name}
</span>
<span className={`line-clamp-1 text-[11px] font-medium ${channelStatusColor(status)}`}>
{statusLabel}
</span>
</div>
</button>
{showDefaultControl && (
// Aligns under the name/status text (icon 2.5rem + gap 0.75rem).
<div className="pl-[3.25rem]">
{isDefault ? (
<span
data-testid={setDefaultTestId}
className="inline-flex items-center justify-center gap-1 rounded-lg border border-primary-400/60 bg-primary-100/70 px-2.5 py-1 text-[11px] font-semibold text-primary-700 dark:border-primary-500/40 dark:bg-primary-500/15 dark:text-primary-200">
<svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
d="M16.704 5.29a1 1 0 010 1.42l-7.5 7.5a1 1 0 01-1.42 0l-3.5-3.5a1 1 0 111.42-1.42l2.79 2.79 6.79-6.79a1 1 0 011.42 0z"
clipRule="evenodd"
/>
</svg>
{t('channels.defaultBadge')}
</span>
) : (
<button
type="button"
data-testid={setDefaultTestId}
onClick={onSetDefault}
disabled={setDefaultBusy}
className="inline-flex items-center justify-center rounded-lg border border-stone-200 bg-white/70 px-2.5 py-1 text-[11px] font-medium text-stone-500 transition-colors hover:border-primary-300 hover:text-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400 dark:hover:border-primary-500/40 dark:hover:text-primary-300">
{t('channels.setAsDefault')}
</button>
)}
</div>
)}
</div>
);
}

Expand Down Expand Up @@ -1031,49 +1096,55 @@ export default function Skills() {
{t('channels.defaultMessaging')}
</p>
</div>
<div
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{channelsGroup.items.map(item => (
<div key={item.id} data-testid={`skill-row-${item.id}`}>
<ChannelTile
def={item.channelDef!}
status={item.channelStatus!}
icon={item.icon}
testId={`skill-install-${item.id}`}
onOpen={() => setChannelModalDef(item.channelDef!)}
/>
{/* One unified surface: each tile shows connection status,
opens setup/configure on click, and owns the "default
messaging channel" selection via its footer control.
Connected channels and not-yet-connected channels are
rendered as two separate grids (no divider/label) so
each group occupies its own rows. */}
{(() => {
// The built-in web channel needs no connection — treat it
// as always available so it stays selectable as default.
const statusFor = (def: ChannelDefinition): ChannelConnectionStatus =>
def.id === 'web' ? 'connected' : bestChannelStatus(def.id as ChannelType);
const renderTile = (def: ChannelDefinition) => {
const channelId = def.id as ChannelType;
return (
<div key={channelId} data-testid={`skill-row-channel-${channelId}`}>
<ChannelTile
def={def}
status={statusFor(def)}
icon={channelIcons[def.icon]}
testId={`skill-install-channel-${channelId}`}
onOpen={() => setChannelModalDef(def)}
isDefault={channelConnections.defaultMessagingChannel === channelId}
onSetDefault={() => void handleSetDefaultChannel(channelId)}
setDefaultTestId={`channel-select-${channelId}`}
setDefaultBusy={defaultChannelBusy !== null}
/>
</div>
);
};
const connected = channelDefs.filter(d => statusFor(d) === 'connected');
const notConnected = channelDefs.filter(d => statusFor(d) !== 'connected');
const gridStyle = {
gridTemplateColumns: 'repeat(auto-fill, minmax(13rem, 1fr))',
};
return (
<div className="space-y-2 sm:space-y-3">
{connected.length > 0 && (
<div className="grid gap-2 sm:gap-3" style={gridStyle}>
{connected.map(renderTile)}
</div>
)}
{notConnected.length > 0 && (
<div className="grid gap-2 sm:gap-3" style={gridStyle}>
{notConnected.map(renderTile)}
</div>
)}
</div>
))}
</div>

<div className="mt-4 pt-3 border-t border-stone-100 dark:border-neutral-800">
<div className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400 mb-2">
{t('channels.defaultMessaging')}
</div>
<div className="grid grid-cols-2 gap-2">
{channelDefs.map(def => {
const channelId = def.id as ChannelType;
const selected =
channelConnections.defaultMessagingChannel === channelId;
return (
<button
key={channelId}
type="button"
data-testid={`channel-select-${channelId}`}
onClick={() => void handleSetDefaultChannel(channelId)}
disabled={defaultChannelBusy !== null}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
selected
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
}`}>
{def.display_name}
</button>
);
})}
</div>
</div>
);
})()}
</div>
)}

Expand Down
Loading
Loading