feat(web): redesign Chat page with slash commands, session drawer, and fix bridge permission handling

- Redesign Sessions → Chat: each project is a chat entry (IM-style)
- Add CommandPalette with all slash commands grouped by category
- Add SessionDrawer (right slide-out panel via /list command)
- Auto-load latest session when entering a project chat
- Fix bridge handleCardAction to properly dispatch perm:, askq:, cmd: actions as messages
- Filter incoming bridge messages by session_key to prevent session mixing
- Add chat/cmd i18n translations for all 5 languages
- Update nav, routing, and header for Chat

Made-with: Cursor
This commit is contained in:
Claude committed 2026-03-26 14:04:04 +08:00
1 parent 44b6ee4edb
commit aef8cdade1
17 files changed
+1951 -89

No files matched your search

+62 -7
View File
@@ -691,7 +691,42 @@ func (a *bridgeAdapter) handleCardAction(raw json.RawMessage) {
slog.Debug("bridge: card_action", "platform", a.platform, "action", ca.Action, "session_key", ca.SessionKey)
ref := a.server.resolveEngine(ca.SessionKey)
if ref == nil || ref.platform.navHandler == nil {
if ref == nil {
return
}
// perm: — permission response; convert to a regular message for the engine
if strings.HasPrefix(ca.Action, "perm:") {
var responseText string
switch ca.Action {
case "perm:allow":
responseText = "allow"
case "perm:deny":
responseText = "deny"
case "perm:allow_all":
responseText = "allow all"
default:
return
}
a.dispatchAsMessage(ref, ca.SessionKey, ca.ReplyCtx, responseText)
return
}
// askq: — AskUserQuestion answer; forward as a regular message
if strings.HasPrefix(ca.Action, "askq:") {
a.dispatchAsMessage(ref, ca.SessionKey, ca.ReplyCtx, ca.Action)
return
}
// cmd: — command shortcut from a card button; forward as a message
if strings.HasPrefix(ca.Action, "cmd:") {
cmdText := strings.TrimPrefix(ca.Action, "cmd:")
a.dispatchAsMessage(ref, ca.SessionKey, ca.ReplyCtx, cmdText)
return
}
// nav: / act: — card navigation and in-place updates
if ref.platform.navHandler == nil {
return
}
@@ -700,12 +735,6 @@ func (a *bridgeAdapter) handleCardAction(raw json.RawMessage) {
return
}
rc := &bridgeReplyCtx{
Platform: a.platform,
SessionKey: ca.SessionKey,
ReplyCtx: ca.ReplyCtx,
}
if a.capabilities["card"] {
_ = a.server.sendToAdapter(a.platform, map[string]any{
"type": "card",
@@ -714,10 +743,36 @@ func (a *bridgeAdapter) handleCardAction(raw json.RawMessage) {
"card": serializeCard(card),
})
} else {
rc := &bridgeReplyCtx{
Platform: a.platform,
SessionKey: ca.SessionKey,
ReplyCtx: ca.ReplyCtx,
}
_ = ref.platform.Reply(context.Background(), rc, card.RenderText())
}
}
// dispatchAsMessage converts a card action into a regular user message
// and dispatches it to the engine's message handler.
func (a *bridgeAdapter) dispatchAsMessage(ref *bridgeEngineRef, sessionKey, replyCtx, content string) {
if ref.platform.handler == nil {
return
}
msg := &Message{
SessionKey: sessionKey,
Platform: a.platform,
UserID: "web-admin",
UserName: "Web Admin",
Content: content,
ReplyCtx: &bridgeReplyCtx{
Platform: a.platform,
SessionKey: sessionKey,
ReplyCtx: replyCtx,
},
}
go ref.platform.handler(ref.platform, msg)
}
func (a *bridgeAdapter) handlePreviewAck(raw json.RawMessage) {
var ack bridgePreviewAck
if err := json.Unmarshal(raw, &ack); err != nil {
+11 -2
View File
@@ -212,13 +212,22 @@ func (m *ManagementServer) handleStatus(w http.ResponseWriter, r *http.Request)
adapters = m.listBridgeAdapters()
}
mgmtJSON(w, http.StatusOK, map[string]any{
resp := map[string]any{
"version": CurrentVersion,
"uptime_seconds": int(time.Since(m.startedAt).Seconds()),
"connected_platforms": platforms,
"projects_count": len(m.engines),
"bridge_adapters": adapters,
})
}
if m.bridgeServer != nil {
resp["bridge"] = map[string]any{
"enabled": true,
"port": m.bridgeServer.port,
"path": m.bridgeServer.path,
"token": m.bridgeServer.token,
}
}
mgmtJSON(w, http.StatusOK, resp)
}
func (m *ManagementServer) handleRestart(w http.ResponseWriter, r *http.Request) {
+4 -4
View File
@@ -5,8 +5,8 @@ import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard';
import ProjectList from '@/pages/Projects/ProjectList';
import ProjectDetail from '@/pages/Projects/ProjectDetail';
import SessionList from '@/pages/Sessions/SessionList';
import SessionChat from '@/pages/Sessions/SessionChat';
import ChatList from '@/pages/Chat/ChatList';
import ChatView from '@/pages/Chat/ChatView';
import CronList from '@/pages/Cron/CronList';
import BridgeAdapters from '@/pages/Bridge/BridgeAdapters';
import SystemConfig from '@/pages/System/Config';
@@ -28,8 +28,8 @@ export default function App() {
<Route index element={<Dashboard />} />
<Route path="projects" element={<ProjectList />} />
<Route path="projects/:name" element={<ProjectDetail />} />
<Route path="sessions" element={<SessionList />} />
<Route path="sessions/:project/:id" element={<SessionChat />} />
<Route path="chat" element={<ChatList />} />
<Route path="chat/:name" element={<ChatView />} />
<Route path="cron" element={<CronList />} />
<Route path="bridge" element={<BridgeAdapters />} />
<Route path="system" element={<SystemConfig />} />
+1 -1
View File
@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
const routeTitles: Record<string, string> = {
'/': 'nav.dashboard',
'/projects': 'nav.projects',
'/sessions': 'nav.sessions',
'/chat': 'nav.chat',
'/cron': 'nav.cron',
'/bridge': 'nav.bridge',
'/system': 'nav.system',
+1 -1
View File
@@ -23,7 +23,7 @@ import { useState } from 'react';
const navItems = [
{ key: 'dashboard', path: '/', icon: LayoutDashboard },
{ key: 'projects', path: '/projects', icon: FolderKanban },
{ key: 'sessions', path: '/sessions', icon: MessageSquare },
{ key: 'chat', path: '/chat', icon: MessageSquare },
{ key: 'cron', path: '/cron', icon: Clock },
{ key: 'bridge', path: '/bridge', icon: Cable },
{ key: 'system', path: '/system', icon: Settings },
+4 -2
View File
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info';
className?: string;
}
const variants = {
@@ -16,12 +17,13 @@ const variants = {
info: 'bg-blue-100/90 dark:bg-blue-900/25 text-blue-700 dark:text-blue-400 border border-blue-200/50 dark:border-blue-500/20',
};
export function Badge({ children, variant = 'default' }: BadgeProps) {
export function Badge({ children, variant = 'default', className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium backdrop-blur-sm',
variants[variant]
variants[variant],
className,
)}
>
{children}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import api from '@/api/client';
export type BridgeIncoming =
| { type: 'register_ack'; ok: boolean; error?: string }
| { type: 'reply'; session_key: string; reply_ctx: string; content: string; format?: string }
| { type: 'reply_stream'; session_key: string; reply_ctx: string; delta: string; full_text: string; preview_handle?: string; done: boolean }
| { type: 'card'; session_key: string; reply_ctx: string; card: any }
| { type: 'buttons'; session_key: string; reply_ctx: string; content: string; buttons: { text: string; data: string }[][] }
| { type: 'typing_start'; session_key: string }
| { type: 'typing_stop'; session_key: string }
| { type: 'preview_start'; ref_id: string; session_key: string; reply_ctx: string; content: string }
| { type: 'update_message'; session_key: string; preview_handle: string; content: string }
| { type: 'delete_message'; session_key: string; preview_handle: string }
| { type: 'error'; code: string; message: string }
| { type: 'pong'; ts: number }
| { type: string; [key: string]: any };
export interface BridgeConfig {
port: number;
path: string;
token: string;
}
export type BridgeStatus = 'connecting' | 'registering' | 'connected' | 'disconnected' | 'error';
export interface UseBridgeSocketOptions {
bridgeCfg: BridgeConfig | null;
platformName?: string;
sessionKey: string;
onMessage: (msg: BridgeIncoming) => void;
}
export function useBridgeSocket({ bridgeCfg, platformName = 'web', sessionKey, onMessage }: UseBridgeSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [status, setStatus] = useState<BridgeStatus>('disconnected');
const send = useCallback((data: Record<string, any>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
const sendMessage = useCallback((content: string) => {
send({
type: 'message',
msg_id: `web-${Date.now()}`,
session_key: sessionKey,
user_id: 'web-admin',
user_name: 'Web Admin',
content,
reply_ctx: sessionKey,
});
}, [send, sessionKey]);
const sendCardAction = useCallback((action: string) => {
send({
type: 'card_action',
session_key: sessionKey,
action,
reply_ctx: sessionKey,
});
}, [send, sessionKey]);
const sendPreviewAck = useCallback((refId: string, handle: string) => {
send({ type: 'preview_ack', ref_id: refId, preview_handle: handle });
}, [send]);
useEffect(() => {
if (!bridgeCfg) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.hostname;
const wsUrl = `${proto}//${host}:${bridgeCfg.port}${bridgeCfg.path}?token=${encodeURIComponent(bridgeCfg.token)}`;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
let alive = true;
const connect = () => {
if (!alive) return;
setStatus('connecting');
ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus('registering');
ws.send(JSON.stringify({
type: 'register',
platform: platformName,
capabilities: ['text', 'card', 'buttons', 'typing', 'update_message', 'preview', 'reconstruct_reply'],
metadata: { version: '1.0.0', description: 'Web Admin Dashboard' },
}));
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data) as BridgeIncoming;
if (msg.type === 'register_ack') {
if (msg.ok) {
setStatus('connected');
pingRef.current = setInterval(() => {
send({ type: 'ping', ts: Date.now() });
}, 25000);
} else {
setStatus('error');
}
}
onMessageRef.current(msg);
} catch { /* ignore parse errors */ }
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
if (pingRef.current) clearInterval(pingRef.current);
if (alive) reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => {
setStatus('error');
};
};
connect();
return () => {
alive = false;
clearTimeout(reconnectTimer);
if (pingRef.current) clearInterval(pingRef.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
};
}, [bridgeCfg, platformName, send]);
return { status, send, sendMessage, sendCardAction, sendPreviewAck };
}
// Fetch bridge config from the management API status endpoint.
export async function fetchBridgeConfig(): Promise<BridgeConfig | null> {
try {
const status = await api.get<any>('/status');
if (status.bridge?.enabled) {
return {
port: status.bridge.port,
path: status.bridge.path,
token: status.bridge.token,
};
}
} catch { /* bridge not available */ }
return null;
}
+48 -1
View File
@@ -3,6 +3,7 @@
"dashboard": "Dashboard",
"projects": "Projects",
"sessions": "Sessions",
"chat": "Chat",
"cron": "Cron",
"bridge": "Bridge",
"system": "System"
@@ -57,7 +58,11 @@
"justNow": "just now",
"allProjects": "All projects",
"chat": "Chat",
"enterSession": "Open session"
"enterSession": "Open session",
"bridgeConnected": "connected",
"bridgeConnecting": "connecting...",
"bridgeDisconnected": "disconnected",
"bridgeNotAvailable": "Bridge not available. Enable [bridge] in config.toml to chat from web."
},
"providers": {
"title": "Providers",
@@ -207,6 +212,48 @@
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "No projects yet",
"noMessages": "No messages yet",
"sessions": "Sessions",
"emptyHint": "Start a conversation with your agent",
"slashHint": "Press / to see available commands",
"inputPlaceholder": "Type a message or press / for commands...",
"commands": "Commands"
},
"cmd": {
"search": "Search commands...",
"groupSession": "Session",
"groupSettings": "Settings",
"groupInfo": "Info",
"groupAdvanced": "Advanced",
"new": "New session",
"list": "Session list",
"switch": "Switch session",
"current": "Current session",
"history": "History",
"stop": "Stop session",
"model": "Model",
"reasoning": "Reasoning",
"mode": "Mode",
"lang": "Language",
"provider": "Provider",
"quiet": "Quiet mode",
"status": "Status",
"help": "Help",
"doctor": "Diagnostics",
"version": "Version",
"whoami": "Who am I",
"commands": "All commands",
"dir": "Work directory",
"cron": "Scheduled jobs",
"heartbeat": "Heartbeat",
"alias": "Aliases",
"config": "Configuration",
"skills": "Skills",
"upgrade": "Upgrade",
"deleteMode": "Delete mode"
},
"theme": {
"light": "Light",
"dark": "Dark",
+48 -1
View File
@@ -3,6 +3,7 @@
"dashboard": "Panel",
"projects": "Proyectos",
"sessions": "Sesiones",
"chat": "Chat",
"cron": "Cron",
"bridge": "Puente",
"system": "Sistema"
@@ -57,7 +58,11 @@
"justNow": "ahora",
"allProjects": "Todos los proyectos",
"chat": "Chat",
"enterSession": "Abrir sesión"
"enterSession": "Abrir sesión",
"bridgeConnected": "conectado",
"bridgeConnecting": "conectando...",
"bridgeDisconnected": "desconectado",
"bridgeNotAvailable": "Bridge no disponible. Habilite [bridge] en config.toml para chatear desde la web."
},
"providers": {
"title": "Proveedores",
@@ -207,6 +212,48 @@
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "No hay proyectos",
"noMessages": "No hay mensajes",
"sessions": "Lista de sesiones",
"emptyHint": "Inicia una conversación con tu agente",
"slashHint": "Presiona / para ver los comandos disponibles",
"inputPlaceholder": "Escribe un mensaje o presiona / para comandos...",
"commands": "Comandos"
},
"cmd": {
"search": "Buscar comandos...",
"groupSession": "Sesión",
"groupSettings": "Configuración",
"groupInfo": "Información",
"groupAdvanced": "Avanzado",
"new": "Nueva sesión",
"list": "Lista de sesiones",
"switch": "Cambiar sesión",
"current": "Sesión actual",
"history": "Historial",
"stop": "Detener sesión",
"model": "Modelo",
"reasoning": "Razonamiento",
"mode": "Modo",
"lang": "Idioma",
"provider": "Proveedor",
"quiet": "Modo silencioso",
"status": "Estado",
"help": "Ayuda",
"doctor": "Diagnóstico",
"version": "Versión",
"whoami": "Quién soy",
"commands": "Todos los comandos",
"dir": "Directorio de trabajo",
"cron": "Tareas programadas",
"heartbeat": "Heartbeat",
"alias": "Alias",
"config": "Configuración",
"skills": "Habilidades",
"upgrade": "Actualizar",
"deleteMode": "Modo eliminación"
},
"theme": {
"light": "Claro",
"dark": "Oscuro",
+48 -1
View File
@@ -3,6 +3,7 @@
"dashboard": "ダッシュボード",
"projects": "プロジェクト",
"sessions": "セッション",
"chat": "チャット",
"cron": "Cron",
"bridge": "ブリッジ",
"system": "システム"
@@ -57,7 +58,11 @@
"justNow": "たった今",
"allProjects": "すべてのプロジェクト",
"chat": "チャット",
"enterSession": "セッションを開く"
"enterSession": "セッションを開く",
"bridgeConnected": "接続済み",
"bridgeConnecting": "接続中...",
"bridgeDisconnected": "未接続",
"bridgeNotAvailable": "ブリッジが利用できません。config.toml で [bridge] を有効にしてください。"
},
"providers": {
"title": "プロバイダー",
@@ -207,6 +212,48 @@
"channelSecret": "チャンネルシークレット",
"channelToken": "チャンネルアクセストークン"
},
"chat": {
"noChats": "プロジェクトがありません",
"noMessages": "メッセージがありません",
"sessions": "セッション一覧",
"emptyHint": "エージェントとの会話を始めましょう",
"slashHint": "/ を押して利用可能なコマンドを表示",
"inputPlaceholder": "メッセージを入力、または / でコマンド...",
"commands": "コマンド"
},
"cmd": {
"search": "コマンドを検索...",
"groupSession": "セッション",
"groupSettings": "設定",
"groupInfo": "情報",
"groupAdvanced": "詳細",
"new": "新規セッション",
"list": "セッション一覧",
"switch": "セッション切替",
"current": "現在のセッション",
"history": "履歴",
"stop": "セッション停止",
"model": "モデル",
"reasoning": "推論モード",
"mode": "動作モード",
"lang": "言語",
"provider": "プロバイダー",
"quiet": "静音モード",
"status": "ステータス",
"help": "ヘルプ",
"doctor": "診断",
"version": "バージョン",
"whoami": "自分の情報",
"commands": "全コマンド",
"dir": "作業ディレクトリ",
"cron": "スケジュールジョブ",
"heartbeat": "ハートビート",
"alias": "エイリアス",
"config": "設定",
"skills": "スキル",
"upgrade": "アップグレード",
"deleteMode": "削除モード"
},
"theme": {
"light": "ライト",
"dark": "ダーク",
+48 -1
View File
@@ -3,6 +3,7 @@
"dashboard": "總覽",
"projects": "專案",
"sessions": "工作階段",
"chat": "對話",
"cron": "排程工作",
"bridge": "橋接",
"system": "系統"
@@ -57,7 +58,11 @@
"justNow": "剛剛",
"allProjects": "全部專案",
"chat": "對話",
"enterSession": "進入工作階段"
"enterSession": "進入工作階段",
"bridgeConnected": "已連線",
"bridgeConnecting": "連線中...",
"bridgeDisconnected": "未連線",
"bridgeNotAvailable": "Bridge 未啟用。請在 config.toml 中啟用 [bridge] 以支援網頁聊天。"
},
"providers": {
"title": "模型供應商",
@@ -207,6 +212,48 @@
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "尚無專案",
"noMessages": "尚無訊息",
"sessions": "工作階段列表",
"emptyHint": "開始與 Agent 對話",
"slashHint": "按 / 查看可用命令",
"inputPlaceholder": "輸入訊息或按 / 使用命令...",
"commands": "命令"
},
"cmd": {
"search": "搜尋命令...",
"groupSession": "工作階段",
"groupSettings": "設定",
"groupInfo": "資訊",
"groupAdvanced": "進階",
"new": "新建工作階段",
"list": "工作階段列表",
"switch": "切換工作階段",
"current": "目前工作階段",
"history": "歷史記錄",
"stop": "停止工作階段",
"model": "模型",
"reasoning": "推理模式",
"mode": "運行模式",
"lang": "語言",
"provider": "提供方",
"quiet": "靜音模式",
"status": "狀態",
"help": "說明",
"doctor": "診斷",
"version": "版本",
"whoami": "我是誰",
"commands": "所有命令",
"dir": "工作目錄",
"cron": "排程工作",
"heartbeat": "心跳",
"alias": "別名",
"config": "設定",
"skills": "技能",
"upgrade": "升級",
"deleteMode": "刪除模式"
},
"theme": {
"light": "淺色",
"dark": "深色",
+48 -1
View File
@@ -3,6 +3,7 @@
"dashboard": "概览",
"projects": "项目",
"sessions": "会话",
"chat": "对话",
"cron": "定时任务",
"bridge": "桥接",
"system": "系统"
@@ -57,7 +58,11 @@
"justNow": "刚刚",
"allProjects": "全部项目",
"chat": "对话",
"enterSession": "进入会话"
"enterSession": "进入会话",
"bridgeConnected": "已连接",
"bridgeConnecting": "连接中...",
"bridgeDisconnected": "未连接",
"bridgeNotAvailable": "Bridge 未启用。请在 config.toml 中启用 [bridge] 以支持网页聊天。"
},
"providers": {
"title": "模型提供方",
@@ -207,6 +212,48 @@
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "暂无项目",
"noMessages": "暂无消息",
"sessions": "会话列表",
"emptyHint": "开始和你的 Agent 对话",
"slashHint": "按 / 查看可用命令",
"inputPlaceholder": "输入消息或按 / 使用命令...",
"commands": "命令"
},
"cmd": {
"search": "搜索命令...",
"groupSession": "会话",
"groupSettings": "设置",
"groupInfo": "信息",
"groupAdvanced": "高级",
"new": "新建会话",
"list": "会话列表",
"switch": "切换会话",
"current": "当前会话",
"history": "历史记录",
"stop": "停止会话",
"model": "模型",
"reasoning": "推理模式",
"mode": "运行模式",
"lang": "语言",
"provider": "提供方",
"quiet": "静默模式",
"status": "状态",
"help": "帮助",
"doctor": "诊断",
"version": "版本",
"whoami": "我是谁",
"commands": "所有命令",
"dir": "工作目录",
"cron": "定时任务",
"heartbeat": "心跳",
"alias": "别名",
"config": "配置",
"skills": "技能",
"upgrade": "升级",
"deleteMode": "删除模式"
},
"theme": {
"light": "浅色",
"dark": "深色",
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { MessageSquare, Bot, User, Circle, FolderKanban } from 'lucide-react';
import { EmptyState, Badge } from '@/components/ui';
import { listProjects, type ProjectSummary } from '@/api/projects';
import { listSessions, type Session } from '@/api/sessions';
import { cn } from '@/lib/utils';
interface ChatEntry {
project: ProjectSummary;
latestSession: Session | null;
}
function timeAgo(iso: string, t: (k: string) => string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('sessions.justNow');
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export default function ChatList() {
const { t } = useTranslation();
const [entries, setEntries] = useState<ChatEntry[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const { projects: projs } = await listProjects();
if (!projs?.length) {
setEntries([]);
return;
}
const results = await Promise.all(
projs.map(async (p) => {
try {
const { sessions } = await listSessions(p.name);
const sorted = (sessions || []).sort(
(a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''),
);
return { project: p, latestSession: sorted[0] || null };
} catch {
return { project: p, latestSession: null };
}
}),
);
results.sort((a, b) => {
const ta = a.latestSession?.updated_at || a.latestSession?.created_at || '';
const tb = b.latestSession?.updated_at || b.latestSession?.created_at || '';
return tb.localeCompare(ta);
});
setEntries(results);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
if (loading && entries.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="max-w-2xl mx-auto space-y-1 animate-fade-in">
{entries.length === 0 ? (
<EmptyState message={t('chat.noChats')} icon={MessageSquare} />
) : (
entries.map(({ project, latestSession }) => {
const hasLive = latestSession?.live;
const lastMsg = latestSession?.last_message;
const ts = latestSession?.updated_at || latestSession?.created_at || '';
return (
<Link key={project.name} to={`/chat/${project.name}`}>
<div
className={cn(
'group flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 cursor-pointer',
'hover:bg-gray-100/70 dark:hover:bg-white/[0.04]',
)}
>
{/* Avatar */}
<div
className={cn(
'w-12 h-12 rounded-2xl flex items-center justify-center shrink-0',
'bg-accent/10 ring-1 ring-accent/20',
)}
>
<FolderKanban size={22} className="text-accent" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{project.name}
</span>
{hasLive && <Circle size={5} className="fill-emerald-500 text-emerald-500 shrink-0" />}
</div>
{ts && (
<span className="text-[11px] text-gray-400 shrink-0">{timeAgo(ts, t)}</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
{lastMsg ? (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate leading-relaxed">
{lastMsg.role === 'user' ? (
<User size={10} className="inline mr-1 -mt-0.5 opacity-60" />
) : (
<Bot size={10} className="inline mr-1 -mt-0.5 opacity-60" />
)}
{lastMsg.content.replace(/\n/g, ' ').slice(0, 80)}
</p>
) : (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">
{t('chat.noMessages')}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge className="text-[9px]">{project.agent_type}</Badge>
<span className="text-[10px] text-gray-400">
{project.sessions_count}
</span>
</div>
</div>
</div>
</div>
</Link>
);
})
)}
</div>
);
}
+642
View File
@@ -0,0 +1,642 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft, Send, User, Bot, Circle, WifiOff,
Copy, Check, FileText, Image as ImageIcon, Loader2,
Slash, ChevronDown,
} from 'lucide-react';
import { Badge, Button } from '@/components/ui';
import { listSessions, getSession, type Session, type SessionDetail } from '@/api/sessions';
import {
useBridgeSocket, fetchBridgeConfig,
type BridgeConfig, type BridgeIncoming, type BridgeStatus,
} from '@/hooks/useBridgeSocket';
import CommandPalette, { type SlashCommand } from './CommandPalette';
import SessionDrawer from './SessionDrawer';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { cn } from '@/lib/utils';
// ── Markdown renderers ───────────────────────────────────────
function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
);
}
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
const codeEl = (children as any)?.props;
const lang = codeEl?.className?.replace(/^language-/, '') || '';
const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : '';
return (
<div className="not-prose relative group my-4">
{lang && (
<div className="absolute top-0 left-0 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 rounded-tl-lg rounded-br-lg border-b border-r border-gray-200 dark:border-gray-700 font-mono">
{lang}
</div>
)}
<CopyButton code={code} />
<pre className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono" {...props}>
{children}
</pre>
</div>
);
}
function InlineCode({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) {
if (className) return <code className={className} {...props}>{children}</code>;
return (
<code className="px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-pink-600 dark:text-pink-400 text-[0.875em] font-mono border border-gray-200/60 dark:border-gray-700/40" {...props}>
{children}
</code>
);
}
function RenderMarkdown({ content }: { content: string }) {
return (
<div className={cn(
'prose max-w-none dark:prose-invert',
'prose-headings:font-semibold prose-headings:tracking-tight',
'prose-h1:text-xl prose-h1:mt-5 prose-h1:mb-3 prose-h1:pb-1.5 prose-h1:border-b prose-h1:border-gray-200 dark:prose-h1:border-gray-700',
'prose-h2:text-lg prose-h2:mt-5 prose-h2:mb-2',
'prose-h3:text-base prose-h3:mt-4 prose-h3:mb-2',
'prose-p:my-2.5 prose-p:leading-relaxed',
'prose-li:my-0.5', 'prose-ul:my-2 prose-ol:my-2',
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline',
'prose-strong:text-gray-900 dark:prose-strong:text-white prose-strong:font-semibold',
'prose-blockquote:border-l-[3px] prose-blockquote:border-accent/40 prose-blockquote:bg-accent/[0.03] prose-blockquote:rounded-r-lg prose-blockquote:py-0.5 prose-blockquote:px-4 prose-blockquote:my-3 prose-blockquote:not-italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-gray-300',
'prose-hr:my-5 prose-hr:border-gray-200 dark:prose-hr:border-gray-700',
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2',
'prose-img:rounded-lg prose-img:shadow-sm',
)}>
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ pre: PreBlock as any, code: InlineCode as any }}>
{content}
</Markdown>
</div>
);
}
// ── Chat message types ───────────────────────────────────────
interface ChatMsg {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
format?: 'text' | 'markdown' | 'card' | 'buttons' | 'image' | 'file';
card?: any;
buttons?: { text: string; data: string }[][];
imageUrl?: string;
fileName?: string;
fileSize?: number;
streaming?: boolean;
timestamp?: string;
}
// ── Card renderer ────────────────────────────────────────────
function CardBlock({ card, onAction }: { card: any; onAction: (v: string) => void }) {
if (!card) return null;
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{card.header && (
<div className={cn('px-4 py-2.5 font-semibold text-sm text-white', colorToBg(card.header.color))}>
{card.header.title}
</div>
)}
<div className="p-4 space-y-3">
{card.elements?.map((el: any, i: number) => (
<CardElement key={i} el={el} onAction={onAction} />
))}
</div>
</div>
);
}
function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) {
if (el.type === 'markdown') return <RenderMarkdown content={el.content} />;
if (el.type === 'divider') return <hr className="border-gray-200 dark:border-gray-700" />;
if (el.type === 'note') return <p className="text-xs text-gray-400">{el.text}</p>;
if (el.type === 'actions') {
return (
<div className="flex flex-wrap gap-2">
{el.buttons?.map((btn: any, j: number) => (
<button key={j} onClick={() => onAction(btn.value)} className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
btn.btn_type === 'primary' ? 'bg-accent text-black hover:bg-accent-dim' :
btn.btn_type === 'danger' ? 'bg-red-500 text-white hover:bg-red-600' :
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
)}>
{btn.text}
</button>
))}
</div>
);
}
if (el.type === 'list_item') {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">{el.text}</span>
<button onClick={() => onAction(el.btn_value)} className="px-2.5 py-1 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim">
{el.btn_text}
</button>
</div>
);
}
if (el.type === 'select') {
return (
<select
defaultValue={el.init_value}
onChange={(e) => onAction(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{el.options?.map((opt: any, j: number) => (
<option key={j} value={opt.value}>{opt.text}</option>
))}
</select>
);
}
return null;
}
function colorToBg(c?: string) {
const map: Record<string, string> = {
blue: 'bg-blue-600', green: 'bg-green-600', red: 'bg-red-600', orange: 'bg-orange-500',
purple: 'bg-purple-600', grey: 'bg-gray-600', turquoise: 'bg-teal-600', violet: 'bg-violet-600',
indigo: 'bg-indigo-600', wathet: 'bg-sky-500', yellow: 'bg-yellow-500', carmine: 'bg-rose-600',
};
return map[c || ''] || 'bg-gray-800';
}
function ButtonsBlock({ content, buttons, onAction }: { content: string; buttons: { text: string; data: string }[][]; onAction: (v: string) => void }) {
return (
<div className="space-y-3">
<RenderMarkdown content={content} />
{buttons.map((row, i) => (
<div key={i} className="flex flex-wrap gap-2">
{row.map((btn, j) => (
<button key={j} onClick={() => onAction(btn.data)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim transition-colors">
{btn.text}
</button>
))}
</div>
))}
</div>
);
}
function FileBlock({ name, size }: { name: string; size?: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<FileText size={16} className="text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{name}</div>
{size !== undefined && <div className="text-xs text-gray-400">{(size / 1024).toFixed(1)} KB</div>}
</div>
</div>
);
}
function ImageBlock({ url }: { url: string }) {
return <img src={url} alt="" className="max-w-sm rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm" />;
}
function StatusBadge({ status }: { status: BridgeStatus }) {
const { t } = useTranslation();
if (status === 'connected') {
return (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
<Circle size={5} className="fill-current" /> {t('sessions.bridgeConnected')}
</span>
);
}
if (status === 'connecting' || status === 'registering') {
return (
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-1.5 py-0.5 rounded-full">
<Loader2 size={9} className="animate-spin" /> {t('sessions.bridgeConnecting')}
</span>
);
}
return (
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
<WifiOff size={9} /> {t('sessions.bridgeDisconnected')}
</span>
);
}
// ── Main component ───────────────────────────────────────────
export default function ChatView() {
const { t } = useTranslation();
const { name: projectName } = useParams<{ name: string }>();
// Session state
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSession, setCurrentSession] = useState<SessionDetail | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const [typing, setTyping] = useState(false);
const [bridgeCfg, setBridgeCfg] = useState<BridgeConfig | null>(null);
// UI state
const [cmdOpen, setCmdOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const messagesEnd = useRef<HTMLDivElement>(null);
const previewHandleCounter = useRef(0);
const cmdBtnRef = useRef<HTMLButtonElement>(null);
const sessionKeyRef = useRef('');
const sessionKey = currentSession?.session_key || '';
sessionKeyRef.current = sessionKey;
// Load project sessions and auto-select latest
const fetchData = useCallback(async () => {
if (!projectName) return;
setLoading(true);
try {
const [{ sessions: allSessions }, cfg] = await Promise.all([
listSessions(projectName),
fetchBridgeConfig(),
]);
setBridgeCfg(cfg);
const sorted = (allSessions || []).sort(
(a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''),
);
setSessions(sorted);
if (sorted.length > 0) {
const latest = sorted[0];
const detail = await getSession(projectName, latest.id, 200);
setCurrentSession(detail);
if (detail.history) {
setMessages(detail.history.map((h, i) => ({
id: `hist-${i}`,
role: h.role as 'user' | 'assistant',
content: h.content,
format: 'markdown',
timestamp: h.timestamp,
})));
}
} else {
setCurrentSession(null);
setMessages([]);
}
} finally {
setLoading(false);
}
}, [projectName]);
useEffect(() => { fetchData(); }, [fetchData]);
// Switch to a different session
const switchToSession = useCallback(async (s: Session) => {
if (!projectName) return;
setDrawerOpen(false);
setLoading(true);
try {
const detail = await getSession(projectName, s.id, 200);
setCurrentSession(detail);
if (detail.history) {
setMessages(detail.history.map((h, i) => ({
id: `hist-${i}`,
role: h.role as 'user' | 'assistant',
content: h.content,
format: 'markdown',
timestamp: h.timestamp,
})));
} else {
setMessages([]);
}
} finally {
setLoading(false);
}
}, [projectName]);
// Handle bridge incoming messages — only process messages for the current session
const handleBridgeMessage = useCallback((msg: BridgeIncoming) => {
const msgKey = (msg as any).session_key;
if (msgKey && sessionKeyRef.current && msgKey !== sessionKeyRef.current) {
return; // ignore messages for other sessions
}
if (msg.type === 'reply') {
setMessages(prev => {
const streamIdx = prev.findIndex(m => m.streaming && m.role === 'assistant');
if (streamIdx >= 0) {
const updated = [...prev];
updated[streamIdx] = { ...updated[streamIdx], content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text', streaming: false };
return updated;
}
return [...prev, { id: `reply-${Date.now()}`, role: 'assistant', content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text' }];
});
setTyping(false);
} else if (msg.type === 'reply_stream') {
const stream = msg as Extract<BridgeIncoming, { type: 'reply_stream' }>;
if (stream.done) {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text, streaming: false };
return updated;
}
return [...prev, { id: `stream-done-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown' }];
});
setTyping(false);
} else {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text };
return updated;
}
return [...prev, { id: `stream-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown', streaming: true }];
});
}
} else if (msg.type === 'card') {
const card = msg as Extract<BridgeIncoming, { type: 'card' }>;
setMessages(prev => [...prev, { id: `card-${Date.now()}`, role: 'assistant', content: '', format: 'card', card: card.card }]);
setTyping(false);
} else if (msg.type === 'buttons') {
const btns = msg as Extract<BridgeIncoming, { type: 'buttons' }>;
setMessages(prev => [...prev, { id: `btn-${Date.now()}`, role: 'assistant', content: btns.content, format: 'buttons', buttons: btns.buttons }]);
setTyping(false);
} else if (msg.type === 'typing_start') {
setTyping(true);
} else if (msg.type === 'typing_stop') {
setTyping(false);
} else if (msg.type === 'preview_start') {
const ps = msg as Extract<BridgeIncoming, { type: 'preview_start' }>;
const handle = `web-preview-${++previewHandleCounter.current}`;
sendPreviewAck(ps.ref_id, handle);
setMessages(prev => [...prev, { id: `stream-${handle}`, role: 'assistant', content: ps.content, format: 'markdown', streaming: true }]);
} else if (msg.type === 'update_message') {
const um = msg as Extract<BridgeIncoming, { type: 'update_message' }>;
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: um.content };
return updated;
}
return prev;
});
} else if (msg.type === 'delete_message') {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) return prev.filter((_, i) => i !== idx);
return prev;
});
}
}, []);
const { status: bridgeStatus, sendMessage: bridgeSend, sendCardAction, sendPreviewAck } = useBridgeSocket({
bridgeCfg,
sessionKey,
onMessage: handleBridgeMessage,
});
// Scroll to bottom on new messages
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, typing]);
// Send message
const handleSend = useCallback(() => {
if (!input.trim() || bridgeStatus !== 'connected') return;
const content = input.trim();
setInput('');
setSending(true);
setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content }]);
bridgeSend(content);
setTimeout(() => setSending(false), 300);
}, [input, bridgeStatus, bridgeSend]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
if (e.key === '/' && !input) {
e.preventDefault();
setCmdOpen(true);
}
};
const handleCmdSelect = useCallback((cmd: SlashCommand) => {
setCmdOpen(false);
if (cmd.cmd === '/list') {
setDrawerOpen(true);
return;
}
if (bridgeStatus !== 'connected') return;
setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content: cmd.cmd }]);
bridgeSend(cmd.cmd);
}, [bridgeStatus, bridgeSend]);
const handleCardAction = useCallback((value: string) => {
if (bridgeStatus !== 'connected') return;
sendCardAction(value);
}, [bridgeStatus, sendCardAction]);
const handleNewSession = useCallback(() => {
if (bridgeStatus !== 'connected') return;
setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content: '/new' }]);
bridgeSend('/new');
setDrawerOpen(false);
}, [bridgeStatus, bridgeSend]);
const canSend = bridgeStatus === 'connected';
if (loading && !currentSession && sessions.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="flex flex-col h-[calc(100vh-8rem)] animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-gray-200 dark:border-gray-800 shrink-0">
<div className="flex items-center gap-3">
<Link to="/chat" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<ArrowLeft size={18} className="text-gray-400" />
</Link>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{projectName}</h2>
<StatusBadge status={bridgeStatus} />
</div>
{currentSession && (
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-accent transition-colors mt-0.5"
>
<span>{currentSession.name || currentSession.id.slice(0, 8)}</span>
<ChevronDown size={12} />
</button>
)}
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto py-6 space-y-5">
{messages.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<div className="w-16 h-16 rounded-2xl bg-accent/10 flex items-center justify-center mb-4">
<Bot size={32} className="text-accent" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{t('chat.emptyHint')}</p>
<p className="text-xs text-gray-400 dark:text-gray-500">{t('chat.slashHint')}</p>
</div>
)}
{messages.map((msg) => {
const isUser = msg.role === 'user';
return (
<div key={msg.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
{!isUser && (
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
)}
<div className={cn(
'rounded-2xl px-5 py-3.5 text-sm',
isUser
? 'max-w-[70%] bg-accent text-black rounded-br-md'
: 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm',
msg.streaming && 'animate-pulse-subtle',
)}>
{msg.format === 'card' ? (
<CardBlock card={msg.card} onAction={handleCardAction} />
) : msg.format === 'buttons' && msg.buttons ? (
<ButtonsBlock content={msg.content} buttons={msg.buttons} onAction={handleCardAction} />
) : msg.format === 'image' && msg.imageUrl ? (
<ImageBlock url={msg.imageUrl} />
) : msg.format === 'file' && msg.fileName ? (
<FileBlock name={msg.fileName} size={msg.fileSize} />
) : isUser ? (
<div className="whitespace-pre-wrap">{msg.content}</div>
) : (
<RenderMarkdown content={msg.content} />
)}
{msg.streaming && (
<span className="inline-block w-1.5 h-4 bg-accent/60 rounded-sm ml-0.5 animate-pulse" />
)}
</div>
{isUser && (
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0 mt-1">
<User size={16} className="text-gray-500" />
</div>
)}
</div>
);
})}
{typing && !messages.some(m => m.streaming) && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
<div className="rounded-2xl px-5 py-3.5 text-sm bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 rounded-bl-md shadow-sm">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={messagesEnd} />
</div>
{/* Input area */}
<div className="border-t border-gray-200 dark:border-gray-800 pt-3 shrink-0">
{canSend ? (
<div className="relative flex items-end gap-2">
{/* Command palette trigger */}
<div className="relative">
<button
ref={cmdBtnRef}
type="button"
onClick={() => setCmdOpen(!cmdOpen)}
className={cn(
'p-3 rounded-xl transition-all duration-200',
cmdOpen
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
)}
title={t('chat.commands')}
>
<Slash size={18} />
</button>
<CommandPalette
open={cmdOpen}
onClose={() => setCmdOpen(false)}
onSelect={handleCmdSelect}
anchorRef={cmdBtnRef}
/>
</div>
{/* Text input */}
<div className="flex-1 relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.inputPlaceholder')}
className="w-full px-4 py-3 text-sm rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors placeholder:text-gray-400"
disabled={sending}
/>
</div>
{/* Send button */}
<button
type="button"
onClick={handleSend}
disabled={sending || !input.trim()}
className="p-3 rounded-xl bg-accent text-black hover:bg-accent-dim transition-colors disabled:opacity-50 flex items-center"
>
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeNotAvailable')}</span>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
<Loader2 size={14} className="animate-spin" />
<span>{t('sessions.bridgeConnecting')}</span>
</div>
)}
</div>
{/* Session drawer */}
<SessionDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
sessions={sessions}
currentSessionId={currentSession?.id || ''}
onSelect={switchToSession}
onNewSession={handleNewSession}
/>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Slash, Search, MessageSquarePlus, List, ArrowRightLeft, Eye, History,
Square, Brain, Cpu, Languages, Layers, Activity, Stethoscope, Info,
Settings, Timer, HeartPulse, Terminal, Tag, Wrench, Upload, Trash2,
Volume2, VolumeX, FolderOpen, HelpCircle, User, BookOpen,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export interface SlashCommand {
cmd: string;
labelKey: string;
icon: React.ElementType;
group: 'session' | 'settings' | 'info' | 'advanced';
local?: boolean; // handled locally, not sent to bridge
}
export const slashCommands: SlashCommand[] = [
// Session
{ cmd: '/new', labelKey: 'cmd.new', icon: MessageSquarePlus, group: 'session' },
{ cmd: '/list', labelKey: 'cmd.list', icon: List, group: 'session', local: true },
{ cmd: '/switch', labelKey: 'cmd.switch', icon: ArrowRightLeft, group: 'session' },
{ cmd: '/current', labelKey: 'cmd.current', icon: Eye, group: 'session' },
{ cmd: '/history', labelKey: 'cmd.history', icon: History, group: 'session' },
{ cmd: '/stop', labelKey: 'cmd.stop', icon: Square, group: 'session' },
// Settings
{ cmd: '/model', labelKey: 'cmd.model', icon: Brain, group: 'settings' },
{ cmd: '/reasoning', labelKey: 'cmd.reasoning', icon: Cpu, group: 'settings' },
{ cmd: '/mode', labelKey: 'cmd.mode', icon: Layers, group: 'settings' },
{ cmd: '/lang', labelKey: 'cmd.lang', icon: Languages, group: 'settings' },
{ cmd: '/provider', labelKey: 'cmd.provider', icon: Activity, group: 'settings' },
{ cmd: '/quiet', labelKey: 'cmd.quiet', icon: VolumeX, group: 'settings' },
// Info
{ cmd: '/status', labelKey: 'cmd.status', icon: Info, group: 'info' },
{ cmd: '/help', labelKey: 'cmd.help', icon: HelpCircle, group: 'info' },
{ cmd: '/doctor', labelKey: 'cmd.doctor', icon: Stethoscope, group: 'info' },
{ cmd: '/version', labelKey: 'cmd.version', icon: Tag, group: 'info' },
{ cmd: '/whoami', labelKey: 'cmd.whoami', icon: User, group: 'info' },
{ cmd: '/commands', labelKey: 'cmd.commands', icon: Terminal, group: 'info' },
// Advanced
{ cmd: '/dir', labelKey: 'cmd.dir', icon: FolderOpen, group: 'advanced' },
{ cmd: '/cron', labelKey: 'cmd.cron', icon: Timer, group: 'advanced' },
{ cmd: '/heartbeat', labelKey: 'cmd.heartbeat', icon: HeartPulse, group: 'advanced' },
{ cmd: '/alias', labelKey: 'cmd.alias', icon: Tag, group: 'advanced' },
{ cmd: '/config', labelKey: 'cmd.config', icon: Settings, group: 'advanced' },
{ cmd: '/skills', labelKey: 'cmd.skills', icon: BookOpen, group: 'advanced' },
{ cmd: '/upgrade', labelKey: 'cmd.upgrade', icon: Upload, group: 'advanced' },
{ cmd: '/delete-mode', labelKey: 'cmd.deleteMode', icon: Trash2, group: 'advanced' },
];
const groupOrder: { key: string; labelKey: string }[] = [
{ key: 'session', labelKey: 'cmd.groupSession' },
{ key: 'settings', labelKey: 'cmd.groupSettings' },
{ key: 'info', labelKey: 'cmd.groupInfo' },
{ key: 'advanced', labelKey: 'cmd.groupAdvanced' },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (cmd: SlashCommand) => void;
anchorRef: React.RefObject<HTMLElement | null>;
}
export default function CommandPalette({ open, onClose, onSelect, anchorRef }: Props) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [activeIdx, setActiveIdx] = useState(0);
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
if (!query) return slashCommands;
const q = query.toLowerCase().replace(/^\//, '');
return slashCommands.filter(
(c) => c.cmd.toLowerCase().includes(q) || t(c.labelKey).toLowerCase().includes(q),
);
}, [query, t]);
useEffect(() => {
if (open) {
setQuery('');
setActiveIdx(0);
setTimeout(() => searchRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose, anchorRef]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && filtered[activeIdx]) {
e.preventDefault();
onSelect(filtered[activeIdx]);
} else if (e.key === 'Escape') {
onClose();
}
};
if (!open) return null;
const grouped = groupOrder
.map((g) => ({
...g,
items: filtered.filter((c) => c.group === g.key),
}))
.filter((g) => g.items.length > 0);
let flatIdx = 0;
return (
<div
ref={panelRef}
className={cn(
'absolute bottom-full left-0 mb-2 w-80 max-h-[420px] flex flex-col rounded-xl overflow-hidden z-50',
'bg-white/95 backdrop-blur-xl border border-gray-200/80 shadow-2xl shadow-black/15',
'dark:bg-[rgba(20,20,20,0.96)] dark:border-white/[0.1] dark:shadow-black/50',
'animate-in slide-in-from-bottom-2 fade-in duration-200',
)}
>
<div className="px-3 pt-3 pb-2">
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-gray-100/80 dark:bg-white/[0.06] border border-gray-200/50 dark:border-white/[0.05]">
<Search size={14} className="text-gray-400 shrink-0" />
<input
ref={searchRef}
value={query}
onChange={(e) => { setQuery(e.target.value); setActiveIdx(0); }}
onKeyDown={handleKeyDown}
placeholder={t('cmd.search', 'Search commands...')}
className="flex-1 bg-transparent text-sm text-gray-900 dark:text-white placeholder:text-gray-400 outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-1.5 pb-2">
{grouped.map((g) => (
<div key={g.key} className="mb-1">
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t(g.labelKey)}
</div>
{g.items.map((cmd) => {
const idx = flatIdx++;
const Icon = cmd.icon;
return (
<button
key={cmd.cmd}
type="button"
onClick={() => onSelect(cmd)}
onMouseEnter={() => setActiveIdx(idx)}
className={cn(
'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors',
idx === activeIdx
? 'bg-accent/15 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-white/[0.06]',
)}
>
<Icon size={15} className={idx === activeIdx ? 'text-accent' : 'text-gray-400'} />
<span className="font-mono text-xs text-gray-500 dark:text-gray-400 w-24 text-left">{cmd.cmd}</span>
<span className="flex-1 text-left truncate">{t(cmd.labelKey)}</span>
</button>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="text-center text-sm text-gray-400 py-6">{t('common.noData')}</div>
)}
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useTranslation } from 'react-i18next';
import {
X, MessageSquare, Circle, User, Bot, Plus,
} from 'lucide-react';
import { Badge } from '@/components/ui';
import type { Session } from '@/api/sessions';
import { cn } from '@/lib/utils';
function timeAgo(iso: string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '<1m';
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
interface Props {
open: boolean;
onClose: () => void;
sessions: Session[];
currentSessionId: string;
onSelect: (session: Session) => void;
onNewSession?: () => void;
}
export default function SessionDrawer({ open, onClose, sessions, currentSessionId, onSelect, onNewSession }: Props) {
const { t } = useTranslation();
return (
<>
{/* Backdrop */}
{open && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/40 z-40 transition-opacity" onClick={onClose} />
)}
{/* Drawer */}
<div
className={cn(
'fixed top-0 right-0 h-full w-80 z-50 flex flex-col transition-transform duration-300 ease-out',
'bg-white/95 backdrop-blur-xl border-l border-gray-200/80 shadow-2xl shadow-black/15',
'dark:bg-[rgba(15,15,15,0.97)] dark:border-white/[0.08] dark:shadow-black/50',
open ? 'translate-x-0' : 'translate-x-full',
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b border-gray-200/80 dark:border-white/[0.08] shrink-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('chat.sessions')}</h3>
<div className="flex items-center gap-1">
{onNewSession && (
<button
type="button"
onClick={onNewSession}
className="p-1.5 rounded-lg text-gray-400 hover:text-accent hover:bg-accent/10 transition-colors"
title={t('cmd.new')}
>
<Plus size={16} />
</button>
)}
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto py-2 px-2">
{sessions.length === 0 ? (
<div className="text-center text-sm text-gray-400 py-8">{t('sessions.noSessions')}</div>
) : (
sessions.map((s) => {
const isCurrent = s.id === currentSessionId;
return (
<button
key={s.id}
type="button"
onClick={() => onSelect(s)}
className={cn(
'w-full text-left p-3 rounded-xl mb-1 transition-all duration-200',
isCurrent
? 'bg-accent/10 ring-1 ring-accent/30'
: 'hover:bg-gray-100/80 dark:hover:bg-white/[0.04]',
)}
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<MessageSquare
size={13}
className={cn(s.live ? 'text-accent' : 'text-gray-400', 'shrink-0')}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{s.name || s.user_name || s.id.slice(0, 8)}
</span>
{s.live && <Circle size={4} className="fill-emerald-500 text-emerald-500 shrink-0" />}
</div>
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">
{timeAgo(s.updated_at || s.created_at)}
</span>
</div>
{s.last_message && (
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1 leading-relaxed mb-1.5 pl-5">
{s.last_message.role === 'user' ? (
<User size={9} className="inline mr-0.5 -mt-0.5 opacity-60" />
) : (
<Bot size={9} className="inline mr-0.5 -mt-0.5 opacity-60" />
)}
{s.last_message.content.replace(/\n/g, ' ').slice(0, 80)}
</p>
)}
<div className="flex items-center gap-1.5 pl-5">
{s.platform && <Badge variant="info" className="text-[9px] px-1 py-0">{s.platform}</Badge>}
<span className="text-[10px] text-gray-400 ml-auto">{s.history_count} msgs</span>
</div>
</button>
);
})
)}
</div>
</div>
</>
);
}
+363 -67
View File
@@ -1,14 +1,20 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, Send, User, Bot, RotateCw, Circle, WifiOff, Copy, Check } from 'lucide-react';
import {
ArrowLeft, Send, User, Bot, RotateCw, Circle, WifiOff,
Copy, Check, FileText, Image as ImageIcon, Loader2,
} from 'lucide-react';
import { Badge, Button } from '@/components/ui';
import { getSession, sendMessage, type SessionDetail } from '@/api/sessions';
import { getSession, type SessionDetail } from '@/api/sessions';
import { useBridgeSocket, fetchBridgeConfig, type BridgeConfig, type BridgeIncoming, type BridgeStatus } from '@/hooks/useBridgeSocket';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { cn } from '@/lib/utils';
// ── Markdown renderers ───────────────────────────────────────
function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
@@ -29,10 +35,7 @@ function CopyButton({ code }: { code: string }) {
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
const codeEl = (children as any)?.props;
const lang = codeEl?.className?.replace(/^language-/, '') || '';
const code = typeof codeEl?.children === 'string'
? codeEl.children.replace(/\n$/, '')
: '';
const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : '';
return (
<div className="not-prose relative group my-4">
{lang && (
@@ -41,10 +44,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
</div>
)}
<CopyButton code={code} />
<pre
className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono"
{...props}
>
<pre className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono" {...props}>
{children}
</pre>
</div>
@@ -60,15 +60,7 @@ function InlineCode({ children, className, ...props }: React.HTMLAttributes<HTML
);
}
function ChatMarkdown({ content, isUser }: { content: string; isUser: boolean }) {
if (isUser) {
return (
<div className="prose prose-sm max-w-none [&_*]:text-black [&_code]:bg-black/10 [&_code]:text-black [&_a]:text-black [&_a]:underline [&>p]:my-0.5 [&_li]:my-0">
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
</div>
);
}
function RenderMarkdown({ content }: { content: string }) {
return (
<div className={cn(
'prose max-w-none dark:prose-invert',
@@ -77,69 +69,342 @@ function ChatMarkdown({ content, isUser }: { content: string; isUser: boolean })
'prose-h2:text-lg prose-h2:mt-5 prose-h2:mb-2',
'prose-h3:text-base prose-h3:mt-4 prose-h3:mb-2',
'prose-p:my-2.5 prose-p:leading-relaxed',
'prose-li:my-0.5',
'prose-ul:my-2 prose-ol:my-2',
'prose-li:my-0.5', 'prose-ul:my-2 prose-ol:my-2',
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline',
'prose-strong:text-gray-900 dark:prose-strong:text-white prose-strong:font-semibold',
'prose-blockquote:border-l-[3px] prose-blockquote:border-accent/40 prose-blockquote:bg-accent/[0.03] prose-blockquote:rounded-r-lg prose-blockquote:py-0.5 prose-blockquote:px-4 prose-blockquote:my-3 prose-blockquote:not-italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-gray-300',
'prose-hr:my-5 prose-hr:border-gray-200 dark:prose-hr:border-gray-700',
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-th:border-gray-200 dark:prose-th:border-gray-700 prose-td:border-gray-200 dark:prose-td:border-gray-700',
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2',
'prose-img:rounded-lg prose-img:shadow-sm',
)}>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
pre: PreBlock as any,
code: InlineCode as any,
}}
>
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ pre: PreBlock as any, code: InlineCode as any }}>
{content}
</Markdown>
</div>
);
}
// ── Chat message types ───────────────────────────────────────
interface ChatMsg {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
format?: 'text' | 'markdown' | 'card' | 'buttons' | 'image' | 'file';
card?: any;
buttons?: { text: string; data: string }[][];
imageUrl?: string;
fileName?: string;
fileSize?: number;
streaming?: boolean;
timestamp?: string;
}
// ── Card renderer ────────────────────────────────────────────
function CardBlock({ card, onAction }: { card: any; onAction: (v: string) => void }) {
if (!card) return null;
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{card.header && (
<div className={cn('px-4 py-2.5 font-semibold text-sm text-white', colorToBg(card.header.color))}>
{card.header.title}
</div>
)}
<div className="p-4 space-y-3">
{card.elements?.map((el: any, i: number) => (
<CardElement key={i} el={el} onAction={onAction} />
))}
</div>
</div>
);
}
function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) {
if (el.type === 'markdown') return <RenderMarkdown content={el.content} />;
if (el.type === 'divider') return <hr className="border-gray-200 dark:border-gray-700" />;
if (el.type === 'note') return <p className="text-xs text-gray-400">{el.text}</p>;
if (el.type === 'actions') {
return (
<div className="flex flex-wrap gap-2">
{el.buttons?.map((btn: any, j: number) => (
<button key={j} onClick={() => onAction(btn.value)} className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
btn.btn_type === 'primary' ? 'bg-accent text-black hover:bg-accent-dim' :
btn.btn_type === 'danger' ? 'bg-red-500 text-white hover:bg-red-600' :
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
)}>
{btn.text}
</button>
))}
</div>
);
}
if (el.type === 'list_item') {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">{el.text}</span>
<button onClick={() => onAction(el.btn_value)} className="px-2.5 py-1 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim">
{el.btn_text}
</button>
</div>
);
}
if (el.type === 'select') {
return (
<select
defaultValue={el.init_value}
onChange={(e) => onAction(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{el.options?.map((opt: any, j: number) => (
<option key={j} value={opt.value}>{opt.text}</option>
))}
</select>
);
}
return null;
}
function colorToBg(c?: string) {
const map: Record<string, string> = {
blue: 'bg-blue-600', green: 'bg-green-600', red: 'bg-red-600', orange: 'bg-orange-500',
purple: 'bg-purple-600', grey: 'bg-gray-600', turquoise: 'bg-teal-600', violet: 'bg-violet-600',
indigo: 'bg-indigo-600', wathet: 'bg-sky-500', yellow: 'bg-yellow-500', carmine: 'bg-rose-600',
};
return map[c || ''] || 'bg-gray-800';
}
// ── Buttons renderer ─────────────────────────────────────────
function ButtonsBlock({ content, buttons, onAction }: { content: string; buttons: { text: string; data: string }[][]; onAction: (v: string) => void }) {
return (
<div className="space-y-3">
<RenderMarkdown content={content} />
{buttons.map((row, i) => (
<div key={i} className="flex flex-wrap gap-2">
{row.map((btn, j) => (
<button key={j} onClick={() => onAction(btn.data)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim transition-colors">
{btn.text}
</button>
))}
</div>
))}
</div>
);
}
// ── File/Image attachments ───────────────────────────────────
function FileBlock({ name, size }: { name: string; size?: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<FileText size={16} className="text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{name}</div>
{size !== undefined && <div className="text-xs text-gray-400">{(size / 1024).toFixed(1)} KB</div>}
</div>
</div>
);
}
function ImageBlock({ url }: { url: string }) {
return <img src={url} alt="" className="max-w-sm rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm" />;
}
// ── Connection status badge ──────────────────────────────────
function StatusBadge({ status }: { status: BridgeStatus }) {
const { t } = useTranslation();
if (status === 'connected') {
return (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
<Circle size={5} className="fill-current" /> {t('sessions.bridgeConnected', 'connected')}
</span>
);
}
if (status === 'connecting' || status === 'registering') {
return (
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-1.5 py-0.5 rounded-full">
<Loader2 size={9} className="animate-spin" /> {t('sessions.bridgeConnecting', 'connecting...')}
</span>
);
}
return (
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
<WifiOff size={9} /> {t('sessions.bridgeDisconnected', 'disconnected')}
</span>
);
}
// ── Main component ───────────────────────────────────────────
export default function SessionChat() {
const { t } = useTranslation();
const { project, id } = useParams<{ project: string; id: string }>();
const [session, setSession] = useState<SessionDetail | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const [typing, setTyping] = useState(false);
const [bridgeCfg, setBridgeCfg] = useState<BridgeConfig | null>(null);
const messagesEnd = useRef<HTMLDivElement>(null);
const streamBufRef = useRef<Map<string, string>>(new Map());
const previewHandleCounter = useRef(0);
const sessionKey = session?.session_key || '';
// Load session data + bridge config
const fetchSession = useCallback(async () => {
if (!project || !id) return;
try {
setLoading(true);
const data = await getSession(project, id, 200);
const [data, cfg] = await Promise.all([
getSession(project, id, 200),
fetchBridgeConfig(),
]);
setSession(data);
setBridgeCfg(cfg);
if (data.history) {
setMessages(data.history.map((h, i) => ({
id: `hist-${i}`,
role: h.role as 'user' | 'assistant',
content: h.content,
format: 'markdown',
timestamp: h.timestamp,
})));
}
} finally {
setLoading(false);
}
}, [project, id]);
useEffect(() => {
fetchSession();
}, [fetchSession]);
useEffect(() => { fetchSession(); }, [fetchSession]);
// Handle bridge incoming messages
const handleBridgeMessage = useCallback((msg: BridgeIncoming) => {
if (msg.type === 'reply') {
setMessages(prev => {
const streamIdx = prev.findIndex(m => m.streaming && m.role === 'assistant');
if (streamIdx >= 0) {
const updated = [...prev];
updated[streamIdx] = { ...updated[streamIdx], content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text', streaming: false };
return updated;
}
return [...prev, {
id: `reply-${Date.now()}`,
role: 'assistant',
content: msg.content,
format: (msg as any).format === 'markdown' ? 'markdown' : 'text',
}];
});
setTyping(false);
} else if (msg.type === 'reply_stream') {
const stream = msg as Extract<BridgeIncoming, { type: 'reply_stream' }>;
if (stream.done) {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text, streaming: false };
return updated;
}
return [...prev, { id: `stream-done-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown' }];
});
setTyping(false);
} else {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text };
return updated;
}
return [...prev, { id: `stream-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown', streaming: true }];
});
}
} else if (msg.type === 'card') {
const card = msg as Extract<BridgeIncoming, { type: 'card' }>;
setMessages(prev => [...prev, {
id: `card-${Date.now()}`,
role: 'assistant',
content: '',
format: 'card',
card: card.card,
}]);
setTyping(false);
} else if (msg.type === 'buttons') {
const btns = msg as Extract<BridgeIncoming, { type: 'buttons' }>;
setMessages(prev => [...prev, {
id: `btn-${Date.now()}`,
role: 'assistant',
content: btns.content,
format: 'buttons',
buttons: btns.buttons,
}]);
setTyping(false);
} else if (msg.type === 'typing_start') {
setTyping(true);
} else if (msg.type === 'typing_stop') {
setTyping(false);
} else if (msg.type === 'preview_start') {
const ps = msg as Extract<BridgeIncoming, { type: 'preview_start' }>;
const handle = `web-preview-${++previewHandleCounter.current}`;
sendPreviewAck(ps.ref_id, handle);
setMessages(prev => [...prev, {
id: `stream-${handle}`,
role: 'assistant',
content: ps.content,
format: 'markdown',
streaming: true,
}]);
} else if (msg.type === 'update_message') {
const um = msg as Extract<BridgeIncoming, { type: 'update_message' }>;
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: um.content };
return updated;
}
return prev;
});
} else if (msg.type === 'delete_message') {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
return prev.filter((_, i) => i !== idx);
}
return prev;
});
}
}, []);
const { status: bridgeStatus, sendMessage: bridgeSend, sendCardAction, sendPreviewAck } = useBridgeSocket({
bridgeCfg,
sessionKey,
onMessage: handleBridgeMessage,
});
// Scroll to bottom on new messages
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
}, [session?.history]);
}, [messages, typing]);
const handleSend = async () => {
if (!input.trim() || !project || !session) return;
const msg = input.trim();
const handleSend = useCallback(() => {
if (!input.trim() || bridgeStatus !== 'connected') return;
const content = input.trim();
setInput('');
setSending(true);
try {
await sendMessage(project, { session_key: session.session_key, message: msg });
setTimeout(fetchSession, 1500);
} finally {
setSending(false);
}
};
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
role: 'user',
content,
}]);
bridgeSend(content);
setTimeout(() => setSending(false), 300);
}, [input, bridgeStatus, bridgeSend]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -148,10 +413,17 @@ export default function SessionChat() {
}
};
const handleCardAction = useCallback((value: string) => {
if (bridgeStatus !== 'connected') return;
sendCardAction(value);
}, [bridgeStatus, sendCardAction]);
if (loading && !session) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
const canSend = bridgeStatus === 'connected';
return (
<div className="flex flex-col h-[calc(100vh-8rem)] animate-fade-in">
{/* Header */}
@@ -163,15 +435,7 @@ export default function SessionChat() {
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{session?.name || id}</h2>
{session?.live ? (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
<Circle size={5} className="fill-current" /> live
</span>
) : (
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
<WifiOff size={9} /> {t('sessions.offline')}
</span>
)}
<StatusBadge status={bridgeStatus} />
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge>{project}</Badge>
@@ -187,13 +451,13 @@ export default function SessionChat() {
{/* Messages */}
<div className="flex-1 overflow-y-auto py-6 space-y-5">
{(!session?.history || session.history.length === 0) && (
{messages.length === 0 && !loading && (
<p className="text-center text-sm text-gray-400 py-12">{t('sessions.noMessages')}</p>
)}
{session?.history?.map((msg, i) => {
{messages.map((msg) => {
const isUser = msg.role === 'user';
return (
<div key={i} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
<div key={msg.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
{!isUser && (
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
@@ -203,9 +467,25 @@ export default function SessionChat() {
'rounded-2xl px-5 py-3.5 text-sm',
isUser
? 'max-w-[70%] bg-accent text-black rounded-br-md'
: 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm'
: 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm',
msg.streaming && 'animate-pulse-subtle',
)}>
<ChatMarkdown content={msg.content} isUser={isUser} />
{msg.format === 'card' ? (
<CardBlock card={msg.card} onAction={handleCardAction} />
) : msg.format === 'buttons' && msg.buttons ? (
<ButtonsBlock content={msg.content} buttons={msg.buttons} onAction={handleCardAction} />
) : msg.format === 'image' && msg.imageUrl ? (
<ImageBlock url={msg.imageUrl} />
) : msg.format === 'file' && msg.fileName ? (
<FileBlock name={msg.fileName} size={msg.fileSize} />
) : isUser ? (
<div className="whitespace-pre-wrap">{msg.content}</div>
) : (
<RenderMarkdown content={msg.content} />
)}
{msg.streaming && (
<span className="inline-block w-1.5 h-4 bg-accent/60 rounded-sm ml-0.5 animate-pulse" />
)}
</div>
{isUser && (
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0 mt-1">
@@ -215,12 +495,26 @@ export default function SessionChat() {
</div>
);
})}
{typing && !messages.some(m => m.streaming) && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
<div className="rounded-2xl px-5 py-3.5 text-sm bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 rounded-bl-md shadow-sm">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={messagesEnd} />
</div>
{/* Input */}
<div className="border-t border-gray-200 dark:border-gray-800 pt-4">
{session?.live ? (
{canSend ? (
<div className="flex gap-3">
<input
value={input}
@@ -236,19 +530,21 @@ export default function SessionChat() {
className="px-4 py-3 rounded-xl bg-accent text-black hover:bg-accent-dim transition-colors disabled:opacity-50 flex items-center gap-2"
>
{sending ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.notLiveHint')}</span>
<span>{t('sessions.bridgeNotAvailable', 'Bridge not available. Enable [bridge] in config.toml to chat from web.')}</span>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
<Loader2 size={14} className="animate-spin" />
<span>{t('sessions.bridgeConnecting', 'Connecting to bridge...')}</span>
</div>
)}
</div>