Skip to content
Open
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
119 changes: 119 additions & 0 deletions src/frontend/src/components/ChannelConfigDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<template>
<Teleport to="body">
<div class="fixed z-50 inset-0 overflow-y-auto" role="dialog" aria-modal="true" :aria-label="title">
<div class="flex items-end sm:items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="$emit('close')"
></div>

<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>

<!-- Dialog -->
<div
ref="panelEl"
tabindex="-1"
class="inline-block align-bottom sm:align-middle bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 w-full sm:max-w-2xl focus:outline-none"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="flex items-center gap-2 text-base font-medium text-gray-900 dark:text-white">
<span v-if="icon" class="text-lg leading-none" aria-hidden="true">{{ icon }}</span>
{{ title }}
</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-1 focus:ring-action-primary-500 rounded"
aria-label="Close"
@click="$emit('close')"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

<!-- Body (the channel config panel) -->
<div class="px-6 py-5 max-h-[70vh] overflow-y-auto">
<slot />
</div>
</div>
</div>
</div>
</Teleport>
</template>

<script setup>
import { onMounted, onUnmounted, nextTick, ref } from 'vue'

defineProps({
title: { type: String, required: true },
icon: { type: String, default: '' },
})

const emit = defineEmits(['close'])

const panelEl = ref(null)
// Element to restore focus to on close (the Configure/Manage button), and the
// body overflow value to restore after the scroll lock.
let prevActive = null
let prevOverflow = ''

const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'

const focusables = () => {
const root = panelEl.value
if (!root) return []
return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => el.offsetParent !== null)
}

// Keep Tab within the dialog (simple wrap-around trap).
const trapTab = (e) => {
const items = focusables()
if (items.length === 0) {
e.preventDefault()
panelEl.value?.focus()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
const outside = !panelEl.value?.contains(active)
if (e.shiftKey && (active === first || outside)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || outside)) {
e.preventDefault()
first.focus()
}
}

// The dialog only exists while open (the parent gates mount via
// v-if="dialogOpen"), so bind these for its whole lifetime.
const onKey = (e) => {
if (e.key === 'Escape') emit('close')
else if (e.key === 'Tab') trapTab(e)
}

onMounted(() => {
prevActive = document.activeElement
prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden' // scroll lock
document.addEventListener('keydown', onKey)
// Move focus into the dialog (first focusable, else the panel itself).
nextTick(() => {
const items = focusables()
;(items[0] || panelEl.value)?.focus()
})
})

onUnmounted(() => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prevOverflow
// Restore focus to whatever opened the dialog.
if (prevActive && typeof prevActive.focus === 'function') prevActive.focus()
})
</script>
90 changes: 90 additions & 0 deletions src/frontend/src/components/ChannelConfigRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<template>
<div class="flex items-center gap-3 px-4 py-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800">
<!-- Channel glyph -->
<span class="shrink-0 text-xl leading-none" aria-hidden="true">{{ icon }}</span>

<!-- Title + status -->
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ title }}</div>
<div class="mt-0.5 flex items-center gap-1.5 text-xs">
<template v-if="loading">
<span class="inline-block shrink-0 w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 animate-pulse"></span>
<span class="text-gray-400 dark:text-gray-500">Checking…</span>
</template>
<template v-else-if="status.connected">
<span
class="inline-block shrink-0 w-2 h-2 rounded-full"
:class="status.warn ? 'bg-status-warning-500' : 'bg-status-success-500'"
></span>
<span class="min-w-0 truncate text-gray-600 dark:text-gray-300">{{ status.label || 'Connected' }}</span>
<span v-if="status.warn" class="shrink-0 text-status-warning-600 dark:text-status-warning-400">· setup needed</span>
</template>
<template v-else>
<span class="inline-block shrink-0 w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></span>
<span class="text-gray-400 dark:text-gray-500">Not connected</span>
</template>
</div>
</div>

<!-- Configure -->
<button
type="button"
class="shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-action-primary-500"
@click="dialogOpen = true"
>
{{ status.connected ? 'Manage' : 'Configure' }}
</button>

<!-- Config dialog: mounted only while open (single gate) so the closed
dialog and its slotted panel never participate in this row's flex
layout. The channel panel renders inside the modal, untouched. -->
<ChannelConfigDialog v-if="dialogOpen" :title="title" :icon="icon" @close="onDialogClose">
<slot />
</ChannelConfigDialog>
</div>
</template>

<script setup>
import { ref, reactive, watch } from 'vue'
import api from '../api'

const props = defineProps({
title: { type: String, required: true },
icon: { type: String, default: '🔗' },
agentName: { type: String, required: true },
// Channel status endpoint (e.g. `/api/agents/{name}/slack/channel`).
statusUrl: { type: String, required: true },
// (responseData) => ({ connected: boolean, label?: string, warn?: boolean })
deriveStatus: { type: Function, required: true },
})

