feat: integrate Claude Agent SDK capabilities — model/command discovery, effort/thinking, MCP runtime, file rewind, structured output

Systematically integrate high-value SDK capabilities while preserving the
multi-provider architecture:

SDK capability discovery (per-provider cache):
- agent-sdk-capabilities.ts: cache models, commands, account info, MCP status
  per provider via Map<providerId, cache> to prevent cross-provider pollution
- claude-client.ts: fire-and-forget captureCapabilities() after query() init
- providers/models: merge SDK models with static defaults
- skills: merge SDK slash commands with filesystem skills

Query options passthrough:
- thinking (adaptive/enabled/disabled), effort (low/medium/high/max),
  outputFormat, agents, enableFileCheckpointing
- /chat page.tsx: first-message path now includes effort/thinking (was missing)
- stream-session-manager: passes effort/thinking in POST body

Graceful interrupt:
- /api/chat/interrupt: conversation.interrupt() before abort fallback
- stream-session-manager: tries interrupt first, aborts after 2s timeout

MCP runtime management:
- /api/plugins/mcp/status: live status from active Query, auto-resolves
  providerId from session DB record (prevents wrong-provider cache writes)
- /api/plugins/mcp/reconnect + toggle: reconnect/enable/disable servers
- McpManager + McpServerList: runtime status indicators with action buttons

File checkpointing & rewind:
- enableFileCheckpointing=true for code mode by default
- rewind_point SSE: only emitted for prompt-level user messages
  (parent_tool_use_id===null) and skips autoTrigger turns
- /api/chat/rewind: dry-run preview + actual rewind via rewindFiles()
- MessageList: RewindButton with dry-run preview

Structured output:
- /api/chat/structured: reads SDKResultSuccess.structured_output as primary
  path, text fallback only when absent

Account info & thinking config:
- /api/sdk/account: cached account info display
- Settings: thinking_mode in ALLOWED_KEYS, thinking mode selector in UI
- Effort selector in MessageInput with lifted state pattern

New files: 11 route handlers + 2 lib modules + 1 test file
Modified files: 18 (types, client, manager, UI components, i18n)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-07 18:22:05 +08:00
1 parent 6c02c135fe
commit c18776f4ca
30 files changed
+1487 -28

No files matched your search

