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}>
|
||||
<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">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
{formatMessage({ id: 'cli-manager.executionDetails' })}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Hash,
|
||||
MessagesSquare,
|
||||
Folder,
|
||||
FileJson,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -34,6 +35,8 @@ export interface ConversationCardProps {
|
||||
execution: CliExecution;
|
||||
/** Called when view action is triggered */
|
||||
onView?: (execution: CliExecution) => void;
|
||||
/** Called when view native session is triggered */
|
||||
onViewNative?: (execution: CliExecution) => void;
|
||||
/** Called when delete action is triggered */
|
||||
onDelete?: (id: string) => void;
|
||||
/** Called when card is clicked */
|
||||
@@ -94,6 +97,7 @@ function getTimeAgo(dateString: string): string {
|
||||
export function ConversationCard({
|
||||
execution,
|
||||
onView,
|
||||
onViewNative,
|
||||
onDelete,
|
||||
onClick,
|
||||
className,
|
||||
@@ -173,6 +177,12 @@ export function ConversationCard({
|
||||
{execution.sourceDir}
|
||||
</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">
|
||||
{status.icon === 'check-circle' && '✓'}
|
||||
{status.icon === 'x-circle' && '✗'}
|
||||
@@ -228,6 +238,12 @@ export function ConversationCard({
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'history.actions.view' })}
|
||||
</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 />
|
||||
<DropdownMenuItem
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user