const loading = ref(true)
const dialogOpen = ref(false)
const status = reactive({ connected: false, label: '', warn: false })

async function fetchStatus() {
loading.value = true
try {
const { data } = await api.get(props.statusUrl)
const s = props.deriveStatus(data) || {}
status.connected = !!s.connected
status.label = s.label || ''
status.warn = !!s.warn
} catch {
// Not configured / not permitted / feature off — show "Not connected".
status.connected = false
status.label = ''
status.warn = false
} finally {
loading.value = false
}
}

// Refetch after the dialog closes so the row reflects edits made inside it.
function onDialogClose() {
dialogOpen.value = false
fetchStatus()
}

watch(() => [props.agentName, props.statusUrl], fetchStatus, { immediate: true })
</script>
67 changes: 56 additions & 11 deletions src/frontend/src/components/SharingPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,33 +96,53 @@
</div>
</div>

<!-- Channels: compact summary rows (detailed config moves to dialogs in #19) -->
<!-- Channels: compact summary rows; config opens in a dialog (#19) -->
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">Channels</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Connect this agent to messaging channels. Expand a row to configure it.
Connect this agent to messaging channels. Use <strong>Configure</strong> to set one up.
</p>
<div class="space-y-2">
<ChannelDisclosure title="Slack" subtitle="@mentions in a Slack channel" icon="💬">
<ChannelConfigRow
title="Slack"
icon="💬"
:agent-name="agentName"
:status-url="`/api/agents/${agentName}/slack/channel`"
:derive-status="slackStatus"
>
<SlackChannelPanel :agent-name="agentName" />
</ChannelDisclosure>
</ChannelConfigRow>

<ChannelDisclosure title="Telegram" subtitle="DMs and group chats via a bot" icon="✈️">
<ChannelConfigRow
title="Telegram"
icon="✈️"
:agent-name="agentName"
:status-url="`/api/agents/${agentName}/telegram`"
:derive-status="telegramStatus"
>
<TelegramChannelPanel :agent-name="agentName" />
</ChannelDisclosure>
</ChannelConfigRow>

<ChannelDisclosure title="WhatsApp" subtitle="DMs via Twilio" icon="📱">
<ChannelConfigRow
title="WhatsApp"
icon="📱"
:agent-name="agentName"
:status-url="`/api/agents/${agentName}/whatsapp`"
:derive-status="whatsappStatus"
>
<WhatsAppChannelPanel :agent-name="agentName" />
</ChannelDisclosure>
</ChannelConfigRow>

<ChannelDisclosure
<ChannelConfigRow
v-if="sessionsStore.voipAvailable"
title="Voice calls"
subtitle="Outbound phone calls via Twilio + Gemini Live"
icon="📞"
:agent-name="agentName"
:status-url="`/api/agents/${agentName}/voip`"
:derive-status="voipStatus"
>
<VoipChannelPanel :agent-name="agentName" />
</ChannelDisclosure>
</ChannelConfigRow>
</div>
</div>

Expand Down Expand Up @@ -152,6 +172,7 @@ import { useAuthStore } from '../stores/auth'
import { useNotification } from '../composables'
import { useSessionsStore } from '../stores/sessions'
import ChannelDisclosure from './ChannelDisclosure.vue'
import ChannelConfigRow from './ChannelConfigRow.vue'
import PublicLinksPanel from './PublicLinksPanel.vue'
import SlackChannelPanel from './SlackChannelPanel.vue'
import TelegramChannelPanel from './TelegramChannelPanel.vue'
Expand Down Expand Up @@ -182,6 +203,30 @@ const loadAgent = () => {
emit('agent-updated')
}

// ---------------------------------------------------------------------------
// Channel summary-row status derivations (#19). Each maps a channel's GET
// response to { connected, label, warn } for the compact row; the full config
// panel renders in the row's Configure dialog.
// ---------------------------------------------------------------------------
const slackStatus = (d) => ({
connected: !!d?.bound,
label: d?.bound ? `#${d.channel_name}` : '',
})
const telegramStatus = (d) => ({
connected: !!d?.configured,
label: d?.configured && d.bot_username ? `@${d.bot_username}` : '',
warn: !!d?.configured && !d?.webhook_url,
})
const whatsappStatus = (d) => ({
connected: !!d?.configured,
label: d?.configured ? (d.from_number || '') : '',
})
const voipStatus = (d) => ({
connected: !!d?.configured,
label: d?.configured ? `${d.from_number || ''}${d.enabled ? '' : ' (disabled)'}` : '',
warn: !!d?.configured && !d?.enabled,
})

// ---------------------------------------------------------------------------
// External access policy + pending requests (Issue #311, reframed by #18)
// ---------------------------------------------------------------------------
Expand Down