+34 -1
View File
@@ -19,6 +19,7 @@ import {
stopStream,
subscribe,
getSnapshot,
getRewindPoints,
respondToPermission,
clearSnapshot,
} from '@/lib/stream-session-manager';
@@ -48,6 +49,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Effort level selected in MessageInput — lifted here so it enters the stream chain
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
// Thinking mode from app settings
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
// Sync model/provider when session data loads (props update after async fetch)
// Unconditional: when modelName is empty (old session with no saved model),
@@ -58,6 +63,18 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
useEffect(() => {
setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
}, [providerId]);
// Fetch thinking mode from app settings
useEffect(() => {
fetch('/api/settings/app')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.settings?.thinking_mode) {
setThinkingMode(data.settings.thinking_mode);
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (initialPermissionProfile) {
setPermissionProfile(initialPermissionProfile);
@@ -78,6 +95,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const statusText = streamSnapshot?.statusText;
const pendingPermission = streamSnapshot?.pendingPermission ?? null;
const permissionResolved = streamSnapshot?.permissionResolved ?? null;
const rewindPoints = getRewindPoints(sessionId);
// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
const pendingImageNoticesRef = useRef<string[]>([]);
@@ -290,6 +308,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
model: currentModel,
providerId: currentProviderId,
autoTrigger: true,
thinking: buildThinkingConfig(),
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
@@ -468,6 +487,14 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}
}, [sessionId, workingDirectory]);
// Build SDK thinking config from settings
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
if (thinkingMode === 'enabled') return { type: 'enabled' };
if (thinkingMode === 'disabled') return { type: 'disabled' };
return undefined;
}, [thinkingMode]);
// Send message — delegates stream management to the manager
const sendMessage = useCallback(
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => {
@@ -512,6 +539,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
files,
systemPromptAppend,
pendingImageNotices: notices,
effort: selectedEffort,
thinking: buildThinkingConfig(),
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
@@ -521,7 +550,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
},
});
},
[sessionId, isStreaming, mode, currentModel, currentProviderId, handleModeChange]
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
);
// Keep sendMessageRef in sync so timeout auto-retry can call it
@@ -663,6 +692,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadEarlierMessages}
rewindPoints={rewindPoints}
sessionId={sessionId}
/>
{/* Permission prompt — rendered outside MessageList so it's always visible at bottom */}
<PermissionPrompt
@@ -692,6 +723,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
mode={mode}
onModeChange={handleModeChange}
onAssistantTrigger={checkAssistantTrigger}
effort={selectedEffort}
onEffortChange={setSelectedEffort}
/>
<ChatComposerActionBar
left={<ImageGenToggle />}
+68
View File
@@ -98,6 +98,9 @@ interface MessageInputProps {
mode?: string;
onModeChange?: (mode: string) => void;
onAssistantTrigger?: () => void;
/** Effort selection lifted to parent for inclusion in the stream chain */
effort?: string;
onEffortChange?: (effort: string | undefined) => void;
}
interface PopoverItem {
@@ -375,6 +378,8 @@ export function MessageInput({
mode = 'code',
onModeChange,
onAssistantTrigger,
effort: effortProp,
onEffortChange,
}: MessageInputProps) {
const { t } = useTranslation();
const imageGen = useImageGen();
@@ -932,6 +937,31 @@ export function MessageInput({
const currentModelValue = modelName || 'sonnet';
const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0];
// Effort selector state — only shown when the current model supports effort
const currentModelMeta = currentModelOption as typeof currentModelOption & { supportsEffort?: boolean; supportedEffortLevels?: string[] };
const showEffortSelector = currentModelMeta.supportsEffort === true;
// Use prop if provided (lifted state), otherwise local state
const [localEffort, setLocalEffort] = useState<string>('high');
const selectedEffort = effortProp ?? localEffort;
const setSelectedEffort = useCallback((v: string) => {
setLocalEffort(v);
onEffortChange?.(v);
}, [onEffortChange]);
const [effortMenuOpen, setEffortMenuOpen] = useState(false);
const effortMenuRef = useRef<HTMLDivElement>(null);
// Close effort menu on outside click
useEffect(() => {
if (!effortMenuOpen) return;
const handler = (e: MouseEvent) => {
if (effortMenuRef.current && !effortMenuRef.current.contains(e.target as Node)) {
setEffortMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [effortMenuOpen]);
// Map isStreaming to ChatStatus for PromptInputSubmit
const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready';
@@ -1191,6 +1221,44 @@ export function MessageInput({
)}
</div>
{/* Effort selector — only visible when model supports effort */}
{showEffortSelector && (
<div className="relative" ref={effortMenuRef}>
<PromptInputButton
onClick={() => setEffortMenuOpen((prev) => !prev)}
>
<span className="text-xs">{t(`messageInput.effort.${selectedEffort}` as TranslationKey)}</span>
<HugeiconsIcon icon={ArrowDown01Icon} className={cn("h-2.5 w-2.5 transition-transform duration-200", effortMenuOpen && "rotate-180")} />
</PromptInputButton>
{effortMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-36 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground">
{t('messageInput.effort.label' as TranslationKey)}
</div>
<div className="py-0.5">
{(currentModelMeta.supportedEffortLevels || ['low', 'medium', 'high', 'max']).map((level) => (
<button
key={level}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
selectedEffort === level ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
setSelectedEffort(level);
setEffortMenuOpen(false);
}}
>
<span className="text-xs">{t(`messageInput.effort.${level}` as TranslationKey)}</span>
{selectedEffort === level && <span className="text-xs">✓</span>}
</button>
))}
</div>
</div>
)}
</div>
)}
</PromptInputTools>
<FileAwareSubmitButton
+123 -6
View File
@@ -1,7 +1,8 @@
'use client';
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useState, useCallback } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { useStickToBottomContext } from 'use-stick-to-bottom';
import type { Message } from '@/types';
import {
@@ -41,6 +42,95 @@ function ScrollOnStream({ isStreaming, messageCount }: { isStreaming: boolean; m
return null;
}
/**
* Rewind button shown on user messages that have file checkpoints.
*/
function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMessageId: string }) {
const { t } = useTranslation();
const [state, setState] = useState<'idle' | 'preview' | 'loading' | 'done'>('idle');
const [preview, setPreview] = useState<{ filesChanged?: string[]; insertions?: number; deletions?: number } | null>(null);
const handleDryRun = useCallback(async () => {
setState('loading');
try {
const res = await fetch('/api/chat/rewind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, userMessageId, dryRun: true }),
});
const data = await res.json();
if (data.canRewind) {
setPreview(data);
setState('preview');
} else {
setState('idle');
}
} catch {
setState('idle');
}
}, [sessionId, userMessageId]);
const handleRewind = useCallback(async () => {
setState('loading');
try {
const res = await fetch('/api/chat/rewind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, userMessageId }),
});
const data = await res.json();
if (data.canRewind !== false) {
setState('done');
setTimeout(() => setState('idle'), 3000);
} else {
setState('idle');
}
} catch {
setState('idle');
}
}, [sessionId, userMessageId]);
if (state === 'done') {
return (
<span className="text-[10px] text-green-600 dark:text-green-400 ml-2">
{t('messageList.rewindDone' as TranslationKey)}
</span>
);
}
if (state === 'preview' && preview) {
return (
<span className="inline-flex items-center gap-1.5 ml-2">
<span className="text-[10px] text-muted-foreground">
{preview.filesChanged?.length || 0} files, +{preview.insertions || 0}/-{preview.deletions || 0}
</span>
<button
onClick={handleRewind}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
>
{t('messageList.rewindConfirm' as TranslationKey)}
</button>
<button
onClick={() => setState('idle')}
className="text-[10px] text-muted-foreground hover:underline"
>
{t('messageList.rewindCancel' as TranslationKey)}
</button>
</span>
);
}
return (
<button
onClick={handleDryRun}
disabled={state === 'loading'}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors ml-2 opacity-0 group-hover:opacity-100"
>
{state === 'loading' ? '...' : t('messageList.rewindToHere' as TranslationKey)}
</button>
);
}
interface ToolUseInfo {
id: string;
name: string;
@@ -53,6 +143,11 @@ interface ToolResultInfo {
is_error?: boolean;
}
/** Rewind points contain SDK UUIDs (not local message IDs) */
interface RewindPoint {
userMessageId: string; // SDK UUID
}
interface MessageListProps {
messages: Message[];
streamingContent: string;
@@ -65,6 +160,9 @@ interface MessageListProps {
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => void;
/** SDK rewind points — only emitted for visible prompt-level user messages (not tool results or auto-triggers), mapped by position */
rewindPoints?: RewindPoint[];
sessionId?: string;
}
export function MessageList({
@@ -79,6 +177,8 @@ export function MessageList({
hasMore,
loadingMore,
onLoadMore,
rewindPoints = [],
sessionId,
}: MessageListProps) {
const { t } = useTranslation();
// Scroll anchor: preserve position when older messages are prepended
@@ -132,11 +232,28 @@ export function MessageList({
</button>
</div>
)}
{messages.map((message) => (
<div key={message.id} id={`msg-${message.id}`}>
<MessageItem message={message} />
</div>
))}
{messages.map((message) => {
// Map rewind points to visible user messages by position:
// Backend only emits rewind_point for prompt-level user messages
// (not tool results, not auto-trigger), so they're 1:1 with visible user messages.
let rewindSdkUuid: string | undefined;
if (message.role === 'user' && sessionId && rewindPoints.length > 0) {
const userMsgsBefore = messages.filter(m => m.role === 'user');
const userIndex = userMsgsBefore.indexOf(message);
if (userIndex >= 0 && userIndex < rewindPoints.length) {
rewindSdkUuid = rewindPoints[userIndex].userMessageId;
}
}
return (
<div key={message.id} id={`msg-${message.id}`} className="group">
<MessageItem message={message} />
{rewindSdkUuid && sessionId && !isStreaming && (
<RewindButton sessionId={sessionId} userMessageId={rewindSdkUuid} />
)}
</div>
);
})}
{isStreaming && (
<StreamingMessage
+93 -2
View File
@@ -3,14 +3,22 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { HugeiconsIcon } from "@hugeicons/react";
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon, RefreshIcon, Wifi01Icon } from "@hugeicons/core-free-icons";
import { McpServerList } from "@/components/plugins/McpServerList";
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
import type { MCPServer } from "@/types";
interface McpRuntimeStatus {
name: string;
status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled';
serverInfo?: { name: string; version: string };
}
export function McpManager() {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, MCPServer>>({});
@@ -20,6 +28,9 @@ export function McpManager() {
const [editingServer, setEditingServer] = useState<MCPServer | undefined>();
const [tab, setTab] = useState<"list" | "json">("list");
const [error, setError] = useState<string | null>(null);
const [runtimeStatus, setRuntimeStatus] = useState<McpRuntimeStatus[]>([]);
const [runtimeLoading, setRuntimeLoading] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const fetchServers = useCallback(async () => {
try {
@@ -39,9 +50,37 @@ export function McpManager() {
}
}, []);
const fetchRuntimeStatus = useCallback(async () => {
setRuntimeLoading(true);
try {
// Try to get active session from stream manager
const sessionsRes = await fetch('/api/chat/sessions?status=active&limit=1');
const sessionsData = await sessionsRes.json();
const sessionId = sessionsData?.sessions?.[0]?.id;
if (!sessionId) {
setActiveSessionId(null);
setRuntimeStatus([]);
return;
}
setActiveSessionId(sessionId);
const res = await fetch(`/api/plugins/mcp/status?sessionId=${encodeURIComponent(sessionId)}`);
const data = await res.json();
if (data.servers) {
setRuntimeStatus(data.servers);
}
} catch {
// Runtime status unavailable
} finally {
setRuntimeLoading(false);
}
}, []);
useEffect(() => {
fetchServers();
}, [fetchServers]);
fetchRuntimeStatus();
}, [fetchServers, fetchRuntimeStatus]);
function handleEdit(name: string, server: MCPServer) {
setEditingName(name);
@@ -188,6 +227,8 @@ export function McpManager() {
servers={servers}
onEdit={handleEdit}
onDelete={handleDelete}
runtimeStatus={runtimeStatus}
activeSessionId={activeSessionId || undefined}
/>
)}
</TabsContent>
@@ -201,6 +242,56 @@ export function McpManager() {
</TabsContent>
</Tabs>
{/* Runtime Status Section */}
<div className="mt-6 border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<HugeiconsIcon icon={Wifi01Icon} className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">{t('mcp.runtimeStatus' as TranslationKey)}</h4>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={fetchRuntimeStatus}
disabled={runtimeLoading}
>
<HugeiconsIcon icon={runtimeLoading ? Loading02Icon : RefreshIcon} className={`h-3 w-3 ${runtimeLoading ? 'animate-spin' : ''}`} />
{t('mcp.refresh' as TranslationKey)}
</Button>
</div>
{!activeSessionId ? (
<p className="text-xs text-muted-foreground py-4 text-center">
{t('mcp.noActiveSession' as TranslationKey)}
</p>
) : runtimeStatus.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">
{t('mcp.noRuntimeStatus' as TranslationKey)}
</p>
) : (
<div className="space-y-1.5">
{runtimeStatus.map((s) => (
<div key={s.name} className="flex items-center justify-between py-1.5 px-2 rounded-md bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
<span className={`h-2 w-2 rounded-full shrink-0 ${
s.status === 'connected' ? 'bg-green-500' :
s.status === 'failed' ? 'bg-red-500' :
s.status === 'pending' ? 'bg-blue-500' :
s.status === 'disabled' ? 'bg-gray-400' :
'bg-yellow-500'
}`} />
<span className="text-xs font-medium truncate">{s.name}</span>
</div>
<Badge variant="outline" className="text-[10px] shrink-0">
{s.status}
</Badge>
</div>
))}
</div>
)}
</div>
<McpServerEditor
open={editorOpen}
onOpenChange={setEditorOpen}
+130 -6
View File
@@ -4,15 +4,24 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon } from "@hugeicons/core-free-icons";
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon, RefreshIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { MCPServer } from '@/types';
import { useState, useCallback } from 'react';
interface McpRuntimeStatus {
name: string;
status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled';
serverInfo?: { name: string; version: string };
}
interface McpServerListProps {
servers: Record<string, MCPServer>;
onEdit: (name: string, server: MCPServer) => void;
onDelete: (name: string) => void;
runtimeStatus?: McpRuntimeStatus[];
activeSessionId?: string;
}
function getServerTypeInfo(server: MCPServer) {
@@ -27,9 +36,68 @@ function getServerTypeInfo(server: MCPServer) {
}
}
export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {
function getStatusBadge(status: McpRuntimeStatus['status']) {
switch (status) {
case 'connected':
return { label: 'Connected', className: 'bg-green-500/10 text-green-600 border-green-500/20' };
case 'failed':
return { label: 'Failed', className: 'bg-red-500/10 text-red-600 border-red-500/20' };
case 'needs-auth':
return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' };
case 'pending':
return { label: 'Pending', className: 'bg-blue-500/10 text-blue-600 border-blue-500/20' };
case 'disabled':
return { label: 'Disabled', className: 'bg-gray-500/10 text-gray-500 border-gray-500/20' };
default:
return { label: status, className: '' };
}
}
export function McpServerList({ servers, onEdit, onDelete, runtimeStatus, activeSessionId }: McpServerListProps) {
const { t } = useTranslation();
const entries = Object.entries(servers);
const [reconnecting, setReconnecting] = useState<Set<string>>(new Set());
const [toggling, setToggling] = useState<Set<string>>(new Set());
const handleReconnect = useCallback(async (serverName: string) => {
if (!activeSessionId) return;
setReconnecting(prev => new Set(prev).add(serverName));
try {
await fetch('/api/plugins/mcp/reconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: activeSessionId, serverName }),
});
} catch {
// Best effort
} finally {
setReconnecting(prev => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
}, [activeSessionId]);
const handleToggle = useCallback(async (serverName: string, enabled: boolean) => {
if (!activeSessionId) return;
setToggling(prev => new Set(prev).add(serverName));
try {
await fetch('/api/plugins/mcp/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: activeSessionId, serverName, enabled }),
});
} catch {
// Best effort
} finally {
setToggling(prev => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
}, [activeSessionId]);
if (entries.length === 0) {
return (
@@ -43,10 +111,23 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
);
}
// Build a lookup for runtime status by server name
const statusByName = new Map<string, McpRuntimeStatus>();
if (runtimeStatus) {
for (const s of runtimeStatus) {
statusByName.set(s.name, s);
}
}
return (
<div className="space-y-3">
{entries.map(([name, server]) => {
const typeInfo = getServerTypeInfo(server);
const runtime = statusByName.get(name);
const statusBadge = runtime ? getStatusBadge(runtime.status) : null;
const isReconnecting = reconnecting.has(name);
const isToggling = toggling.has(name);
return (
<Card key={name}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
@@ -57,17 +138,60 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
<Badge variant="outline" className="text-xs shrink-0">
{typeInfo.label}
</Badge>
<Badge variant="secondary" className="text-xs shrink-0">
{t('provider.configured')}
</Badge>
{statusBadge ? (
<Badge variant="outline" className={`text-xs shrink-0 ${statusBadge.className}`}>
{statusBadge.label}
</Badge>
) : (
<Badge variant="secondary" className="text-xs shrink-0">
{t('provider.configured')}
</Badge>
)}
</div>
<CardDescription className="text-xs mt-1 font-mono">
{server.url
? server.url
: `${server.command} ${server.args?.join(' ') || ''}`}
</CardDescription>
{runtime?.serverInfo && (
<p className="text-[10px] text-muted-foreground mt-0.5">
{runtime.serverInfo.name} v{runtime.serverInfo.version}
</p>
)}
</div>
<div className="flex gap-1 shrink-0">
{/* Reconnect button — only for failed servers */}
{runtime?.status === 'failed' && activeSessionId && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isReconnecting}
onClick={() => handleReconnect(name)}
title={t('mcp.reconnect' as TranslationKey)}
>
{isReconnecting ? (
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
) : (
<HugeiconsIcon icon={RefreshIcon} className="h-3.5 w-3.5" />
)}
</Button>
)}
{/* Enable button — only for disabled servers */}
{runtime?.status === 'disabled' && activeSessionId && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
disabled={isToggling}
onClick={() => handleToggle(name, true)}
>
{isToggling ? (
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
) : null}
{t('mcp.enable' as TranslationKey)}
</Button>
)}
<Button
variant="ghost"
size="icon"
+81 -1
View File
@@ -18,6 +18,7 @@ import { ReloadIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { useUpdate } from "@/hooks/useUpdate";
import { useTranslation } from "@/hooks/useTranslation";
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
function UpdateCard() {
@@ -121,6 +122,8 @@ export function GeneralSection() {
const [skipPermissions, setSkipPermissions] = useState(false);
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
const [skipPermSaving, setSkipPermSaving] = useState(false);
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
const [accountInfo, setAccountInfo] = useState<{ email?: string; organization?: string; subscriptionType?: string } | null>(null);
const { t, locale, setLocale } = useTranslation();
const fetchAppSettings = useCallback(async () => {
@@ -130,15 +133,48 @@ export function GeneralSection() {
const data = await res.json();
const appSettings = data.settings || {};
setSkipPermissions(appSettings.dangerously_skip_permissions === "true");
if (appSettings.thinking_mode) {
setThinkingMode(appSettings.thinking_mode);
}
}
} catch {
// ignore
}
}, []);
const fetchAccountInfo = useCallback(async () => {
try {
const res = await fetch("/api/sdk/account");
if (res.ok) {
const data = await res.json();
if (data.account) {
setAccountInfo(data.account);
}
}
} catch {
// Account info not available
}
}, []);
useEffect(() => {
fetchAppSettings();
}, [fetchAppSettings]);
fetchAccountInfo();
}, [fetchAppSettings, fetchAccountInfo]);
const saveThinkingMode = async (mode: string) => {
setThinkingMode(mode);
try {
await fetch("/api/settings/app", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: { thinking_mode: mode },
}),
});
} catch {
// ignore
}
};
const handleSkipPermToggle = (checked: boolean) => {
if (checked) {
@@ -216,6 +252,50 @@ export function GeneralSection() {
</div>
</div>
{/* Thinking mode */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.thinkingMode' as TranslationKey)}</h2>
<p className="text-xs text-muted-foreground">{t('settings.thinkingModeDesc' as TranslationKey)}</p>
</div>
<Select value={thinkingMode} onValueChange={saveThinkingMode}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="adaptive">{t('settings.thinkingAdaptive' as TranslationKey)}</SelectItem>
<SelectItem value="enabled">{t('settings.thinkingEnabled' as TranslationKey)}</SelectItem>
<SelectItem value="disabled">{t('settings.thinkingDisabled' as TranslationKey)}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Account info */}
{accountInfo && (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium mb-2">{t('settings.accountInfo' as TranslationKey)}</h2>
<div className="space-y-1">
{accountInfo.email && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('settings.email' as TranslationKey)}:</span> {accountInfo.email}
</p>
)}
{accountInfo.organization && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('settings.organization' as TranslationKey)}:</span> {accountInfo.organization}
</p>
)}
{accountInfo.subscriptionType && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('settings.subscription' as TranslationKey)}:</span> {accountInfo.subscriptionType}
</p>
)}
</div>
</div>
)}
{/* Skip-permissions warning dialog */}
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
<AlertDialogContent>