mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat(hooks): add hook management and session timeline features
- Add hook quick templates component with configurable templates - Refactor NativeSessionPanel to use new SessionTimeline component - Add OpenCode session parser for parsing OpenCode CLI sessions - Enhance API with session-related endpoints - Add locale translations for hooks and native session features - Update hook commands and routes for better hook management
This commit is contained in:
@@ -182,6 +182,15 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
'-e',
|
||||
'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_SUMMARY",transcript:p.transcript_path||"",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'project-state-inject',
|
||||
name: 'Project State Inject',
|
||||
description: 'Inject project guidelines and recent dev history at session start',
|
||||
category: 'indexing',
|
||||
trigger: 'SessionStart',
|
||||
command: 'ccw',
|
||||
args: ['hook', 'project-state', '--stdin']
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -205,6 +214,7 @@ const TEMPLATE_ICONS: Record<string, typeof Bell> = {
|
||||
'git-auto-stage': GitBranch,
|
||||
'post-edit-index': Database,
|
||||
'session-end-summary': FileBarChart,
|
||||
'project-state-inject': FileBarChart,
|
||||
};
|
||||
|
||||
// ========== Category Names ==========
|
||||
|
||||
@@ -6,25 +6,16 @@
|
||||
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,
|
||||
@@ -32,11 +23,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { useNativeSession } from '@/hooks/useNativeSession';
|
||||
import type {
|
||||
NativeSessionTurn,
|
||||
NativeToolCall,
|
||||
NativeTokenInfo,
|
||||
} from '@/lib/api';
|
||||
import { SessionTimeline } from './SessionTimeline';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -46,21 +33,6 @@ export interface NativeSessionPanelProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface TurnCardProps {
|
||||
turn: NativeSessionTurn;
|
||||
isLatest: boolean;
|
||||
}
|
||||
|
||||
interface TokenDisplayProps {
|
||||
tokens: NativeTokenInfo;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ToolCallItemProps {
|
||||
toolCall: NativeToolCall;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
@@ -76,16 +48,6 @@ function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'su
|
||||
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
|
||||
*/
|
||||
@@ -106,173 +68,6 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 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) {
|
||||
const { formatMessage } = useIntl();
|
||||
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">{formatMessage({ id: 'nativeSession.toolCall.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">{formatMessage({ id: 'nativeSession.toolCall.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 ==========
|
||||
|
||||
/**
|
||||
@@ -350,17 +145,7 @@ export function NativeSessionPanel({
|
||||
)}
|
||||
</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 */}
|
||||
{/* Content Area with SessionTimeline */}
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
@@ -375,24 +160,9 @@ export function NativeSessionPanel({
|
||||
<span>{formatMessage({ id: 'nativeSession.error', defaultMessage: 'Failed to load session' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : session && session.turns.length > 0 ? (
|
||||
) : session ? (
|
||||
<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>
|
||||
<SessionTimeline session={session} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center py-16 text-muted-foreground">
|
||||
|
||||
416
ccw/frontend/src/components/shared/SessionTimeline.tsx
Normal file
416
ccw/frontend/src/components/shared/SessionTimeline.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
// ========================================
|
||||
// SessionTimeline Component
|
||||
// ========================================
|
||||
// Timeline visualization for native CLI session turns, tokens, and tool calls
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
User,
|
||||
Bot,
|
||||
Brain,
|
||||
Wrench,
|
||||
Coins,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type {
|
||||
NativeSession,
|
||||
NativeSessionTurn,
|
||||
NativeTokenInfo,
|
||||
NativeToolCall,
|
||||
} from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface SessionTimelineProps {
|
||||
session: NativeSession;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TurnNodeProps {
|
||||
turn: NativeSessionTurn;
|
||||
isLatest: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
interface TokenBarProps {
|
||||
tokens: NativeTokenInfo;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ToolCallPanelProps {
|
||||
toolCall: NativeToolCall;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for tool call
|
||||
*/
|
||||
function getToolStatusIcon(status?: string): React.ReactNode {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'success':
|
||||
return <CheckCircle className="h-3.5 w-3.5 text-green-500" />;
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return <XCircle className="h-3.5 w-3.5 text-destructive" />;
|
||||
case 'running':
|
||||
case 'pending':
|
||||
return <Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <Wrench className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge variant for tool call status
|
||||
*/
|
||||
function getStatusVariant(status?: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'warning';
|
||||
case 'running':
|
||||
case 'pending':
|
||||
return 'info';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
/**
|
||||
* TokenBar - Horizontal stacked bar for token usage
|
||||
*/
|
||||
function TokenBar({ tokens, className }: TokenBarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const total = tokens.total || 0;
|
||||
const input = tokens.input || 0;
|
||||
const output = tokens.output || 0;
|
||||
const cached = tokens.cached || 0;
|
||||
|
||||
// Calculate percentages
|
||||
const inputPercent = total > 0 ? (input / total) * 100 : 0;
|
||||
const outputPercent = total > 0 ? (output / total) * 100 : 0;
|
||||
const cachedPercent = total > 0 ? (cached / total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{/* Visual bar */}
|
||||
<div className="flex h-2 w-full rounded-full overflow-hidden bg-muted">
|
||||
{input > 0 && (
|
||||
<div
|
||||
className="bg-blue-500/80"
|
||||
style={{ width: `${inputPercent}%` }}
|
||||
title={formatMessage({ id: 'nativeSession.timeline.tokens.input', defaultMessage: 'Input: {count}' }, { count: formatTokenCount(input) })}
|
||||
/>
|
||||
)}
|
||||
{output > 0 && (
|
||||
<div
|
||||
className="bg-green-500/80"
|
||||
style={{ width: `${outputPercent}%` }}
|
||||
title={formatMessage({ id: 'nativeSession.timeline.tokens.output', defaultMessage: 'Output: {count}' }, { count: formatTokenCount(output) })}
|
||||
/>
|
||||
)}
|
||||
{cached > 0 && (
|
||||
<div
|
||||
className="bg-amber-500/80"
|
||||
style={{ width: `${cachedPercent}%` }}
|
||||
title={formatMessage({ id: 'nativeSession.timeline.tokens.cached', defaultMessage: 'Cached: {count}' }, { count: formatTokenCount(cached) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Labels */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Coins className="h-3 w-3" />
|
||||
{formatTokenCount(total)}
|
||||
</span>
|
||||
{input > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowDownUp className="h-3 w-3 text-blue-500" />
|
||||
{formatTokenCount(input)}
|
||||
</span>
|
||||
)}
|
||||
{output > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
out: {formatTokenCount(output)}
|
||||
</span>
|
||||
)}
|
||||
{cached > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Archive className="h-3 w-3 text-amber-500" />
|
||||
{formatTokenCount(cached)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCallPanel - Collapsible panel for tool call details
|
||||
*/
|
||||
function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="border border-border/50 rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{getToolStatusIcon(toolCall.output ? 'completed' : undefined)}
|
||||
<span className="font-mono font-medium">{toolCall.name}</span>
|
||||
<span className="text-muted-foreground text-xs">#{index + 1}</span>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(toolCall.output ? 'completed' : undefined)} className="text-xs">
|
||||
{formatMessage({ id: 'nativeSession.timeline.toolCall.completed', defaultMessage: 'completed' })}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Collapsible content */}
|
||||
{isExpanded && (
|
||||
<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.5">
|
||||
{formatMessage({ id: 'nativeSession.toolCall.input', defaultMessage: 'Input' })}
|
||||
</p>
|
||||
<pre className="p-2.5 bg-muted/30 rounded-md text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||
{toolCall.arguments}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.output && (
|
||||
<div className="p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{formatMessage({ id: 'nativeSession.toolCall.output', defaultMessage: 'Output' })}
|
||||
</p>
|
||||
<pre className="p-2.5 bg-muted/30 rounded-md text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||
{toolCall.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!toolCall.arguments && !toolCall.output && (
|
||||
<div className="p-3 text-xs text-muted-foreground italic">
|
||||
{formatMessage({ id: 'nativeSession.timeline.toolCall.noData', defaultMessage: 'No data available' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TurnNode - Single conversation turn on the timeline
|
||||
*/
|
||||
function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const isUser = turn.role === 'user';
|
||||
const RoleIcon = isUser ? User : Bot;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* Timeline column */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Node dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full border-2 shrink-0',
|
||||
isUser
|
||||
? 'bg-primary/10 border-primary text-primary'
|
||||
: 'bg-blue-500/10 border-blue-500 text-blue-500',
|
||||
isLatest && 'ring-2 ring-offset-2 ring-offset-background',
|
||||
isLatest && (isUser ? 'ring-primary/50' : 'ring-blue-500/50')
|
||||
)}
|
||||
>
|
||||
<RoleIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{/* Vertical connector line */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 flex-1 bg-border min-h-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content column */}
|
||||
<div className="flex-1 pb-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'font-semibold text-sm capitalize',
|
||||
isUser ? 'text-primary' : 'text-blue-500'
|
||||
)}>
|
||||
{turn.role}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'nativeSession.timeline.turnNumber', defaultMessage: 'Turn #{number}' },
|
||||
{ number: turn.turnNumber }
|
||||
)}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<Badge variant="default" className="text-xs h-5 px-1.5">
|
||||
{formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{turn.timestamp && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(turn.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content card */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border overflow-hidden',
|
||||
isUser
|
||||
? 'bg-muted/30 border-border'
|
||||
: 'bg-blue-500/5 dark:bg-blue-500/10 border-blue-500/20'
|
||||
)}
|
||||
>
|
||||
{/* Message content */}
|
||||
{turn.content && (
|
||||
<div className="p-4">
|
||||
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
|
||||
{turn.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thoughts section */}
|
||||
{turn.thoughts && turn.thoughts.length > 0 && (
|
||||
<details className="group/thoughts border-t border-border/50">
|
||||
<summary className="flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer hover:bg-muted/30 select-none">
|
||||
<Brain className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({turn.thoughts.length})
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="px-4 pb-3 space-y-1 text-sm text-muted-foreground list-disc list-inside">
|
||||
{turn.thoughts.map((thought, i) => (
|
||||
<li key={i} className="leading-relaxed pl-2">{thought}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Tool calls section */}
|
||||
{turn.toolCalls && turn.toolCalls.length > 0 && (
|
||||
<div className="border-t border-border/50 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<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>
|
||||
</div>
|
||||
{turn.toolCalls.map((tc, i) => (
|
||||
<ToolCallPanel key={i} toolCall={tc} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token usage bar */}
|
||||
{turn.tokens && (
|
||||
<div className="border-t border-border/50 px-4 py-3">
|
||||
<TokenBar tokens={turn.tokens} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* SessionTimeline - Timeline visualization for native CLI sessions
|
||||
*
|
||||
* Displays conversation turns in a vertical timeline layout with:
|
||||
* - Left side: Timeline nodes with role icons
|
||||
* - Right side: Content cards with messages, thoughts, and tool calls
|
||||
* - Token usage bars with stacked input/output/cached visualization
|
||||
* - Collapsible tool call panels
|
||||
*/
|
||||
export function SessionTimeline({ session, className }: SessionTimelineProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const turns = session.turns || [];
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-0', className)}>
|
||||
{/* Session token summary bar */}
|
||||
{session.totalTokens && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-muted/30 border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })}
|
||||
</p>
|
||||
<TokenBar tokens={session.totalTokens} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline turns */}
|
||||
{turns.length > 0 ? (
|
||||
<div className="relative">
|
||||
{turns.map((turn, idx) => (
|
||||
<TurnNode
|
||||
key={turn.turnNumber}
|
||||
turn={turn}
|
||||
isLatest={idx === turns.length - 1}
|
||||
isLast={idx === turns.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
{formatMessage({ id: 'nativeSession.empty', defaultMessage: 'No session data available' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionTimeline;
|
||||
@@ -2064,7 +2064,30 @@ export interface NativeSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch native CLI session content by execution ID
|
||||
* Options for fetching native session
|
||||
*/
|
||||
export interface FetchNativeSessionOptions {
|
||||
executionId?: string;
|
||||
projectPath?: string;
|
||||
/** Direct file path to session file (bypasses ccw execution ID lookup) */
|
||||
filePath?: string;
|
||||
/** Tool type for file path query: claude | opencode | codex | qwen | gemini | auto */
|
||||
tool?: 'claude' | 'opencode' | 'codex' | 'qwen' | 'gemini' | 'auto';
|
||||
/** Output format: json (default) | text | pairs */
|
||||
format?: 'json' | 'text' | 'pairs';
|
||||
/** Include thoughts in text format */
|
||||
thoughts?: boolean;
|
||||
/** Include tool calls in text format */
|
||||
tools?: boolean;
|
||||
/** Include token counts in text format */
|
||||
tokens?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch native CLI session content by execution ID or file path
|
||||
* @param executionId - CCW execution ID (backward compatible)
|
||||
* @param projectPath - Optional project path
|
||||
* @deprecated Use fetchNativeSessionWithOptions for new features
|
||||
*/
|
||||
export async function fetchNativeSession(
|
||||
executionId: string,
|
||||
@@ -2077,6 +2100,88 @@ export async function fetchNativeSession(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch native CLI session content with full options
|
||||
* Supports both execution ID lookup and direct file path query
|
||||
*/
|
||||
export async function fetchNativeSessionWithOptions(
|
||||
options: FetchNativeSessionOptions
|
||||
): Promise<NativeSession | string | Array<{ turn: number; userPrompt: string; assistantResponse: string; timestamp: string }>> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Priority: filePath > executionId
|
||||
if (options.filePath) {
|
||||
params.set('filePath', options.filePath);
|
||||
if (options.tool) params.set('tool', options.tool);
|
||||
} else if (options.executionId) {
|
||||
params.set('id', options.executionId);
|
||||
} else {
|
||||
throw new Error('Either executionId or filePath is required');
|
||||
}
|
||||
|
||||
if (options.projectPath) params.set('path', options.projectPath);
|
||||
if (options.format) params.set('format', options.format);
|
||||
if (options.thoughts) params.set('thoughts', 'true');
|
||||
if (options.tools) params.set('tools', 'true');
|
||||
if (options.tokens) params.set('tokens', 'true');
|
||||
|
||||
const url = `/api/cli/native-session?${params.toString()}`;
|
||||
|
||||
// Text format returns string, others return JSON
|
||||
if (options.format === 'text') {
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || response.statusText);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
return fetchApi<NativeSession | Array<{ turn: number; userPrompt: string; assistantResponse: string; timestamp: string }>>(url);
|
||||
}
|
||||
|
||||
// ========== Native Sessions List API ==========
|
||||
|
||||
/**
|
||||
* Native session metadata for list endpoint
|
||||
*/
|
||||
export interface NativeSessionListItem {
|
||||
id: string;
|
||||
tool: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
startTime: string;
|
||||
updatedAt: string;
|
||||
projectHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native sessions list response
|
||||
*/
|
||||
export interface NativeSessionsListResponse {
|
||||
sessions: NativeSessionListItem[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of native CLI sessions
|
||||
* @param tool - Filter by tool type (optional)
|
||||
* @param project - Filter by project path (optional)
|
||||
*/
|
||||
export async function fetchNativeSessions(
|
||||
tool?: 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode',
|
||||
project?: string
|
||||
): Promise<NativeSessionsListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (tool) params.set('tool', tool);
|
||||
if (project) params.set('project', project);
|
||||
|
||||
const query = params.toString();
|
||||
return fetchApi<NativeSessionsListResponse>(
|
||||
`/api/cli/native-sessions${query ? `?${query}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
// ========== CLI Tools Config API ==========
|
||||
|
||||
export interface CliToolsConfigResponse {
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
"session-end-summary": {
|
||||
"name": "Session End Summary",
|
||||
"description": "Send session summary to dashboard on session end"
|
||||
},
|
||||
"project-state-inject": {
|
||||
"name": "Project State Inject",
|
||||
"description": "Inject project guidelines and recent dev history at session start"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -6,6 +6,20 @@
|
||||
"output": "Output tokens",
|
||||
"cached": "Cached tokens"
|
||||
},
|
||||
"timeline": {
|
||||
"turnNumber": "Turn #{number}",
|
||||
"tokens": {
|
||||
"input": "Input: {count}",
|
||||
"output": "Output: {count}",
|
||||
"cached": "Cached: {count}"
|
||||
},
|
||||
"toolCall": {
|
||||
"completed": "completed",
|
||||
"running": "running",
|
||||
"error": "error",
|
||||
"noData": "No data available"
|
||||
}
|
||||
},
|
||||
"turn": {
|
||||
"latest": "Latest",
|
||||
"thoughts": "Thoughts",
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
"session-end-summary": {
|
||||
"name": "会话结束摘要",
|
||||
"description": "会话结束时发送摘要到仪表盘"
|
||||
},
|
||||
"project-state-inject": {
|
||||
"name": "项目状态注入",
|
||||
"description": "会话启动时注入项目约束和最近开发历史"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -6,6 +6,20 @@
|
||||
"output": "输出 Token",
|
||||
"cached": "缓存 Token"
|
||||
},
|
||||
"timeline": {
|
||||
"turnNumber": "第 {number} 轮",
|
||||
"tokens": {
|
||||
"input": "输入: {count}",
|
||||
"output": "输出: {count}",
|
||||
"cached": "缓存: {count}"
|
||||
},
|
||||
"toolCall": {
|
||||
"completed": "已完成",
|
||||
"running": "运行中",
|
||||
"error": "错误",
|
||||
"noData": "无数据"
|
||||
}
|
||||
},
|
||||
"turn": {
|
||||
"latest": "最新",
|
||||
"thoughts": "思考过程",
|
||||
|
||||
Reference in New Issue
Block a user