mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: update usage recommendations across multiple workflow commands to require user confirmation and improve clarity
This commit is contained in:
401
ccw/frontend/src/components/commands/CommandGroupAccordion.tsx
Normal file
401
ccw/frontend/src/components/commands/CommandGroupAccordion.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
// ========================================
|
||||
// CommandGroupAccordion Component
|
||||
// ========================================
|
||||
// Accordion component for displaying command groups with toggle switches
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@/components/ui/Collapsible';
|
||||
import type { Command } from '@/lib/api';
|
||||
|
||||
export interface CommandGroupAccordionProps {
|
||||
/** Group name (e.g., 'cli', 'workflow', 'workflow/review') */
|
||||
groupName: string;
|
||||
/** Commands in this group */
|
||||
commands: Command[];
|
||||
/** Is this group expanded */
|
||||
isExpanded: boolean;
|
||||
/** Toggle expand/collapse */
|
||||
onToggleExpand: (groupName: string) => void;
|
||||
/** Toggle individual command enabled state */
|
||||
onToggleCommand: (name: string, enabled: boolean) => void;
|
||||
/** Toggle all commands in group */
|
||||
onToggleGroup: (groupName: string, enable: boolean) => void;
|
||||
/** Is toggling in progress */
|
||||
isToggling: boolean;
|
||||
/** Show disabled commands */
|
||||
showDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for a command group
|
||||
* Uses top-level parent's icon for nested groups
|
||||
*/
|
||||
function getGroupIcon(groupName: string): React.ReactNode {
|
||||
const groupIcons: Record<string, string> = {
|
||||
cli: 'terminal',
|
||||
workflow: 'git-branch',
|
||||
memory: 'brain',
|
||||
task: 'clipboard-list',
|
||||
issue: 'alert-circle',
|
||||
loop: 'repeat',
|
||||
skill: 'sparkles',
|
||||
other: 'folder',
|
||||
};
|
||||
|
||||
const topLevel = groupName.split('/')[0];
|
||||
return groupIcons[topLevel] || 'folder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for a command group
|
||||
* Uses top-level parent's color for nested groups
|
||||
*/
|
||||
function getGroupColorClass(groupName: string): string {
|
||||
const groupColors: Record<string, string> = {
|
||||
cli: 'text-primary bg-primary/10',
|
||||
workflow: 'text-success bg-success/10',
|
||||
memory: 'text-indigo bg-indigo/10',
|
||||
task: 'text-warning bg-warning/10',
|
||||
issue: 'text-destructive bg-destructive/10',
|
||||
loop: 'text-purple bg-purple/10',
|
||||
skill: 'text-pink bg-pink/10',
|
||||
other: 'text-muted-foreground bg-muted',
|
||||
};
|
||||
|
||||
const topLevel = groupName.split('/')[0];
|
||||
return groupColors[topLevel] || 'text-muted-foreground bg-muted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format group name for display
|
||||
* Converts nested paths like 'workflow/review' -> 'Workflow > Review'
|
||||
*/
|
||||
function formatGroupName(groupName: string): string {
|
||||
if (!groupName.includes('/')) {
|
||||
return groupName;
|
||||
}
|
||||
|
||||
const parts = groupName.split('/');
|
||||
return parts.map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Lucide icon component by name
|
||||
*/
|
||||
function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> {
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
terminal: ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
),
|
||||
'git-branch': ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
),
|
||||
brain: ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
|
||||
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
|
||||
</svg>
|
||||
),
|
||||
'clipboard-list': ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="M12 11h4" />
|
||||
<path d="M12 16h4" />
|
||||
<path d="M8 11h.01" />
|
||||
<path d="M8 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
'alert-circle': ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
),
|
||||
repeat: ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m17 2 4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<path d="m7 22-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
),
|
||||
sparkles: ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4" />
|
||||
<path d="M19 17v4" />
|
||||
<path d="M3 5h4" />
|
||||
<path d="M17 19h4" />
|
||||
</svg>
|
||||
),
|
||||
folder: ({ className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return iconMap[iconName] || iconMap.folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* CommandGroupAccordion component
|
||||
* Displays a collapsible group of commands with toggle switches
|
||||
*/
|
||||
export function CommandGroupAccordion({
|
||||
groupName,
|
||||
commands,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onToggleCommand,
|
||||
onToggleGroup,
|
||||
isToggling,
|
||||
showDisabled = false,
|
||||
}: CommandGroupAccordionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const enabledCommands = commands.filter((cmd) => cmd.enabled);
|
||||
const disabledCommands = commands.filter((cmd) => !cmd.enabled);
|
||||
const allEnabled = enabledCommands.length === commands.length && commands.length > 0;
|
||||
|
||||
// Filter commands based on showDisabled setting
|
||||
const visibleCommands = showDisabled ? commands : enabledCommands;
|
||||
|
||||
const iconName = getGroupIcon(groupName);
|
||||
const colorClass = getGroupColorClass(groupName);
|
||||
const displayName = formatGroupName(groupName);
|
||||
const IconComponent = getIconComponent(iconName);
|
||||
const indentLevel = (groupName.match(/\//g) || []).length;
|
||||
|
||||
const handleToggleGroup = (checked: boolean) => {
|
||||
onToggleGroup(groupName, checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('mb-4', indentLevel > 0 && 'ml-5')} style={indentLevel > 0 ? { marginLeft: `${indentLevel * 20}px` } : undefined}>
|
||||
<Collapsible open={isExpanded} onOpenChange={(open) => onToggleExpand(groupName)}>
|
||||
{/* Group Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-card border border-border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center gap-3 flex-1 cursor-pointer">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', colorClass)}>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{displayName}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{enabledCommands.length}/{commands.length} {formatMessage({ id: 'commands.group.enabled' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Group Toggle Switch */}
|
||||
<Switch
|
||||
checked={allEnabled}
|
||||
onCheckedChange={handleToggleGroup}
|
||||
disabled={isToggling || commands.length === 0}
|
||||
className={cn('data-[state=checked]:bg-success')}
|
||||
title={
|
||||
allEnabled
|
||||
? formatMessage({ id: 'commands.group.clickToDisableAll' })
|
||||
: formatMessage({ id: 'commands.group.clickToEnableAll' })
|
||||
}
|
||||
/>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{commands.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Content - Commands Table */}
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '200px' }} />
|
||||
<col style={{ width: 'auto' }} />
|
||||
<col style={{ width: '100px' }} />
|
||||
<col style={{ width: '80px' }} />
|
||||
</colgroup>
|
||||
<thead className="bg-muted/30 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'commands.table.name' })}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'commands.table.description' })}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'commands.table.scope' })}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'commands.table.status' })}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{visibleCommands.map((command) => (
|
||||
<CommandRow
|
||||
key={command.name}
|
||||
command={command}
|
||||
onToggle={onToggleCommand}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
))}
|
||||
{visibleCommands.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{showDisabled
|
||||
? formatMessage({ id: 'commands.group.noCommands' })
|
||||
: formatMessage({ id: 'commands.group.noEnabledCommands' })}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommandRow component - Internal component for table row
|
||||
*/
|
||||
interface CommandRowProps {
|
||||
command: Command;
|
||||
onToggle: (name: string, enabled: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function CommandRow({ command, onToggle, disabled }: CommandRowProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const isDisabled = !command.enabled;
|
||||
|
||||
return (
|
||||
<tr className={cn('hover:bg-muted/20 transition-colors', isDisabled && 'opacity-60')}>
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground">
|
||||
<code className="break-words">/{command.name}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="line-clamp-2 break-words">
|
||||
{command.description || formatMessage({ id: 'commands.card.noDescription' })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-xs text-muted-foreground">
|
||||
<span className="whitespace-nowrap">{command.location || 'project'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
checked={command.enabled}
|
||||
onCheckedChange={(checked) => onToggle(command.name, checked)}
|
||||
disabled={disabled}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandGroupAccordion;
|
||||
78
ccw/frontend/src/components/commands/LocationSwitcher.tsx
Normal file
78
ccw/frontend/src/components/commands/LocationSwitcher.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// ========================================
|
||||
// LocationSwitcher Component
|
||||
// ========================================
|
||||
// Toggle between Project and User command locations
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Folder, User } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface LocationSwitcherProps {
|
||||
/** Current selected location */
|
||||
currentLocation: 'project' | 'user';
|
||||
/** Callback when location changes */
|
||||
onLocationChange: (location: 'project' | 'user') => void;
|
||||
/** Number of project commands (optional, for display) */
|
||||
projectCount?: number;
|
||||
/** Number of user commands (optional, for display) */
|
||||
userCount?: number;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Translation prefix (default: 'commands') */
|
||||
translationPrefix?: 'commands' | 'skills';
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwitcher component
|
||||
* Toggle switch for Project vs User command location
|
||||
*/
|
||||
export function LocationSwitcher({
|
||||
currentLocation,
|
||||
onLocationChange,
|
||||
projectCount,
|
||||
userCount,
|
||||
disabled = false,
|
||||
translationPrefix = 'commands',
|
||||
}: LocationSwitcherProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="inline-flex bg-muted rounded-lg p-1">
|
||||
<button
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-md transition-all flex items-center gap-1.5',
|
||||
currentLocation === 'project'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => onLocationChange('project')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Folder className="w-3.5 h-3.5" />
|
||||
<span>{formatMessage({ id: `${translationPrefix}.location.project` })}</span>
|
||||
{projectCount !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">({projectCount})</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-md transition-all flex items-center gap-1.5',
|
||||
currentLocation === 'user'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => onLocationChange('user')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{formatMessage({ id: `${translationPrefix}.location.user` })}</span>
|
||||
{userCount !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">({userCount})</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocationSwitcher;
|
||||
9
ccw/frontend/src/components/commands/index.ts
Normal file
9
ccw/frontend/src/components/commands/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// ========================================
|
||||
// Commands Components Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { CommandGroupAccordion } from './CommandGroupAccordion';
|
||||
export type { CommandGroupAccordionProps } from './CommandGroupAccordion';
|
||||
|
||||
export { LocationSwitcher } from './LocationSwitcher';
|
||||
export type { LocationSwitcherProps } from './LocationSwitcher';
|
||||
@@ -20,7 +20,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type HookTriggerType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||
export type HookTriggerType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||
|
||||
export interface HookCardData {
|
||||
name: string;
|
||||
@@ -45,6 +45,8 @@ export interface HookCardProps {
|
||||
|
||||
function getTriggerIcon(trigger: HookTriggerType) {
|
||||
switch (trigger) {
|
||||
case 'SessionStart':
|
||||
return '🎬';
|
||||
case 'UserPromptSubmit':
|
||||
return '⚡';
|
||||
case 'PreToolUse':
|
||||
@@ -60,6 +62,8 @@ function getTriggerIcon(trigger: HookTriggerType) {
|
||||
|
||||
function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' {
|
||||
switch (trigger) {
|
||||
case 'SessionStart':
|
||||
return 'default';
|
||||
case 'UserPromptSubmit':
|
||||
return 'default';
|
||||
case 'PreToolUse':
|
||||
|
||||
@@ -147,6 +147,7 @@ export function HookFormDialog({
|
||||
};
|
||||
|
||||
const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [
|
||||
{ value: 'SessionStart', label: 'cliHooks.trigger.SessionStart' },
|
||||
{ value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' },
|
||||
{ value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' },
|
||||
{ value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' },
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface HookTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
category: TemplateCategory;
|
||||
trigger: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||
trigger: 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||
command: string;
|
||||
args?: string[];
|
||||
matcher?: string;
|
||||
@@ -57,79 +57,28 @@ export interface HookQuickTemplatesProps {
|
||||
*/
|
||||
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
{
|
||||
id: 'ccw-status-tracker',
|
||||
name: 'CCW Status Tracker',
|
||||
description: 'Parse CCW status.json and display current/next command',
|
||||
id: 'session-start-notify',
|
||||
name: 'Session Start Notify',
|
||||
description: 'Notify dashboard when a new workflow session is created',
|
||||
category: 'notification',
|
||||
trigger: 'SessionStart',
|
||||
command: 'node',
|
||||
args: [
|
||||
'-e',
|
||||
'const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_CREATED",timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'session-state-watch',
|
||||
name: 'Session State Watch',
|
||||
description: 'Watch for session metadata file changes (workflow-session.json)',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
matcher: 'Write|Edit',
|
||||
command: 'node',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && [[ "$FILE_PATH" == *"status.json" ]] && ccw hook parse-status --path "$FILE_PATH" || true'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ccw-notify',
|
||||
name: 'CCW Dashboard Notify',
|
||||
description: 'Send notifications to CCW dashboard when files are written',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'codexlens-update',
|
||||
name: 'CodexLens Auto-Update',
|
||||
description: 'Update CodexLens index when files are written or edited',
|
||||
category: 'indexing',
|
||||
trigger: 'Stop',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'git-add',
|
||||
name: 'Auto Git Stage',
|
||||
description: 'Automatically stage written files to git',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'lint-check',
|
||||
name: 'Auto ESLint',
|
||||
description: 'Run ESLint on JavaScript/TypeScript files after write',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'log-tool',
|
||||
name: 'Tool Usage Logger',
|
||||
description: 'Log all tool executions to a file for audit trail',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"'
|
||||
'-e',
|
||||
'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){const fs=require("fs");try{const content=fs.readFileSync(file,"utf8");const data=JSON.parse(content);const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_STATE_CHANGED",file:file,sessionId:data.session_id||"",status:data.status||"unknown",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})}catch(e){}}'
|
||||
]
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -126,7 +126,7 @@ function ExternalDependenciesSection({ dependencies }: ExternalDependenciesSecti
|
||||
{dependencies.map((dep, index) => (
|
||||
<Badge key={index} variant="secondary" className="px-3 py-1.5">
|
||||
{dep.package}
|
||||
{dep.version && <span className="ml-1 text-muted-foreground">@{dep.version}</span>}
|
||||
{dep.version && <span className="ml-1 text-foreground">@{dep.version}</span>}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -19,11 +19,11 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
||||
// Simplify tool name (e.g., gemini-2.5-pro -> gemini)
|
||||
const toolNameShort = execution.tool.split('-')[0];
|
||||
|
||||
// Status color mapping
|
||||
// Status color mapping - using softer, semantic colors
|
||||
const statusColor = {
|
||||
running: 'bg-green-500 animate-pulse',
|
||||
completed: 'bg-blue-500',
|
||||
error: 'bg-red-500',
|
||||
running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse',
|
||||
completed: 'bg-slate-400 dark:bg-slate-500',
|
||||
error: 'bg-rose-500',
|
||||
}[execution.status];
|
||||
|
||||
return (
|
||||
@@ -31,34 +31,36 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
||||
value={execution.id}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'gap-2 text-xs px-3 py-1.5',
|
||||
'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted/50 hover:bg-muted/70',
|
||||
'transition-colors'
|
||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
|
||||
: 'bg-muted/30 hover:bg-muted/50 border-border/30',
|
||||
'transition-all'
|
||||
)}
|
||||
>
|
||||
{/* Status indicator dot */}
|
||||
<span className={cn('w-2 h-2 rounded-full shrink-0', statusColor)} />
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', statusColor)} />
|
||||
|
||||
{/* Simplified tool name */}
|
||||
<span className="font-medium">{toolNameShort}</span>
|
||||
<span className="font-medium text-[11px]">{toolNameShort}</span>
|
||||
|
||||
{/* Execution mode */}
|
||||
<span className="opacity-70">{execution.mode}</span>
|
||||
|
||||
{/* Line count statistics */}
|
||||
<span className="text-[10px] opacity-50 tabular-nums">
|
||||
{execution.output.length} lines
|
||||
{/* Execution mode - show on hover */}
|
||||
<span className="opacity-0 group-hover:opacity-70 text-[10px] transition-opacity">
|
||||
{execution.mode}
|
||||
</span>
|
||||
|
||||
{/* Close button */}
|
||||
{/* Line count statistics - show on hover */}
|
||||
<span className="opacity-0 group-hover:opacity-50 text-[9px] tabular-nums transition-opacity">
|
||||
{execution.output.length}
|
||||
</span>
|
||||
|
||||
{/* Close button - show on hover */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-1 p-0.5 rounded hover:bg-destructive/20 transition-colors"
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-rose-500/20 transition-opacity opacity-0 group-hover:opacity-100"
|
||||
aria-label="Close execution tab"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-2.5 w-2.5 text-rose-600 dark:text-rose-400" />
|
||||
</button>
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { JsonField } from './JsonField';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -37,6 +39,7 @@ export interface JsonCardProps {
|
||||
type TypeConfig = {
|
||||
icon: typeof Wrench;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
};
|
||||
@@ -45,38 +48,44 @@ const TYPE_CONFIGS: Record<string, TypeConfig> = {
|
||||
tool_call: {
|
||||
icon: Wrench,
|
||||
label: 'Tool Call',
|
||||
color: 'text-green-400',
|
||||
bg: 'bg-green-950/30 border-green-900/50',
|
||||
shortLabel: 'Tool',
|
||||
color: 'text-indigo-600 dark:text-indigo-400',
|
||||
bg: 'border-l-indigo-500',
|
||||
},
|
||||
metadata: {
|
||||
icon: Info,
|
||||
label: 'Metadata',
|
||||
color: 'text-yellow-400',
|
||||
bg: 'bg-yellow-950/30 border-yellow-900/50',
|
||||
shortLabel: 'Info',
|
||||
color: 'text-slate-600 dark:text-slate-400',
|
||||
bg: 'border-l-slate-400',
|
||||
},
|
||||
system: {
|
||||
icon: Settings,
|
||||
label: 'System',
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-950/30 border-blue-900/50',
|
||||
shortLabel: 'Sys',
|
||||
color: 'text-slate-600 dark:text-slate-400',
|
||||
bg: 'border-l-slate-400',
|
||||
},
|
||||
stdout: {
|
||||
icon: Code,
|
||||
label: 'Data',
|
||||
color: 'text-cyan-400',
|
||||
bg: 'bg-cyan-950/30 border-cyan-900/50',
|
||||
shortLabel: 'Out',
|
||||
color: 'text-teal-600 dark:text-teal-400',
|
||||
bg: 'border-l-teal-500',
|
||||
},
|
||||
stderr: {
|
||||
icon: AlertTriangle,
|
||||
label: 'Error',
|
||||
color: 'text-red-400',
|
||||
bg: 'bg-red-950/30 border-red-900/50',
|
||||
shortLabel: 'Err',
|
||||
color: 'text-rose-600 dark:text-rose-400',
|
||||
bg: 'border-l-rose-500',
|
||||
},
|
||||
thought: {
|
||||
icon: Brain,
|
||||
label: 'Thought',
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-950/30 border-purple-900/50',
|
||||
shortLabel: '💭',
|
||||
color: 'text-violet-600 dark:text-violet-400',
|
||||
bg: 'border-l-violet-500',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -88,97 +97,78 @@ export function JsonCard({
|
||||
timestamp,
|
||||
onCopy,
|
||||
}: JsonCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
const entries = Object.entries(data);
|
||||
const visibleCount = isExpanded ? entries.length : 3;
|
||||
const hasMore = entries.length > 3;
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const config = TYPE_CONFIGS[type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-lg overflow-hidden my-2', config.bg)}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 cursor-pointer',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5 transition-colors'
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('h-4 w-4', config.color)} />
|
||||
<span className="text-sm font-medium">{config.label}</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">
|
||||
{entries.length}
|
||||
</Badge>
|
||||
</div>
|
||||
// Check if data has a 'content' field
|
||||
const hasContentField = 'content' in data && typeof data.content === 'string';
|
||||
const content = hasContentField ? (data.content as string) : '';
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{timestamp && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{new Date(timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
// If has content field, render as streaming output
|
||||
if (hasContentField) {
|
||||
// Check if content looks like markdown
|
||||
const isMarkdown = content.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
|
||||
return (
|
||||
<div className={cn('border-l-2 rounded-r my-1.5 py-1 px-2 group relative bg-background', config.bg)}>
|
||||
<div className="pr-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy?.();
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRaw(!showRaw);
|
||||
}}
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
</Button>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render as card with fields
|
||||
const entries = Object.entries(data).filter(([key]) =>
|
||||
key !== 'type' && key !== 'timestamp' && key !== 'role' && key !== 'id'
|
||||
);
|
||||
const visibleCount = isExpanded ? entries.length : 1;
|
||||
const hasMore = entries.length > 1;
|
||||
|
||||
const handleCopyCard = () => {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
navigator.clipboard.writeText(content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('border-l-2 rounded-r my-1.5 py-1 px-2 group relative bg-background text-xs', config.bg)}>
|
||||
{/* Copy button - show on hover */}
|
||||
<button
|
||||
onClick={handleCopyCard}
|
||||
className="absolute top-1 right-1 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted"
|
||||
title="Copy JSON"
|
||||
>
|
||||
<Copy className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{showRaw ? (
|
||||
<pre className="p-3 text-xs bg-black/20 overflow-x-auto max-h-60">
|
||||
<code>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{entries.slice(0, visibleCount).map(([key, value]) => (
|
||||
<JsonField key={key} fieldName={key} value={value} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-xs text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-left"
|
||||
>
|
||||
{isExpanded
|
||||
? '▲ Show less'
|
||||
: `▼ Show ${entries.length - 3} more fields`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="pr-6">
|
||||
{entries.slice(0, visibleCount).map(([key, value]) => (
|
||||
<JsonField key={key} fieldName={key} value={value} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-2 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors text-left rounded"
|
||||
>
|
||||
{isExpanded
|
||||
? '▲ Show less'
|
||||
: `▼ Show ${entries.length - 1} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface JsonFieldProps {
|
||||
@@ -8,32 +9,70 @@ export interface JsonFieldProps {
|
||||
|
||||
export function JsonField({ fieldName, value }: JsonFieldProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isObject = value !== null && typeof value === 'object';
|
||||
const isNested = isObject && (Array.isArray(value) || Object.keys(value).length > 0);
|
||||
|
||||
// Skip rendering certain fields
|
||||
if (fieldName === 'type' || fieldName === 'timestamp' || fieldName === 'role' || fieldName === 'id' || fieldName === 'content') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const renderPrimitiveValue = (val: unknown): React.ReactNode => {
|
||||
if (val === null) return <span className="text-muted-foreground italic">null</span>;
|
||||
if (typeof val === 'boolean') return <span className="text-purple-400 font-medium">{String(val)}</span>;
|
||||
if (typeof val === 'boolean') return <span className="text-purple-400">{String(val)}</span>;
|
||||
if (typeof val === 'number') return <span className="text-orange-400 font-mono">{String(val)}</span>;
|
||||
if (typeof val === 'string') {
|
||||
// Check if it's a JSON string
|
||||
const trimmed = val.trim();
|
||||
const isLong = trimmed.length > 80;
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
return <span className="text-green-400">"{trimmed.substring(0, 30)}..."</span>;
|
||||
return (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<span>"{trimmed.substring(0, isLong ? 50 : trimmed.length)}{isLong ? '...' : ''}"</span>
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCopy(val as string); }}
|
||||
className="opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
||||
title="Copy full value"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-green-400">"{val}"</span>;
|
||||
return (
|
||||
<span className="text-green-600 flex items-center gap-1 group">
|
||||
<span className={isLong ? 'truncate' : ''}> "{val}"</span>
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCopy(val as string); }}
|
||||
className="opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted shrink-0"
|
||||
title="Copy full value"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 px-3 py-2 hover:bg-black/5 dark:hover:bg-white/5 transition-colors',
|
||||
'text-sm'
|
||||
'flex items-start gap-2 px-2 py-1 group',
|
||||
'text-xs'
|
||||
)}>
|
||||
{/* Field name */}
|
||||
<span className="shrink-0 font-mono text-cyan-400 min-w-[100px]">
|
||||
<span className="shrink-0 font-mono text-cyan-600 dark:text-cyan-400 min-w-[70px] text-xs">
|
||||
{fieldName}
|
||||
</span>
|
||||
|
||||
@@ -46,27 +85,27 @@ export function JsonField({ fieldName, value }: JsonFieldProps) {
|
||||
<details
|
||||
open={isExpanded}
|
||||
onToggle={(e) => setIsExpanded(e.currentTarget.open)}
|
||||
className="group"
|
||||
className="group/summary"
|
||||
>
|
||||
<summary className="cursor-pointer list-none flex items-center gap-1 hover:text-foreground">
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<summary className="cursor-pointer list-none flex items-center gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
{Array.isArray(value) ? (
|
||||
<span className="text-blue-400">Array[{value.length}]</span>
|
||||
<span className="text-blue-500">[{value.length}]</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">Object{'{'}{Object.keys(value).length}{'}'}</span>
|
||||
<span className="text-yellow-500">{'{'}{Object.keys(value).length}{'}'}</span>
|
||||
)}
|
||||
</summary>
|
||||
{isExpanded && (
|
||||
<div className="ml-4 mt-2 space-y-1">
|
||||
<div className="ml-3 mt-1 space-y-0.5">
|
||||
{Array.isArray(value)
|
||||
? value.map((item, i) => (
|
||||
<div key={i} className="pl-2 border-l border-border/30">
|
||||
<div key={i} className="pl-1 border-l border-border/20">
|
||||
{typeof item === 'object' && item !== null ? (
|
||||
<JsonField fieldName={`[${i}]`} value={item} />
|
||||
) : (
|
||||
renderPrimitiveValue(item)
|
||||
<span className="text-xs">{renderPrimitiveValue(item)}</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
@@ -78,7 +117,9 @@ export function JsonField({ fieldName, value }: JsonFieldProps) {
|
||||
)}
|
||||
</details>
|
||||
) : (
|
||||
<div className="break-all">{renderPrimitiveValue(value)}</div>
|
||||
<div className="break-all text-xs">
|
||||
{renderPrimitiveValue(value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,45 +23,23 @@ export interface OutputLineProps {
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Get the icon component for a given output line type
|
||||
* Get the icon component with color for a given output line type
|
||||
*/
|
||||
function getOutputLineIcon(type: OutputLineProps['line']['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return <Brain className="h-3 w-3" />;
|
||||
return <Brain className="h-3 w-3 text-violet-500" />;
|
||||
case 'system':
|
||||
return <Settings className="h-3 w-3" />;
|
||||
return <Settings className="h-3 w-3 text-slate-400" />;
|
||||
case 'stderr':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
return <AlertCircle className="h-3 w-3 text-rose-500" />;
|
||||
case 'metadata':
|
||||
return <Info className="h-3 w-3" />;
|
||||
return <Info className="h-3 w-3 text-slate-400" />;
|
||||
case 'tool_call':
|
||||
return <Wrench className="h-3 w-3" />;
|
||||
return <Wrench className="h-3 w-3 text-indigo-500" />;
|
||||
case 'stdout':
|
||||
default:
|
||||
return <MessageCircle className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSS class name for a given output line type
|
||||
* Reuses the existing implementation from LogBlock utils
|
||||
*/
|
||||
function getOutputLineClass(type: OutputLineProps['line']['type']): string {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return 'text-purple-400';
|
||||
case 'system':
|
||||
return 'text-blue-400';
|
||||
case 'stderr':
|
||||
return 'text-red-400';
|
||||
case 'metadata':
|
||||
return 'text-yellow-400';
|
||||
case 'tool_call':
|
||||
return 'text-green-400';
|
||||
case 'stdout':
|
||||
default:
|
||||
return 'text-foreground';
|
||||
return <MessageCircle className="h-3 w-3 text-teal-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +50,8 @@ function getOutputLineClass(type: OutputLineProps['line']['type']): string {
|
||||
*
|
||||
* Features:
|
||||
* - Auto-detects JSON content and renders with JsonCard
|
||||
* - Shows appropriate icon based on line type
|
||||
* - Applies color styling based on line type
|
||||
* - Shows colored icon based on line type
|
||||
* - Different card styles for different types
|
||||
* - Supports copy functionality
|
||||
*/
|
||||
export function OutputLine({ line, onCopy }: OutputLineProps) {
|
||||
@@ -81,25 +59,22 @@ export function OutputLine({ line, onCopy }: OutputLineProps) {
|
||||
const jsonDetection = useMemo(() => detectJsonInLine(line.content), [line.content]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2 text-xs', getOutputLineClass(line.type))}>
|
||||
{/* Icon indicator */}
|
||||
<span className="text-muted-foreground shrink-0 mt-0.5">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{jsonDetection.isJson && jsonDetection.parsed ? (
|
||||
<JsonCard
|
||||
data={jsonDetection.parsed}
|
||||
type={line.type as 'tool_call' | 'metadata' | 'system' | 'stdout'}
|
||||
timestamp={line.timestamp}
|
||||
onCopy={() => onCopy?.(line.content)}
|
||||
/>
|
||||
) : (
|
||||
<span className="break-all whitespace-pre-wrap">{line.content}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{jsonDetection.isJson && jsonDetection.parsed ? (
|
||||
<JsonCard
|
||||
data={jsonDetection.parsed}
|
||||
type={line.type as 'tool_call' | 'metadata' | 'system' | 'stdout' | 'stderr' | 'thought'}
|
||||
timestamp={undefined}
|
||||
onCopy={() => onCopy?.(line.content)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-1.5 items-start">
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
<span className="break-all whitespace-pre-wrap text-foreground flex-1">{line.content}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
// ========================================
|
||||
|
||||
// Main components
|
||||
export { CliStreamMonitorNew as CliStreamMonitor } from './CliStreamMonitorNew';
|
||||
export type { CliStreamMonitorNewProps as CliStreamMonitorProps } from './CliStreamMonitorNew';
|
||||
export { default as CliStreamMonitor } from '../CliStreamMonitorLegacy';
|
||||
export type { CliStreamMonitorProps } from '../CliStreamMonitorLegacy';
|
||||
|
||||
export { CliStreamMonitorNew } from './CliStreamMonitorNew';
|
||||
export type { CliStreamMonitorNewProps } from './CliStreamMonitorNew';
|
||||
|
||||
export { default as CliStreamMonitorLegacy } from '../CliStreamMonitorLegacy';
|
||||
export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from '../CliStreamMonitorLegacy';
|
||||
|
||||
@@ -19,27 +19,27 @@ function StatusIndicator({ status, duration }: StatusIndicatorProps) {
|
||||
|
||||
if (status === 'thinking') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{formatMessage({ id: 'cliMonitor.thinking' })}
|
||||
<span className="animate-pulse">🟡</span>
|
||||
<span className="animate-pulse">●</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'streaming') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
<span className="flex items-center gap-1 text-[10px] text-blue-600 dark:text-blue-400">
|
||||
{formatMessage({ id: 'cliMonitor.streaming' })}
|
||||
<span className="animate-pulse">🔵</span>
|
||||
<span className="animate-pulse">●</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
||||
Error
|
||||
<span>❌</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-rose-600 dark:text-rose-400">
|
||||
Err
|
||||
<span>●</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ function StatusIndicator({ status, duration }: StatusIndicatorProps) {
|
||||
if (duration !== undefined) {
|
||||
const seconds = (duration / 1000).toFixed(1);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{seconds}s
|
||||
</span>
|
||||
);
|
||||
@@ -110,28 +110,28 @@ export function AssistantMessage({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-purple-50/50 dark:bg-purple-950/30 border-l-4 border-purple-500 rounded-r-lg overflow-hidden transition-all',
|
||||
'bg-violet-50/60 dark:bg-violet-950/40 border-l-2 border-violet-400 dark:border-violet-500 rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors',
|
||||
'flex items-center gap-2 px-2.5 py-1.5 cursor-pointer hover:bg-violet-100/40 dark:hover:bg-violet-900/30 transition-colors',
|
||||
'group'
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Bot className="h-4 w-4 text-purple-600 dark:text-purple-400 shrink-0" />
|
||||
<span className="text-sm font-semibold text-purple-900 dark:text-purple-100">
|
||||
<Bot className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-violet-900 dark:text-violet-100">
|
||||
{modelName}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<StatusIndicator status={status} duration={duration} />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||
'h-3 w-3 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
@@ -141,49 +141,43 @@ export function AssistantMessage({
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="px-3 py-2 bg-purple-50/30 dark:bg-purple-950/20">
|
||||
<div className="bg-white/50 dark:bg-black/20 rounded border border-purple-200/50 dark:border-purple-800/50 p-3">
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||
<div className="px-2.5 py-2 bg-violet-50/40 dark:bg-violet-950/30">
|
||||
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
|
||||
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Footer */}
|
||||
{/* Metadata Footer - simplified */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-1.5 bg-purple-50/30 dark:bg-purple-950/20',
|
||||
'text-xs text-muted-foreground',
|
||||
'justify-between'
|
||||
'flex items-center justify-between px-2.5 py-1 bg-violet-50/40 dark:bg-violet-950/30',
|
||||
'text-[10px] text-muted-foreground group'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{tokenCount !== undefined && (
|
||||
<span>{formatMessage({ id: 'cliMonitor.tokens' }, { count: tokenCount.toLocaleString() })}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{duration !== undefined && (
|
||||
<span>{formatMessage({ id: 'cliMonitor.duration' }, { value: formatDuration(duration) })}</span>
|
||||
<span className="opacity-70">{formatDuration(duration)}</span>
|
||||
)}
|
||||
{tokenCount !== undefined && (
|
||||
<span className="opacity-50 group-hover:opacity-70 transition-opacity">
|
||||
{tokenCount.toLocaleString()} tok
|
||||
</span>
|
||||
)}
|
||||
{modelName && <span>{formatMessage({ id: 'cliMonitor.model' }, { name: modelName })}</span>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-7 px-2 text-xs"
|
||||
className="h-5 px-1.5 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copied' })}
|
||||
</>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copy' })}
|
||||
</>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,46 +25,38 @@ export function ErrorMessage({
|
||||
className
|
||||
}: ErrorMessageProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const timeString = timestamp
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-destructive/10 border-l-4 border-destructive rounded-r-lg overflow-hidden transition-all',
|
||||
'bg-rose-50/60 dark:bg-rose-950/40 border-l-2 border-rose-500 dark:border-rose-400 rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||
{timeString && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
[{timeString}]
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
{/* Header - simplified */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-rose-600 dark:text-rose-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-rose-900 dark:text-rose-100">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 py-2 bg-destructive/5">
|
||||
<p className="text-sm text-destructive-foreground whitespace-pre-wrap break-words">
|
||||
<div className="px-2.5 py-2 bg-rose-50/40 dark:bg-rose-950/30">
|
||||
<p className="text-xs text-rose-900 dark:text-rose-100 whitespace-pre-wrap break-words">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/5">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5 bg-rose-50/40 dark:bg-rose-950/30">
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="h-8 px-3 text-xs border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
className="h-6 px-2 text-[10px] border-rose-500/30 text-rose-700 dark:text-rose-300 hover:bg-rose-500/10"
|
||||
>
|
||||
{formatMessage({ id: 'cliMonitor.retry' })}
|
||||
</Button>
|
||||
@@ -74,7 +66,7 @@ export function ErrorMessage({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{formatMessage({ id: 'cliMonitor.dismiss' })}
|
||||
</Button>
|
||||
|
||||
@@ -23,37 +23,43 @@ export function SystemMessage({
|
||||
}: SystemMessageProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const timeString = timestamp
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/30 dark:bg-muted/20 border-l-2 border-info rounded-r-lg overflow-hidden transition-all',
|
||||
'bg-slate-50/60 dark:bg-slate-950/40 border-l-2 border-slate-400 dark:border-slate-500 rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer hover:bg-slate-100/40 dark:hover:bg-slate-900/30 transition-colors group"
|
||||
onClick={() => content && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 text-info shrink-0" />
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
[{timeString}]
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground truncate flex-1">
|
||||
<Info className="h-3 w-3 text-slate-500 dark:text-slate-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate flex-1">
|
||||
{title}
|
||||
</span>
|
||||
{timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground font-mono opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{timeString}
|
||||
</span>
|
||||
)}
|
||||
{metadata && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[10px] text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{metadata}
|
||||
</span>
|
||||
)}
|
||||
{content && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||
'h-3 w-3 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
@@ -62,7 +68,7 @@ export function SystemMessage({
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && content && (
|
||||
<div className="px-3 py-2 bg-muted/20 border-t border-border/50">
|
||||
<div className="px-2.5 py-2 bg-slate-50/40 dark:bg-slate-950/30 border-t border-slate-200/30 dark:border-slate-800/30">
|
||||
<div className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,6 @@ export function UserMessage({
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeString = timestamp
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
||||
: '';
|
||||
|
||||
// Auto-reset copied state
|
||||
useEffect(() => {
|
||||
@@ -46,51 +43,45 @@ export function UserMessage({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-blue-50/50 dark:bg-blue-950/30 border-l-4 border-blue-500 rounded-r-lg overflow-hidden transition-all',
|
||||
'bg-sky-50/60 dark:bg-sky-950/40 border-l-2 border-sky-500 dark:border-sky-400 rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{/* Header - simplified */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-blue-100/50 dark:hover:bg-blue-900/30 transition-colors',
|
||||
'flex items-center gap-2 px-2.5 py-1.5 cursor-pointer hover:bg-sky-100/40 dark:hover:bg-sky-900/30 transition-colors',
|
||||
'group'
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<span className="text-sm font-semibold text-blue-900 dark:text-blue-100">
|
||||
<User className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-sky-900 dark:text-sky-100">
|
||||
{formatMessage({ id: 'cliMonitor.user' })}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform ml-auto',
|
||||
'h-3 w-3 text-muted-foreground transition-transform ml-auto',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
{timeString && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
[{timeString}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="px-3 py-2 bg-blue-50/30 dark:bg-blue-950/20">
|
||||
<div className="bg-white/50 dark:bg-black/20 rounded border border-blue-200/50 dark:border-blue-800/50 p-3">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-words font-sans">
|
||||
<div className="px-2.5 py-2 bg-sky-50/40 dark:bg-sky-950/30">
|
||||
<div className="bg-white/60 dark:bg-black/30 rounded border border-sky-200/40 dark:border-sky-800/30 p-2.5">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Actions - simplified */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 bg-blue-50/30 dark:bg-blue-950/20',
|
||||
'justify-end'
|
||||
'flex items-center justify-end gap-1.5 px-2.5 py-1 bg-sky-50/40 dark:bg-sky-950/30 group',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -98,18 +89,12 @@ export function UserMessage({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-7 px-2 text-xs"
|
||||
className="h-5 px-1.5 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copied' })}
|
||||
</>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copy' })}
|
||||
</>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</Button>
|
||||
{onViewRaw && (
|
||||
@@ -117,10 +102,10 @@ export function UserMessage({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onViewRaw}
|
||||
className="h-7 px-2 text-xs"
|
||||
className="h-5 px-1.5 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{formatMessage({ id: 'cliMonitor.rawJson' })}
|
||||
<ChevronDown className="h-3 w-3 ml-1" />
|
||||
<ChevronDown className="h-2.5 w-2.5 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,9 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook
|
||||
// New components for Tab + JSON Cards
|
||||
import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab';
|
||||
import { OutputLine } from './CliStreamMonitor/components/OutputLine';
|
||||
import { JsonCard } from './CliStreamMonitor/components/JsonCard';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// ========== Types for CLI WebSocket Messages ==========
|
||||
|
||||
@@ -74,6 +77,73 @@ function formatDuration(ms: number): string {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
// ========== Output Line Card Renderer ==========
|
||||
|
||||
/**
|
||||
* Get border color class for line type
|
||||
*/
|
||||
function getBorderColorForType(type: CliOutputLine['type']): string {
|
||||
const borderColors = {
|
||||
tool_call: 'border-l-indigo-500',
|
||||
metadata: 'border-l-slate-400',
|
||||
system: 'border-l-slate-400',
|
||||
stdout: 'border-l-teal-500',
|
||||
stderr: 'border-l-rose-500',
|
||||
thought: 'border-l-violet-500',
|
||||
};
|
||||
return borderColors[type] || 'border-l-slate-400';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single output line as a card
|
||||
*/
|
||||
interface OutputLineCardProps {
|
||||
line: CliOutputLine;
|
||||
onCopy?: (content: string) => void;
|
||||
}
|
||||
|
||||
function OutputLineCard({ line, onCopy }: OutputLineCardProps) {
|
||||
const borderColor = getBorderColorForType(line.type);
|
||||
const trimmed = line.content.trim();
|
||||
|
||||
// Check if line is JSON with 'content' field
|
||||
let contentToRender = trimmed;
|
||||
let isMarkdown = false;
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if ('content' in parsed && typeof parsed.content === 'string') {
|
||||
contentToRender = parsed.content;
|
||||
// Check if content looks like markdown
|
||||
isMarkdown = !!contentToRender.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, use original content
|
||||
// Check if original content looks like markdown
|
||||
isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background ${borderColor}`}>
|
||||
<div className="pr-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{contentToRender}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
|
||||
{contentToRender}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export interface CliStreamMonitorProps {
|
||||
@@ -411,13 +481,17 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredOutput.map((line, index) => (
|
||||
<OutputLine
|
||||
key={`${line.timestamp}-${index}`}
|
||||
line={line}
|
||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
// Group output lines by type
|
||||
const groupedOutput = groupOutputLines(filteredOutput);
|
||||
return groupedOutput.map((group, groupIndex) => (
|
||||
<OutputGroupRenderer
|
||||
key={`group-${group.type}-${groupIndex}`}
|
||||
group={group}
|
||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
Handle,
|
||||
Position,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
|
||||
import type { FlowControl } from '@/lib/api';
|
||||
|
||||
// Custom node types
|
||||
@@ -27,39 +30,87 @@ interface FlowchartNodeData extends Record<string, unknown> {
|
||||
output?: string;
|
||||
type: 'pre-analysis' | 'implementation' | 'section';
|
||||
dependsOn?: string[];
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
|
||||
}
|
||||
|
||||
// Status icon component
|
||||
const StatusIcon: React.FC<{ status?: string; className?: string }> = ({ status, className = 'h-4 w-4' }) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className={`${className} text-green-500`} />;
|
||||
case 'in_progress':
|
||||
return <Loader2 className={`${className} text-amber-500 animate-spin`} />;
|
||||
case 'blocked':
|
||||
return <Circle className={`${className} text-red-500`} />;
|
||||
case 'skipped':
|
||||
return <Circle className={`${className} text-gray-400`} />;
|
||||
default:
|
||||
return <Circle className={`${className} text-gray-300`} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Custom node component
|
||||
const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => {
|
||||
const isPreAnalysis = data.type === 'pre-analysis';
|
||||
const isSection = data.type === 'section';
|
||||
const isCompleted = data.status === 'completed';
|
||||
const isInProgress = data.status === 'in_progress';
|
||||
|
||||
if (isSection) {
|
||||
return (
|
||||
<div className="px-4 py-2 bg-muted rounded border-2 border-border">
|
||||
<div className="px-4 py-2 bg-muted rounded border-2 border-border relative">
|
||||
<Handle type="target" position={Position.Top} className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<span className="text-sm font-semibold text-foreground">{data.label}</span>
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Color scheme based on status
|
||||
let nodeColor = isPreAnalysis ? '#f59e0b' : '#3b82f6';
|
||||
let bgClass = isPreAnalysis
|
||||
? 'bg-amber-50 border-amber-500 dark:bg-amber-950/30'
|
||||
: 'bg-blue-50 border-blue-500 dark:bg-blue-950/30';
|
||||
let stepBgClass = isPreAnalysis ? 'bg-amber-500 text-white' : 'bg-blue-500 text-white';
|
||||
|
||||
// Override for completed status
|
||||
if (isCompleted) {
|
||||
nodeColor = '#22c55e'; // green-500
|
||||
bgClass = 'bg-green-50 border-green-500 dark:bg-green-950/30';
|
||||
stepBgClass = 'bg-green-500 text-white';
|
||||
} else if (isInProgress) {
|
||||
nodeColor = '#f59e0b'; // amber-500
|
||||
bgClass = 'bg-amber-50 border-amber-500 dark:bg-amber-950/30';
|
||||
stepBgClass = 'bg-amber-500 text-white';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 rounded-lg border-2 shadow-sm min-w-[280px] max-w-[400px] ${
|
||||
isPreAnalysis
|
||||
? 'bg-amber-50 border-amber-500 dark:bg-amber-950/30'
|
||||
: 'bg-blue-50 border-blue-500 dark:bg-blue-950/30'
|
||||
}`}
|
||||
className={`px-4 py-3 rounded-lg border-2 shadow-sm min-w-[280px] max-w-[400px] relative ${bgClass}`}
|
||||
>
|
||||
{/* Top handle for incoming edges */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !-top-1.5"
|
||||
style={{ background: nodeColor, border: `2px solid ${nodeColor}` }}
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
isPreAnalysis ? 'bg-amber-500 text-white' : 'bg-blue-500 text-white'
|
||||
}`}
|
||||
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${stepBgClass}`}
|
||||
>
|
||||
{data.step}
|
||||
{isCompleted ? <CheckCircle className="h-4 w-4" /> : data.step}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground">{data.label}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-semibold ${isCompleted ? 'text-green-700 dark:text-green-400' : 'text-foreground'}`}>
|
||||
{data.label}
|
||||
</span>
|
||||
{data.status && data.status !== 'pending' && (
|
||||
<StatusIcon status={data.status} className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
{data.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">{data.description}</div>
|
||||
)}
|
||||
@@ -70,6 +121,14 @@ const CustomNode: React.FC<{ data: FlowchartNodeData }> = ({ data }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom handle for outgoing edges */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-3 !h-3 !-bottom-1.5"
|
||||
style={{ background: nodeColor, border: `2px solid ${nodeColor}` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -136,6 +195,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
target: nodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { stroke: '#f59e0b', strokeWidth: 2 },
|
||||
markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' },
|
||||
});
|
||||
} else {
|
||||
initialEdges.push({
|
||||
@@ -144,6 +205,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
target: nodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { stroke: '#f59e0b', strokeWidth: 2 },
|
||||
markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,7 +238,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
target: implSectionId,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: 'hsl(var(--primary))' },
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,11 +250,52 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
|
||||
// Handle both string and ImplementationStep types
|
||||
const isString = typeof step === 'string';
|
||||
const label = isString ? step : (step.title || `Step ${step.step}`);
|
||||
const description = isString ? undefined : step.description;
|
||||
const stepNumber = isString ? (idx + 1) : step.step;
|
||||
|
||||
// Extract just the number from strings like "Step 1", "step1", etc.
|
||||
const rawStep = isString ? (idx + 1) : (step.step || idx + 1);
|
||||
const stepNumber = typeof rawStep === 'string'
|
||||
? (rawStep.match(/\d+/)?.[0] || idx + 1)
|
||||
: rawStep;
|
||||
|
||||
// Try multiple fields for label (matching JS version priority)
|
||||
// Check for content in various possible field names
|
||||
let label: string;
|
||||
let description: string | undefined;
|
||||
|
||||
if (isString) {
|
||||
label = step;
|
||||
} else {
|
||||
// Try title first (JS version uses this), then action, description, phase, or any string value
|
||||
label = step.title || step.action || step.phase || step.description || '';
|
||||
|
||||
// If still empty, try to extract any non-empty string from the step object
|
||||
if (!label) {
|
||||
const stepKeys = Object.keys(step).filter(k =>
|
||||
k !== 'step' && k !== 'depends_on' && k !== 'modification_points' && k !== 'logic_flow'
|
||||
);
|
||||
for (const key of stepKeys) {
|
||||
const val = step[key as keyof typeof step];
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
label = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
if (!label) {
|
||||
label = `Step ${stepNumber}`;
|
||||
}
|
||||
|
||||
// Set description if different from label
|
||||
description = step.description && step.description !== label ? step.description : undefined;
|
||||
}
|
||||
|
||||
const dependsOn = isString ? undefined : step.depends_on?.map((d: number | string) => `impl-${Number(d) - 1}`);
|
||||
|
||||
// Extract status from step (may be in 'status' field or other locations)
|
||||
const stepStatus = isString ? undefined : (step.status as string | undefined);
|
||||
|
||||
initialNodes.push({
|
||||
id: nodeId,
|
||||
type: 'custom',
|
||||
@@ -201,6 +306,7 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
step: stepNumber,
|
||||
type: 'implementation' as const,
|
||||
dependsOn,
|
||||
status: stepStatus,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,6 +318,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
target: nodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' },
|
||||
});
|
||||
} else {
|
||||
// Sequential edge with styled connection
|
||||
@@ -221,7 +329,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
target: nodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { stroke: 'hsl(var(--primary))', strokeWidth: 2 },
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
markerEnd: { type: 'arrowclosed' as const, color: '#3b82f6' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,7 +344,8 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
target: nodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { strokeDasharray: '5,5', stroke: 'hsl(var(--warning))' },
|
||||
style: { strokeDasharray: '5,5', stroke: '#f59e0b', strokeWidth: 2 },
|
||||
markerEnd: { type: 'arrowclosed' as const, color: '#f59e0b' },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -300,6 +410,10 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as FlowchartNodeData;
|
||||
if (data.type === 'section') return '#9ca3af';
|
||||
// Status-based colors
|
||||
if (data.status === 'completed') return '#22c55e'; // green-500
|
||||
if (data.status === 'in_progress') return '#f59e0b'; // amber-500
|
||||
if (data.status === 'blocked') return '#ef4444'; // red-500
|
||||
if (data.type === 'pre-analysis') return '#f59e0b';
|
||||
return '#3b82f6';
|
||||
}}
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
Eye,
|
||||
Archive,
|
||||
Trash2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import type { SessionMetadata } from '@/types/store';
|
||||
|
||||
@@ -175,17 +178,12 @@ export function SessionCard({
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Header - Session ID as title */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-card-foreground truncate">
|
||||
{session.title || session.session_id}
|
||||
<h3 className="font-bold text-card-foreground text-sm tracking-wide uppercase truncate">
|
||||
{session.session_id}
|
||||
</h3>
|
||||
{session.title && session.title !== session.session_id && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{session.session_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
@@ -231,8 +229,15 @@ export function SessionCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{/* Title as description */}
|
||||
{session.title && (
|
||||
<p className="text-sm text-foreground line-clamp-2 mb-3">
|
||||
{session.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta info - enriched */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{formatDate(session.created_at)}
|
||||
@@ -241,6 +246,18 @@ export function SessionCard({
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{progress.total} {formatMessage({ id: 'sessions.card.tasks' })}
|
||||
</span>
|
||||
{progress.total > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||
{progress.completed} {formatMessage({ id: 'sessions.card.completed' })}
|
||||
</span>
|
||||
)}
|
||||
{session.updated_at && session.updated_at !== session.created_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'sessions.card.updated' })}: {formatDate(session.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar (only show if not planning and has tasks) */}
|
||||
@@ -254,16 +271,19 @@ export function SessionCard({
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
className={cn(
|
||||
"h-full transition-all duration-300",
|
||||
progress.percentage === 100 ? "bg-success" : "bg-primary"
|
||||
)}
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{session.description && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{/* Description (if exists and different from title) */}
|
||||
{session.description && session.description !== session.title && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2 italic">
|
||||
{session.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">{taskId}</span>
|
||||
<Badge variant={statusConfig.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: statusConfig.label })}
|
||||
@@ -188,13 +188,14 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
|
||||
{/* Tab Content (scrollable) */}
|
||||
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
|
||||
{/* Overview Tab */}
|
||||
{/* Overview Tab - Rich display matching JS version */}
|
||||
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="space-y-6">
|
||||
{/* Description */}
|
||||
<div className="space-y-4">
|
||||
{/* Description Section */}
|
||||
{taskDescription && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<span>📝</span>
|
||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.description' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
@@ -203,30 +204,94 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope Section */}
|
||||
{(task as LiteTask).meta?.scope && (
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<span>📁</span>
|
||||
Scope
|
||||
</h3>
|
||||
<div className="pl-3 border-l-2 border-primary">
|
||||
<code className="text-sm text-foreground">{(task as LiteTask).meta?.scope}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acceptance Criteria Section */}
|
||||
{(task as LiteTask).context?.acceptance && (task as LiteTask).context!.acceptance!.length > 0 && (
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<span>✅</span>
|
||||
{formatMessage({ id: 'liteTasks.acceptanceCriteria' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{(task as LiteTask).context!.acceptance!.map((criterion, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5">○</span>
|
||||
<span className="text-sm text-foreground">{criterion}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus Paths / Reference Section */}
|
||||
{(task as LiteTask).context?.focus_paths && (task as LiteTask).context!.focus_paths!.length > 0 && (
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<span>📚</span>
|
||||
{formatMessage({ id: 'liteTasks.focusPaths' })}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{(task as LiteTask).context!.focus_paths!.map((path, i) => (
|
||||
<code key={i} className="block text-xs bg-muted px-3 py-1.5 rounded text-foreground font-mono">
|
||||
{path}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies Section */}
|
||||
{(task as LiteTask).context?.depends_on && (task as LiteTask).context!.depends_on!.length > 0 && (
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<span>🔗</span>
|
||||
{formatMessage({ id: 'liteTasks.dependsOn' })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(task as LiteTask).context!.depends_on!.map((dep, i) => (
|
||||
<Badge key={i} variant="secondary">{dep}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pre-analysis Steps */}
|
||||
{flowControl?.pre_analysis && flowControl.pre_analysis.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<span>🔍</span>
|
||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.preAnalysis' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{flowControl.pre_analysis.map((step, index) => (
|
||||
<div key={index} className="p-3 bg-card rounded-md border border-border shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{step.step}</p>
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{step.step || step.action}</p>
|
||||
{step.action && step.action !== step.step && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{step.action}</p>
|
||||
{step.commands && step.commands.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded border">
|
||||
{step.commands.join('; ')}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{step.commands && step.commands.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{step.commands.map((cmd, i) => (
|
||||
<code key={i} className="text-xs bg-muted px-2 py-0.5 rounded">{cmd}</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -236,41 +301,78 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
|
||||
{/* Implementation Steps */}
|
||||
{flowControl?.implementation_approach && flowControl.implementation_approach.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<span>📋</span>
|
||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<ol className="space-y-3">
|
||||
{flowControl.implementation_approach.map((step, index) => {
|
||||
const isString = typeof step === 'string';
|
||||
const title = isString ? step : (step.title || `Step ${step.step || index + 1}`);
|
||||
const description = isString ? undefined : step.description;
|
||||
const stepNumber = isString ? (index + 1) : (step.step || index + 1);
|
||||
// Extract just the number from strings like "Step 1", "step1", etc.
|
||||
const rawStep = isString ? (index + 1) : (step.step || index + 1);
|
||||
const stepNumber = typeof rawStep === 'string'
|
||||
? (rawStep.match(/\d+/)?.[0] || index + 1)
|
||||
: rawStep;
|
||||
|
||||
// Try multiple fields for title (matching JS version)
|
||||
let stepTitle: string;
|
||||
let stepDesc: string | undefined;
|
||||
|
||||
if (isString) {
|
||||
stepTitle = step;
|
||||
} else {
|
||||
// Try title first, then action, phase, description
|
||||
stepTitle = step.title || step.action || step.phase || '';
|
||||
|
||||
// If empty, try any string value from the object
|
||||
if (!stepTitle) {
|
||||
const stepKeys = Object.keys(step).filter(k =>
|
||||
k !== 'step' && k !== 'depends_on' && k !== 'modification_points' && k !== 'logic_flow'
|
||||
);
|
||||
for (const key of stepKeys) {
|
||||
const val = step[key as keyof typeof step];
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
stepTitle = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
if (!stepTitle) {
|
||||
stepTitle = `Step ${stepNumber}`;
|
||||
}
|
||||
|
||||
// Description if different from title
|
||||
stepDesc = step.description && step.description !== stepTitle ? step.description : undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="p-3 bg-card rounded-md border border-border shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||
{stepNumber}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||
{stepNumber}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{stepTitle}</p>
|
||||
{stepDesc && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{stepDesc}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!taskDescription &&
|
||||
(!flowControl?.pre_analysis || flowControl.pre_analysis.length === 0) &&
|
||||
(!flowControl?.implementation_approach || flowControl.implementation_approach.length === 0) && (
|
||||
!(task as LiteTask).meta?.scope &&
|
||||
!((task as LiteTask).context?.acceptance?.length) &&
|
||||
!((task as LiteTask).context?.focus_paths?.length) &&
|
||||
!(flowControl?.pre_analysis?.length) &&
|
||||
!(flowControl?.implementation_approach?.length) && (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -33,10 +33,13 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -95,3 +95,18 @@ export {
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "./Collapsible";
|
||||
|
||||
// AlertDialog (Radix)
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from "./AlertDialog";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// ========================================
|
||||
// Workspace Selector Component
|
||||
// ========================================
|
||||
// Dropdown for selecting recent workspaces with manual path input dialog
|
||||
// Dropdown for selecting recent workspaces with folder browser and manual path input
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { ChevronDown, X, FolderOpen, Check } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -89,6 +89,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
const [isBrowseOpen, setIsBrowseOpen] = useState(false);
|
||||
const [manualPath, setManualPath] = useState('');
|
||||
|
||||
// Hidden file input for folder selection
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/**
|
||||
* Handle path selection from dropdown
|
||||
*/
|
||||
@@ -112,33 +115,40 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle open browse dialog - tries file dialog first, falls back to manual input
|
||||
* Handle open folder browser - trigger hidden file input click
|
||||
*/
|
||||
const handleBrowseFolder = useCallback(async () => {
|
||||
const handleBrowseFolder = useCallback(() => {
|
||||
setIsDropdownOpen(false);
|
||||
|
||||
// Try to use Electron/Electron-Tauri file dialog API if available
|
||||
if ((window as any).electronAPI?.showOpenDialog) {
|
||||
try {
|
||||
const result = await (window as any).electronAPI.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
|
||||
if (result && result.filePaths && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
await switchWorkspace(selectedPath);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder dialog:', error);
|
||||
// Fall through to manual input dialog
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: open manual path input dialog
|
||||
setIsBrowseOpen(true);
|
||||
// Trigger the hidden file input click
|
||||
folderInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle folder selection from file input
|
||||
*/
|
||||
const handleFolderSelect = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
// Get the path from the first file
|
||||
const firstFile = files[0];
|
||||
// The webkitRelativePath contains the full path relative to the selected folder
|
||||
// We need to get the parent directory path
|
||||
const relativePath = firstFile.webkitRelativePath;
|
||||
const folderPath = relativePath.substring(0, relativePath.indexOf('/'));
|
||||
|
||||
// In browser environment, we can't get the full absolute path
|
||||
// We need to ask the user to confirm or use the folder name
|
||||
// For now, open the manual dialog with the folder name as hint
|
||||
setManualPath(folderPath);
|
||||
setIsBrowseOpen(true);
|
||||
}
|
||||
// Reset input value to allow selecting the same folder again
|
||||
e.target.value = '';
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle manual path submission
|
||||
*/
|
||||
@@ -214,18 +224,23 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
key={path}
|
||||
onClick={() => handleSelectPath(path)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 cursor-pointer group',
|
||||
isCurrent && 'bg-accent'
|
||||
'flex items-center gap-2 cursor-pointer group/path-item pr-8',
|
||||
isCurrent && 'bg-accent/50'
|
||||
)}
|
||||
title={path}
|
||||
>
|
||||
<span className="flex-1 truncate">{truncatedItemPath}</span>
|
||||
<span className={cn(
|
||||
'flex-1 truncate',
|
||||
isCurrent && 'font-medium'
|
||||
)}>
|
||||
{truncatedItemPath}
|
||||
</span>
|
||||
|
||||
{/* Delete button for non-current paths */}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={(e) => handleRemovePath(e, path)}
|
||||
className="opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground rounded p-0.5 transition-opacity"
|
||||
className="absolute right-2 opacity-0 group-hover/path-item:opacity-100 hover:bg-destructive/10 hover:text-destructive rounded p-0.5 transition-all"
|
||||
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||
title={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||
>
|
||||
@@ -233,10 +248,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Check icon for current workspace */}
|
||||
{isCurrent && (
|
||||
<span className="text-xs text-primary">
|
||||
{formatMessage({ id: 'workspace.selector.current' })}
|
||||
</span>
|
||||
<Check className="h-4 w-4 text-emerald-500 absolute right-2" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -245,16 +259,49 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
|
||||
{recentPaths.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Browse button to open manual path dialog */}
|
||||
{/* Browse button to open folder selector */}
|
||||
<DropdownMenuItem
|
||||
onClick={handleBrowseFolder}
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
{formatMessage({ id: 'workspace.selector.browse' })}
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{formatMessage({ id: 'workspace.selector.browse' })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'workspace.selector.browseHint' })}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Manual path input option */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDropdownOpen(false);
|
||||
setIsBrowseOpen(true);
|
||||
}}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{formatMessage({ id: 'workspace.selector.manualPath' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Hidden file input for folder selection */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFolderSelect}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{/* Manual path input dialog */}
|
||||
<Dialog open={isBrowseOpen} onOpenChange={setIsBrowseOpen}>
|
||||
<DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user