- Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms.<name>.enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer.
194 lines
5.6 KiB
TypeScript
194 lines
5.6 KiB
TypeScript
import type { ComponentProps, ReactNode } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface StatusbarMenuItem {
|
|
id: string
|
|
icon?: ReactNode
|
|
label: string
|
|
className?: string
|
|
disabled?: boolean
|
|
hidden?: boolean
|
|
href?: string
|
|
onSelect?: () => void
|
|
title?: string
|
|
to?: string
|
|
}
|
|
|
|
export interface StatusbarItem {
|
|
id: string
|
|
label?: ReactNode
|
|
detail?: ReactNode
|
|
icon?: ReactNode
|
|
className?: string
|
|
disabled?: boolean
|
|
hidden?: boolean
|
|
href?: string
|
|
menuClassName?: string
|
|
menuContent?: ReactNode
|
|
menuItems?: readonly StatusbarMenuItem[]
|
|
onSelect?: () => void
|
|
title?: string
|
|
to?: string
|
|
variant?: 'action' | 'link' | 'menu' | 'text'
|
|
}
|
|
|
|
export type StatusbarItemSide = 'left' | 'right'
|
|
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
|
|
|
|
interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
|
leftItems?: readonly StatusbarItem[]
|
|
items?: readonly StatusbarItem[]
|
|
}
|
|
|
|
const statusbarItemClass =
|
|
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
|
|
|
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
|
const navigate = useNavigate()
|
|
|
|
return (
|
|
<footer
|
|
className={cn(
|
|
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
|
{leftItems
|
|
.filter(item => !item.hidden)
|
|
.map(item => (
|
|
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
|
))}
|
|
</div>
|
|
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
|
{items
|
|
.filter(item => !item.hidden)
|
|
.map(item => (
|
|
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
|
))}
|
|
</div>
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
|
const content = (
|
|
<>
|
|
{item.icon}
|
|
{item.label && <span className="truncate">{item.label}</span>}
|
|
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
|
|
</>
|
|
)
|
|
|
|
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
|
|
|
|
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className={cn(statusbarItemClass, item.className)}
|
|
disabled={item.disabled}
|
|
title={title}
|
|
type="button"
|
|
>
|
|
{content}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
|
side="top"
|
|
sideOffset={8}
|
|
>
|
|
{item.menuContent
|
|
? item.menuContent
|
|
: (item.menuItems ?? [])
|
|
.filter(menuItem => !menuItem.hidden)
|
|
.map(menuItem => (
|
|
<DropdownMenuItem
|
|
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
|
disabled={menuItem.disabled}
|
|
key={menuItem.id}
|
|
onSelect={() => {
|
|
if (menuItem.to) {
|
|
navigate(menuItem.to)
|
|
}
|
|
|
|
menuItem.onSelect?.()
|
|
}}
|
|
>
|
|
{menuItem.href ? (
|
|
<a
|
|
className="inline-flex w-full items-center gap-2"
|
|
href={menuItem.href}
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
title={menuItem.title ?? menuItem.label}
|
|
>
|
|
{menuItem.icon}
|
|
<span className="truncate">{menuItem.label}</span>
|
|
</a>
|
|
) : (
|
|
<>
|
|
{menuItem.icon}
|
|
<span className="truncate">{menuItem.label}</span>
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90',
|
|
item.className
|
|
)}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (item.href || item.variant === 'link') {
|
|
return (
|
|
<a
|
|
className={cn(statusbarItemClass, item.className)}
|
|
href={item.href}
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
title={title}
|
|
>
|
|
{content}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
className={cn(statusbarItemClass, item.className)}
|
|
disabled={item.disabled}
|
|
onClick={() => {
|
|
if (item.to) {
|
|
navigate(item.to)
|
|
}
|
|
|
|
item.onSelect?.()
|
|
}}
|
|
title={title}
|
|
type="button"
|
|
>
|
|
{content}
|
|
</button>
|
|
)
|
|
}
|