mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: add Chinese localization and new assets for CCW documentation
- Created LICENSE.txt for JavaScript assets including NProgress and React libraries. - Added runtime JavaScript file for main functionality. - Introduced new favicon and logo SVG assets for branding. - Added comprehensive FAQ section in Chinese, covering CCW features, installation, workflows, AI model support, and troubleshooting.
This commit is contained in:
405
ccw/frontend/src/components/lite-tasks/LiteContextContent.tsx
Normal file
405
ccw/frontend/src/components/lite-tasks/LiteContextContent.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
// ========================================
|
||||||
|
// LiteContextContent Component
|
||||||
|
// ========================================
|
||||||
|
// Extracted from LiteTasksPage - renders context data sections for lite sessions
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import {
|
||||||
|
Compass,
|
||||||
|
Stethoscope,
|
||||||
|
FolderOpen,
|
||||||
|
FileText,
|
||||||
|
Package,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
Code,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import {
|
||||||
|
ExplorationsSection,
|
||||||
|
AssetsCard,
|
||||||
|
ConflictDetectionCard,
|
||||||
|
} from '@/components/session-detail/context';
|
||||||
|
import type { ExplorationsData } from '@/components/session-detail/context/ExplorationsSection';
|
||||||
|
import type {
|
||||||
|
LiteSessionContext,
|
||||||
|
LiteDiagnosisItem,
|
||||||
|
LiteTaskSession,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert diagnoses from either `items[]` (lite-scanner) or `data{}` (session-routes) format
|
||||||
|
* into a uniform LiteDiagnosisItem array.
|
||||||
|
*/
|
||||||
|
function getDiagnosisItems(diagnoses: LiteSessionContext['diagnoses']): LiteDiagnosisItem[] {
|
||||||
|
if (!diagnoses) return [];
|
||||||
|
if (diagnoses.items?.length) return diagnoses.items;
|
||||||
|
if (diagnoses.data) {
|
||||||
|
return Object.entries(diagnoses.data).map(([angle, content]) => ({
|
||||||
|
id: angle,
|
||||||
|
title: angle.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase()),
|
||||||
|
...(typeof content === 'object' && content !== null ? content : {}),
|
||||||
|
})) as LiteDiagnosisItem[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContextSection - Collapsible section wrapper for context items
|
||||||
|
*/
|
||||||
|
function ContextSection({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
defaultOpen = true,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
|
||||||
|
{badge && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">{badge}</Badge>
|
||||||
|
)}
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<CardContent className="px-3 pb-3 pt-0">
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContextContent - Renders the context data sections for a lite session
|
||||||
|
*/
|
||||||
|
function ContextContent({
|
||||||
|
contextData,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
contextData: LiteSessionContext;
|
||||||
|
session: LiteTaskSession;
|
||||||
|
}) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const plan = session.plan || {};
|
||||||
|
const ctx = contextData.context;
|
||||||
|
|
||||||
|
const hasExplorations = !!(contextData.explorations?.manifest);
|
||||||
|
const diagnosisItems = getDiagnosisItems(contextData.diagnoses);
|
||||||
|
const hasDiagnoses = !!(contextData.diagnoses?.manifest || diagnosisItems.length > 0);
|
||||||
|
const hasContext = !!ctx;
|
||||||
|
const hasFocusPaths = !!(plan.focus_paths as string[] | undefined)?.length;
|
||||||
|
const hasSummary = !!(plan.summary as string | undefined);
|
||||||
|
const hasAnyContent = hasExplorations || hasDiagnoses || hasContext || hasFocusPaths || hasSummary;
|
||||||
|
|
||||||
|
if (!hasAnyContent) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Package className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.empty' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Explorations Section */}
|
||||||
|
{hasExplorations && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Compass className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.explorations' })}
|
||||||
|
badge={
|
||||||
|
contextData.explorations?.manifest?.exploration_count
|
||||||
|
? formatMessage(
|
||||||
|
{ id: 'liteTasks.contextPanel.explorationsCount' },
|
||||||
|
{ count: contextData.explorations.manifest.exploration_count }
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!!contextData.explorations?.manifest?.task_description && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}:
|
||||||
|
</span>{' '}
|
||||||
|
{String(contextData.explorations.manifest.task_description)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!contextData.explorations?.manifest?.complexity && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'liteTasks.contextPanel.complexity' })}:
|
||||||
|
</span>{' '}
|
||||||
|
<Badge variant="info" className="text-[10px]">
|
||||||
|
{String(contextData.explorations.manifest.complexity)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contextData.explorations?.data && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{Object.keys(contextData.explorations.data).map((angle) => (
|
||||||
|
<Badge key={angle} variant="secondary" className="text-[10px] capitalize">
|
||||||
|
{angle.replace(/-/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Diagnoses Section */}
|
||||||
|
{hasDiagnoses && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Stethoscope className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.diagnoses' })}
|
||||||
|
badge={
|
||||||
|
diagnosisItems.length > 0
|
||||||
|
? formatMessage(
|
||||||
|
{ id: 'liteTasks.contextPanel.diagnosesCount' },
|
||||||
|
{ count: diagnosisItems.length }
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{diagnosisItems.map((item, i) => (
|
||||||
|
<div key={i} className="text-xs text-muted-foreground py-1 border-b border-border/50 last:border-0">
|
||||||
|
{item.title || item.description || `Diagnosis ${i + 1}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context Package Section */}
|
||||||
|
{hasContext && ctx && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Package className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.contextPackage' })}
|
||||||
|
>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{ctx.task_description && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}:</span>{' '}
|
||||||
|
{ctx.task_description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.constraints && ctx.constraints.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.constraints' })}:</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-2">
|
||||||
|
{ctx.constraints.map((c, i) => (
|
||||||
|
<div key={i} className="text-muted-foreground flex items-start gap-1">
|
||||||
|
<span className="text-primary/50">•</span>
|
||||||
|
<span>{c}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.focus_paths && ctx.focus_paths.length > 0 && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })}:</span>{' '}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{ctx.focus_paths.map((p, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="text-[10px] font-mono">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.relevant_files && ctx.relevant_files.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.relevantFiles' })}:</span>{' '}
|
||||||
|
<Badge variant="outline" className="text-[10px] align-middle">
|
||||||
|
{ctx.relevant_files.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 pl-2 max-h-32 overflow-y-auto">
|
||||||
|
{ctx.relevant_files.map((f, i) => {
|
||||||
|
const filePath = typeof f === 'string' ? f : f.path;
|
||||||
|
const reason = typeof f === 'string' ? undefined : f.reason;
|
||||||
|
return (
|
||||||
|
<div key={i} className="group flex items-start gap-1 text-muted-foreground hover:bg-muted/30 rounded px-1 py-0.5">
|
||||||
|
<span className="text-primary/50 shrink-0">{i + 1}.</span>
|
||||||
|
<span className="font-mono text-xs truncate flex-1" title={filePath}>
|
||||||
|
{filePath}
|
||||||
|
</span>
|
||||||
|
{reason && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 truncate ml-1" title={reason}>
|
||||||
|
({reason})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.dependencies && ctx.dependencies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.dependencies' })}:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ctx.dependencies.map((d, i) => {
|
||||||
|
const depInfo = typeof d === 'string'
|
||||||
|
? { name: d, type: '', version: '' }
|
||||||
|
: d as { name: string; type?: string; version?: string };
|
||||||
|
return (
|
||||||
|
<Badge key={i} variant="outline" className="text-[10px]">
|
||||||
|
{depInfo.name}
|
||||||
|
{depInfo.version && <span className="ml-1 opacity-70">@{depInfo.version}</span>}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.session_id && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.sessionId' })}:</span>{' '}
|
||||||
|
<span className="font-mono bg-muted/50 px-1.5 py-0.5 rounded">{ctx.session_id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.metadata && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1">
|
||||||
|
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.metadata' })}:</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 pl-2 text-muted-foreground">
|
||||||
|
{Object.entries(ctx.metadata).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-[10px] text-primary/60">{k}:</span>
|
||||||
|
<span className="truncate">{String(v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conflict Risks (simple inline list) */}
|
||||||
|
{ctx?.conflict_risks && Array.isArray(ctx.conflict_risks) && ctx.conflict_risks.length > 0 && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<AlertTriangle className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.conflictRisks' })}
|
||||||
|
>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{ctx.conflict_risks.map((r, i) => {
|
||||||
|
const desc = typeof r === 'string' ? r : r.description;
|
||||||
|
const severity = typeof r === 'string' ? undefined : r.severity;
|
||||||
|
return (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
{severity && <Badge variant={severity === 'high' ? 'destructive' : 'warning'} className="text-[10px]">{severity}</Badge>}
|
||||||
|
<span>{desc}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Structured Conflict Detection (higher priority than simple conflict_risks) */}
|
||||||
|
{ctx?.conflict_detection && (
|
||||||
|
<ConflictDetectionCard data={ctx.conflict_detection} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assets */}
|
||||||
|
{ctx?.assets && (
|
||||||
|
<AssetsCard data={ctx.assets} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enhanced Explorations - when angle data has detail fields */}
|
||||||
|
{contextData.explorations?.data &&
|
||||||
|
Object.values(contextData.explorations.data).some(angle =>
|
||||||
|
angle.project_structure?.length || angle.relevant_files?.length || angle.patterns?.length
|
||||||
|
) && (
|
||||||
|
<ExplorationsSection data={{
|
||||||
|
manifest: {
|
||||||
|
task_description: contextData.explorations.manifest?.task_description || '',
|
||||||
|
complexity: contextData.explorations.manifest?.complexity,
|
||||||
|
exploration_count: contextData.explorations.manifest?.exploration_count || Object.keys(contextData.explorations.data).length,
|
||||||
|
},
|
||||||
|
data: contextData.explorations.data as ExplorationsData['data'],
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Focus Paths from Plan */}
|
||||||
|
{hasFocusPaths && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<FolderOpen className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(plan.focus_paths as string[]).map((p, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="text-[10px] font-mono">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan Summary */}
|
||||||
|
{hasSummary && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.summary' })}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{plan.summary as string}</p>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw JSON Debug View */}
|
||||||
|
{contextData.context && (
|
||||||
|
<ContextSection
|
||||||
|
icon={<Code className="h-4 w-4" />}
|
||||||
|
title={formatMessage({ id: 'liteTasks.contextPanel.rawJson' })}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<pre className="text-xs font-mono bg-muted p-3 rounded-lg overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(contextData.context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</ContextSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ContextContent as LiteContextContent, ContextSection as LiteContextSection };
|
||||||
@@ -348,7 +348,7 @@ export function CliStreamPanel({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col p-0">
|
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between pr-8">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Terminal className="h-5 w-5" />
|
<Terminal className="h-5 w-5" />
|
||||||
{formatMessage({ id: 'cli-manager.executionDetails' })}
|
{formatMessage({ id: 'cli-manager.executionDetails' })}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Folder,
|
Folder,
|
||||||
|
FileJson,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -34,6 +35,8 @@ export interface ConversationCardProps {
|
|||||||
execution: CliExecution;
|
execution: CliExecution;
|
||||||
/** Called when view action is triggered */
|
/** Called when view action is triggered */
|
||||||
onView?: (execution: CliExecution) => void;
|
onView?: (execution: CliExecution) => void;
|
||||||
|
/** Called when view native session is triggered */
|
||||||
|
onViewNative?: (execution: CliExecution) => void;
|
||||||
/** Called when delete action is triggered */
|
/** Called when delete action is triggered */
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
/** Called when card is clicked */
|
/** Called when card is clicked */
|
||||||
@@ -94,6 +97,7 @@ function getTimeAgo(dateString: string): string {
|
|||||||
export function ConversationCard({
|
export function ConversationCard({
|
||||||
execution,
|
execution,
|
||||||
onView,
|
onView,
|
||||||
|
onViewNative,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
@@ -173,6 +177,12 @@ export function ConversationCard({
|
|||||||
{execution.sourceDir}
|
{execution.sourceDir}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{execution.hasNativeSession && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs">
|
||||||
|
<FileJson className="h-3 w-3" />
|
||||||
|
native
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Badge variant={status.variant} className="gap-1 text-xs ml-auto">
|
<Badge variant={status.variant} className="gap-1 text-xs ml-auto">
|
||||||
{status.icon === 'check-circle' && '✓'}
|
{status.icon === 'check-circle' && '✓'}
|
||||||
{status.icon === 'x-circle' && '✗'}
|
{status.icon === 'x-circle' && '✗'}
|
||||||
@@ -228,6 +238,12 @@ export function ConversationCard({
|
|||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
{formatMessage({ id: 'history.actions.view' })}
|
{formatMessage({ id: 'history.actions.view' })}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{execution.hasNativeSession && (
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onViewNative?.(execution); }}>
|
||||||
|
<FileJson className="mr-2 h-4 w-4" />
|
||||||
|
{formatMessage({ id: 'history.actions.viewNative', defaultMessage: 'View Native Session' })}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => handleAction(e, 'delete')}
|
onClick={(e) => handleAction(e, 'delete')}
|
||||||
|
|||||||
435
ccw/frontend/src/components/shared/NativeSessionPanel.tsx
Normal file
435
ccw/frontend/src/components/shared/NativeSessionPanel.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
// ========================================
|
||||||
|
// NativeSessionPanel Component
|
||||||
|
// ========================================
|
||||||
|
// Dialog for displaying native CLI session content (Gemini/Codex/Qwen)
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
Wrench,
|
||||||
|
Copy,
|
||||||
|
Clock,
|
||||||
|
Hash,
|
||||||
|
FolderOpen,
|
||||||
|
FileJson,
|
||||||
|
Coins,
|
||||||
|
ArrowDownUp,
|
||||||
|
Archive,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/Dialog';
|
||||||
|
import { useNativeSession } from '@/hooks/useNativeSession';
|
||||||
|
import type {
|
||||||
|
NativeSessionTurn,
|
||||||
|
NativeToolCall,
|
||||||
|
NativeTokenInfo,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface NativeSessionPanelProps {
|
||||||
|
executionId: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnCardProps {
|
||||||
|
turn: NativeSessionTurn;
|
||||||
|
isLatest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenDisplayProps {
|
||||||
|
tokens: NativeTokenInfo;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCallItemProps {
|
||||||
|
toolCall: NativeToolCall;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge variant for tool name
|
||||||
|
*/
|
||||||
|
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
|
||||||
|
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
|
||||||
|
gemini: 'info',
|
||||||
|
codex: 'success',
|
||||||
|
qwen: 'warning',
|
||||||
|
opencode: 'secondary',
|
||||||
|
};
|
||||||
|
return variants[tool?.toLowerCase()] || 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format token number with compact notation
|
||||||
|
*/
|
||||||
|
function formatTokenCount(count: number | undefined): string {
|
||||||
|
if (count == null || count === 0) return '0';
|
||||||
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
||||||
|
return count.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string to a max length with ellipsis
|
||||||
|
*/
|
||||||
|
function truncate(str: string, maxLen: number): string {
|
||||||
|
if (str.length <= maxLen) return str;
|
||||||
|
return str.slice(0, maxLen) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard
|
||||||
|
*/
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sub-Components ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TokenDisplay - Compact token info line
|
||||||
|
*/
|
||||||
|
function TokenDisplay({ tokens, className }: TokenDisplayProps) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-3 text-xs text-muted-foreground', className)}>
|
||||||
|
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.total', defaultMessage: 'Total tokens' })}>
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
{formatTokenCount(tokens.total)}
|
||||||
|
</span>
|
||||||
|
{tokens.input != null && (
|
||||||
|
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.input', defaultMessage: 'Input tokens' })}>
|
||||||
|
<ArrowDownUp className="h-3 w-3" />
|
||||||
|
{formatTokenCount(tokens.input)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tokens.output != null && (
|
||||||
|
<span title={formatMessage({ id: 'nativeSession.tokens.output', defaultMessage: 'Output tokens' })}>
|
||||||
|
out: {formatTokenCount(tokens.output)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tokens.cached != null && tokens.cached > 0 && (
|
||||||
|
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.cached', defaultMessage: 'Cached tokens' })}>
|
||||||
|
<Archive className="h-3 w-3" />
|
||||||
|
{formatTokenCount(tokens.cached)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToolCallItem - Single tool call display with collapsible details
|
||||||
|
*/
|
||||||
|
function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
|
||||||
|
return (
|
||||||
|
<details className="group/tool border border-border/50 rounded-md overflow-hidden">
|
||||||
|
<summary className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer hover:bg-muted/50 select-none">
|
||||||
|
<Wrench className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="font-mono font-medium">{toolCall.name}</span>
|
||||||
|
<span className="text-muted-foreground">#{index + 1}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-border/50 divide-y divide-border/50">
|
||||||
|
{toolCall.arguments && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Input</p>
|
||||||
|
<pre className="p-2 bg-muted/30 rounded text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-60 overflow-y-auto">
|
||||||
|
{toolCall.arguments}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolCall.output && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Output</p>
|
||||||
|
<pre className="p-2 bg-muted/30 rounded text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-60 overflow-y-auto">
|
||||||
|
{toolCall.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TurnCard - Single conversation turn
|
||||||
|
*/
|
||||||
|
function TurnCard({ turn, isLatest }: TurnCardProps) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const isUser = turn.role === 'user';
|
||||||
|
const RoleIcon = isUser ? User : Bot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden transition-all',
|
||||||
|
isUser
|
||||||
|
? 'bg-muted/30'
|
||||||
|
: 'bg-blue-500/5 dark:bg-blue-500/10',
|
||||||
|
isLatest && 'ring-2 ring-primary/50 shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Turn Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RoleIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
isUser ? 'text-primary' : 'text-blue-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-sm capitalize">{turn.role}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
#{turn.turnNumber}
|
||||||
|
</span>
|
||||||
|
{isLatest && (
|
||||||
|
<Badge variant="default" className="text-xs h-5 px-1.5">
|
||||||
|
{formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
{turn.timestamp && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(turn.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{turn.tokens && (
|
||||||
|
<TokenDisplay tokens={turn.tokens} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Turn Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{turn.content && (
|
||||||
|
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto">
|
||||||
|
{turn.content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thoughts Section */}
|
||||||
|
{turn.thoughts && turn.thoughts.length > 0 && (
|
||||||
|
<details className="group/thoughts">
|
||||||
|
<summary className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground text-muted-foreground select-none py-1">
|
||||||
|
<Brain className="h-4 w-4" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">({turn.thoughts.length})</span>
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 space-y-1 pl-6 text-sm text-muted-foreground list-disc">
|
||||||
|
{turn.thoughts.map((thought, i) => (
|
||||||
|
<li key={i} className="leading-relaxed">{thought}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tool Calls Section */}
|
||||||
|
{turn.toolCalls && turn.toolCalls.length > 0 && (
|
||||||
|
<details className="group/calls">
|
||||||
|
<summary className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground text-muted-foreground select-none py-1">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">({turn.toolCalls.length})</span>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{turn.toolCalls.map((tc, i) => (
|
||||||
|
<ToolCallItem key={i} toolCall={tc} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Component ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NativeSessionPanel - Dialog for displaying native CLI session content
|
||||||
|
*
|
||||||
|
* Shows session metadata, token summary, and all conversation turns
|
||||||
|
* with thoughts and tool calls for Gemini/Codex/Qwen native sessions.
|
||||||
|
*/
|
||||||
|
export function NativeSessionPanel({
|
||||||
|
executionId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: NativeSessionPanelProps) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { data: session, isLoading, error } = useNativeSession(open ? executionId : null);
|
||||||
|
|
||||||
|
const [copiedField, setCopiedField] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = React.useCallback(async (text: string, field: string) => {
|
||||||
|
const ok = await copyToClipboard(text);
|
||||||
|
if (ok) {
|
||||||
|
setCopiedField(field);
|
||||||
|
setTimeout(() => setCopiedField(null), 2000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileJson className="h-5 w-5" />
|
||||||
|
{formatMessage({ id: 'nativeSession.title', defaultMessage: 'Native Session' })}
|
||||||
|
</DialogTitle>
|
||||||
|
{session && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={getToolVariant(session.tool)}>
|
||||||
|
{session.tool.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
{session.model && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{session.model}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-xs text-muted-foreground font-mono"
|
||||||
|
title={session.sessionId}
|
||||||
|
>
|
||||||
|
{truncate(session.sessionId, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{session && (
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mt-2">
|
||||||
|
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.meta.startTime', defaultMessage: 'Start time' })}>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{session.workingDir && (
|
||||||
|
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.meta.workingDir', defaultMessage: 'Working directory' })}>
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
<span className="font-mono max-w-48 truncate">{session.workingDir}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.projectHash && (
|
||||||
|
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.meta.projectHash', defaultMessage: 'Project hash' })}>
|
||||||
|
<Hash className="h-3 w-3" />
|
||||||
|
<span className="font-mono">{truncate(session.projectHash, 12)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{session.turns.length} {formatMessage({ id: 'nativeSession.meta.turns', defaultMessage: 'turns' })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Token Summary Bar */}
|
||||||
|
{session?.totalTokens && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2.5 border-b bg-muted/30 shrink-0">
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })}
|
||||||
|
</span>
|
||||||
|
<TokenDisplay tokens={session.totalTokens} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center py-16">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>{formatMessage({ id: 'nativeSession.loading', defaultMessage: 'Loading session...' })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center py-16">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>{formatMessage({ id: 'nativeSession.error', defaultMessage: 'Failed to load session' })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : session && session.turns.length > 0 ? (
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{session.turns.map((turn, idx) => (
|
||||||
|
<React.Fragment key={turn.turnNumber}>
|
||||||
|
<TurnCard
|
||||||
|
turn={turn}
|
||||||
|
isLatest={idx === session.turns.length - 1}
|
||||||
|
/>
|
||||||
|
{/* Connector line between turns */}
|
||||||
|
{idx < session.turns.length - 1 && (
|
||||||
|
<div className="flex justify-center" aria-hidden="true">
|
||||||
|
<div className="w-px h-4 bg-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'nativeSession.empty', defaultMessage: 'No session data available' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
{session && (
|
||||||
|
<div className="flex items-center gap-2 px-6 py-4 border-t bg-muted/30 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCopy(session.sessionId, 'sessionId')}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{copiedField === 'sessionId'
|
||||||
|
? formatMessage({ id: 'nativeSession.footer.copied', defaultMessage: 'Copied!' })
|
||||||
|
: formatMessage({ id: 'nativeSession.footer.copySessionId', defaultMessage: 'Copy Session ID' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const json = JSON.stringify(session, null, 2);
|
||||||
|
handleCopy(json, 'json');
|
||||||
|
}}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<FileJson className="h-4 w-4 mr-2" />
|
||||||
|
{copiedField === 'json'
|
||||||
|
? formatMessage({ id: 'nativeSession.footer.copied', defaultMessage: 'Copied!' })
|
||||||
|
: formatMessage({ id: 'nativeSession.footer.exportJson', defaultMessage: 'Export JSON' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
crossCliCopy,
|
crossCliCopy,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
type McpServersResponse,
|
type McpServersResponse,
|
||||||
|
type McpProjectConfigType,
|
||||||
type McpTemplate,
|
type McpTemplate,
|
||||||
type McpTemplateInstallRequest,
|
type McpTemplateInstallRequest,
|
||||||
type AllProjectsResponse,
|
type AllProjectsResponse,
|
||||||
@@ -124,70 +125,75 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
|
|||||||
// ========== Mutations ==========
|
// ========== Mutations ==========
|
||||||
|
|
||||||
export interface UseUpdateMcpServerReturn {
|
export interface UseUpdateMcpServerReturn {
|
||||||
updateServer: (serverName: string, config: Partial<McpServer>) => Promise<McpServer>;
|
updateServer: (serverName: string, config: Partial<McpServer>, configType?: McpProjectConfigType) => Promise<McpServer>;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateMcpServer(): UseUpdateMcpServerReturn {
|
export function useUpdateMcpServer(): UseUpdateMcpServerReturn {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: ({ serverName, config }: { serverName: string; config: Partial<McpServer> }) =>
|
mutationFn: ({ serverName, config, configType }: { serverName: string; config: Partial<McpServer>; configType?: McpProjectConfigType }) =>
|
||||||
updateMcpServer(serverName, config),
|
updateMcpServer(serverName, config, { projectPath: projectPath ?? undefined, configType }),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateServer: (serverName, config) => mutation.mutateAsync({ serverName, config }),
|
updateServer: (serverName, config, configType) => mutation.mutateAsync({ serverName, config, configType }),
|
||||||
isUpdating: mutation.isPending,
|
isUpdating: mutation.isPending,
|
||||||
error: mutation.error,
|
error: mutation.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCreateMcpServerReturn {
|
export interface UseCreateMcpServerReturn {
|
||||||
createServer: (server: Omit<McpServer, 'name'>) => Promise<McpServer>;
|
createServer: (server: McpServer, configType?: McpProjectConfigType) => Promise<McpServer>;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateMcpServer(): UseCreateMcpServerReturn {
|
export function useCreateMcpServer(): UseCreateMcpServerReturn {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (server: Omit<McpServer, 'name'>) => createMcpServer(server),
|
mutationFn: ({ server, configType }: { server: McpServer; configType?: McpProjectConfigType }) =>
|
||||||
|
createMcpServer(server, { projectPath: projectPath ?? undefined, configType }),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createServer: mutation.mutateAsync,
|
createServer: (server, configType) => mutation.mutateAsync({ server, configType }),
|
||||||
isCreating: mutation.isPending,
|
isCreating: mutation.isPending,
|
||||||
error: mutation.error,
|
error: mutation.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseDeleteMcpServerReturn {
|
export interface UseDeleteMcpServerReturn {
|
||||||
deleteServer: (serverName: string) => Promise<void>;
|
deleteServer: (serverName: string, scope: 'project' | 'global') => Promise<void>;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteMcpServer(): UseDeleteMcpServerReturn {
|
export function useDeleteMcpServer(): UseDeleteMcpServerReturn {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (serverName: string) => deleteMcpServer(serverName),
|
mutationFn: ({ serverName, scope }: { serverName: string; scope: 'project' | 'global' }) =>
|
||||||
|
deleteMcpServer(serverName, scope, { projectPath: projectPath ?? undefined }),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deleteServer: mutation.mutateAsync,
|
deleteServer: (serverName, scope) => mutation.mutateAsync({ serverName, scope }),
|
||||||
isDeleting: mutation.isPending,
|
isDeleting: mutation.isPending,
|
||||||
error: mutation.error,
|
error: mutation.error,
|
||||||
};
|
};
|
||||||
@@ -201,10 +207,11 @@ export interface UseToggleMcpServerReturn {
|
|||||||
|
|
||||||
export function useToggleMcpServer(): UseToggleMcpServerReturn {
|
export function useToggleMcpServer(): UseToggleMcpServerReturn {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: ({ serverName, enabled }: { serverName: string; enabled: boolean }) =>
|
mutationFn: ({ serverName, enabled }: { serverName: string; enabled: boolean }) =>
|
||||||
toggleMcpServer(serverName, enabled),
|
toggleMcpServer(serverName, enabled, { projectPath: projectPath ?? undefined }),
|
||||||
onMutate: async ({ serverName, enabled }) => {
|
onMutate: async ({ serverName, enabled }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: mcpServersKeys.all });
|
await queryClient.cancelQueries({ queryKey: mcpServersKeys.all });
|
||||||
const previousServers = queryClient.getQueryData<McpServersResponse>(mcpServersKeys.list());
|
const previousServers = queryClient.getQueryData<McpServersResponse>(mcpServersKeys.list());
|
||||||
|
|||||||
84
ccw/frontend/src/hooks/useNativeSession.ts
Normal file
84
ccw/frontend/src/hooks/useNativeSession.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// ========================================
|
||||||
|
// useNativeSession Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hook for native CLI session content
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchNativeSession,
|
||||||
|
type NativeSession,
|
||||||
|
} from '../lib/api';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
|
// ========== Query Keys ==========
|
||||||
|
|
||||||
|
export const nativeSessionKeys = {
|
||||||
|
all: ['nativeSession'] as const,
|
||||||
|
details: () => [...nativeSessionKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string | null) => [...nativeSessionKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Constants ==========
|
||||||
|
|
||||||
|
const STALE_TIME = 5 * 60 * 1000;
|
||||||
|
const GC_TIME = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface UseNativeSessionOptions {
|
||||||
|
staleTime?: number;
|
||||||
|
gcTime?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseNativeSessionReturn {
|
||||||
|
data: NativeSession | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching native CLI session content
|
||||||
|
*
|
||||||
|
* @param executionId - The CCW execution ID to fetch native session for
|
||||||
|
* @param options - Query options
|
||||||
|
*/
|
||||||
|
export function useNativeSession(
|
||||||
|
executionId: string | null,
|
||||||
|
options: UseNativeSessionOptions = {}
|
||||||
|
): UseNativeSessionReturn {
|
||||||
|
const { staleTime = STALE_TIME, gcTime = GC_TIME, enabled = true } = options;
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
|
const query = useQuery<NativeSession>({
|
||||||
|
queryKey: nativeSessionKeys.detail(executionId),
|
||||||
|
queryFn: () => {
|
||||||
|
if (!executionId) throw new Error('executionId is required');
|
||||||
|
return fetchNativeSession(executionId, projectPath);
|
||||||
|
},
|
||||||
|
enabled: !!executionId && enabled,
|
||||||
|
staleTime,
|
||||||
|
gcTime,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: query.data,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1599,6 +1599,9 @@ export interface CliExecution {
|
|||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
sourceDir?: string;
|
sourceDir?: string;
|
||||||
turn_count?: number;
|
turn_count?: number;
|
||||||
|
hasNativeSession?: boolean;
|
||||||
|
nativeSessionId?: string;
|
||||||
|
nativeSessionPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryResponse {
|
export interface HistoryResponse {
|
||||||
@@ -1606,11 +1609,13 @@ export interface HistoryResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch CLI execution history for a specific workspace
|
* Fetch CLI execution history with native session info
|
||||||
* @param projectPath - Optional project path to filter data by workspace
|
* @param projectPath - Optional project path to filter data by workspace
|
||||||
*/
|
*/
|
||||||
export async function fetchHistory(projectPath?: string): Promise<HistoryResponse> {
|
export async function fetchHistory(projectPath?: string): Promise<HistoryResponse> {
|
||||||
const url = projectPath ? `/api/cli/history?path=${encodeURIComponent(projectPath)}` : '/api/cli/history';
|
const url = projectPath
|
||||||
|
? `/api/cli/history-native?path=${encodeURIComponent(projectPath)}`
|
||||||
|
: '/api/cli/history-native';
|
||||||
const data = await fetchApi<{ executions?: CliExecution[] }>(url);
|
const data = await fetchApi<{ executions?: CliExecution[] }>(url);
|
||||||
return {
|
return {
|
||||||
executions: data.executions ?? [],
|
executions: data.executions ?? [],
|
||||||
@@ -1740,6 +1745,57 @@ export interface ConversationTurn {
|
|||||||
exit_code?: number;
|
exit_code?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Native Session Types ==========
|
||||||
|
|
||||||
|
export interface NativeTokenInfo {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cached?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeToolCall {
|
||||||
|
name: string;
|
||||||
|
arguments?: string;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeSessionTurn {
|
||||||
|
turnNumber: number;
|
||||||
|
timestamp: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
thoughts?: string[];
|
||||||
|
toolCalls?: NativeToolCall[];
|
||||||
|
tokens?: NativeTokenInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeSession {
|
||||||
|
sessionId: string;
|
||||||
|
tool: string;
|
||||||
|
model?: string;
|
||||||
|
projectHash?: string;
|
||||||
|
workingDir?: string;
|
||||||
|
startTime: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
turns: NativeSessionTurn[];
|
||||||
|
totalTokens?: NativeTokenInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch native CLI session content by execution ID
|
||||||
|
*/
|
||||||
|
export async function fetchNativeSession(
|
||||||
|
executionId: string,
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<NativeSession> {
|
||||||
|
const params = new URLSearchParams({ id: executionId });
|
||||||
|
if (projectPath) params.set('path', projectPath);
|
||||||
|
return fetchApi<NativeSession>(
|
||||||
|
`/api/cli/native-session?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== CLI Tools Config API ==========
|
// ========== CLI Tools Config API ==========
|
||||||
|
|
||||||
export interface CliToolsConfigResponse {
|
export interface CliToolsConfigResponse {
|
||||||
@@ -1889,15 +1945,79 @@ export async function fetchLiteTaskSession(
|
|||||||
* Fetch context data for a lite task session
|
* Fetch context data for a lite task session
|
||||||
* Uses the session-detail API with type=context
|
* Uses the session-detail API with type=context
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Context package core type (compatible with lite and full context-package.json)
|
||||||
|
export interface LiteContextPackage {
|
||||||
|
// Basic fields (lite task context)
|
||||||
|
task_description?: string;
|
||||||
|
constraints?: string[];
|
||||||
|
focus_paths?: string[];
|
||||||
|
relevant_files?: Array<string | { path: string; reason?: string }>;
|
||||||
|
dependencies?: string[] | Array<{ name: string; type?: string; version?: string }>;
|
||||||
|
conflict_risks?: string[] | Array<{ description: string; severity?: string }>;
|
||||||
|
session_id?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Extended fields (full context-package.json)
|
||||||
|
project_context?: {
|
||||||
|
tech_stack?: {
|
||||||
|
languages?: Array<{ name: string; file_count?: number }>;
|
||||||
|
frameworks?: string[];
|
||||||
|
libraries?: string[];
|
||||||
|
};
|
||||||
|
architecture_patterns?: string[];
|
||||||
|
};
|
||||||
|
assets?: {
|
||||||
|
documentation?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
|
||||||
|
source_code?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
|
||||||
|
tests?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
|
||||||
|
};
|
||||||
|
test_context?: Record<string, unknown>;
|
||||||
|
conflict_detection?: {
|
||||||
|
risk_level?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
mitigation_strategy?: string;
|
||||||
|
risk_factors?: { test_gaps?: string[]; existing_implementations?: string[] };
|
||||||
|
affected_modules?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiteExplorationAngle {
|
||||||
|
project_structure?: string[];
|
||||||
|
relevant_files?: string[];
|
||||||
|
patterns?: string[];
|
||||||
|
dependencies?: string[];
|
||||||
|
integration_points?: string[];
|
||||||
|
testing?: string[];
|
||||||
|
findings?: string[];
|
||||||
|
recommendations?: string[];
|
||||||
|
risks?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiteDiagnosisItem {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
symptom?: string;
|
||||||
|
root_cause?: string;
|
||||||
|
issues?: Array<{ file: string; line?: number; severity?: string; message: string }>;
|
||||||
|
affected_files?: string[];
|
||||||
|
fix_hints?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiteSessionContext {
|
export interface LiteSessionContext {
|
||||||
context?: Record<string, unknown>;
|
context?: LiteContextPackage;
|
||||||
explorations?: {
|
explorations?: {
|
||||||
manifest?: Record<string, unknown>;
|
manifest?: {
|
||||||
data?: Record<string, unknown>;
|
task_description?: string;
|
||||||
|
complexity?: string;
|
||||||
|
exploration_count?: number;
|
||||||
|
};
|
||||||
|
data?: Record<string, LiteExplorationAngle>;
|
||||||
};
|
};
|
||||||
diagnoses?: {
|
diagnoses?: {
|
||||||
manifest?: Record<string, unknown>;
|
manifest?: Record<string, unknown>;
|
||||||
items?: Array<Record<string, unknown>>;
|
data?: Record<string, unknown>; // Backend session-routes format
|
||||||
|
items?: LiteDiagnosisItem[]; // lite-scanner format (compat)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2028,56 +2148,321 @@ export async function fetchMcpConfig(): Promise<{
|
|||||||
userServers: Record<string, any>;
|
userServers: Record<string, any>;
|
||||||
enterpriseServers: Record<string, any>;
|
enterpriseServers: Record<string, any>;
|
||||||
configSources: string[];
|
configSources: string[];
|
||||||
codex?: { servers: Record<string, any>; configPath: string };
|
codex?: { servers: Record<string, any>; configPath: string; exists?: boolean };
|
||||||
}> {
|
}> {
|
||||||
return fetchApi('/api/mcp-config');
|
return fetchApi('/api/mcp-config');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isUnknownRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathForCompare(inputPath: string): string {
|
||||||
|
const trimmed = inputPath.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
let normalized = trimmed.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Handle /d/path -> D:/path (matches backend normalization)
|
||||||
|
if (/^\/[a-zA-Z]\//.test(normalized)) {
|
||||||
|
normalized = normalized.charAt(1).toUpperCase() + ':' + normalized.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize drive letter casing
|
||||||
|
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||||
|
normalized = normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProjectConfigKey(projects: Record<string, unknown>, projectPath?: string): string | null {
|
||||||
|
if (!projectPath) return null;
|
||||||
|
|
||||||
|
const desired = normalizePathForCompare(projectPath);
|
||||||
|
if (!desired) return null;
|
||||||
|
|
||||||
|
for (const key of Object.keys(projects)) {
|
||||||
|
if (normalizePathForCompare(key) === desired) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to exact key match if present
|
||||||
|
return projectPath in projects ? projectPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerConfig(config: unknown): { command: string; args?: string[]; env?: Record<string, string> } {
|
||||||
|
if (!isUnknownRecord(config)) {
|
||||||
|
return { command: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const command =
|
||||||
|
typeof config.command === 'string'
|
||||||
|
? config.command
|
||||||
|
: typeof config.url === 'string'
|
||||||
|
? config.url
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const args = Array.isArray(config.args)
|
||||||
|
? config.args.filter((arg): arg is string => typeof arg === 'string')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const env = isUnknownRecord(config.env)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(config.env).flatMap(([key, value]) =>
|
||||||
|
typeof value === 'string' ? [[key, value]] : []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
args: args && args.length > 0 ? args : undefined,
|
||||||
|
env: env && Object.keys(env).length > 0 ? env : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all MCP servers (project and global scope) for a specific workspace
|
* Fetch all MCP servers (project and global scope) for a specific workspace
|
||||||
* @param projectPath - Optional project path to filter data by workspace
|
* @param projectPath - Optional project path to filter data by workspace
|
||||||
*/
|
*/
|
||||||
export async function fetchMcpServers(projectPath?: string): Promise<McpServersResponse> {
|
export async function fetchMcpServers(projectPath?: string): Promise<McpServersResponse> {
|
||||||
const url = projectPath ? `/api/mcp/servers?path=${encodeURIComponent(projectPath)}` : '/api/mcp/servers';
|
const config = await fetchMcpConfig();
|
||||||
const data = await fetchApi<{ project?: McpServer[]; global?: McpServer[] }>(url);
|
|
||||||
|
const projectsRecord = isUnknownRecord(config.projects) ? (config.projects as UnknownRecord) : {};
|
||||||
|
const projectKey = findProjectConfigKey(projectsRecord, projectPath);
|
||||||
|
const projectConfig = projectKey && isUnknownRecord(projectsRecord[projectKey])
|
||||||
|
? (projectsRecord[projectKey] as UnknownRecord)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const disabledServers = projectConfig && Array.isArray(projectConfig.disabledMcpServers)
|
||||||
|
? projectConfig.disabledMcpServers.filter((name): name is string => typeof name === 'string')
|
||||||
|
: [];
|
||||||
|
const disabledSet = new Set(disabledServers);
|
||||||
|
|
||||||
|
const userServers = isUnknownRecord(config.userServers) ? (config.userServers as UnknownRecord) : {};
|
||||||
|
const enterpriseServers = isUnknownRecord(config.enterpriseServers) ? (config.enterpriseServers as UnknownRecord) : {};
|
||||||
|
|
||||||
|
const projectServersRecord = projectConfig && isUnknownRecord(projectConfig.mcpServers)
|
||||||
|
? (projectConfig.mcpServers as UnknownRecord)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const global: McpServer[] = Object.entries(userServers).map(([name, raw]) => {
|
||||||
|
const normalized = normalizeServerConfig(raw);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...normalized,
|
||||||
|
enabled: !disabledSet.has(name),
|
||||||
|
scope: 'global',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const project: McpServer[] = Object.entries(projectServersRecord)
|
||||||
|
// Avoid duplicates: if defined globally/enterprise, treat it as global
|
||||||
|
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
|
||||||
|
.map(([name, raw]) => {
|
||||||
|
const normalized = normalizeServerConfig(raw);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...normalized,
|
||||||
|
enabled: !disabledSet.has(name),
|
||||||
|
scope: 'project',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project: data.project ?? [],
|
project,
|
||||||
global: data.global ?? [],
|
global,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpProjectConfigType = 'mcp' | 'claude';
|
||||||
|
|
||||||
|
export interface McpServerMutationOptions {
|
||||||
|
/** Required for project-scoped mutations and for enabled/disabled toggles */
|
||||||
|
projectPath?: string;
|
||||||
|
/** Controls where project servers are stored (.mcp.json vs legacy .claude.json) */
|
||||||
|
configType?: McpProjectConfigType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProjectPath(projectPath: string | undefined, ctx: string): string {
|
||||||
|
const trimmed = projectPath?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error(`${ctx}: projectPath is required`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toServerConfig(server: { command: string; args?: string[]; env?: Record<string, string> }): UnknownRecord {
|
||||||
|
const config: UnknownRecord = { command: server.command };
|
||||||
|
if (server.args && server.args.length > 0) config.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) config.env = server.env;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update MCP server configuration
|
* Update MCP server configuration
|
||||||
*/
|
*/
|
||||||
export async function updateMcpServer(
|
export async function updateMcpServer(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
config: Partial<McpServer>
|
config: Partial<McpServer>,
|
||||||
|
options: McpServerMutationOptions = {}
|
||||||
): Promise<McpServer> {
|
): Promise<McpServer> {
|
||||||
return fetchApi<McpServer>(`/api/mcp/servers/${encodeURIComponent(serverName)}`, {
|
if (!config.scope) {
|
||||||
method: 'PATCH',
|
throw new Error('updateMcpServer: scope is required');
|
||||||
body: JSON.stringify(config),
|
}
|
||||||
|
if (typeof config.command !== 'string' || !config.command.trim()) {
|
||||||
|
throw new Error('updateMcpServer: command is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConfig = toServerConfig({
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (config.scope === 'global') {
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-add-global-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ serverName, serverConfig }),
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const projectPath = requireProjectPath(options.projectPath, 'updateMcpServer');
|
||||||
|
const configType = options.configType ?? 'mcp';
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName, serverConfig, configType }),
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof config.enabled === 'boolean') {
|
||||||
|
const projectPath = options.projectPath?.trim();
|
||||||
|
if (projectPath) {
|
||||||
|
const toggleRes = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName, enable: config.enabled }),
|
||||||
|
});
|
||||||
|
if (toggleRes?.error) {
|
||||||
|
throw new Error(toggleRes.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.projectPath) {
|
||||||
|
const servers = await fetchMcpServers(options.projectPath);
|
||||||
|
return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? {
|
||||||
|
name: serverName,
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
scope: config.scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: serverName,
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
scope: config.scope,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new MCP server
|
* Create a new MCP server
|
||||||
*/
|
*/
|
||||||
export async function createMcpServer(
|
export async function createMcpServer(
|
||||||
server: Omit<McpServer, 'name'>
|
server: McpServer,
|
||||||
|
options: McpServerMutationOptions = {}
|
||||||
): Promise<McpServer> {
|
): Promise<McpServer> {
|
||||||
return fetchApi<McpServer>('/api/mcp/servers', {
|
if (!server.name?.trim()) {
|
||||||
method: 'POST',
|
throw new Error('createMcpServer: name is required');
|
||||||
body: JSON.stringify(server),
|
}
|
||||||
});
|
if (!server.command?.trim()) {
|
||||||
|
throw new Error('createMcpServer: command is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverName = server.name.trim();
|
||||||
|
const serverConfig = toServerConfig(server);
|
||||||
|
|
||||||
|
if (server.scope === 'global') {
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-add-global-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ serverName, serverConfig }),
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const projectPath = requireProjectPath(options.projectPath, 'createMcpServer');
|
||||||
|
const configType = options.configType ?? 'mcp';
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName, serverConfig, configType }),
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforced enabled/disabled is project-scoped (via disabledMcpServers list)
|
||||||
|
if (server.enabled === false) {
|
||||||
|
const projectPath = requireProjectPath(options.projectPath, 'createMcpServer');
|
||||||
|
const toggleRes = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName, enable: false }),
|
||||||
|
});
|
||||||
|
if (toggleRes?.error) {
|
||||||
|
throw new Error(toggleRes.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.projectPath) {
|
||||||
|
const servers = await fetchMcpServers(options.projectPath);
|
||||||
|
return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? server;
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an MCP server
|
* Delete an MCP server
|
||||||
*/
|
*/
|
||||||
export async function deleteMcpServer(serverName: string): Promise<void> {
|
export async function deleteMcpServer(
|
||||||
await fetchApi<void>(`/api/mcp/servers/${encodeURIComponent(serverName)}`, {
|
serverName: string,
|
||||||
method: 'DELETE',
|
scope: 'project' | 'global',
|
||||||
|
options: McpServerMutationOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
if (scope === 'global') {
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-remove-global-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ serverName }),
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPath = requireProjectPath(options.projectPath, 'deleteMcpServer');
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-remove-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName }),
|
||||||
});
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2085,12 +2470,26 @@ export async function deleteMcpServer(serverName: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function toggleMcpServer(
|
export async function toggleMcpServer(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
enabled: boolean
|
enabled: boolean,
|
||||||
|
options: McpServerMutationOptions = {}
|
||||||
): Promise<McpServer> {
|
): Promise<McpServer> {
|
||||||
return fetchApi<McpServer>(`/api/mcp/servers/${encodeURIComponent(serverName)}/toggle`, {
|
const projectPath = requireProjectPath(options.projectPath, 'toggleMcpServer');
|
||||||
|
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-toggle', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ projectPath, serverName, enable: enabled }),
|
||||||
});
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await fetchMcpServers(projectPath);
|
||||||
|
return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? {
|
||||||
|
name: serverName,
|
||||||
|
command: '',
|
||||||
|
enabled,
|
||||||
|
scope: 'project',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Codex MCP API ==========
|
// ========== Codex MCP API ==========
|
||||||
@@ -2112,17 +2511,37 @@ export interface CodexMcpServersResponse {
|
|||||||
* Codex MCP servers are read-only (managed via config file)
|
* Codex MCP servers are read-only (managed via config file)
|
||||||
*/
|
*/
|
||||||
export async function fetchCodexMcpServers(): Promise<CodexMcpServersResponse> {
|
export async function fetchCodexMcpServers(): Promise<CodexMcpServersResponse> {
|
||||||
return fetchApi<CodexMcpServersResponse>('/api/mcp/codex-servers');
|
const data = await fetchApi<{ servers?: Record<string, unknown>; configPath: string; exists?: boolean }>('/api/codex-mcp-config');
|
||||||
|
const serversRecord = isUnknownRecord(data.servers) ? (data.servers as UnknownRecord) : {};
|
||||||
|
|
||||||
|
const servers: CodexMcpServer[] = Object.entries(serversRecord).map(([name, raw]) => {
|
||||||
|
const normalized = normalizeServerConfig(raw);
|
||||||
|
const enabled = isUnknownRecord(raw) ? (raw.enabled !== false) : true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...normalized,
|
||||||
|
enabled,
|
||||||
|
// Codex config is global for the CLI; scope is only used for UI badges in Claude mode
|
||||||
|
scope: 'global',
|
||||||
|
configPath: data.configPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { servers, configPath: data.configPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new MCP server to Codex config
|
* Add a new MCP server to Codex config
|
||||||
* Note: This requires write access to Codex config.toml
|
* Note: This requires write access to Codex config.toml
|
||||||
*/
|
*/
|
||||||
export async function addCodexMcpServer(server: Omit<McpServer, 'name'>): Promise<CodexMcpServer> {
|
export async function addCodexMcpServer(
|
||||||
return fetchApi<CodexMcpServer>('/api/mcp/codex-add', {
|
serverName: string,
|
||||||
|
serverConfig: Record<string, unknown>
|
||||||
|
): Promise<{ success?: boolean; error?: string }> {
|
||||||
|
return fetchApi<{ success?: boolean; error?: string }>('/api/codex-mcp-add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(server),
|
body: JSON.stringify({ serverName, serverConfig }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2227,7 +2646,9 @@ export async function fetchMcpTemplatesByCategory(category: string): Promise<Mcp
|
|||||||
* Fetch all projects for cross-project operations
|
* Fetch all projects for cross-project operations
|
||||||
*/
|
*/
|
||||||
export async function fetchAllProjects(): Promise<AllProjectsResponse> {
|
export async function fetchAllProjects(): Promise<AllProjectsResponse> {
|
||||||
return fetchApi<AllProjectsResponse>('/api/projects/all');
|
const config = await fetchMcpConfig();
|
||||||
|
const projects = Object.keys(config.projects ?? {}).sort((a, b) => a.localeCompare(b));
|
||||||
|
return { projects };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2236,10 +2657,46 @@ export async function fetchAllProjects(): Promise<AllProjectsResponse> {
|
|||||||
export async function fetchOtherProjectsServers(
|
export async function fetchOtherProjectsServers(
|
||||||
projectPaths?: string[]
|
projectPaths?: string[]
|
||||||
): Promise<OtherProjectsServersResponse> {
|
): Promise<OtherProjectsServersResponse> {
|
||||||
const url = projectPaths
|
const config = await fetchMcpConfig();
|
||||||
? `/api/projects/other-servers?paths=${projectPaths.map(p => encodeURIComponent(p)).join(',')}`
|
const userServers = isUnknownRecord(config.userServers) ? (config.userServers as UnknownRecord) : {};
|
||||||
: '/api/projects/other-servers';
|
const enterpriseServers = isUnknownRecord(config.enterpriseServers) ? (config.enterpriseServers as UnknownRecord) : {};
|
||||||
return fetchApi<OtherProjectsServersResponse>(url);
|
|
||||||
|
const filterSet = projectPaths && projectPaths.length > 0
|
||||||
|
? new Set(projectPaths.map((p) => normalizePathForCompare(p)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const servers: OtherProjectsServersResponse['servers'] = {};
|
||||||
|
|
||||||
|
for (const [path, rawProjectConfig] of Object.entries(config.projects ?? {})) {
|
||||||
|
const normalizedPath = normalizePathForCompare(path);
|
||||||
|
if (filterSet && !filterSet.has(normalizedPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectConfig = isUnknownRecord(rawProjectConfig) ? (rawProjectConfig as UnknownRecord) : {};
|
||||||
|
const projectServersRecord = isUnknownRecord(projectConfig.mcpServers)
|
||||||
|
? (projectConfig.mcpServers as UnknownRecord)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const disabledServers = Array.isArray(projectConfig.disabledMcpServers)
|
||||||
|
? projectConfig.disabledMcpServers.filter((name): name is string => typeof name === 'string')
|
||||||
|
: [];
|
||||||
|
const disabledSet = new Set(disabledServers);
|
||||||
|
|
||||||
|
servers[path] = Object.entries(projectServersRecord)
|
||||||
|
// Exclude globally-defined servers; this section is meant for project-local discovery
|
||||||
|
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
|
||||||
|
.map(([name, raw]) => {
|
||||||
|
const normalized = normalizeServerConfig(raw);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...normalized,
|
||||||
|
enabled: !disabledSet.has(name),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { servers };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Cross-CLI Operations ==========
|
// ========== Cross-CLI Operations ==========
|
||||||
@@ -2250,10 +2707,93 @@ export async function fetchOtherProjectsServers(
|
|||||||
export async function crossCliCopy(
|
export async function crossCliCopy(
|
||||||
request: CrossCliCopyRequest
|
request: CrossCliCopyRequest
|
||||||
): Promise<CrossCliCopyResponse> {
|
): Promise<CrossCliCopyResponse> {
|
||||||
return fetchApi<CrossCliCopyResponse>('/api/mcp/cross-cli-copy', {
|
const serverNames = request.serverNames ?? [];
|
||||||
method: 'POST',
|
if (serverNames.length === 0 || request.source === request.target) {
|
||||||
body: JSON.stringify(request),
|
return { success: true, copied: [], failed: [] };
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const copied: string[] = [];
|
||||||
|
const failed: Array<{ name: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Claude -> Codex (upserts into ~/.codex/config.toml via backend)
|
||||||
|
if (request.source === 'claude' && request.target === 'codex') {
|
||||||
|
const config = await fetchMcpConfig();
|
||||||
|
const projectsRecord = isUnknownRecord(config.projects) ? (config.projects as UnknownRecord) : {};
|
||||||
|
const projectKey = findProjectConfigKey(projectsRecord, request.projectPath ?? undefined);
|
||||||
|
const projectConfig = projectKey && isUnknownRecord(projectsRecord[projectKey])
|
||||||
|
? (projectsRecord[projectKey] as UnknownRecord)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const projectServersRecord = projectConfig && isUnknownRecord(projectConfig.mcpServers)
|
||||||
|
? (projectConfig.mcpServers as UnknownRecord)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
for (const name of serverNames) {
|
||||||
|
try {
|
||||||
|
const rawConfig =
|
||||||
|
projectServersRecord[name] ??
|
||||||
|
(config.userServers ? (config.userServers as UnknownRecord)[name] : undefined) ??
|
||||||
|
(config.enterpriseServers ? (config.enterpriseServers as UnknownRecord)[name] : undefined);
|
||||||
|
|
||||||
|
if (!isUnknownRecord(rawConfig)) {
|
||||||
|
failed.push({ name, error: 'Source server config not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await addCodexMcpServer(name, rawConfig);
|
||||||
|
if (result?.error) {
|
||||||
|
failed.push({ name, error: result.error });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
copied.push(name);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
failed.push({ name, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: copied.length > 0, copied, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex -> Claude (defaults to copying into current project via /api/mcp-copy-server)
|
||||||
|
if (request.source === 'codex' && request.target === 'claude') {
|
||||||
|
const projectPath = requireProjectPath(request.projectPath, 'crossCliCopy');
|
||||||
|
|
||||||
|
const codex = await fetchApi<{ servers?: Record<string, unknown> }>('/api/codex-mcp-config');
|
||||||
|
const codexServers = isUnknownRecord(codex.servers) ? (codex.servers as UnknownRecord) : {};
|
||||||
|
|
||||||
|
for (const name of serverNames) {
|
||||||
|
try {
|
||||||
|
const rawConfig = codexServers[name];
|
||||||
|
if (!isUnknownRecord(rawConfig)) {
|
||||||
|
failed.push({ name, error: 'Source server config not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName: name, serverConfig: rawConfig, configType: 'mcp' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
failed.push({ name, error: result.error });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
copied.push(name);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
failed.push({ name, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: copied.length > 0, copied, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
copied: [],
|
||||||
|
failed: serverNames.map((name) => ({ name, error: 'Unsupported copy direction' })),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== CLI Endpoints API ==========
|
// ========== CLI Endpoints API ==========
|
||||||
@@ -2598,11 +3138,10 @@ export async function copyMcpServerToProject(
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
type?: string;
|
type?: string;
|
||||||
},
|
},
|
||||||
projectPath?: string,
|
projectPath: string,
|
||||||
configType: 'mcp' | 'claude' = 'mcp'
|
configType: 'mcp' | 'claude' = 'mcp'
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
// Use current project path from URL or fallback
|
const path = requireProjectPath(projectPath, 'copyMcpServerToProject');
|
||||||
const path = projectPath || window.location.pathname.split('/').filter(Boolean)[0] || '';
|
|
||||||
|
|
||||||
return fetchApi<{ success: boolean; error?: string }>('/api/mcp-copy-server', {
|
return fetchApi<{ success: boolean; error?: string }>('/api/mcp-copy-server', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -71,7 +71,14 @@
|
|||||||
"focusPaths": "Focus Paths",
|
"focusPaths": "Focus Paths",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"taskDescription": "Task Description",
|
"taskDescription": "Task Description",
|
||||||
"complexity": "Complexity"
|
"complexity": "Complexity",
|
||||||
|
"constraints": "Constraints",
|
||||||
|
"relevantFiles": "Relevant Files",
|
||||||
|
"dependencies": "Dependencies",
|
||||||
|
"sessionId": "Session ID",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"conflictRisks": "Conflict Risks",
|
||||||
|
"rawJson": "Raw JSON"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
|
|||||||
@@ -71,7 +71,14 @@
|
|||||||
"focusPaths": "关注路径",
|
"focusPaths": "关注路径",
|
||||||
"summary": "摘要",
|
"summary": "摘要",
|
||||||
"taskDescription": "任务描述",
|
"taskDescription": "任务描述",
|
||||||
"complexity": "复杂度"
|
"complexity": "复杂度",
|
||||||
|
"constraints": "约束",
|
||||||
|
"relevantFiles": "相关文件",
|
||||||
|
"dependencies": "依赖",
|
||||||
|
"sessionId": "会话ID",
|
||||||
|
"metadata": "元数据",
|
||||||
|
"conflictRisks": "冲突风险",
|
||||||
|
"rawJson": "原始 JSON"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
Package,
|
Package,
|
||||||
Loader2,
|
Loader2,
|
||||||
Compass,
|
|
||||||
Stethoscope,
|
|
||||||
FolderOpen,
|
|
||||||
FileText,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -43,6 +39,7 @@ import { Tabs, TabsContent } from '@/components/ui/Tabs';
|
|||||||
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
|
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
|
||||||
import { TaskDrawer } from '@/components/shared/TaskDrawer';
|
import { TaskDrawer } from '@/components/shared/TaskDrawer';
|
||||||
import { fetchLiteSessionContext, type LiteTask, type LiteTaskSession, type LiteSessionContext } from '@/lib/api';
|
import { fetchLiteSessionContext, type LiteTask, type LiteTaskSession, type LiteSessionContext } from '@/lib/api';
|
||||||
|
import { LiteContextContent } from '@/components/lite-tasks/LiteContextContent';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
||||||
@@ -223,7 +220,7 @@ function ExpandedSessionPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!contextLoading && !contextError && contextData && (
|
{!contextLoading && !contextError && contextData && (
|
||||||
<ContextContent contextData={contextData} session={session} />
|
<LiteContextContent contextData={contextData} session={session} />
|
||||||
)}
|
)}
|
||||||
{!contextLoading && !contextError && !contextData && !session.path && (
|
{!contextLoading && !contextError && !contextData && !session.path && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
@@ -240,295 +237,8 @@ function ExpandedSessionPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ContextContent - Renders the context data sections
|
* ContextContent - Extracted to @/components/lite-tasks/LiteContextContent.tsx
|
||||||
*/
|
*/
|
||||||
function ContextContent({
|
|
||||||
contextData,
|
|
||||||
session,
|
|
||||||
}: {
|
|
||||||
contextData: LiteSessionContext;
|
|
||||||
session: LiteTaskSession;
|
|
||||||
}) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const plan = session.plan || {};
|
|
||||||
const hasExplorations = !!(contextData.explorations?.manifest);
|
|
||||||
const hasDiagnoses = !!(contextData.diagnoses?.manifest || contextData.diagnoses?.items?.length);
|
|
||||||
const hasContext = !!contextData.context;
|
|
||||||
const hasFocusPaths = !!(plan.focus_paths as string[] | undefined)?.length;
|
|
||||||
const hasSummary = !!(plan.summary as string | undefined);
|
|
||||||
const hasAnyContent = hasExplorations || hasDiagnoses || hasContext || hasFocusPaths || hasSummary;
|
|
||||||
|
|
||||||
if (!hasAnyContent) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<Package className="h-8 w-8 text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'liteTasks.contextPanel.empty' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Explorations Section */}
|
|
||||||
{hasExplorations && (
|
|
||||||
<ContextSection
|
|
||||||
icon={<Compass className="h-4 w-4" />}
|
|
||||||
title={formatMessage({ id: 'liteTasks.contextPanel.explorations' })}
|
|
||||||
badge={
|
|
||||||
contextData.explorations?.manifest?.exploration_count
|
|
||||||
? formatMessage(
|
|
||||||
{ id: 'liteTasks.contextPanel.explorationsCount' },
|
|
||||||
{ count: contextData.explorations.manifest.exploration_count as number }
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{!!contextData.explorations?.manifest?.task_description && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}:
|
|
||||||
</span>{' '}
|
|
||||||
{String(contextData.explorations.manifest.task_description)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!!contextData.explorations?.manifest?.complexity && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{formatMessage({ id: 'liteTasks.contextPanel.complexity' })}:
|
|
||||||
</span>{' '}
|
|
||||||
<Badge variant="info" className="text-[10px]">
|
|
||||||
{String(contextData.explorations.manifest.complexity)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{contextData.explorations?.data && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
{Object.keys(contextData.explorations.data).map((angle) => (
|
|
||||||
<Badge key={angle} variant="secondary" className="text-[10px] capitalize">
|
|
||||||
{angle.replace(/-/g, ' ')}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ContextSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Diagnoses Section */}
|
|
||||||
{hasDiagnoses && (
|
|
||||||
<ContextSection
|
|
||||||
icon={<Stethoscope className="h-4 w-4" />}
|
|
||||||
title={formatMessage({ id: 'liteTasks.contextPanel.diagnoses' })}
|
|
||||||
badge={
|
|
||||||
contextData.diagnoses?.items?.length
|
|
||||||
? formatMessage(
|
|
||||||
{ id: 'liteTasks.contextPanel.diagnosesCount' },
|
|
||||||
{ count: contextData.diagnoses.items.length }
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{contextData.diagnoses?.items?.map((item, i) => (
|
|
||||||
<div key={i} className="text-xs text-muted-foreground py-1 border-b border-border/50 last:border-0">
|
|
||||||
{(item.title as string) || (item.description as string) || `Diagnosis ${i + 1}`}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ContextSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Context Package Section */}
|
|
||||||
{hasContext && (
|
|
||||||
<ContextSection
|
|
||||||
icon={<Package className="h-4 w-4" />}
|
|
||||||
title={formatMessage({ id: 'liteTasks.contextPanel.contextPackage' })}
|
|
||||||
>
|
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
{contextData.context.task_description && (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}:</span>{' '}
|
|
||||||
{contextData.context.task_description as string}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextData.context.constraints && contextData.context.constraints.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground mb-1">
|
|
||||||
<span className="font-medium text-foreground">约束:</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 pl-2">
|
|
||||||
{contextData.context.constraints.map((c, i) => (
|
|
||||||
<div key={i} className="text-muted-foreground flex items-start gap-1">
|
|
||||||
<span className="text-primary/50">•</span>
|
|
||||||
<span>{c as string}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextData.context.focus_paths && contextData.context.focus_paths.length > 0 && (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })}:</span>{' '}
|
|
||||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
|
||||||
{contextData.context.focus_paths.map((p, i) => (
|
|
||||||
<Badge key={i} variant="secondary" className="text-[10px] font-mono">
|
|
||||||
{p as string}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextData.context.relevant_files && contextData.context.relevant_files.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground mb-1">
|
|
||||||
<span className="font-medium text-foreground">相关文件:</span>{' '}
|
|
||||||
<Badge variant="outline" className="text-[10px] align-middle">
|
|
||||||
{contextData.context.relevant_files.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5 pl-2 max-h-32 overflow-y-auto">
|
|
||||||
{contextData.context.relevant_files.map((f, i) => {
|
|
||||||
const filePath = typeof f === 'string' ? f : (f as { path: string; reason?: string }).path;
|
|
||||||
const reason = typeof f === 'string' ? undefined : (f as { path: string; reason?: string }).reason;
|
|
||||||
return (
|
|
||||||
<div key={i} className="group flex items-start gap-1 text-muted-foreground hover:bg-muted/30 rounded px-1 py-0.5">
|
|
||||||
<span className="text-primary/50 shrink-0">{i + 1}.</span>
|
|
||||||
<span className="font-mono text-xs truncate flex-1" title={filePath as string}>
|
|
||||||
{filePath as string}
|
|
||||||
</span>
|
|
||||||
{reason && (
|
|
||||||
<span className="text-[10px] text-muted-foreground/60 truncate ml-1" title={reason}>
|
|
||||||
({reason})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextData.context.dependencies && contextData.context.dependencies.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground mb-1">
|
|
||||||
<span className="font-medium text-foreground">依赖:</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{contextData.context.dependencies.map((d, i) => {
|
|
||||||
const depInfo = typeof d === 'string'
|
|
||||||
? { name: d, type: '', version: '' }
|
|
||||||
: d as { name: string; type?: string; version?: string };
|
|
||||||
return (
|
|
||||||
<Badge key={i} variant="outline" className="text-[10px]">
|
|
||||||
{depInfo.name}
|
|
||||||
{depInfo.version && <span className="ml-1 opacity-70">@{depInfo.version}</span>}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextData.context.session_id && (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">会话ID:</span>{' '}
|
|
||||||
<span className="font-mono bg-muted/50 px-1.5 py-0.5 rounded">{contextData.context.session_id as string}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contextData.context.metadata && (
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground mb-1">
|
|
||||||
<span className="font-medium text-foreground">元数据:</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 pl-2 text-muted-foreground">
|
|
||||||
{Object.entries(contextData.context.metadata as Record<string, unknown>).map(([k, v]) => (
|
|
||||||
<div key={k} className="flex items-center gap-1">
|
|
||||||
<span className="font-mono text-[10px] text-primary/60">{k}:</span>
|
|
||||||
<span className="truncate">{String(v)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ContextSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Focus Paths from Plan */}
|
|
||||||
{hasFocusPaths && (
|
|
||||||
<ContextSection
|
|
||||||
icon={<FolderOpen className="h-4 w-4" />}
|
|
||||||
title={formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{(plan.focus_paths as string[]).map((p, i) => (
|
|
||||||
<Badge key={i} variant="secondary" className="text-[10px] font-mono">
|
|
||||||
{p}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ContextSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plan Summary */}
|
|
||||||
{hasSummary && (
|
|
||||||
<ContextSection
|
|
||||||
icon={<FileText className="h-4 w-4" />}
|
|
||||||
title={formatMessage({ id: 'liteTasks.contextPanel.summary' })}
|
|
||||||
>
|
|
||||||
<p className="text-xs text-muted-foreground">{plan.summary as string}</p>
|
|
||||||
</ContextSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ContextSection - Collapsible section wrapper for context items
|
|
||||||
*/
|
|
||||||
function ContextSection({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
badge,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
badge?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [isOpen, setIsOpen] = React.useState(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="border-border" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">{icon}</span>
|
|
||||||
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
|
|
||||||
{badge && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">{badge}</Badge>
|
|
||||||
)}
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<CardContent className="px-3 pb-3 pt-0">
|
|
||||||
{children}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type MultiCliExpandedTab = 'tasks' | 'discussion' | 'context';
|
type MultiCliExpandedTab = 'tasks' | 'discussion' | 'context';
|
||||||
|
|
||||||
@@ -861,7 +571,7 @@ function ExpandedMultiCliPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!contextLoading && !contextError && contextData && (
|
{!contextLoading && !contextError && contextData && (
|
||||||
<ContextContent contextData={contextData} session={session} />
|
<LiteContextContent contextData={contextData} session={session} />
|
||||||
)}
|
)}
|
||||||
{!contextLoading && !contextError && !contextData && !session.path && (
|
{!contextLoading && !contextError && !contextData && !session.path && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user