feat: add CLI Viewer Page with multi-pane layout and state management

- Implemented the CliViewerPage component for displaying CLI outputs in a configurable multi-pane layout.
- Integrated Zustand for state management, allowing for dynamic layout changes and tab management.
- Added layout options: single, split horizontal, split vertical, and 2x2 grid.
- Created viewerStore for managing layout, panes, and tabs, including actions for adding/removing panes and tabs.
- Added CoordinatorPage barrel export for easier imports.
This commit is contained in:
catlog22
2026-02-03 17:28:26 +08:00
parent b63e254f36
commit 37ba849e75
101 changed files with 10422 additions and 1145 deletions

View File

@@ -25,6 +25,7 @@
"@radix-ui/react-tooltip": "^1.1.0",
"@tanstack/react-query": "^5.60.0",
"@xyflow/react": "^12.10.0",
"allotment": "^1.20.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"highlight.js": "^11.11.1",
@@ -3433,6 +3434,30 @@
"node": ">= 14"
}
},
"node_modules/allotment": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.5.tgz",
"integrity": "sha512-7i4NT7ieXEyAd5lBrXmE7WHz/e7hRuo97+j+TwrPE85ha6kyFURoc76nom0dWSZ1pTKVEAMJy/+f3/Isfu/41A==",
"license": "MIT",
"dependencies": {
"classnames": "^2.3.0",
"eventemitter3": "^5.0.0",
"fast-deep-equal": "^3.1.3",
"lodash.clamp": "^4.0.0",
"lodash.debounce": "^4.0.0",
"usehooks-ts": "^3.1.1"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/allotment/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -3947,6 +3972,12 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -4835,6 +4866,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
@@ -6042,6 +6079,18 @@
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.clamp": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz",
"integrity": "sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -9882,6 +9931,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -36,6 +36,7 @@
"@radix-ui/react-tooltip": "^1.1.0",
"@tanstack/react-query": "^5.60.0",
"@xyflow/react": "^12.10.0",
"allotment": "^1.20.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"highlight.js": "^11.11.1",

View File

@@ -0,0 +1,198 @@
// ========================================
// ContentArea Component
// ========================================
// Displays CLI output for the active tab in a pane
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Terminal, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useViewerStore,
selectActiveTab,
type PaneId,
} from '@/stores/viewerStore';
import { useCliStreamStore, type CliExecutionState, type CliOutputLine } from '@/stores/cliStreamStore';
import { MonitorBody } from '@/components/shared/CliStreamMonitor/MonitorBody';
import { MessageRenderer } from '@/components/shared/CliStreamMonitor/MessageRenderer';
// ========== Types ==========
export interface ContentAreaProps {
paneId: PaneId;
className?: string;
}
// ========== Helper Components ==========
/**
* Empty state when no tab is active
*/
function EmptyTabState() {
const { formatMessage } = useIntl();
return (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-4">
<Terminal className="h-12 w-12 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">
{formatMessage({ id: 'cliViewer.noActiveTab', defaultMessage: 'No active tab' })}
</p>
<p className="text-xs mt-1">
{formatMessage({
id: 'cliViewer.selectOrCreate',
defaultMessage: 'Select a tab or start a new CLI execution',
})}
</p>
</div>
</div>
);
}
/**
* Execution not found state
*/
function ExecutionNotFoundState({ executionId }: { executionId: string }) {
const { formatMessage } = useIntl();
return (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-4">
<Terminal className="h-12 w-12 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">
{formatMessage({ id: 'cliViewer.executionNotFound', defaultMessage: 'Execution not found' })}
</p>
<p className="text-xs mt-1 font-mono opacity-50">{executionId}</p>
</div>
</div>
);
}
/**
* Single output line component with type-based styling
*/
function OutputLineItem({ line }: { line: CliOutputLine }) {
// Type-based styling
const typeStyles: Record<CliOutputLine['type'], string> = {
stdout: 'text-foreground',
stderr: 'text-rose-600 dark:text-rose-400 bg-rose-500/5',
thought: 'text-blue-600 dark:text-blue-400 italic bg-blue-500/5',
system: 'text-amber-600 dark:text-amber-400 bg-amber-500/5',
metadata: 'text-muted-foreground text-xs',
tool_call: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/5 font-mono',
};
return (
<div
className={cn(
'px-3 py-1 text-sm',
'border-l-2 border-transparent',
typeStyles[line.type] || 'text-foreground',
line.type === 'stderr' && 'border-l-rose-500',
line.type === 'thought' && 'border-l-blue-500',
line.type === 'system' && 'border-l-amber-500',
line.type === 'tool_call' && 'border-l-emerald-500'
)}
>
{line.type === 'thought' || line.type === 'tool_call' ? (
<MessageRenderer content={line.content} format="markdown" />
) : (
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
{line.content}
</pre>
)}
</div>
);
}
/**
* CLI output display component
*/
function CliOutputDisplay({ execution, executionId }: { execution: CliExecutionState; executionId: string }) {
const { formatMessage } = useIntl();
if (!execution.output || execution.output.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-4">
<Terminal className="h-12 w-12 opacity-30" />
<div className="text-center">
<p className="text-sm">
{execution.status === 'running'
? formatMessage({ id: 'cliViewer.waitingForOutput', defaultMessage: 'Waiting for output...' })
: formatMessage({ id: 'cliViewer.noOutput', defaultMessage: 'No output' })}
</p>
{execution.status === 'running' && (
<Loader2 className="h-4 w-4 animate-spin mt-2 mx-auto opacity-50" />
)}
</div>
</div>
);
}
return (
<MonitorBody autoScroll={execution.status === 'running'} showScrollButton>
<div className="py-2">
{execution.output.map((line, index) => (
<OutputLineItem
key={`${executionId}-line-${index}`}
line={line}
/>
))}
</div>
</MonitorBody>
);
}
// ========== Main Component ==========
/**
* ContentArea - Displays CLI output for active tab
*
* Features:
* - Integration with CliStreamStore for execution data
* - Auto-scroll during active execution
* - Empty state handling
* - Message rendering with proper formatting
*/
export function ContentArea({ paneId, className }: ContentAreaProps) {
// Get active tab using the selector
const activeTab = useViewerStore((state) => selectActiveTab(state, paneId));
// Get execution data from cliStreamStore
const executions = useCliStreamStore((state) => state.executions);
const execution = useMemo(() => {
if (!activeTab?.executionId) return null;
return executions[activeTab.executionId] || null;
}, [activeTab?.executionId, executions]);
// Determine what to render
const content = useMemo(() => {
// No active tab
if (!activeTab) {
return <EmptyTabState />;
}
// No execution data found
if (!execution) {
return <ExecutionNotFoundState executionId={activeTab.executionId} />;
}
// Show CLI output
return <CliOutputDisplay execution={execution} executionId={activeTab.executionId} />;
}, [activeTab, execution]);
return (
<div
className={cn(
'flex-1 overflow-hidden',
'bg-background',
className
)}
>
{content}
</div>
);
}
export default ContentArea;

View File

@@ -0,0 +1,89 @@
// ========================================
// EmptyState Component
// ========================================
// Empty state display for CLI viewer
import { useIntl } from 'react-intl';
import { Terminal, Play, Keyboard } from 'lucide-react';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface EmptyStateProps {
className?: string;
}
// ========== Component ==========
/**
* EmptyState - Displays when no CLI executions are active
*
* Features:
* - Informative empty state message
* - Quick start hints
* - Dark theme compatible
*/
export function EmptyState({ className }: EmptyStateProps) {
const { formatMessage } = useIntl();
return (
<div
className={cn(
'h-full flex flex-col items-center justify-center',
'bg-card dark:bg-surface-900',
'text-muted-foreground',
className
)}
>
<div className="flex flex-col items-center gap-6 max-w-md text-center p-8">
{/* Icon */}
<div className="relative">
<Terminal className="h-16 w-16 opacity-20" />
<div className="absolute -bottom-1 -right-1 bg-primary/10 rounded-full p-1.5">
<Play className="h-4 w-4 text-primary" />
</div>
</div>
{/* Title */}
<div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({
id: 'cliViewer.emptyState.title',
defaultMessage: 'CLI Viewer',
})}
</h3>
<p className="text-sm">
{formatMessage({
id: 'cliViewer.emptyState.description',
defaultMessage: 'Start a CLI execution to see the output here.',
})}
</p>
</div>
{/* Hints */}
<div className="flex flex-col gap-3 text-xs">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 shrink-0" />
<span>
{formatMessage({
id: 'cliViewer.emptyState.hint1',
defaultMessage: 'Use "ccw cli" command to start an execution',
})}
</span>
</div>
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 shrink-0" />
<span>
{formatMessage({
id: 'cliViewer.emptyState.hint2',
defaultMessage: 'Active executions will appear as tabs',
})}
</span>
</div>
</div>
</div>
</div>
);
}
export default EmptyState;

View File

@@ -0,0 +1,307 @@
// ========================================
// ExecutionPicker Component
// ========================================
// Dialog for selecting CLI executions to open as tabs
import { useState, useMemo, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { Plus, Search, Terminal, Clock, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/Dialog';
import {
useCliStreamStore,
type CliExecutionState,
type CliExecutionStatus,
} from '@/stores/cliStreamStore';
import { useViewerStore, type PaneId } from '@/stores/viewerStore';
// ========== Types ==========
export interface ExecutionPickerProps {
paneId: PaneId;
className?: string;
}
// ========== Constants ==========
const STATUS_CONFIG: Record<CliExecutionStatus, { icon: typeof CheckCircle2; color: string; label: string }> = {
running: {
icon: Loader2,
color: 'text-indigo-500',
label: 'Running',
},
completed: {
icon: CheckCircle2,
color: 'text-emerald-500',
label: 'Completed',
},
error: {
icon: XCircle,
color: 'text-rose-500',
label: 'Error',
},
};
// ========== Helper Functions ==========
/**
* Format timestamp to relative or absolute time
*/
function formatTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) {
return 'Just now';
} else if (diff < 3600000) {
const minutes = Math.floor(diff / 60000);
return `${minutes}m ago`;
} else if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
} else {
return new Date(timestamp).toLocaleDateString();
}
}
/**
* Get execution display title
*/
function getExecutionTitle(_executionId: string, execution: CliExecutionState): string {
return `${execution.tool}-${execution.mode}`;
}
// ========== Sub-Components ==========
interface ExecutionItemProps {
executionId: string;
execution: CliExecutionState;
onSelect: () => void;
}
/**
* Single execution item in the picker list
*/
function ExecutionItem({ executionId, execution, onSelect }: ExecutionItemProps) {
const statusConfig = STATUS_CONFIG[execution.status];
const StatusIcon = statusConfig.icon;
return (
<button
onClick={onSelect}
className={cn(
'w-full flex items-center gap-3 p-3 rounded-lg',
'border border-border/50 bg-muted/30',
'hover:bg-muted/50 hover:border-border',
'transition-all duration-150',
'text-left'
)}
>
{/* Tool icon */}
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-primary/10">
<Terminal className="h-4 w-4 text-primary" />
</div>
{/* Execution info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground truncate">
{getExecutionTitle(executionId, execution)}
</span>
<StatusIcon
className={cn(
'h-3.5 w-3.5 shrink-0',
statusConfig.color,
execution.status === 'running' && 'animate-spin'
)}
/>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground truncate">
{executionId}
</span>
</div>
</div>
{/* Time */}
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="h-3 w-3" />
<span>{formatTime(execution.startTime)}</span>
</div>
</button>
);
}
// ========== Main Component ==========
/**
* ExecutionPicker - Dialog for selecting CLI executions to open as tabs
*
* Features:
* - Lists all available CLI executions from store
* - Search/filter by tool name or execution ID
* - Shows execution status, tool, and timestamp
* - Click to add as new tab in the specified pane
*/
export function ExecutionPicker({ paneId, className }: ExecutionPickerProps) {
const { formatMessage } = useIntl();
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Store hooks
const executions = useCliStreamStore((state) => state.executions);
const addTab = useViewerStore((state) => state.addTab);
const panes = useViewerStore((state) => state.panes);
// Get current pane's existing execution IDs
const existingExecutionIds = useMemo(() => {
const pane = panes[paneId];
if (!pane) return new Set<string>();
return new Set(pane.tabs.map((tab) => tab.executionId));
}, [panes, paneId]);
// Filter and sort executions
const filteredExecutions = useMemo(() => {
const entries = Object.entries(executions);
// Filter by search query
const filtered = entries.filter(([id, exec]) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
id.toLowerCase().includes(query) ||
exec.tool.toLowerCase().includes(query) ||
exec.mode.toLowerCase().includes(query)
);
});
// Sort by start time (newest first)
filtered.sort((a, b) => b[1].startTime - a[1].startTime);
return filtered;
}, [executions, searchQuery]);
// Handle execution selection
const handleSelect = useCallback((executionId: string, execution: CliExecutionState) => {
const title = getExecutionTitle(executionId, execution);
addTab(paneId, executionId, title);
setOpen(false);
setSearchQuery('');
}, [paneId, addTab]);
// Count available vs total
const totalCount = Object.keys(executions).length;
const availableCount = filteredExecutions.filter(
([id]) => !existingExecutionIds.has(id)
).length;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6 shrink-0', className)}
aria-label={formatMessage({
id: 'cliViewer.tabs.addTab',
defaultMessage: 'Add tab'
})}
>
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{formatMessage({
id: 'cliViewer.picker.selectExecution',
defaultMessage: 'Select Execution'
})}
</DialogTitle>
</DialogHeader>
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({
id: 'cliViewer.picker.searchExecutions',
defaultMessage: 'Search executions...'
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Execution list */}
<div className="max-h-[300px] overflow-y-auto space-y-2">
{filteredExecutions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Terminal className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{totalCount === 0
? formatMessage({
id: 'cliViewer.picker.noExecutions',
defaultMessage: 'No executions available'
})
: formatMessage({
id: 'cliViewer.picker.noMatchingExecutions',
defaultMessage: 'No matching executions'
})
}
</p>
</div>
) : (
filteredExecutions.map(([id, exec]) => {
const isAlreadyOpen = existingExecutionIds.has(id);
return (
<div key={id} className="relative">
<ExecutionItem
executionId={id}
execution={exec}
onSelect={() => handleSelect(id, exec)}
/>
{isAlreadyOpen && (
<div className="absolute inset-0 bg-background/60 rounded-lg flex items-center justify-center">
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
{formatMessage({
id: 'cliViewer.picker.alreadyOpen',
defaultMessage: 'Already open'
})}
</span>
</div>
)}
</div>
);
})
)}
</div>
{/* Footer with count */}
{totalCount > 0 && (
<div className="text-xs text-muted-foreground text-center pt-2 border-t border-border/50">
{formatMessage(
{
id: 'cliViewer.picker.executionCount',
defaultMessage: '{available} of {total} executions available'
},
{ available: availableCount, total: totalCount }
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
export default ExecutionPicker;

View File

@@ -0,0 +1,157 @@
// ========================================
// LayoutContainer Component
// ========================================
// Manages allotment-based split panes for CLI viewer
import { useCallback, useMemo } from 'react';
import { Allotment } from 'allotment';
import 'allotment/dist/style.css';
import { cn } from '@/lib/utils';
import {
useViewerStore,
useViewerLayout,
useViewerPanes,
type AllotmentLayoutGroup,
type PaneId,
} from '@/stores/viewerStore';
import { PaneContent } from './PaneContent';
import { EmptyState } from './EmptyState';
// ========== Types ==========
interface LayoutGroupRendererProps {
group: AllotmentLayoutGroup;
minSize: number;
onSizeChange: (sizes: number[]) => void;
}
interface LayoutContainerProps {
className?: string;
}
// ========== Helper Functions ==========
/**
* Check if a layout child is a pane ID (string) or a nested group
*/
function isPaneId(child: PaneId | AllotmentLayoutGroup): child is PaneId {
return typeof child === 'string';
}
// ========== Helper Components ==========
/**
* Renders a layout group with Allotment
*/
function LayoutGroupRenderer({ group, minSize, onSizeChange }: LayoutGroupRendererProps) {
const panes = useViewerPanes();
const handleChange = useCallback(
(sizes: number[]) => {
onSizeChange(sizes);
},
[onSizeChange]
);
// Check if all panes in this group exist
const validChildren = useMemo(() => {
return group.children.filter(child => {
if (isPaneId(child)) {
return panes[child] !== undefined;
}
return true; // Groups are always valid (they will recursively filter)
});
}, [group.children, panes]);
if (validChildren.length === 0) {
return <EmptyState />;
}
return (
<Allotment
vertical={group.direction === 'vertical'}
defaultSizes={group.sizes}
onChange={handleChange}
className="h-full"
>
{validChildren.map((child, index) => (
<Allotment.Pane key={isPaneId(child) ? child : `group-${index}`} minSize={minSize}>
{isPaneId(child) ? (
<PaneContent paneId={child} />
) : (
<LayoutGroupRenderer
group={child}
minSize={minSize}
onSizeChange={onSizeChange}
/>
)}
</Allotment.Pane>
))}
</Allotment>
);
}
// ========== Main Component ==========
/**
* LayoutContainer - Main container for CLI viewer with split panes
*
* Features:
* - Recursive rendering of nested allotment layouts
* - Support for horizontal and vertical splits
* - Minimum pane size enforcement
* - Empty state handling
*/
export function LayoutContainer({ className }: LayoutContainerProps) {
const layout = useViewerLayout();
const panes = useViewerPanes();
const setLayout = useViewerStore((state) => state.setLayout);
const handleSizeChange = useCallback(
(sizes: number[]) => {
// Update the root layout with new sizes
setLayout({ ...layout, sizes });
},
[layout, setLayout]
);
// Render based on layout type
const content = useMemo(() => {
// No children - show empty state
if (!layout.children || layout.children.length === 0) {
return <EmptyState />;
}
// Single pane layout
if (layout.children.length === 1 && isPaneId(layout.children[0])) {
const paneId = layout.children[0];
if (!panes[paneId]) {
return <EmptyState />;
}
return <PaneContent paneId={paneId} />;
}
// Group layout
return (
<LayoutGroupRenderer
group={layout}
minSize={200}
onSizeChange={handleSizeChange}
/>
);
}, [layout, panes, handleSizeChange]);
return (
<div
className={cn(
'h-full w-full overflow-hidden',
'bg-background',
className
)}
>
{content}
</div>
);
}
export default LayoutContainer;

View File

@@ -0,0 +1,81 @@
// ========================================
// PaneContent Component
// ========================================
// Container for TabBar and ContentArea within a pane
import { useCallback } from 'react';
import { cn } from '@/lib/utils';
import {
useViewerStore,
useViewerPanes,
useFocusedPaneId,
type PaneId,
} from '@/stores/viewerStore';
import { TabBar } from './TabBar';
import { ContentArea } from './ContentArea';
// ========== Types ==========
export interface PaneContentProps {
paneId: PaneId;
className?: string;
}
// ========== Component ==========
/**
* PaneContent - Combines TabBar and ContentArea for a single pane
*
* Features:
* - Focused pane highlighting
* - Click to focus
* - TabBar for tab management
* - ContentArea for CLI output display
*/
export function PaneContent({ paneId, className }: PaneContentProps) {
const panes = useViewerPanes();
const pane = panes[paneId];
const focusedPaneId = useFocusedPaneId();
const setFocusedPane = useViewerStore((state) => state.setFocusedPane);
const isFocused = focusedPaneId === paneId;
const handleClick = useCallback(() => {
if (!isFocused) {
setFocusedPane(paneId);
}
}, [isFocused, paneId, setFocusedPane]);
if (!pane) {
return (
<div className={cn('h-full flex items-center justify-center', className)}>
<span className="text-muted-foreground text-sm">Pane not found</span>
</div>
);
}
return (
<div
className={cn(
'h-full flex flex-col',
'bg-card dark:bg-surface-900',
'border border-border/50',
'rounded-sm overflow-hidden',
// Focus ring when pane is focused
isFocused && 'ring-1 ring-primary/50',
className
)}
onClick={handleClick}
role="region"
aria-label={`CLI Viewer Pane ${paneId}`}
>
{/* Tab Bar */}
<TabBar paneId={paneId} />
{/* Content Area */}
<ContentArea paneId={paneId} />
</div>
);
}
export default PaneContent;

View File

@@ -0,0 +1,257 @@
// ========================================
// TabBar Component
// ========================================
// Tab management for CLI viewer panes
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';
import {
useViewerStore,
useViewerPanes,
type PaneId,
type TabState,
} from '@/stores/viewerStore';
import { ExecutionPicker } from './ExecutionPicker';
// ========== Types ==========
export interface TabBarProps {
paneId: PaneId;
className?: string;
}
interface TabItemProps {
tab: TabState;
isActive: boolean;
onSelect: () => void;
onClose: (e: React.MouseEvent) => void;
onTogglePin: (e: React.MouseEvent) => void;
}
// ========== Constants ==========
const STATUS_COLORS = {
running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse',
completed: 'bg-emerald-500',
error: 'bg-rose-500',
idle: 'bg-slate-400 dark:bg-slate-500',
};
// ========== Helper Components ==========
/**
* Individual tab item
*/
function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps) {
// Simplify title for display
const displayTitle = useMemo(() => {
// If title contains tool name pattern, extract it
const parts = tab.title.split('-');
return parts[0] || tab.title;
}, [tab.title]);
return (
<button
onClick={onSelect}
className={cn(
'group relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs',
'border border-border/50 shrink-0 min-w-0 max-w-[160px]',
'transition-all duration-150',
isActive
? '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',
tab.isPinned && 'border-amber-500/50'
)}
title={tab.title}
>
{/* Status indicator dot */}
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', STATUS_COLORS.idle)} />
{/* Tool name */}
<span className="font-medium text-[11px] truncate">{displayTitle}</span>
{/* Pin indicator (always visible if pinned) */}
{tab.isPinned && (
<Pin className="h-2.5 w-2.5 text-amber-500 shrink-0" />
)}
{/* Action buttons (visible on hover) */}
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
{/* Pin/Unpin button */}
<button
onClick={onTogglePin}
className="p-0.5 rounded hover:bg-primary/10 transition-colors"
aria-label={tab.isPinned ? 'Unpin tab' : 'Pin tab'}
>
{tab.isPinned ? (
<PinOff className="h-2.5 w-2.5 text-amber-500" />
) : (
<Pin className="h-2.5 w-2.5 text-muted-foreground hover:text-amber-500" />
)}
</button>
{/* Close button (hidden if pinned) */}
{!tab.isPinned && (
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-rose-500/20 transition-colors"
aria-label="Close tab"
>
<X className="h-2.5 w-2.5 text-rose-600 dark:text-rose-400" />
</button>
)}
</div>
</button>
);
}
// ========== Main Component ==========
/**
* TabBar - Manages tabs within a pane
*
* Features:
* - Tab display with status indicators
* - Active tab highlighting
* - Close button on hover
* - Pin/unpin functionality
* - Pane actions dropdown
*/
export function TabBar({ paneId, className }: TabBarProps) {
const { formatMessage } = useIntl();
const panes = useViewerPanes();
const pane = panes[paneId];
const setActiveTab = useViewerStore((state) => state.setActiveTab);
const removeTab = useViewerStore((state) => state.removeTab);
const togglePinTab = useViewerStore((state) => state.togglePinTab);
const addPane = useViewerStore((state) => state.addPane);
const removePane = useViewerStore((state) => state.removePane);
const handleTabSelect = useCallback(
(tabId: string) => {
setActiveTab(paneId, tabId);
},
[paneId, setActiveTab]
);
const handleTabClose = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
removeTab(paneId, tabId);
},
[paneId, removeTab]
);
const handleTogglePin = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
togglePinTab(tabId);
},
[togglePinTab]
);
const handleSplitHorizontal = useCallback(() => {
addPane(paneId, 'horizontal');
}, [paneId, addPane]);
const handleSplitVertical = useCallback(() => {
addPane(paneId, 'vertical');
}, [paneId, addPane]);
const handleClosePane = useCallback(() => {
removePane(paneId);
}, [paneId, removePane]);
// Sort tabs: pinned first, then by order
const sortedTabs = useMemo(() => {
if (!pane) return [];
return [...pane.tabs].sort((a, b) => {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1;
}
return a.order - b.order;
});
}, [pane]);
if (!pane) {
return null;
}
return (
<div
className={cn(
'flex items-center gap-1 px-2 py-1.5',
'bg-muted/30 border-b border-border/50',
'overflow-x-auto scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent',
className
)}
>
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
{sortedTabs.length === 0 ? (
<span className="text-xs text-muted-foreground px-2">
{formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })}
</span>
) : (
sortedTabs.map((tab) => (
<TabItem
key={tab.id}
tab={tab}
isActive={pane.activeTabId === tab.id}
onSelect={() => handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)}
onTogglePin={(e) => handleTogglePin(e, tab.id)}
/>
))
)}
</div>
{/* Add tab button */}
<ExecutionPicker paneId={paneId} />
{/* Pane actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Pane actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleSplitHorizontal}>
<SplitSquareHorizontal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cliViewer.paneActions.splitHorizontal', defaultMessage: 'Split Horizontal' })}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSplitVertical}>
<SplitSquareVertical className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cliViewer.paneActions.splitVertical', defaultMessage: 'Split Vertical' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleClosePane}
className="text-rose-600 dark:text-rose-400"
>
<X className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cliViewer.paneActions.closePane', defaultMessage: 'Close Pane' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default TabBar;

View File

@@ -0,0 +1,28 @@
// ========================================
// CLI Viewer Component Exports
// ========================================
// Barrel export for CLI viewer components
// Main layout container
export { LayoutContainer } from './LayoutContainer';
export type { default as LayoutContainerType } from './LayoutContainer';
// Pane content
export { PaneContent } from './PaneContent';
export type { PaneContentProps } from './PaneContent';
// Tab bar
export { TabBar } from './TabBar';
export type { TabBarProps } from './TabBar';
// Execution picker
export { ExecutionPicker } from './ExecutionPicker';
export type { ExecutionPickerProps } from './ExecutionPicker';
// Content area
export { ContentArea } from './ContentArea';
export type { ContentAreaProps } from './ContentArea';
// Empty state
export { EmptyState } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';

View File

@@ -0,0 +1,234 @@
// ========================================
// CoordinatorEmptyState Component
// ========================================
// Modern empty state with tech-inspired design for coordinator start page
import { useIntl } from 'react-intl';
import { Play, Rocket, Zap, GitBranch } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
export interface CoordinatorEmptyStateProps {
onStart: () => void;
disabled?: boolean;
className?: string;
}
/**
* Empty state component with modern tech-inspired design
* Displays when no coordinator execution is active
*/
export function CoordinatorEmptyState({
onStart,
disabled = false,
className,
}: CoordinatorEmptyStateProps) {
const { formatMessage } = useIntl();
return (
<div
className={cn(
'relative flex items-center justify-center min-h-[600px] overflow-hidden',
className
)}
>
{/* Animated Background - Using theme colors */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background">
{/* Grid Pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `
linear-gradient(var(--primary) 1px, transparent 1px),
linear-gradient(90deg, var(--primary) 1px, transparent 1px)
`,
backgroundSize: '50px 50px',
}}
/>
{/* Animated Gradient Orbs - Using primary color */}
<div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse"
style={{
background: 'radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)',
opacity: 0.15,
}}
/>
<div
className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse"
style={{
background: 'radial-gradient(circle, hsl(var(--secondary)) 0%, transparent 70%)',
animationDelay: '1s',
opacity: 0.15,
}}
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse"
style={{
background: 'radial-gradient(circle, hsl(var(--accent)) 0%, transparent 70%)',
animationDelay: '2s',
opacity: 0.1,
}}
/>
</div>
{/* Main Content */}
<div className="relative z-10 max-w-2xl mx-auto px-8 text-center">
{/* Hero Icon */}
<div className="relative mb-8 inline-block">
<div
className="absolute inset-0 rounded-full blur-2xl opacity-40 animate-pulse"
style={{ background: 'hsl(var(--primary))' }}
/>
<div
className="relative p-6 rounded-full shadow-2xl text-white"
style={{ background: 'hsl(var(--primary))' }}
>
<Rocket className="w-16 h-16" strokeWidth={2} />
</div>
</div>
{/* Title */}
<h1 className="text-4xl font-bold mb-4 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.title' })}
</h1>
{/* Subtitle */}
<p className="text-lg text-muted-foreground mb-12 max-w-lg mx-auto">
{formatMessage({ id: 'coordinator.emptyState.subtitle' })}
</p>
{/* Start Button - Using primary theme color */}
<Button
size="lg"
onClick={onStart}
disabled={disabled}
className="group relative px-8 py-6 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
style={{
background: 'hsl(var(--primary))',
color: 'hsl(var(--primary-foreground))',
}}
>
<Play className="w-6 h-6 mr-2 group-hover:scale-110 transition-transform" />
{formatMessage({ id: 'coordinator.emptyState.startButton' })}
<div
className="absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity blur-xl"
style={{ background: 'hsl(var(--primary) / 0.3)' }}
/>
</Button>
{/* Feature Cards */}
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Feature 1 */}
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'hsl(var(--primary) / 0.05)' }}
/>
<div className="relative">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: 'hsl(var(--primary) / 0.1)', color: 'hsl(var(--primary))' }}
>
<Zap className="w-6 h-6" />
</div>
<h3 className="font-semibold mb-2 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature1.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature1.description' })}
</p>
</div>
</div>
{/* Feature 2 */}
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'hsl(var(--secondary) / 0.05)' }}
/>
<div className="relative">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: 'hsl(var(--secondary) / 0.1)', color: 'hsl(var(--secondary))' }}
>
<GitBranch className="w-6 h-6" />
</div>
<h3 className="font-semibold mb-2 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature2.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature2.description' })}
</p>
</div>
</div>
{/* Feature 3 */}
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'hsl(var(--accent) / 0.05)' }}
/>
<div className="relative">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: 'hsl(var(--accent) / 0.1)', color: 'hsl(var(--accent))' }}
>
<Play className="w-6 h-6" />
</div>
<h3 className="font-semibold mb-2 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature3.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature3.description' })}
</p>
</div>
</div>
</div>
{/* Quick Start Guide */}
<div className="mt-12 text-left bg-card/50 backdrop-blur-sm rounded-xl p-6 border border-border">
<h3 className="font-semibold mb-4 text-foreground flex items-center gap-2">
<span
className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-semibold"
style={{ background: 'hsl(var(--primary))' }}
>
</span>
{formatMessage({ id: 'coordinator.emptyState.quickStart.title' })}
</h3>
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-start gap-3">
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white"
style={{ background: 'hsl(var(--primary))' }}
>
1
</span>
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step1' })}</p>
</div>
<div className="flex items-start gap-3">
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white"
style={{ background: 'hsl(var(--secondary))' }}
>
2
</span>
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step2' })}</p>
</div>
<div className="flex items-start gap-3">
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white"
style={{ background: 'hsl(var(--accent))' }}
>
3
</span>
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step3' })}</p>
</div>
</div>
</div>
</div>
</div>
);
}
export default CoordinatorEmptyState;

View File

@@ -1,25 +1,22 @@
// ========================================
// Coordinator Input Modal Component
// Coordinator Input Modal Component (Multi-Step)
// ========================================
// Modal dialog for starting coordinator execution with task description and parameters
// Two-step modal: Welcome page -> Template & Parameters
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Loader2 } from 'lucide-react';
import { Loader2, Rocket, Zap, GitBranch, Eye, ChevronRight, ChevronLeft } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Textarea';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -33,12 +30,22 @@ interface FormErrors {
parameters?: string;
}
// ========== Constants ==========
const TEMPLATES = [
{ id: 'feature-dev', nameKey: 'coordinator.multiStep.step2.templates.featureDev', description: 'Complete feature development workflow' },
{ id: 'api-integration', nameKey: 'coordinator.multiStep.step2.templates.apiIntegration', description: 'Third-party API integration' },
{ id: 'performance', nameKey: 'coordinator.multiStep.step2.templates.performanceOptimization', description: 'System performance analysis' },
{ id: 'documentation', nameKey: 'coordinator.multiStep.step2.templates.documentGeneration', description: 'Auto-generate documentation' },
] as const;
const TOTAL_STEPS = 2;
// ========== Validation Helper ==========
function validateForm(taskDescription: string, parameters: string): FormErrors {
const errors: FormErrors = {};
// Validate task description
if (!taskDescription.trim()) {
errors.taskDescription = 'coordinator.validation.taskDescriptionRequired';
} else {
@@ -50,11 +57,10 @@ function validateForm(taskDescription: string, parameters: string): FormErrors {
}
}
// Validate parameters if provided
if (parameters.trim()) {
try {
JSON.parse(parameters.trim());
} catch (error) {
} catch {
errors.parameters = 'coordinator.validation.parametersInvalidJson';
}
}
@@ -62,6 +68,32 @@ function validateForm(taskDescription: string, parameters: string): FormErrors {
return errors;
}
// ========== Feature Card Data ==========
const FEATURES = [
{
icon: Zap,
titleKey: 'coordinator.multiStep.step1.feature1.title',
descriptionKey: 'coordinator.multiStep.step1.feature1.description',
bgClass: 'bg-primary/10',
iconClass: 'text-primary',
},
{
icon: GitBranch,
titleKey: 'coordinator.multiStep.step1.feature2.title',
descriptionKey: 'coordinator.multiStep.step1.feature2.description',
bgClass: 'bg-secondary/10',
iconClass: 'text-secondary-foreground',
},
{
icon: Eye,
titleKey: 'coordinator.multiStep.step1.feature3.title',
descriptionKey: 'coordinator.multiStep.step1.feature3.description',
bgClass: 'bg-accent/10',
iconClass: 'text-accent-foreground',
},
] as const;
// ========== Component ==========
export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalProps) {
@@ -69,18 +101,25 @@ export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalPr
const { success, error: showError } = useNotifications();
const { startCoordinator } = useCoordinatorStore();
// Step state
const [step, setStep] = useState(1);
// Form state
const [taskDescription, setTaskDescription] = useState('');
const [parameters, setParameters] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form when modal opens/closes
// Reset all state when modal opens/closes
useEffect(() => {
if (open) {
setStep(1);
setTaskDescription('');
setParameters('');
setSelectedTemplate(null);
setErrors({});
setIsSubmitting(false);
}
}, [open]);
@@ -95,15 +134,25 @@ export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalPr
setParameters(value);
}
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
// Handle submit
// Handle template selection
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
const template = TEMPLATES.find((t) => t.id === templateId);
if (template) {
setTaskDescription(template.description);
if (errors.taskDescription) {
setErrors((prev) => ({ ...prev, taskDescription: undefined }));
}
}
};
// Handle submit - preserved exactly from original
const handleSubmit = async () => {
// Validate form
const validationErrors = validateForm(taskDescription, parameters);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
@@ -112,13 +161,10 @@ export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalPr
setIsSubmitting(true);
try {
// Parse parameters if provided
const parsedParams = parameters.trim() ? JSON.parse(parameters.trim()) : undefined;
// Generate execution ID
const executionId = `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Call API to start coordinator
const response = await fetch('/api/coordinator/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -134,7 +180,6 @@ export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalPr
throw new Error(error.message || 'Failed to start coordinator');
}
// Call store to update state
await startCoordinator(executionId, taskDescription.trim(), parsedParams);
success(formatMessage({ id: 'coordinator.success.started' }));
@@ -148,99 +193,246 @@ export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalPr
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'coordinator.modal.title' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'coordinator.modal.description' })}
</DialogDescription>
</DialogHeader>
// Navigation
const handleNext = () => setStep(2);
const handleBack = () => setStep(1);
<div className="space-y-4 py-4">
{/* Task Description */}
<div className="space-y-2">
<Label htmlFor="task-description" className="text-base font-medium">
{formatMessage({ id: 'coordinator.form.taskDescription' })}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="task-description"
value={taskDescription}
onChange={(e) => handleFieldChange('taskDescription', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.taskDescriptionPlaceholder' })}
rows={6}
className={errors.taskDescription ? 'border-destructive' : ''}
disabled={isSubmitting}
/>
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>
{formatMessage({ id: 'coordinator.form.characterCount' }, {
current: taskDescription.length,
min: 10,
max: 2000,
})}
</span>
{taskDescription.length >= 10 && taskDescription.length <= 2000 && (
<span className="text-green-600">Valid</span>
// ========== Step 1: Welcome ==========
const renderStep1 = () => (
<div className="flex flex-col items-center px-6 py-8">
{/* Hero Icon */}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground mb-6">
<Rocket className="h-8 w-8" />
</div>
{/* Title & Subtitle */}
<h2 className="text-2xl font-bold text-foreground mb-2">
{formatMessage({ id: 'coordinator.multiStep.step1.title' })}
</h2>
<p className="text-sm text-muted-foreground mb-8 text-center max-w-md">
{formatMessage({ id: 'coordinator.multiStep.step1.subtitle' })}
</p>
{/* Feature Cards */}
<div className="grid grid-cols-3 gap-4 w-full">
{FEATURES.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.titleKey}
className={cn(
'flex flex-col items-center rounded-xl p-5 text-center',
feature.bgClass
)}
>
<Icon className={cn('h-6 w-6 mb-3', feature.iconClass)} />
<span className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: feature.titleKey })}
</span>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: feature.descriptionKey })}
</span>
</div>
{errors.taskDescription && (
<p className="text-sm text-destructive">
{formatMessage({ id: errors.taskDescription })}
</p>
)}
</div>
);
})}
</div>
</div>
);
{/* Parameters (Optional) */}
<div className="space-y-2">
<Label htmlFor="parameters" className="text-base font-medium">
{formatMessage({ id: 'coordinator.form.parameters' })}
</Label>
<Input
id="parameters"
value={parameters}
onChange={(e) => handleFieldChange('parameters', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.parametersPlaceholder' })}
className={`font-mono text-sm ${errors.parameters ? 'border-destructive' : ''}`}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'coordinator.form.parametersHelp' })}
</p>
{errors.parameters && (
<p className="text-sm text-destructive">
{formatMessage({ id: errors.parameters })}
</p>
// ========== Step 2: Template + Parameters ==========
const renderStep2 = () => (
<div className="flex min-h-[380px]">
{/* Left Column: Template Selection */}
<div className="w-2/5 border-r border-border p-5">
<h3 className="text-sm font-semibold text-foreground mb-3">
{formatMessage({ id: 'coordinator.multiStep.step2.templateLabel' })}
</h3>
<div className="space-y-2">
{TEMPLATES.map((template) => {
const isSelected = selectedTemplate === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => handleTemplateSelect(template.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:bg-muted/50'
)}
>
{/* Radio dot */}
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
isSelected
? 'border-primary'
: 'border-muted-foreground/40'
)}
>
{isSelected && (
<span className="h-2 w-2 rounded-full bg-primary" />
)}
</span>
<span className={cn(
'text-sm',
isSelected ? 'font-medium text-foreground' : 'text-muted-foreground'
)}>
{formatMessage({ id: template.nameKey })}
</span>
</button>
);
})}
</div>
</div>
{/* Right Column: Parameter Form */}
<div className="w-3/5 p-5 space-y-4">
{/* Task Description */}
<div className="space-y-2">
<Label htmlFor="task-description" className="text-sm font-medium">
{formatMessage({ id: 'coordinator.form.taskDescription' })}
<span className="text-destructive ml-0.5">*</span>
</Label>
<Textarea
id="task-description"
value={taskDescription}
onChange={(e) => handleFieldChange('taskDescription', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.taskDescriptionPlaceholder' })}
rows={5}
className={cn(
'resize-none',
errors.taskDescription && 'border-destructive'
)}
disabled={isSubmitting}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{formatMessage(
{ id: 'coordinator.form.characterCount' },
{ current: taskDescription.length, min: 10, max: 2000 }
)}
</span>
{taskDescription.length >= 10 && taskDescription.length <= 2000 && (
<span className="text-primary">Valid</span>
)}
</div>
{errors.taskDescription && (
<p className="text-xs text-destructive">
{formatMessage({ id: errors.taskDescription })}
</p>
)}
</div>
<DialogFooter>
{/* Custom Parameters */}
<div className="space-y-2">
<Label htmlFor="parameters" className="text-sm font-medium">
{formatMessage({ id: 'coordinator.form.parameters' })}
</Label>
<Textarea
id="parameters"
value={parameters}
onChange={(e) => handleFieldChange('parameters', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.parametersPlaceholder' })}
rows={3}
className={cn(
'resize-none font-mono text-sm',
errors.parameters && 'border-destructive'
)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'coordinator.form.parametersHelp' })}
</p>
{errors.parameters && (
<p className="text-xs text-destructive">
{formatMessage({ id: errors.parameters })}
</p>
)}
</div>
</div>
</div>
);
// ========== Footer ==========
const renderFooter = () => (
<div className="flex items-center justify-between border-t border-border px-6 py-4">
{/* Left: Step indicator + Back */}
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'coordinator.multiStep.progress.step' },
{ current: step, total: TOTAL_STEPS }
)}
</span>
{step === 2 && (
<Button
variant="outline"
onClick={onClose}
variant="ghost"
size="sm"
onClick={handleBack}
disabled={isSubmitting}
>
{formatMessage({ id: 'common.actions.cancel' })}
<ChevronLeft className="mr-1 h-4 w-4" />
{formatMessage({ id: 'coordinator.multiStep.actions.back' })}
</Button>
)}
</div>
{/* Right: Cancel + Next/Submit */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onClose}
disabled={isSubmitting}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
{step === 1 ? (
<Button size="sm" onClick={handleNext}>
{formatMessage({ id: 'coordinator.multiStep.actions.next' })}
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
) : (
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{formatMessage({ id: 'coordinator.form.starting' })}
</>
) : (
formatMessage({ id: 'coordinator.form.start' })
formatMessage({ id: 'coordinator.multiStep.actions.submit' })
)}
</Button>
</DialogFooter>
)}
</div>
</div>
);
// ========== Render ==========
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl gap-0 p-0 overflow-hidden">
{/* Visually hidden title for accessibility */}
<DialogTitle className="sr-only">
{formatMessage({ id: 'coordinator.modal.title' })}
</DialogTitle>
{/* Step Content */}
{step === 1 ? renderStep1() : renderStep2()}
{/* Footer */}
{renderFooter()}
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,137 @@
// ========================================
// CoordinatorTaskCard Component
// ========================================
// Task card component for displaying task overview in horizontal list
import { useIntl } from 'react-intl';
import { Clock, CheckCircle, XCircle, Loader2, CircleDashed } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
export interface TaskStatus {
id: string;
name: string;
description?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: { completed: number; total: number };
startedAt?: string;
completedAt?: string;
}
export interface CoordinatorTaskCardProps {
task: TaskStatus;
isSelected: boolean;
onClick: () => void;
className?: string;
}
/**
* Task card component displaying task status and progress
* Used in horizontal scrolling task list
*/
export function CoordinatorTaskCard({
task,
isSelected,
onClick,
className,
}: CoordinatorTaskCardProps) {
const { formatMessage } = useIntl();
// Map status to badge variant
const getStatusVariant = (status: TaskStatus['status']) => {
switch (status) {
case 'pending':
return 'secondary';
case 'running':
return 'warning';
case 'completed':
return 'success';
case 'failed':
return 'destructive';
case 'cancelled':
return 'outline';
default:
return 'default';
}
};
// Get status icon
const getStatusIcon = (status: TaskStatus['status']) => {
switch (status) {
case 'pending':
return <CircleDashed className="w-3 h-3" />;
case 'running':
return <Loader2 className="w-3 h-3 animate-spin" />;
case 'completed':
return <CheckCircle className="w-3 h-3" />;
case 'failed':
return <XCircle className="w-3 h-3" />;
case 'cancelled':
return <XCircle className="w-3 h-3" />;
default:
return null;
}
};
// Format time display
const formatTime = (dateString?: string) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return null;
}
};
const displayTime = task.startedAt ? formatTime(task.startedAt) : null;
return (
<Card
className={cn(
'min-w-[180px] max-w-[220px] p-4 cursor-pointer transition-all duration-200',
'hover:border-primary/50 hover:shadow-md',
isSelected && 'border-primary ring-1 ring-primary/20',
className
)}
onClick={onClick}
>
{/* Task Name */}
<h3 className="font-medium text-sm text-foreground truncate mb-2" title={task.name}>
{task.name}
</h3>
{/* Status Badge */}
<div className="mb-3">
<Badge variant={getStatusVariant(task.status)} className="gap-1">
{getStatusIcon(task.status)}
{formatMessage({ id: `coordinator.status.${task.status}` })}
</Badge>
</div>
{/* Progress */}
<div className="text-xs text-muted-foreground mb-2">
<span className="font-medium">{task.progress.completed}</span>
<span>/</span>
<span>{task.progress.total}</span>
<span className="ml-1">
{formatMessage({ id: 'coordinator.taskCard.nodes' })}
</span>
</div>
{/* Time */}
{displayTime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{displayTime}</span>
<span className="ml-1">
{formatMessage({ id: 'coordinator.taskCard.started' })}
</span>
</div>
)}
</Card>
);
}
export default CoordinatorTaskCard;

View File

@@ -0,0 +1,140 @@
// ========================================
// CoordinatorTaskList Component
// ========================================
// Horizontal scrolling task list with filter and sort controls
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Filter, ArrowUpDown, Inbox } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { CoordinatorTaskCard, TaskStatus } from './CoordinatorTaskCard';
import { cn } from '@/lib/utils';
export type FilterOption = 'all' | 'running' | 'completed' | 'failed';
export type SortOption = 'time' | 'name';
export interface CoordinatorTaskListProps {
tasks: TaskStatus[];
selectedTaskId: string | null;
onTaskSelect: (taskId: string) => void;
className?: string;
}
/**
* Horizontal scrolling task list with filtering and sorting
* Displays task cards in a row with overflow scroll
*/
export function CoordinatorTaskList({
tasks,
selectedTaskId,
onTaskSelect,
className,
}: CoordinatorTaskListProps) {
const { formatMessage } = useIntl();
const [filter, setFilter] = useState<FilterOption>('all');
const [sort, setSort] = useState<SortOption>('time');
// Filter tasks
const filteredTasks = useMemo(() => {
let result = [...tasks];
// Apply filter
if (filter !== 'all') {
result = result.filter((task) => task.status === filter);
}
// Apply sort
result.sort((a, b) => {
if (sort === 'time') {
// Sort by start time (newest first), pending tasks last
const timeA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const timeB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return timeB - timeA;
} else {
// Sort by name alphabetically
return a.name.localeCompare(b.name);
}
});
return result;
}, [tasks, filter, sort]);
return (
<div className={cn('space-y-4', className)}>
{/* Controls Row */}
<div className="flex items-center gap-3">
{/* Filter Select */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<Select value={filter} onValueChange={(v) => setFilter(v as FilterOption)}>
<SelectTrigger className="w-[140px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{formatMessage({ id: 'coordinator.taskList.filter.all' })}
</SelectItem>
<SelectItem value="running">
{formatMessage({ id: 'coordinator.taskList.filter.running' })}
</SelectItem>
<SelectItem value="completed">
{formatMessage({ id: 'coordinator.taskList.filter.completed' })}
</SelectItem>
<SelectItem value="failed">
{formatMessage({ id: 'coordinator.taskList.filter.failed' })}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Select */}
<div className="flex items-center gap-2">
<ArrowUpDown className="w-4 h-4 text-muted-foreground" />
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
<SelectTrigger className="w-[120px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="time">
{formatMessage({ id: 'coordinator.taskList.sort.time' })}
</SelectItem>
<SelectItem value="name">
{formatMessage({ id: 'coordinator.taskList.sort.name' })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Task Cards - Horizontal Scroll */}
{filteredTasks.length > 0 ? (
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{filteredTasks.map((task) => (
<CoordinatorTaskCard
key={task.id}
task={task}
isSelected={task.id === selectedTaskId}
onClick={() => onTaskSelect(task.id)}
/>
))}
</div>
) : (
/* Empty State */
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Inbox className="w-12 h-12 mb-4 opacity-50" />
<p className="text-sm">
{formatMessage({ id: 'coordinator.taskList.empty' })}
</p>
</div>
)}
</div>
);
}
export default CoordinatorTaskList;

View File

@@ -21,3 +21,12 @@ export type { CoordinatorLogStreamProps } from './CoordinatorLogStream';
export { CoordinatorQuestionModal } from './CoordinatorQuestionModal';
export type { CoordinatorQuestionModalProps } from './CoordinatorQuestionModal';
export { CoordinatorEmptyState } from './CoordinatorEmptyState';
export type { CoordinatorEmptyStateProps } from './CoordinatorEmptyState';
export { CoordinatorTaskCard } from './CoordinatorTaskCard';
export type { CoordinatorTaskCardProps, TaskStatus } from './CoordinatorTaskCard';
export { CoordinatorTaskList } from './CoordinatorTaskList';
export type { CoordinatorTaskListProps, FilterOption, SortOption } from './CoordinatorTaskList';

View File

@@ -0,0 +1,165 @@
// ========================================
// DashboardWidgetConfig Component
// ========================================
// Configuration panel for managing dashboard widgets visibility and layout
import * as React from 'react';
import { useIntl } from 'react-intl';
import { ChevronDown, Eye, EyeOff, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { cn } from '@/lib/utils';
import type { WidgetConfig } from '@/types/store';
export interface DashboardWidgetConfigProps {
/** List of widget configurations */
widgets: WidgetConfig[];
/** Callback when widget visibility changes */
onWidgetToggle: (widgetId: string) => void;
/** Callback when reset layout is requested */
onResetLayout: () => void;
/** Whether the panel is currently open */
isOpen?: boolean;
/** Callback when open state changes */
onOpenChange?: (open: boolean) => void;
}
/**
* DashboardWidgetConfig - Widget configuration panel
*
* Allows users to:
* - Toggle widget visibility
* - Reset layout to defaults
* - Quickly manage what widgets appear on dashboard
*/
export function DashboardWidgetConfig({
widgets,
onWidgetToggle,
onResetLayout,
isOpen = false,
onOpenChange,
}: DashboardWidgetConfigProps) {
const { formatMessage } = useIntl();
const [open, setOpen] = React.useState(isOpen);
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
};
const visibleCount = widgets.filter((w) => w.visible).length;
const allVisible = visibleCount === widgets.length;
const handleToggleAll = () => {
// If all visible, hide all. If any hidden, show all.
widgets.forEach((widget) => {
if (allVisible) {
onWidgetToggle(widget.i);
} else if (!widget.visible) {
onWidgetToggle(widget.i);
}
});
};
const handleResetLayout = () => {
onResetLayout();
setOpen(false);
};
return (
<div className="relative">
{/* Toggle button */}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenChange(!open)}
className="gap-2"
aria-label="Toggle widget configuration"
aria-expanded={open}
>
<Eye className="h-4 w-4" />
{formatMessage({ id: 'common.dashboard.config.title' })}
<ChevronDown
className={cn('h-4 w-4 transition-transform', open && 'rotate-180')}
/>
</Button>
{/* Dropdown panel */}
{open && (
<div className="absolute right-0 top-full mt-2 w-64 rounded-lg border border-border bg-card shadow-lg z-50">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'common.dashboard.config.widgets' })}
</h3>
<span className="text-xs text-muted-foreground">
{visibleCount}/{widgets.length}
</span>
</div>
{/* Toggle all button */}
<div>
<Button
variant="ghost"
size="sm"
onClick={handleToggleAll}
className="w-full justify-start gap-2 text-xs h-8"
>
{allVisible ? (
<>
<EyeOff className="h-3.5 w-3.5" />
{formatMessage({ id: 'common.dashboard.config.hideAll' })}
</>
) : (
<>
<Eye className="h-3.5 w-3.5" />
{formatMessage({ id: 'common.dashboard.config.showAll' })}
</>
)}
</Button>
</div>
{/* Widget list */}
<div className="space-y-2 border-t border-border pt-4">
{widgets.map((widget) => (
<div
key={widget.i}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50 transition-colors"
>
<Checkbox
id={`widget-${widget.i}`}
checked={widget.visible}
onCheckedChange={() => onWidgetToggle(widget.i)}
className="h-4 w-4"
/>
<label
htmlFor={`widget-${widget.i}`}
className="flex-1 text-sm text-foreground cursor-pointer"
>
{widget.name}
</label>
</div>
))}
</div>
{/* Reset button */}
<div className="border-t border-border pt-4">
<Button
variant="outline"
size="sm"
onClick={handleResetLayout}
className="w-full justify-start gap-2"
>
<RotateCcw className="h-3.5 w-3.5" />
{formatMessage({ id: 'common.dashboard.config.resetLayout' })}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
export default DashboardWidgetConfig;

View File

@@ -0,0 +1,99 @@
// ========================================
// WidgetWrapper Component
// ========================================
// Wrapper component for dashboard widgets with drag handle and common styling
import * as React from 'react';
import { GripVertical, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
export interface WidgetWrapperProps {
/** Widget ID for identification */
id: string;
/** Widget title displayed in header */
title: string;
/** Children to render inside the widget */
children: React.ReactNode;
/** Whether the widget can be dragged */
isDraggable?: boolean;
/** Whether the widget can be removed */
canRemove?: boolean;
/** Callback when remove button is clicked */
onRemove?: (id: string) => void;
/** Additional CSS classes */
className?: string;
/** Whether to show the header with drag handle */
showHeader?: boolean;
/** Style prop passed by react-grid-layout */
style?: React.CSSProperties;
}
/**
* WidgetWrapper - Standardized wrapper for dashboard widgets
*
* Uses forwardRef to support react-grid-layout which requires
* refs on child elements for positioning and measurement.
*/
export const WidgetWrapper = React.forwardRef<HTMLDivElement, WidgetWrapperProps>(
function WidgetWrapper(
{
id,
title,
children,
isDraggable = true,
canRemove = false,
onRemove,
className,
showHeader = true,
style,
...rest
},
ref
) {
const handleRemove = React.useCallback(() => {
onRemove?.(id);
}, [id, onRemove]);
return (
<div ref={ref} className={cn('h-full flex flex-col', className)} style={style} {...rest}>
{/* Header with drag handle */}
{showHeader && (
<div className="flex items-center justify-between px-2 py-1 border-b border-border/50 bg-muted/30 rounded-t-lg">
<div className="flex items-center gap-2">
{/* Drag handle - must have .drag-handle class for react-grid-layout */}
{isDraggable && (
<div className="drag-handle cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted transition-colors">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
)}
<span className="text-sm font-medium text-foreground">{title}</span>
</div>
{/* Widget actions */}
<div className="flex items-center gap-1">
{canRemove && onRemove && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleRemove}
aria-label={`Remove ${title} widget`}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)}
{/* Widget content */}
<div className="flex-1 min-h-0">
{children}
</div>
</div>
);
}
);
export default WidgetWrapper;

View File

@@ -7,44 +7,33 @@ import type { WidgetConfig, DashboardLayouts, DashboardLayoutState } from '@/typ
/** Widget IDs used across the dashboard */
export const WIDGET_IDS = {
STATS: 'detailed-stats',
WORKFLOW_TASK: 'workflow-task',
RECENT_SESSIONS: 'recent-sessions',
WORKFLOW_STATUS: 'workflow-status-pie',
ACTIVITY: 'activity-line',
TASK_TYPES: 'task-type-bar',
} as const;
/** Default widget configurations */
export const DEFAULT_WIDGETS: WidgetConfig[] = [
{ i: WIDGET_IDS.STATS, name: 'Statistics', visible: true, minW: 4, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, name: 'Recent Sessions', visible: true, minW: 4, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, name: 'Workflow Status', visible: true, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, name: 'Activity', visible: true, minW: 4, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, name: 'Task Types', visible: true, minW: 3, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_TASK, name: 'Workflow & Tasks', visible: true, minW: 6, minH: 4 },
{ i: WIDGET_IDS.RECENT_SESSIONS, name: 'Recent Sessions', visible: true, minW: 6, minH: 3 },
];
/** Default responsive layouts */
export const DEFAULT_LAYOUTS: DashboardLayouts = {
lg: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 12, h: 2, minW: 4, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 4, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 6, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 6, w: 7, h: 4, minW: 4, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 7, y: 6, w: 5, h: 4, minW: 3, minH: 3 },
// Row 1: Combined WorkflowTask (full width - includes Stats, Workflow, Tasks, Heatmap)
{ i: WIDGET_IDS.WORKFLOW_TASK, x: 0, y: 0, w: 12, h: 5, minW: 6, minH: 4 },
// Row 2: Recent Sessions (full width)
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 5, w: 12, h: 4, minW: 6, minH: 3 },
],
md: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 6, h: 2, minW: 3, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 6, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 10, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 14, w: 6, h: 4, minW: 3, minH: 3 },
// Medium: Stack vertically, full width each
{ i: WIDGET_IDS.WORKFLOW_TASK, x: 0, y: 0, w: 6, h: 5, minW: 4, minH: 4 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 5, w: 6, h: 4, minW: 4, minH: 3 },
],
sm: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 2, h: 3, minW: 2, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 3, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 7, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 11, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 15, w: 2, h: 4, minW: 2, minH: 3 },
// Small: Stack vertically
{ i: WIDGET_IDS.WORKFLOW_TASK, x: 0, y: 0, w: 2, h: 8, minW: 2, minH: 6 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 8, w: 2, h: 5, minW: 2, minH: 4 },
],
};

View File

@@ -0,0 +1,138 @@
// ========================================
// ActivityHeatmapWidget Component
// ========================================
// Widget showing activity distribution as a vertical heatmap (narrow layout)
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { useActivityTimeline } from '@/hooks/useActivityTimeline';
import { cn } from '@/lib/utils';
export interface ActivityHeatmapWidgetProps {
className?: string;
}
const WEEKS = 12; // 12 weeks = ~3 months
const DAYS_PER_WEEK = 7;
const TOTAL_CELLS = WEEKS * DAYS_PER_WEEK;
// Generate heatmap data for WEEKS x 7 grid
function generateHeatmapData(activityData: number[] = []): { value: number; intensity: number }[] {
const heatmap: { value: number; intensity: number }[] = [];
for (let i = 0; i < TOTAL_CELLS; i++) {
const value = activityData[i] ?? Math.floor(Math.random() * 10);
const intensity = Math.min(100, (value / 10) * 100);
heatmap.push({ value, intensity });
}
return heatmap;
}
function getIntensityColor(intensity: number): string {
if (intensity === 0) return 'bg-muted/50';
if (intensity < 25) return 'bg-primary/20';
if (intensity < 50) return 'bg-primary/40';
if (intensity < 75) return 'bg-primary/60';
return 'bg-primary';
}
// Short day labels for narrow layout
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
function ActivityHeatmapWidgetComponent({ className }: ActivityHeatmapWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading } = useActivityTimeline();
const activityValues = data?.map((item) => item.sessions + item.tasks) || [];
const heatmapData = generateHeatmapData(activityValues);
// Get month labels for week rows
const getWeekLabel = (weekIdx: number): string => {
const date = new Date();
date.setDate(date.getDate() - (WEEKS - 1 - weekIdx) * 7);
// Only show month for first week of each month
if (weekIdx === 0 || date.getDate() <= 7) {
return date.toLocaleString('default', { month: 'short' });
}
return '';
};
return (
<Card className={cn('h-full p-3 flex flex-col', className)}>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.activity' })}
</h3>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="h-full w-full bg-muted rounded animate-pulse" />
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
{/* Day header row */}
<div className="flex gap-[2px] mb-1">
<div className="w-8 shrink-0" /> {/* Spacer for month labels */}
{DAY_LABELS.map((label, i) => (
<div
key={i}
className="flex-1 text-center text-[9px] text-muted-foreground font-medium"
>
{label}
</div>
))}
</div>
{/* Vertical grid: rows = weeks (flowing down), columns = days */}
<div className="flex-1 flex flex-col gap-[2px] min-h-0 overflow-auto">
{Array.from({ length: WEEKS }).map((_, weekIdx) => {
const monthLabel = getWeekLabel(weekIdx);
return (
<div key={weekIdx} className="flex gap-[2px] items-center">
{/* Month label */}
<div className="w-8 shrink-0 text-[9px] text-muted-foreground truncate">
{monthLabel}
</div>
{/* Day cells for this week */}
{Array.from({ length: DAYS_PER_WEEK }).map((_, dayIdx) => {
const cellIndex = weekIdx * DAYS_PER_WEEK + dayIdx;
const cell = heatmapData[cellIndex];
return (
<div
key={dayIdx}
className={cn(
'flex-1 aspect-square rounded-sm border border-border/30 transition-opacity hover:opacity-80 cursor-help relative group min-w-0',
getIntensityColor(cell.intensity)
)}
title={`${cell.value} activities`}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-foreground text-background rounded text-[10px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10 transition-opacity">
{cell.value}
</div>
</div>
);
})}
</div>
);
})}
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-1 mt-2 text-[9px] text-muted-foreground">
<span>Less</span>
{[0, 25, 50, 75, 100].map((intensity, i) => (
<div
key={i}
className={cn('w-[8px] h-[8px] rounded-sm border border-border/30', getIntensityColor(intensity))}
/>
))}
<span>More</span>
</div>
</div>
)}
</Card>
);
}
export const ActivityHeatmapWidget = memo(ActivityHeatmapWidgetComponent);
export default ActivityHeatmapWidget;

View File

@@ -28,9 +28,9 @@ export interface ActivityLineChartWidgetProps {
*/
function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useActivityTimeline();
const { data, isLoading } = useActivityTimeline();
// Use mock data if API is not ready
// Use mock data if API call fails or returns no data
const chartData = data || generateMockActivityTimeline();
return (
@@ -41,10 +41,6 @@ function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineC
</h3>
{isLoading ? (
<ChartSkeleton type="line" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<ActivityLineChart data={chartData} height={280} />
)}

View File

@@ -119,8 +119,8 @@ function DetailedStatsWidgetComponent({ className, ...props }: DetailedStatsWidg
return (
<div {...props} className={className}>
<Card className="h-full p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<Card className="h-full p-4 flex flex-col">
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 md:gap-4">
{isLoading
? Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
: statCards.map((card) => (

View File

@@ -1,103 +1,401 @@
// ========================================
// RecentSessionsWidget Component
// ========================================
// Widget wrapper for recent sessions list in dashboard grid layout
// Widget showing recent sessions across different task types (workflow, lite, orchestrator)
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { FolderKanban } from 'lucide-react';
import {
FolderKanban,
Workflow,
Zap,
Play,
Clock,
CheckCircle2,
XCircle,
PauseCircle,
FileEdit,
Wrench,
GitBranch,
Tag,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
import { useSessions } from '@/hooks/useSessions';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { useSessions } from '@/hooks/useSessions';
import { useLiteTasks } from '@/hooks/useLiteTasks';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
export interface RecentSessionsWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
/** Maximum number of sessions to display */
maxSessions?: number;
maxItems?: number;
}
// Task type definitions
type TaskType = 'all' | 'workflow' | 'lite' | 'orchestrator';
// Unified task item for display
interface UnifiedTaskItem {
id: string;
name: string;
type: TaskType;
subType?: string;
status: string;
statusKey: string; // i18n key for status
createdAt: string;
description?: string;
tags?: string[];
progress?: number;
}
// Tab configuration for different task types
const TABS: { key: TaskType; label: string; icon: React.ElementType }[] = [
{ key: 'all', label: 'home.tabs.allTasks', icon: FolderKanban },
{ key: 'workflow', label: 'home.tabs.workflow', icon: Workflow },
{ key: 'lite', label: 'home.tabs.liteTasks', icon: Zap },
{ key: 'orchestrator', label: 'home.tabs.orchestrator', icon: Play },
];
// Status icon mapping
const statusIcons: Record<string, React.ElementType> = {
in_progress: Loader2,
running: Loader2,
planning: FileEdit,
completed: CheckCircle2,
failed: XCircle,
paused: PauseCircle,
pending: Clock,
cancelled: XCircle,
idle: Clock,
initializing: Loader2,
};
// Status color mapping
const statusColors: Record<string, string> = {
in_progress: 'bg-warning/20 text-warning border-warning/30',
running: 'bg-warning/20 text-warning border-warning/30',
planning: 'bg-violet-500/20 text-violet-600 border-violet-500/30',
completed: 'bg-success/20 text-success border-success/30',
failed: 'bg-destructive/20 text-destructive border-destructive/30',
paused: 'bg-slate-400/20 text-slate-500 border-slate-400/30',
pending: 'bg-muted text-muted-foreground border-border',
cancelled: 'bg-destructive/20 text-destructive border-destructive/30',
idle: 'bg-muted text-muted-foreground border-border',
initializing: 'bg-info/20 text-info border-info/30',
};
// Status to i18n key mapping
const statusI18nKeys: Record<string, string> = {
in_progress: 'inProgress',
running: 'running',
planning: 'planning',
completed: 'completed',
failed: 'failed',
paused: 'paused',
pending: 'pending',
cancelled: 'cancelled',
idle: 'idle',
initializing: 'initializing',
};
// Lite task sub-type icons
const liteTypeIcons: Record<string, React.ElementType> = {
'lite-plan': FileEdit,
'lite-fix': Wrench,
'multi-cli-plan': GitBranch,
};
// Task type colors
const typeColors: Record<TaskType, string> = {
all: 'bg-muted text-muted-foreground',
workflow: 'bg-primary/20 text-primary',
lite: 'bg-amber-500/20 text-amber-600',
orchestrator: 'bg-violet-500/20 text-violet-600',
};
function TaskItemCard({ item, onClick }: { item: UnifiedTaskItem; onClick: () => void }) {
const { formatMessage } = useIntl();
const StatusIcon = statusIcons[item.status] || Clock;
const TypeIcon = item.subType ? (liteTypeIcons[item.subType] || Zap) :
item.type === 'workflow' ? Workflow :
item.type === 'orchestrator' ? Play : Zap;
const isAnimated = item.status === 'in_progress' || item.status === 'running' || item.status === 'initializing';
return (
<button
onClick={onClick}
className="w-full text-left p-3 rounded-lg border border-border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all group"
>
<div className="flex items-start gap-2.5">
<div className={cn('p-1.5 rounded-md shrink-0', typeColors[item.type])}>
<TypeIcon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
{/* Header: name + status */}
<div className="flex items-start gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground truncate flex-1 group-hover:text-primary transition-colors">
{item.name}
</h4>
<Badge className={cn('text-[10px] px-1.5 py-0 shrink-0 border', statusColors[item.status])}>
<StatusIcon className={cn('h-2.5 w-2.5 mr-0.5', isAnimated && 'animate-spin')} />
{formatMessage({ id: `common.status.${item.statusKey}` })}
</Badge>
</div>
{/* Description */}
{item.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-1.5">
{item.description}
</p>
)}
{/* Progress bar (if available) */}
{typeof item.progress === 'number' && item.progress > 0 && (
<div className="flex items-center gap-2 mb-1.5">
<Progress value={item.progress} className="h-1 flex-1 bg-muted" />
<span className="text-[10px] text-muted-foreground w-8 text-right">{item.progress}%</span>
</div>
)}
{/* Footer: time + tags */}
<div className="flex items-center gap-2 flex-wrap">
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<Clock className="h-2.5 w-2.5" />
{item.createdAt}
</span>
{item.subType && (
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background">
{item.subType}
</Badge>
)}
{item.tags && item.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-[9px] px-1 py-0 gap-0.5 bg-background">
<Tag className="h-2 w-2" />
{tag}
</Badge>
))}
{item.tags && item.tags.length > 2 && (
<span className="text-[9px] text-muted-foreground">+{item.tags.length - 2}</span>
)}
</div>
</div>
</div>
</button>
);
}
function TaskItemSkeleton() {
return (
<div className="p-3 rounded-lg border border-border bg-card animate-pulse">
<div className="flex items-start gap-2.5">
<div className="w-8 h-8 rounded-md bg-muted" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="h-4 bg-muted rounded flex-1" />
<div className="h-4 w-16 bg-muted rounded" />
</div>
<div className="h-3 bg-muted rounded w-3/4 mb-2" />
<div className="flex gap-2">
<div className="h-3 w-16 bg-muted rounded" />
<div className="h-3 w-12 bg-muted rounded" />
</div>
</div>
</div>
</div>
);
}
/**
* RecentSessionsWidget - Dashboard widget showing recent workflow sessions
*
* Displays recent active sessions (max 6 by default) with navigation to session detail.
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function RecentSessionsWidgetComponent({
className,
maxSessions = 6,
...props
maxItems = 6,
}: RecentSessionsWidgetProps) {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const [activeTab, setActiveTab] = React.useState<TaskType>('all');
// Fetch recent sessions (active only)
const { activeSessions, isLoading } = useSessions({
// Fetch workflow sessions
const { activeSessions, isLoading: sessionsLoading } = useSessions({
filter: { location: 'active' },
});
// Get recent sessions (sorted by creation date)
const recentSessions = React.useMemo(
() =>
[...activeSessions]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, maxSessions),
[activeSessions, maxSessions]
);
// Fetch lite tasks
const { allSessions: liteSessions, isLoading: liteLoading } = useLiteTasks();
const handleSessionClick = (sessionId: string) => {
navigate(`/sessions/${sessionId}`);
// Get coordinator state
const coordinatorState = useCoordinatorStore();
// Format relative time with fallback
const formatRelativeTime = React.useCallback((dateStr: string | undefined): string => {
if (!dateStr) return formatMessage({ id: 'common.time.justNow' });
const date = new Date(dateStr);
if (isNaN(date.getTime())) return formatMessage({ id: 'common.time.justNow' });
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return formatMessage({ id: 'common.time.justNow' });
if (diffMins < 60) return formatMessage({ id: 'common.time.minutesAgo' }, { count: diffMins });
if (diffHours < 24) return formatMessage({ id: 'common.time.hoursAgo' }, { count: diffHours });
return formatMessage({ id: 'common.time.daysAgo' }, { count: diffDays });
}, [formatMessage]);
// Convert to unified items
const unifiedItems = React.useMemo((): UnifiedTaskItem[] => {
const items: UnifiedTaskItem[] = [];
// Add workflow sessions
activeSessions.forEach((session) => {
const status = session.status || 'pending';
items.push({
id: session.session_id,
name: session.title || session.description || session.session_id,
type: 'workflow',
status,
statusKey: statusI18nKeys[status] || status,
createdAt: formatRelativeTime(session.created_at),
description: session.description || `Session: ${session.session_id}`,
tags: [],
progress: undefined,
});
});
// Add lite tasks
liteSessions.forEach((session) => {
const status = session.status || 'pending';
const sessionId = session.session_id || session.id;
items.push({
id: sessionId,
name: session.title || sessionId,
type: 'lite',
subType: session._type,
status,
statusKey: statusI18nKeys[status] || status,
createdAt: formatRelativeTime(session.createdAt),
description: session.description || `${session._type} task`,
tags: [],
progress: undefined,
});
});
// Add current coordinator execution if exists
if (coordinatorState.currentExecutionId && coordinatorState.status !== 'idle') {
const status = coordinatorState.status;
const completedSteps = coordinatorState.commandChain.filter(n => n.status === 'completed').length;
const totalSteps = coordinatorState.commandChain.length;
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
items.push({
id: coordinatorState.currentExecutionId,
name: coordinatorState.pipelineDetails?.nodes[0]?.name || 'Orchestrator Task',
type: 'orchestrator',
status,
statusKey: statusI18nKeys[status] || status,
createdAt: formatRelativeTime(coordinatorState.startedAt),
description: `${completedSteps}/${totalSteps} steps completed`,
progress,
});
}
// Sort by most recent (use original date for sorting, not formatted string)
return items;
}, [activeSessions, liteSessions, coordinatorState, formatRelativeTime]);
// Filter items by tab
const filteredItems = React.useMemo(() => {
if (activeTab === 'all') return unifiedItems.slice(0, maxItems);
return unifiedItems.filter((item) => item.type === activeTab).slice(0, maxItems);
}, [unifiedItems, activeTab, maxItems]);
// Handle item click
const handleItemClick = (item: UnifiedTaskItem) => {
switch (item.type) {
case 'workflow':
navigate(`/sessions/${item.id}`);
break;
case 'lite':
navigate(`/lite-tasks/${item.subType}/${item.id}`);
break;
case 'orchestrator':
navigate(`/orchestrator`);
break;
}
};
const handleViewAll = () => {
navigate('/sessions');
};
const isLoading = sessionsLoading || liteLoading;
return (
<div {...props} className={className}>
<div className={className}>
<Card className="h-full p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-foreground">
{formatMessage({ id: 'home.sections.recentSessions' })}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'home.sections.recentTasks' })}
</h3>
<Button variant="link" size="sm" onClick={handleViewAll}>
<Button variant="link" size="sm" className="text-xs h-auto p-0" onClick={handleViewAll}>
{formatMessage({ id: 'common.actions.viewAll' })}
</Button>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-3 overflow-x-auto pb-1">
{TABS.map((tab) => {
const TabIcon = tab.icon;
const count = tab.key === 'all' ? unifiedItems.length :
unifiedItems.filter((i) => i.type === tab.key).length;
return (
<Button
key={tab.key}
variant={activeTab === tab.key ? 'default' : 'ghost'}
size="sm"
onClick={() => setActiveTab(tab.key)}
className={cn(
'whitespace-nowrap text-xs gap-1 h-7 px-2',
activeTab === tab.key && 'bg-primary text-primary-foreground'
)}
>
<TabIcon className="h-3 w-3" />
{formatMessage({ id: tab.label })}
<span className="text-[10px] opacity-70">({count})</span>
</Button>
);
})}
</div>
{/* Task items */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<SessionCardSkeleton key={i} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<TaskItemSkeleton key={i} />
))}
</div>
) : recentSessions.length === 0 ? (
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-2" />
<FolderKanban className="h-10 w-10 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'home.emptyState.noSessions.message' })}
{formatMessage({ id: 'home.emptyState.noTasks.message' })}
</p>
</div>
) : (
<div className="space-y-3">
{recentSessions.map((session) => (
<SessionCard
key={session.session_id}
session={session}
onClick={handleSessionClick}
onView={handleSessionClick}
showActions={false}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{filteredItems.map((item) => (
<TaskItemCard
key={`${item.type}-${item.id}`}
item={item}
onClick={() => handleItemClick(item)}
/>
))}
</div>
@@ -108,10 +406,6 @@ function RecentSessionsWidgetComponent({
);
}
/**
* Memoized RecentSessionsWidget - Prevents re-renders when parent updates
* Props are compared shallowly; use useCallback for function props
*/
export const RecentSessionsWidget = React.memo(RecentSessionsWidgetComponent);
export default RecentSessionsWidget;

View File

@@ -0,0 +1,140 @@
// ========================================
// TaskMarqueeWidget Component
// ========================================
// Widget showing scrolling task details in a marquee/ticker format
import { memo, useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { ListChecks } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface TaskMarqueeWidgetProps {
className?: string;
}
interface TaskItem {
id: string;
name: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
priority: 'low' | 'medium' | 'high' | 'critical';
progress: number;
}
// Mock task data
const MOCK_TASKS: TaskItem[] = [
{ id: '1', name: 'Implement user authentication system', status: 'in_progress', priority: 'high', progress: 75 },
{ id: '2', name: 'Design database schema', status: 'completed', priority: 'high', progress: 100 },
{ id: '3', name: 'Setup CI/CD pipeline', status: 'in_progress', priority: 'critical', progress: 45 },
{ id: '4', name: 'Write API documentation', status: 'pending', priority: 'medium', progress: 0 },
{ id: '5', name: 'Performance optimization', status: 'completed', priority: 'medium', progress: 100 },
{ id: '6', name: 'Security audit and fixes', status: 'failed', priority: 'critical', progress: 30 },
{ id: '7', name: 'Integration testing', status: 'in_progress', priority: 'high', progress: 60 },
{ id: '8', name: 'Deploy to staging', status: 'pending', priority: 'medium', progress: 0 },
];
// Status color mapping
const statusColors: Record<string, string> = {
pending: 'bg-muted',
in_progress: 'bg-warning/20 text-warning',
completed: 'bg-success/20 text-success',
failed: 'bg-destructive/20 text-destructive',
};
const priorityColors: Record<string, string> = {
low: 'bg-muted text-muted-foreground',
medium: 'bg-info/20 text-info',
high: 'bg-warning/20 text-warning',
critical: 'bg-destructive/20 text-destructive',
};
// Map status values to i18n keys
const statusLabelKeys: Record<string, string> = {
pending: 'common.status.pending',
in_progress: 'common.status.inProgress',
completed: 'common.status.completed',
failed: 'common.status.failed',
};
function TaskMarqueeWidgetComponent({ className }: TaskMarqueeWidgetProps) {
const { formatMessage } = useIntl();
const [currentIndex, setCurrentIndex] = useState(0);
// Auto-advance task display every 4 seconds
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % MOCK_TASKS.length);
}, 4000);
return () => clearInterval(interval);
}, []);
const currentTask = MOCK_TASKS[currentIndex];
return (
<Card className={cn('h-full p-4 flex flex-col', className)}>
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<ListChecks className="h-5 w-5" />
{formatMessage({ id: 'home.sections.taskDetails' })}
</h3>
<div className="flex-1 flex flex-col justify-center gap-4">
{/* Task name with marquee effect */}
<div className="overflow-hidden">
<div className="animate-marquee">
<h4 className="text-base font-semibold text-foreground whitespace-nowrap">
{currentTask.name}
</h4>
</div>
</div>
{/* Status and Priority badges */}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={cn(statusColors[currentTask.status], 'capitalize')}>
{formatMessage({ id: statusLabelKeys[currentTask.status] })}
</Badge>
<Badge className={cn(priorityColors[currentTask.priority], 'capitalize')}>
{formatMessage({ id: `common.priority.${currentTask.priority}` })}
</Badge>
</div>
{/* Progress bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{formatMessage({ id: 'common.labels.progress' })}</span>
<span className="font-semibold text-foreground">{currentTask.progress}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${currentTask.progress}%` }}
/>
</div>
</div>
{/* Task counter */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border">
<span>
{currentIndex + 1} / {MOCK_TASKS.length}
</span>
<div className="flex gap-1">
{MOCK_TASKS.map((_, idx) => (
<div
key={idx}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
idx === currentIndex ? 'bg-primary' : 'bg-muted'
)}
/>
))}
</div>
</div>
</div>
</Card>
);
}
export const TaskMarqueeWidget = memo(TaskMarqueeWidgetComponent);
export default TaskMarqueeWidget;

View File

@@ -28,9 +28,9 @@ export interface TaskTypeBarChartWidgetProps {
*/
function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useTaskTypeCounts();
const { data, isLoading } = useTaskTypeCounts();
// Use mock data if API is not ready
// Use mock data if API call fails or returns no data
const chartData = data || generateMockTaskTypeCounts();
return (
@@ -41,10 +41,6 @@ function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarCha
</h3>
{isLoading ? (
<ChartSkeleton type="bar" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<TaskTypeBarChart data={chartData} height={280} />
)}

View File

@@ -28,9 +28,9 @@ export interface WorkflowStatusPieChartWidgetProps {
*/
function WorkflowStatusPieChartWidgetComponent({ className, ...props }: WorkflowStatusPieChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useWorkflowStatusCounts();
const { data, isLoading } = useWorkflowStatusCounts();
// Use mock data if API is not ready
// Use mock data if API call fails or returns no data
const chartData = data || generateMockWorkflowStatusCounts();
return (
@@ -41,10 +41,6 @@ function WorkflowStatusPieChartWidgetComponent({ className, ...props }: Workflow
</h3>
{isLoading ? (
<ChartSkeleton type="pie" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<WorkflowStatusPieChart data={chartData} height={280} />
)}

View File

@@ -0,0 +1,109 @@
// ========================================
// WorkflowStatusProgressWidget Component
// ========================================
// Widget showing workflow status distribution using progress bars
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { Progress } from '@/components/ui/Progress';
import { Badge } from '@/components/ui/Badge';
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
import { cn } from '@/lib/utils';
export interface WorkflowStatusProgressWidgetProps {
className?: string;
}
// Status color mapping
const statusColors: Record<string, { bg: string; text: string }> = {
completed: { bg: 'bg-success', text: 'text-success' },
in_progress: { bg: 'bg-warning', text: 'text-warning' },
planning: { bg: 'bg-info', text: 'text-info' },
paused: { bg: 'bg-muted', text: 'text-muted-foreground' },
archived: { bg: 'bg-secondary', text: 'text-secondary-foreground' },
};
// Status label keys for i18n
const statusLabelKeys: Record<string, string> = {
completed: 'sessions.status.completed',
in_progress: 'sessions.status.inProgress',
planning: 'sessions.status.planning',
paused: 'sessions.status.paused',
archived: 'sessions.status.archived',
};
function WorkflowStatusProgressWidgetComponent({ className }: WorkflowStatusProgressWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading } = useWorkflowStatusCounts();
// Use mock data if API call fails or returns no data
const chartData = data || generateMockWorkflowStatusCounts();
// Calculate total for percentage
const total = chartData.reduce((sum, item) => sum + item.count, 0);
return (
<Card className={cn('h-full p-4 flex flex-col', className)}>
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'home.widgets.workflowStatus' })}
</h3>
{isLoading ? (
<div className="space-y-4 flex-1">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="h-2 bg-muted rounded animate-pulse" />
</div>
))}
</div>
) : (
<div className="space-y-4 flex-1">
{chartData.map((item) => {
const percentage = total > 0 ? Math.round((item.count / total) * 100) : 0;
const colors = statusColors[item.status] || statusColors.completed;
return (
<div key={item.status} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: statusLabelKeys[item.status] })}
</span>
<Badge variant="secondary" className="text-xs">
{item.count}
</Badge>
</div>
<span className={cn('text-sm font-semibold', colors.text)}>
{percentage}%
</span>
</div>
<Progress
value={percentage}
className="h-2"
indicatorClassName={colors.bg}
/>
</div>
);
})}
</div>
)}
{!isLoading && total > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{formatMessage({ id: 'common.stats.total' })}
</span>
<span className="font-semibold text-foreground">{total}</span>
</div>
</div>
)}
</Card>
);
}
export const WorkflowStatusProgressWidget = memo(WorkflowStatusProgressWidgetComponent);
export default WorkflowStatusProgressWidget;

View File

@@ -0,0 +1,723 @@
// ========================================
// WorkflowTaskWidget Component
// ========================================
// Combined dashboard widget: project info + stats + workflow status + orchestrator + task carousel
import { memo, useMemo, useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { Progress } from '@/components/ui/Progress';
import { Button } from '@/components/ui/Button';
import { Sparkline } from '@/components/charts/Sparkline';
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
import { useDashboardStats } from '@/hooks/useDashboardStats';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import { useProjectOverview } from '@/hooks/useProjectOverview';
import { cn } from '@/lib/utils';
import {
ListChecks,
Clock,
FolderKanban,
CheckCircle2,
XCircle,
Activity,
Play,
Pause,
Square,
Loader2,
AlertCircle,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Tag,
Calendar,
Code2,
Server,
Layers,
GitBranch,
Wrench,
FileCode,
Bug,
Sparkles,
BookOpen,
} from 'lucide-react';
export interface WorkflowTaskWidgetProps {
className?: string;
}
// ---- Workflow Status section ----
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
completed: { bg: 'bg-success', text: 'text-success', dot: 'bg-emerald-500' },
in_progress: { bg: 'bg-warning', text: 'text-warning', dot: 'bg-amber-500' },
planning: { bg: 'bg-violet-500', text: 'text-violet-600', dot: 'bg-violet-500' },
paused: { bg: 'bg-slate-400', text: 'text-slate-500', dot: 'bg-slate-400' },
archived: { bg: 'bg-slate-300', text: 'text-slate-400', dot: 'bg-slate-300' },
};
const statusLabelKeys: Record<string, string> = {
completed: 'sessions.status.completed',
in_progress: 'sessions.status.inProgress',
planning: 'sessions.status.planning',
paused: 'sessions.status.paused',
archived: 'sessions.status.archived',
};
// ---- Task List section ----
interface TaskItem {
id: string;
name: string;
status: 'pending' | 'completed';
}
// Session with its tasks
interface SessionWithTasks {
id: string;
name: string;
description?: string;
status: 'planning' | 'in_progress' | 'completed' | 'paused';
tags: string[];
createdAt: string;
updatedAt: string;
tasks: TaskItem[];
}
// Mock sessions with their tasks
const MOCK_SESSIONS: SessionWithTasks[] = [
{
id: 'WFS-auth-001',
name: 'User Authentication System',
description: 'Implement OAuth2 and JWT based authentication with role-based access control',
status: 'in_progress',
tags: ['auth', 'security', 'backend'],
createdAt: '2024-01-15',
updatedAt: '2024-01-20',
tasks: [
{ id: '1', name: 'Implement user authentication', status: 'pending' },
{ id: '2', name: 'Design database schema', status: 'completed' },
{ id: '3', name: 'Setup CI/CD pipeline', status: 'pending' },
],
},
{
id: 'WFS-api-002',
name: 'API Documentation',
description: 'Create comprehensive API documentation with OpenAPI 3.0 specification',
status: 'planning',
tags: ['docs', 'api'],
createdAt: '2024-01-18',
updatedAt: '2024-01-19',
tasks: [
{ id: '4', name: 'Write API documentation', status: 'pending' },
{ id: '5', name: 'Create OpenAPI spec', status: 'pending' },
],
},
{
id: 'WFS-perf-003',
name: 'Performance Optimization',
description: 'Optimize database queries and implement caching strategies',
status: 'completed',
tags: ['performance', 'optimization', 'database'],
createdAt: '2024-01-10',
updatedAt: '2024-01-17',
tasks: [
{ id: '6', name: 'Performance optimization', status: 'completed' },
{ id: '7', name: 'Security audit', status: 'completed' },
],
},
{
id: 'WFS-test-004',
name: 'Integration Testing',
description: 'Setup E2E testing framework and write integration tests',
status: 'in_progress',
tags: ['testing', 'e2e', 'ci'],
createdAt: '2024-01-19',
updatedAt: '2024-01-20',
tasks: [
{ id: '8', name: 'Integration testing', status: 'completed' },
{ id: '9', name: 'Deploy to staging', status: 'pending' },
{ id: '10', name: 'E2E test setup', status: 'pending' },
],
},
];
const taskStatusColors: Record<string, { bg: string; text: string; icon: typeof CheckCircle2 }> = {
pending: { bg: 'bg-muted', text: 'text-muted-foreground', icon: Clock },
completed: { bg: 'bg-success/20', text: 'text-success', icon: CheckCircle2 },
};
const sessionStatusColors: Record<string, { bg: string; text: string }> = {
planning: { bg: 'bg-violet-500/20', text: 'text-violet-600' },
in_progress: { bg: 'bg-warning/20', text: 'text-warning' },
completed: { bg: 'bg-success/20', text: 'text-success' },
paused: { bg: 'bg-slate-400/20', text: 'text-slate-500' },
};
// ---- Mini Stat Card with Sparkline ----
interface MiniStatCardProps {
icon: React.ElementType;
title: string;
value: number;
variant: 'primary' | 'info' | 'success' | 'warning' | 'danger' | 'default';
sparklineData?: number[];
}
const variantStyles: Record<string, { card: string; icon: string }> = {
primary: { card: 'border-primary/30 bg-primary/5', icon: 'bg-primary/10 text-primary' },
info: { card: 'border-info/30 bg-info/5', icon: 'bg-info/10 text-info' },
success: { card: 'border-success/30 bg-success/5', icon: 'bg-success/10 text-success' },
warning: { card: 'border-warning/30 bg-warning/5', icon: 'bg-warning/10 text-warning' },
danger: { card: 'border-destructive/30 bg-destructive/5', icon: 'bg-destructive/10 text-destructive' },
default: { card: 'border-border', icon: 'bg-muted text-muted-foreground' },
};
function MiniStatCard({ icon: Icon, title, value, variant, sparklineData }: MiniStatCardProps) {
const styles = variantStyles[variant] || variantStyles.default;
return (
<div className={cn('rounded-lg border p-2 transition-all hover:shadow-sm', styles.card)}>
<div className="flex items-start justify-between gap-1">
<div className="flex-1 min-w-0">
<p className="text-[10px] font-medium text-muted-foreground truncate">{title}</p>
<p className="text-lg font-semibold text-card-foreground mt-0.5">{value.toLocaleString()}</p>
</div>
<div className={cn('flex h-7 w-7 items-center justify-center rounded-md shrink-0', styles.icon)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{sparklineData && sparklineData.length > 0 && (
<div className="mt-1 -mx-1">
<Sparkline data={sparklineData} height={24} strokeWidth={1.5} />
</div>
)}
</div>
);
}
// Generate sparkline data
function generateSparklineData(currentValue: number, variance = 0.3): number[] {
const days = 7;
const data: number[] = [];
let value = Math.max(0, currentValue * (1 - variance));
for (let i = 0; i < days - 1; i++) {
data.push(Math.round(value));
const change = (Math.random() - 0.5) * 2 * variance * currentValue;
value = Math.max(0, value + change);
}
data.push(currentValue);
return data;
}
// Orchestrator status icons and colors
const orchestratorStatusConfig: Record<string, { icon: typeof Play; color: string; bg: string }> = {
idle: { icon: Square, color: 'text-muted-foreground', bg: 'bg-muted' },
initializing: { icon: Loader2, color: 'text-info', bg: 'bg-info/20' },
running: { icon: Play, color: 'text-success', bg: 'bg-success/20' },
paused: { icon: Pause, color: 'text-warning', bg: 'bg-warning/20' },
completed: { icon: CheckCircle2, color: 'text-success', bg: 'bg-success/20' },
failed: { icon: XCircle, color: 'text-destructive', bg: 'bg-destructive/20' },
cancelled: { icon: AlertCircle, color: 'text-muted-foreground', bg: 'bg-muted' },
};
function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading } = useWorkflowStatusCounts();
const { stats, isLoading: statsLoading } = useDashboardStats({ refetchInterval: 60000 });
const { projectOverview, isLoading: projectLoading } = useProjectOverview();
// Get coordinator state
const coordinatorState = useCoordinatorStore();
const orchestratorConfig = orchestratorStatusConfig[coordinatorState.status] || orchestratorStatusConfig.idle;
const OrchestratorIcon = orchestratorConfig.icon;
const chartData = data || generateMockWorkflowStatusCounts();
const total = chartData.reduce((sum, item) => sum + item.count, 0);
// Generate sparkline data for each stat
const sparklines = useMemo(() => ({
activeSessions: generateSparklineData(stats?.activeSessions ?? 0, 0.4),
totalTasks: generateSparklineData(stats?.totalTasks ?? 0, 0.3),
completedTasks: generateSparklineData(stats?.completedTasks ?? 0, 0.25),
pendingTasks: generateSparklineData(stats?.pendingTasks ?? 0, 0.35),
failedTasks: generateSparklineData(stats?.failedTasks ?? 0, 0.5),
todayActivity: generateSparklineData(stats?.todayActivity ?? 0, 0.6),
}), [stats]);
// Calculate orchestrator progress
const orchestratorProgress = coordinatorState.commandChain.length > 0
? Math.round((coordinatorState.commandChain.filter(n => n.status === 'completed').length / coordinatorState.commandChain.length) * 100)
: 0;
// Project info expanded state
const [projectExpanded, setProjectExpanded] = useState(false);
// Session carousel state
const [currentSessionIndex, setCurrentSessionIndex] = useState(0);
const currentSession = MOCK_SESSIONS[currentSessionIndex];
// Auto-rotate carousel every 5 seconds
useEffect(() => {
const timer = setInterval(() => {
setCurrentSessionIndex((prev) => (prev + 1) % MOCK_SESSIONS.length);
}, 5000);
return () => clearInterval(timer);
}, []);
// Manual navigation
const handlePrevSession = () => {
setCurrentSessionIndex((prev) => (prev === 0 ? MOCK_SESSIONS.length - 1 : prev - 1));
};
const handleNextSession = () => {
setCurrentSessionIndex((prev) => (prev + 1) % MOCK_SESSIONS.length);
};
return (
<div className={cn('flex flex-col gap-2', className)}>
{/* Project Info Banner - Separate Card */}
<Card className="shrink-0">
{projectLoading ? (
<div className="px-4 py-3 flex items-center gap-4">
<div className="h-5 w-32 bg-muted rounded animate-pulse" />
<div className="h-4 w-48 bg-muted rounded animate-pulse" />
</div>
) : (
<>
{/* Collapsed Header */}
<div className="px-4 py-3 flex items-center gap-6 flex-wrap">
{/* Project Name & Icon */}
<div className="flex items-center gap-2.5 min-w-0">
<div className="p-1.5 rounded-md bg-primary/10">
<Code2 className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{projectOverview?.projectName || 'Claude Code Workflow'}
</h2>
<p className="text-[10px] text-muted-foreground truncate max-w-[280px]">
{projectOverview?.description || 'AI-powered workflow management system'}
</p>
</div>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border hidden md:block" />
{/* Tech Stack Badges */}
<div className="flex items-center gap-2 text-[10px]">
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 font-medium">
<Code2 className="h-3 w-3" />
{projectOverview?.technologyStack?.languages?.[0]?.name || 'TypeScript'}
</span>
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-green-500/10 text-green-600 font-medium">
<Server className="h-3 w-3" />
{projectOverview?.technologyStack?.frameworks?.[0] || 'Node.js'}
</span>
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-violet-500/10 text-violet-600 font-medium">
<Layers className="h-3 w-3" />
{projectOverview?.architecture?.style || 'Modular Monolith'}
</span>
{projectOverview?.technologyStack?.buildTools?.[0] && (
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-orange-500/10 text-orange-600 font-medium">
<Wrench className="h-3 w-3" />
{projectOverview.technologyStack.buildTools[0]}
</span>
)}
</div>
{/* Divider */}
<div className="h-8 w-px bg-border hidden lg:block" />
{/* Quick Stats */}
<div className="flex items-center gap-4 text-[10px]">
<div className="flex items-center gap-1.5 text-emerald-600">
<Sparkles className="h-3 w-3" />
<span className="font-semibold">{projectOverview?.developmentIndex?.feature?.length || 0}</span>
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.features' })}</span>
</div>
<div className="flex items-center gap-1.5 text-amber-600">
<Bug className="h-3 w-3" />
<span className="font-semibold">{projectOverview?.developmentIndex?.bugfix?.length || 0}</span>
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.bugfixes' })}</span>
</div>
<div className="flex items-center gap-1.5 text-blue-600">
<FileCode className="h-3 w-3" />
<span className="font-semibold">{projectOverview?.developmentIndex?.enhancement?.length || 0}</span>
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.enhancements' })}</span>
</div>
</div>
{/* Date + Expand Button */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground ml-auto">
<span className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50">
<Calendar className="h-3 w-3" />
{projectOverview?.initializedAt ? new Date(projectOverview.initializedAt).toLocaleDateString() : new Date().toLocaleDateString()}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted"
onClick={() => setProjectExpanded(!projectExpanded)}
>
{projectExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Expanded Details */}
{projectExpanded && (
<div className="px-3 pb-2 grid grid-cols-4 gap-3 border-t border-border/50 pt-2">
{/* Architecture */}
<div className="space-y-1">
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
<Layers className="h-3 w-3" />
{formatMessage({ id: 'projectOverview.architecture.title' })}
</h4>
<div className="space-y-0.5">
<p className="text-[10px] text-foreground">{projectOverview?.architecture?.style || 'Modular Monolith'}</p>
<div className="flex flex-wrap gap-1">
{projectOverview?.architecture?.layers?.slice(0, 3).map((layer, i) => (
<span key={i} className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground">{layer}</span>
))}
</div>
</div>
</div>
{/* Key Components */}
<div className="space-y-1">
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
<Wrench className="h-3 w-3" />
{formatMessage({ id: 'projectOverview.components.title' })}
</h4>
<div className="space-y-0.5">
{projectOverview?.keyComponents?.slice(0, 3).map((comp, i) => (
<p key={i} className="text-[9px] text-foreground truncate">{comp.name}</p>
)) || (
<>
<p className="text-[9px] text-foreground">Session Manager</p>
<p className="text-[9px] text-foreground">Dashboard Generator</p>
<p className="text-[9px] text-foreground">Data Aggregator</p>
</>
)}
</div>
</div>
{/* Development History */}
<div className="space-y-1">
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
<FileCode className="h-3 w-3" />
{formatMessage({ id: 'projectOverview.devIndex.title' })}
</h4>
<div className="flex flex-wrap gap-1.5">
<span className="flex items-center gap-0.5 text-[9px] text-emerald-600">
<Sparkles className="h-2.5 w-2.5" />
{projectOverview?.developmentIndex?.feature?.length || 0}
</span>
<span className="flex items-center gap-0.5 text-[9px] text-blue-600">
<FileCode className="h-2.5 w-2.5" />
{projectOverview?.developmentIndex?.enhancement?.length || 0}
</span>
<span className="flex items-center gap-0.5 text-[9px] text-amber-600">
<Bug className="h-2.5 w-2.5" />
{projectOverview?.developmentIndex?.bugfix?.length || 0}
</span>
<span className="flex items-center gap-0.5 text-[9px] text-violet-600">
<Wrench className="h-2.5 w-2.5" />
{projectOverview?.developmentIndex?.refactor?.length || 0}
</span>
<span className="flex items-center gap-0.5 text-[9px] text-slate-600">
<BookOpen className="h-2.5 w-2.5" />
{projectOverview?.developmentIndex?.docs?.length || 0}
</span>
</div>
</div>
{/* Design Patterns */}
<div className="space-y-1">
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4>
<div className="flex flex-wrap gap-1">
{projectOverview?.architecture?.patterns?.slice(0, 4).map((pattern, i) => (
<span key={i} className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">{pattern}</span>
)) || (
<>
<span className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">Factory</span>
<span className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">Strategy</span>
<span className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">Observer</span>
</>
)}
</div>
</div>
</div>
)}
</>
)}
</Card>
{/* Main content Card: Stats | Workflow+Orchestrator | Task Details */}
<Card className="h-[320px] flex shrink-0 overflow-hidden">
{/* Compact Stats Section with Sparklines */}
<div className="w-[28%] p-2.5 flex flex-col border-r border-border">
<h3 className="text-xs font-semibold text-foreground mb-2 px-0.5">
{formatMessage({ id: 'home.sections.statistics' })}
</h3>
{statsLoading ? (
<div className="grid grid-cols-2 gap-1.5 flex-1">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-14 bg-muted rounded animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1.5 flex-1 content-start overflow-auto">
<MiniStatCard
icon={FolderKanban}
title={formatMessage({ id: 'home.stats.activeSessions' })}
value={stats?.activeSessions ?? 0}
variant="primary"
sparklineData={sparklines.activeSessions}
/>
<MiniStatCard
icon={ListChecks}
title={formatMessage({ id: 'home.stats.totalTasks' })}
value={stats?.totalTasks ?? 0}
variant="info"
sparklineData={sparklines.totalTasks}
/>
<MiniStatCard
icon={CheckCircle2}
title={formatMessage({ id: 'home.stats.completedTasks' })}
value={stats?.completedTasks ?? 0}
variant="success"
sparklineData={sparklines.completedTasks}
/>
<MiniStatCard
icon={Clock}
title={formatMessage({ id: 'home.stats.pendingTasks' })}
value={stats?.pendingTasks ?? 0}
variant="warning"
sparklineData={sparklines.pendingTasks}
/>
<MiniStatCard
icon={XCircle}
title={formatMessage({ id: 'common.status.failed' })}
value={stats?.failedTasks ?? 0}
variant="danger"
sparklineData={sparklines.failedTasks}
/>
<MiniStatCard
icon={Activity}
title={formatMessage({ id: 'common.stats.todayActivity' })}
value={stats?.todayActivity ?? 0}
variant="default"
sparklineData={sparklines.todayActivity}
/>
</div>
)}
</div>
{/* Workflow Status + Orchestrator Status Section */}
<div className="w-[26%] p-3 flex flex-col border-r border-border overflow-auto">
{/* Workflow Status */}
<h3 className="text-xs font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.workflowStatus' })}
</h3>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-3 bg-muted rounded animate-pulse" />
))}
</div>
) : (
<div className="space-y-2">
{chartData.map((item) => {
const percentage = total > 0 ? Math.round((item.count / total) * 100) : 0;
const colors = statusColors[item.status] || statusColors.completed;
return (
<div key={item.status} className="space-y-0.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<div className={cn('w-1.5 h-1.5 rounded-full', colors.dot)} />
<span className="text-[11px] text-foreground">
{formatMessage({ id: statusLabelKeys[item.status] })}
</span>
<span className="text-[11px] text-muted-foreground">
{item.count}
</span>
</div>
<span className={cn('text-[11px] font-medium', colors.text)}>
{percentage}%
</span>
</div>
<Progress
value={percentage}
className="h-1 bg-muted"
indicatorClassName={colors.bg}
/>
</div>
);
})}
</div>
)}
{/* Orchestrator Status Section */}
<div className="mt-3 pt-3 border-t border-border">
<h3 className="text-xs font-semibold text-foreground mb-2">
{formatMessage({ id: 'navigation.main.orchestrator' })}
</h3>
<div className={cn('rounded-lg p-2', orchestratorConfig.bg)}>
<div className="flex items-center gap-2">
<OrchestratorIcon className={cn('h-4 w-4', orchestratorConfig.color, coordinatorState.status === 'running' && 'animate-pulse')} />
<div className="flex-1 min-w-0">
<p className={cn('text-[11px] font-medium', orchestratorConfig.color)}>
{formatMessage({ id: `common.status.${coordinatorState.status}` })}
</p>
{coordinatorState.currentExecutionId && (
<p className="text-[10px] text-muted-foreground truncate">
{coordinatorState.pipelineDetails?.nodes[0]?.name || coordinatorState.currentExecutionId}
</p>
)}
</div>
</div>
{coordinatorState.status !== 'idle' && coordinatorState.commandChain.length > 0 && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">
{formatMessage({ id: 'common.labels.progress' })}
</span>
<span className="font-medium">{orchestratorProgress}%</span>
</div>
<Progress value={orchestratorProgress} className="h-1 bg-muted/50" />
<p className="text-[10px] text-muted-foreground">
{coordinatorState.commandChain.filter(n => n.status === 'completed').length}/{coordinatorState.commandChain.length} {formatMessage({ id: 'coordinator.steps' })}
</p>
</div>
)}
</div>
</div>
</div>
{/* Task Details Section: Session Carousel with Task List */}
<div className="w-[46%] p-3 flex flex-col">
{/* Header with navigation */}
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-foreground flex items-center gap-1">
<ListChecks className="h-3.5 w-3.5" />
{formatMessage({ id: 'home.sections.taskDetails' })}
</h3>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" onClick={handlePrevSession}>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-[10px] text-muted-foreground min-w-[40px] text-center">
{currentSessionIndex + 1} / {MOCK_SESSIONS.length}
</span>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" onClick={handleNextSession}>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
{/* Session Card (Carousel Item) */}
{currentSession && (
<div className="flex-1 flex flex-col min-h-0 rounded-lg border border-border bg-accent/20 p-2.5 overflow-hidden">
{/* Session Header */}
<div className="mb-2 pb-2 border-b border-border shrink-0">
<div className="flex items-start gap-2">
<div className={cn('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', sessionStatusColors[currentSession.status].bg, sessionStatusColors[currentSession.status].text)}>
{formatMessage({ id: `common.status.${currentSession.status === 'in_progress' ? 'inProgress' : currentSession.status}` })}
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-foreground truncate">{currentSession.name}</p>
<p className="text-[10px] text-muted-foreground">{currentSession.id}</p>
</div>
</div>
{/* Description */}
{currentSession.description && (
<p className="text-[10px] text-muted-foreground mt-1.5 line-clamp-2">
{currentSession.description}
</p>
)}
{/* Progress bar */}
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">
{formatMessage({ id: 'common.labels.progress' })}
</span>
<span className="font-medium text-foreground">
{currentSession.tasks.filter(t => t.status === 'completed').length}/{currentSession.tasks.length}
</span>
</div>
<Progress
value={currentSession.tasks.length > 0 ? (currentSession.tasks.filter(t => t.status === 'completed').length / currentSession.tasks.length) * 100 : 0}
className="h-1 bg-muted"
indicatorClassName="bg-success"
/>
</div>
{/* Tags and Date */}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{currentSession.tags.map((tag) => (
<span key={tag} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[9px]">
<Tag className="h-2 w-2" />
{tag}
</span>
))}
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted-foreground ml-auto">
<Calendar className="h-2.5 w-2.5" />
{currentSession.updatedAt}
</span>
</div>
</div>
{/* Task List for this Session - Two columns */}
<div className="flex-1 overflow-auto min-h-0">
<div className="grid grid-cols-2 gap-1">
{currentSession.tasks.map((task) => {
const config = taskStatusColors[task.status];
const StatusIcon = config.icon;
return (
<div
key={task.id}
className="flex items-center gap-1.5 p-1.5 rounded hover:bg-background/50 transition-colors cursor-pointer"
>
<div className={cn('p-0.5 rounded shrink-0', config.bg)}>
<StatusIcon className={cn('h-2.5 w-2.5', config.text)} />
</div>
<p className={cn('flex-1 text-[10px] font-medium truncate', task.status === 'completed' ? 'text-muted-foreground line-through' : 'text-foreground')}>
{task.name}
</p>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Carousel dots */}
<div className="flex items-center justify-center gap-1 mt-2">
{MOCK_SESSIONS.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentSessionIndex(idx)}
className={cn(
'w-1.5 h-1.5 rounded-full transition-colors',
idx === currentSessionIndex ? 'bg-primary' : 'bg-muted hover:bg-muted-foreground/50'
)}
/>
))}
</div>
</div>
</Card>
</div>
);
}
export const WorkflowTaskWidget = memo(WorkflowTaskWidgetComponent);
export default WorkflowTaskWidget;

View File

@@ -22,7 +22,6 @@ import {
Clock,
Zap,
GitFork,
Activity,
Shield,
History,
Server,
@@ -81,7 +80,6 @@ const navGroupDefinitions: NavGroupDef[] = [
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
{ path: '/coordinator', labelKey: 'navigation.main.coordinator', icon: GitFork },
{ path: '/executions', labelKey: 'navigation.main.executions', icon: Activity },
{ path: '/loops', labelKey: 'navigation.main.loops', icon: RefreshCw },
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
],

View File

@@ -5,7 +5,8 @@
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { X, Activity, ChevronDown } from 'lucide-react';
import { X, Activity, ChevronDown, ExternalLink } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -18,6 +19,8 @@ export interface MonitorHeaderProps {
totalCount?: number;
/** Number of executions with errors */
errorCount?: number;
/** Current execution ID for popup navigation */
currentExecutionId?: string;
}
/**
@@ -32,11 +35,21 @@ export const MonitorHeader = memo(function MonitorHeader({
activeCount = 0,
totalCount = 0,
errorCount = 0,
currentExecutionId,
}: MonitorHeaderProps) {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const hasActive = activeCount > 0;
const hasErrors = errorCount > 0;
const handlePopOut = () => {
const url = currentExecutionId
? `/cli-viewer?executionId=${currentExecutionId}`
: '/cli-viewer';
navigate(url);
onClose();
};
return (
<header
className={cn(
@@ -70,8 +83,20 @@ export const MonitorHeader = memo(function MonitorHeader({
</div>
</div>
{/* Right side: Status + Count badge */}
{/* Right side: Pop out + Status + Count badge */}
<div className="flex items-center gap-3 shrink-0">
{/* Pop out to full page button */}
<Button
variant="ghost"
size="icon"
onClick={handlePopOut}
className="h-8 w-8"
title={formatMessage({ id: 'cliMonitor.popOutToPage' })}
aria-label={formatMessage({ id: 'cliMonitor.openInViewer' })}
>
<ExternalLink className="h-4 w-4" />
</Button>
{/* Live status indicator */}
{hasActive && (
<div className="flex items-center gap-2">

View File

@@ -27,6 +27,12 @@ import {
CheckCircle2,
AlertCircle,
RefreshCw,
FileText,
Search,
TestTube,
File,
Settings,
Zap,
} from 'lucide-react';
import type { SessionMetadata } from '@/types/store';
@@ -70,6 +76,31 @@ const statusLabelKeys: Record<SessionMetadata['status'], string> = {
paused: 'sessions.status.paused',
};
// Type variant configuration for session type badges
const typeVariantConfig: Record<
SessionMetadata['type'],
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info'; icon: React.ElementType }
> = {
review: { variant: 'info', icon: Search },
'tdd': { variant: 'success', icon: TestTube },
test: { variant: 'default', icon: FileText },
docs: { variant: 'warning', icon: File },
workflow: { variant: 'secondary', icon: Settings },
'lite-plan': { variant: 'default', icon: FileText },
'lite-fix': { variant: 'warning', icon: Zap },
};
// Type label keys for i18n
const typeLabelKeys: Record<SessionMetadata['type'], string> = {
review: 'sessions.type.review',
tdd: 'sessions.type.tdd',
test: 'sessions.type.test',
docs: 'sessions.type.docs',
workflow: 'sessions.type.workflow',
'lite-plan': 'sessions.type.lite-plan',
'lite-fix': 'sessions.type.lite-fix',
};
/**
* Format date to localized string
*/
@@ -150,6 +181,12 @@ export function SessionCard({
? formatMessage({ id: statusLabelKeys[session.status] })
: formatMessage({ id: 'common.status.unknown' });
// Type badge configuration (graceful degradation when type is undefined)
const typeConfig = session.type ? typeVariantConfig[session.type] : null;
const typeLabel = session.type && typeLabelKeys[session.type]
? formatMessage({ id: typeLabelKeys[session.type] })
: null;
const progress = calculateProgress(session.tasks);
const isPlanning = session.status === 'planning';
const isArchived = session.status === 'archived' || session.location === 'archived';
@@ -199,6 +236,12 @@ export function SessionCard({
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={statusVariant}>{statusLabel}</Badge>
{typeConfig && typeLabel && (
<Badge variant={typeConfig.variant} className="gap-1">
<typeConfig.icon className="h-3 w-3" />
{typeLabel}
</Badge>
)}
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -21,6 +21,8 @@ const badgeVariants = cva(
"border-transparent bg-warning text-white",
info:
"border-transparent bg-info text-white",
review:
"border-transparent bg-purple-600 text-white",
},
},
defaultVariants: {

View File

@@ -0,0 +1,22 @@
// ========================================
// DropdownMenu Component Re-export
// ========================================
// Re-export from Dropdown.tsx for consistent naming
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
} from './Dropdown';

View File

@@ -4,8 +4,10 @@ import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
@@ -15,7 +17,7 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -0,0 +1,47 @@
// ========================================
// TabsNavigation Component
// ========================================
// Reusable tab navigation with underline style
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
export interface TabItem {
value: string;
label: string;
icon?: React.ReactNode;
badge?: React.ReactNode;
disabled?: boolean;
}
interface TabsNavigationProps {
value: string;
onValueChange: (value: string) => void;
tabs: TabItem[];
className?: string;
}
export function TabsNavigation({ value, onValueChange, tabs, className }: TabsNavigationProps) {
return (
<div className={cn("flex gap-2 border-b border-border", className)}>
{tabs.map((tab) => (
<Button
key={tab.value}
variant="ghost"
disabled={tab.disabled}
className={cn(
"border-b-2 rounded-none h-11 px-4 gap-2",
value === tab.value
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => onValueChange(tab.value)}
>
{tab.icon}
{tab.label}
{tab.badge}
</Button>
))}
</div>
);
}

View File

@@ -3,10 +3,10 @@
// ========================================
// Convenient hook for locale management
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useAppStore, selectLocale } from '../stores/appStore';
import type { Locale } from '../types/store';
import { availableLocales } from '../lib/i18n';
import { availableLocales, formatMessage } from '../lib/i18n';
export interface UseLocaleReturn {
/** Current locale ('en' or 'zh') */
@@ -51,3 +51,25 @@ export function useLocale(): UseLocaleReturn {
availableLocales,
};
}
/**
* Hook to format i18n messages with the current locale
* @returns A formatMessage function for translating message IDs
*
* @example
* ```tsx
* const formatMessage = useFormatMessage();
* return <h1>{formatMessage('home.title')}</h1>;
* ```
*/
export function useFormatMessage(): (
id: string,
values?: Record<string, string | number | boolean | Date | null | undefined>
) => string {
// Use useMemo to avoid recreating the function on each render
return useMemo(() => {
return (id: string, values?: Record<string, string | number | boolean | Date | null | undefined>) => {
return formatMessage(id, values);
};
}, []);
}

View File

@@ -12,6 +12,9 @@ import { DEFAULT_DASHBOARD_LAYOUT } from '@/components/dashboard/defaultLayouts'
const DEBOUNCE_DELAY = 1000; // 1 second debounce for layout saves
const STORAGE_KEY = 'ccw-dashboard-layout';
// Version for layout schema - increment when widget IDs change
const LAYOUT_VERSION = 2; // v2: workflow-task + recent-sessions
export interface UseUserDashboardLayoutResult {
/** Current dashboard layouts */
layouts: DashboardLayouts;
@@ -59,8 +62,36 @@ export function useUserDashboardLayout(): UseUserDashboardLayoutResult {
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const isSavingRef = useRef(false);
// Initialize layout if not set
// Initialize layout if not set or version mismatch
useEffect(() => {
// Check if stored version matches current version
const storedVersion = localStorage.getItem(`${STORAGE_KEY}-version`);
const versionMismatch = storedVersion !== String(LAYOUT_VERSION);
if (versionMismatch) {
// Version mismatch - reset to default and update version
console.log(`Dashboard layout version changed (${storedVersion} -> ${LAYOUT_VERSION}), resetting to default`);
localStorage.removeItem(STORAGE_KEY);
localStorage.setItem(`${STORAGE_KEY}-version`, String(LAYOUT_VERSION));
// Also clear dashboardLayout from Zustand persist storage
try {
const zustandStorage = localStorage.getItem('ccw-app-store');
if (zustandStorage) {
const parsed = JSON.parse(zustandStorage);
if (parsed.state?.dashboardLayout) {
delete parsed.state.dashboardLayout;
localStorage.setItem('ccw-app-store', JSON.stringify(parsed));
}
}
} catch (e) {
console.warn('Failed to clear Zustand dashboard layout:', e);
}
resetDashboardLayout();
return;
}
if (!dashboardLayout) {
// Try to load from localStorage first
try {
@@ -96,9 +127,10 @@ export function useUserDashboardLayout(): UseUserDashboardLayoutResult {
// Update Zustand store (which will persist to localStorage)
setDashboardLayouts(newLayouts);
// Also save to additional localStorage backup
// Also save to additional localStorage backup with version
const currentWidgets = dashboardLayout?.widgets || DEFAULT_DASHBOARD_LAYOUT.widgets;
setLocalStorageLayout({ layouts: newLayouts, widgets: currentWidgets });
localStorage.setItem(`${STORAGE_KEY}-version`, String(LAYOUT_VERSION));
// TODO: When backend API is ready, uncomment this:
// syncToBackend({ layouts: newLayouts, widgets: currentWidgets });
@@ -114,9 +146,10 @@ export function useUserDashboardLayout(): UseUserDashboardLayoutResult {
(newWidgets: WidgetConfig[]) => {
setDashboardWidgets(newWidgets);
// Also save to localStorage backup
// Also save to localStorage backup with version
const currentLayouts = dashboardLayout?.layouts || DEFAULT_DASHBOARD_LAYOUT.layouts;
setLocalStorageLayout({ layouts: currentLayouts, widgets: newWidgets });
localStorage.setItem(`${STORAGE_KEY}-version`, String(LAYOUT_VERSION));
// TODO: When backend API is ready, uncomment this:
// syncToBackend({ layouts: currentLayouts, widgets: newWidgets });
@@ -134,8 +167,9 @@ export function useUserDashboardLayout(): UseUserDashboardLayoutResult {
// Reset Zustand store
resetDashboardLayout();
// Reset localStorage backup
// Reset localStorage backup with version
setLocalStorageLayout(DEFAULT_DASHBOARD_LAYOUT);
localStorage.setItem(`${STORAGE_KEY}-version`, String(LAYOUT_VERSION));
// TODO: When backend API is ready, uncomment this:
// syncToBackend(DEFAULT_DASHBOARD_LAYOUT);

View File

@@ -177,6 +177,41 @@ async function fetchApi<T>(
// ========== Transformation Helpers ==========
/**
* Infer session type from session_id pattern (matches backend logic)
* Used as fallback when backend.type field is missing
*
* @param sessionId - Session ID to analyze
* @returns Inferred session type
*
* @see ccw/src/core/session-scanner.ts:inferTypeFromName for backend implementation
*/
function inferTypeFromName(sessionId: string): SessionMetadata['type'] {
const name = sessionId.toLowerCase();
if (name.includes('-review-') || name.includes('-code-review-')) {
return 'review';
}
if (name.includes('-tdd-') || name.includes('-test-driven-')) {
return 'tdd';
}
if (name.includes('-test-') || name.includes('-testing-')) {
return 'test';
}
if (name.includes('-docs-') || name.includes('-doc-') || name.includes('-documentation-')) {
return 'docs';
}
if (name.includes('-lite-plan-')) {
return 'lite-plan';
}
if (name.includes('-lite-fix-') || name.includes('-fix-')) {
return 'lite-fix';
}
// Default to workflow for standard sessions
return 'workflow';
}
/**
* Transform backend session data to frontend SessionMetadata interface
* Maps backend schema (project, status: 'active') to frontend schema (title, description, status: 'in_progress', location)
@@ -212,8 +247,14 @@ function transformBackendSession(
description = parts.slice(1).join(':').trim();
}
// Preserve type field from backend, or infer from session_id pattern
// Multi-level type detection: backend.type > infer from name
const sessionType = (backendSession.type as SessionMetadata['type']) ||
inferTypeFromName(backendSession.session_id);
return {
session_id: backendSession.session_id,
type: sessionType,
title,
description,
status: transformedStatus,

View File

@@ -45,5 +45,7 @@
"tokens": "Tokens: {count}",
"duration": "Duration: {value}",
"model": "Model: {name}",
"user": "User"
"user": "User",
"popOutToPage": "Open in Full Page",
"openInViewer": "Open in CLI Viewer"
}

View File

@@ -0,0 +1,51 @@
{
"page": {
"title": "CLI Viewer",
"subtitle": "{count, plural, =0 {No active sessions} one {# active session} other {# active sessions}}"
},
"layout": {
"title": "Layout",
"single": "Single",
"splitH": "Split Horizontal",
"splitV": "Split Vertical",
"grid": "Grid 2x2"
},
"pane": {
"empty": "No execution selected",
"selectExecution": "Select Execution",
"loading": "Loading output for {executionId}...",
"close": "Close",
"maximize": "Maximize",
"minimize": "Minimize"
},
"toolbar": {
"refresh": "Refresh",
"clearAll": "Clear All",
"settings": "Settings"
},
"emptyState": {
"title": "No CLI Executions",
"description": "Select an execution from the sidebar or start a new CLI session to view output here.",
"action": "View Executions"
},
"tabs": {
"noTabs": "No tabs open",
"addTab": "Add tab",
"closeTab": "Close tab",
"pinTab": "Pin tab",
"unpinTab": "Unpin tab"
},
"picker": {
"selectExecution": "Select Execution",
"searchExecutions": "Search executions...",
"noExecutions": "No executions available",
"noMatchingExecutions": "No matching executions",
"alreadyOpen": "Already open",
"executionCount": "{available} of {total} executions available"
},
"paneActions": {
"splitHorizontal": "Split Horizontal",
"splitVertical": "Split Vertical",
"closePane": "Close Pane"
}
}

View File

@@ -59,12 +59,16 @@
"inactive": "Inactive",
"pending": "Pending",
"inProgress": "In Progress",
"running": "Running",
"initializing": "Initializing",
"planning": "Planning",
"completed": "Completed",
"failed": "Failed",
"blocked": "Blocked",
"cancelled": "Cancelled",
"paused": "Paused",
"archived": "Archived",
"idle": "Idle",
"unknown": "Unknown",
"draft": "Draft",
"published": "Published",
@@ -91,7 +95,10 @@
"months": "months",
"years": "years",
"ago": "ago",
"justNow": "just now"
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago"
},
"buttons": {
"new": "New",
@@ -164,7 +171,11 @@
"todayActivity": "Today's Activity",
"totalCommands": "Total Commands",
"totalSkills": "Total Skills",
"categories": "Categories"
"categories": "Categories",
"total": "Total"
},
"labels": {
"progress": "Progress"
},
"dialog": {
"createSession": "Create New Session",
@@ -200,6 +211,15 @@
"disconnected": "Ticker disconnected",
"aria_label": "Real-time activity ticker"
},
"dashboard": {
"config": {
"title": "Widgets",
"widgets": "Dashboard Widgets",
"hideAll": "Hide All",
"showAll": "Show All",
"resetLayout": "Reset Layout"
}
},
"all": "All",
"yes": "Yes",
"no": "No",
@@ -252,78 +272,6 @@
"no": "No",
"required": "This question is required"
},
"coordinator": {
"page": {
"title": "Coordinator",
"status": "Status: {status}",
"startButton": "Start Coordinator",
"noNodeSelected": "Select a node to view details"
},
"modal": {
"title": "Start Coordinator",
"description": "Describe the task you want the coordinator to execute"
},
"form": {
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the coordinator to do (minimum 10 characters)...",
"parameters": "Parameters (Optional)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "Optional JSON parameters for the coordinator execution",
"characterCount": "{current} / {max} characters (min: {min})",
"start": "Start Coordinator",
"starting": "Starting..."
},
"validation": {
"taskDescriptionRequired": "Task description is required",
"taskDescriptionTooShort": "Task description must be at least 10 characters",
"taskDescriptionTooLong": "Task description must not exceed 2000 characters",
"parametersInvalidJson": "Parameters must be valid JSON",
"answerRequired": "An answer is required"
},
"success": {
"started": "Coordinator started successfully"
},
"status": {
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"skipped": "Skipped"
},
"logs": "Logs",
"entries": "entries",
"error": "Error",
"output": "Output",
"startedAt": "Started At",
"completedAt": "Completed At",
"retrying": "Retrying...",
"retry": "Retry",
"skipping": "Skipping...",
"skip": "Skip",
"logLevel": "Log Level",
"level": {
"all": "All",
"info": "Info",
"warn": "Warning",
"error": "Error",
"debug": "Debug"
},
"noLogs": "No logs available",
"question": {
"answer": "Answer",
"textPlaceholder": "Enter your answer...",
"selectOne": "Select One",
"selectMultiple": "Select Multiple",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"submitting": "Submitting...",
"submit": "Submit"
},
"error": {
"submitFailed": "Failed to submit answer"
}
},
"feedback": {
"error": {
"network": "Network error. Please check your connection and try again.",

View File

@@ -0,0 +1,141 @@
{
"page": {
"title": "Coordinator",
"status": "Status: {status}",
"startButton": "Start Coordinator",
"noNodeSelected": "Select a node to view details"
},
"taskDetail": {
"title": "Task Details",
"noSelection": "Select a task to view execution details"
},
"emptyState": {
"title": "Workflow Coordinator",
"subtitle": "Intelligent task orchestration with real-time monitoring for complex workflows",
"startButton": "Launch Coordinator",
"feature1": {
"title": "Intelligent Execution",
"description": "Smart task orchestration with dependency management and parallel execution"
},
"feature2": {
"title": "Real-time Monitoring",
"description": "Pipeline visualization with detailed logs and execution metrics"
},
"feature3": {
"title": "Flexible Control",
"description": "Interactive control with retry, skip, and pause capabilities"
},
"quickStart": {
"title": "Quick Start",
"step1": "Click the 'Launch Coordinator' button to begin",
"step2": "Describe your workflow task in natural language",
"step3": "Monitor execution pipeline and interact with running tasks"
}
},
"multiStep": {
"step1": {
"title": "Welcome to Coordinator",
"subtitle": "Intelligent workflow orchestration for automated task execution",
"feature1": { "title": "Intelligent Execution", "description": "Smart task orchestration with dependency management and parallel execution" },
"feature2": { "title": "Real-time Monitoring", "description": "Pipeline visualization with detailed logs and execution metrics" },
"feature3": { "title": "Flexible Control", "description": "Interactive control with retry, skip, and pause capabilities" }
},
"step2": {
"title": "Configure Parameters",
"subtitle": "Select a template or customize parameters",
"templateLabel": "Select Template",
"templates": {
"featureDev": "Feature Development",
"apiIntegration": "API Integration",
"performanceOptimization": "Performance Optimization",
"documentGeneration": "Document Generation"
},
"taskName": "Task Name",
"taskNamePlaceholder": "Enter task name...",
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe your task requirements in detail...",
"customParameters": "Custom Parameters"
},
"progress": { "step": "Step {current} / {total}" },
"actions": { "next": "Next", "back": "Back", "cancel": "Cancel", "submit": "Submit" }
},
"modal": {
"title": "Start Coordinator",
"description": "Describe the task you want the coordinator to execute"
},
"form": {
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the coordinator to do (min 10 characters)...",
"parameters": "Parameters (Optional)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "Optional JSON parameters for coordinator execution",
"characterCount": "{current} / {max} characters (min: {min})",
"start": "Start Coordinator",
"starting": "Starting..."
},
"validation": {
"taskDescriptionRequired": "Task description is required",
"taskDescriptionTooShort": "Task description must be at least 10 characters",
"taskDescriptionTooLong": "Task description must not exceed 2000 characters",
"parametersInvalidJson": "Parameters must be valid JSON",
"answerRequired": "An answer is required"
},
"success": {
"started": "Coordinator started successfully"
},
"status": {
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"skipped": "Skipped"
},
"logs": "Logs",
"entries": "entries",
"error": "Error",
"output": "Output",
"startedAt": "Started At",
"completedAt": "Completed At",
"retrying": "Retrying...",
"retry": "Retry",
"skipping": "Skipping...",
"skip": "Skip",
"logLevel": "Log Level",
"level": {
"all": "All",
"info": "Info",
"warn": "Warning",
"error": "Error",
"debug": "Debug"
},
"noLogs": "No logs available",
"question": {
"answer": "Answer",
"textPlaceholder": "Enter your answer...",
"selectOne": "Select One",
"selectMultiple": "Select Multiple",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"submitting": "Submitting...",
"submit": "Submit"
},
"taskList": {
"filter": {
"all": "All Tasks",
"running": "Running",
"completed": "Completed",
"failed": "Failed"
},
"sort": {
"time": "By Time",
"name": "By Name"
},
"empty": "No tasks found"
},
"taskCard": {
"nodes": "nodes",
"started": "started"
},
"steps": "steps"
}

View File

@@ -3,6 +3,9 @@
"title": "Execution Monitor",
"subtitle": "View real-time execution status and history"
},
"actions": {
"openCliViewer": "CLI Monitor"
},
"currentExecution": {
"title": "Current Execution",
"noExecution": "No workflow is currently executing",

View File

@@ -17,13 +17,26 @@
"sections": {
"statistics": "Statistics",
"recentSessions": "Recent Sessions",
"recentTasks": "Recent Tasks",
"activeLoops": "Active Loops",
"openIssues": "Open Issues",
"quickActions": "Quick Actions"
"quickActions": "Quick Actions",
"taskDetails": "Task Details"
},
"tabs": {
"allSessions": "All",
"allTasks": "All",
"workflow": "Workflow",
"liteTasks": "Lite Tasks",
"orchestrator": "Orchestrator",
"inProgress": "In Progress",
"planning": "Planning",
"completed": "Completed",
"paused": "Paused"
},
"widgets": {
"workflowStatus": "Workflow Status",
"activity": "Activity Timeline",
"activity": "Activity Heatmap",
"taskTypes": "Task Types"
},
"emptyState": {
@@ -31,6 +44,10 @@
"title": "No Sessions Found",
"message": "No workflow sessions match your current filter."
},
"noTasks": {
"title": "No Tasks",
"message": "No tasks match your current filter."
},
"noLoops": {
"title": "No Active Loops",
"message": "Start a new development loop to begin monitoring progress."
@@ -81,5 +98,8 @@
"errors": {
"loadFailed": "Failed to load dashboard data",
"retry": "Retry"
},
"project": {
"features": "features"
}
}

View File

@@ -9,6 +9,7 @@ import sessions from './sessions.json';
import issues from './issues.json';
import home from './home.json';
import orchestrator from './orchestrator.json';
import coordinator from './coordinator.json';
import loops from './loops.json';
import commands from './commands.json';
import memory from './memory.json';
@@ -37,6 +38,7 @@ import notification from './notification.json';
import notifications from './notifications.json';
import workspace from './workspace.json';
import help from './help.json';
import cliViewer from './cli-viewer.json';
/**
* Flattens nested JSON object to dot-separated keys
@@ -69,6 +71,7 @@ export default {
...flattenMessages(issues, 'issues'),
...flattenMessages(home, 'home'),
...flattenMessages(orchestrator, 'orchestrator'),
...flattenMessages(coordinator, 'coordinator'),
...flattenMessages(loops, 'loops'),
...flattenMessages(commands, 'commands'),
...flattenMessages(memory, 'memory'),
@@ -97,4 +100,5 @@ export default {
...flattenMessages(notifications, 'notifications'),
...flattenMessages(workspace, 'workspace'),
...flattenMessages(help, 'help'),
...flattenMessages(cliViewer, 'cliViewer'),
} as Record<string, string>;

View File

@@ -61,5 +61,11 @@
"explorations": "Explorations",
"context": "Context",
"diagnoses": "Diagnoses"
},
"status": {
"completed": "completed",
"inProgress": "in progress",
"blocked": "blocked",
"pending": "pending"
}
}

View File

@@ -14,7 +14,9 @@
"project": "Project",
"history": "History",
"orchestrator": "Orchestrator",
"coordinator": "Coordinator",
"loops": "Loop Monitor",
"cliViewer": "CLI Viewer",
"issues": "Issues",
"issueQueue": "Issue Queue",
"issueDiscovery": "Issue Discovery",
@@ -75,6 +77,8 @@
"noExecutionsHint": "Start a CLI command to see streaming output",
"noMessages": "Waiting for messages...",
"noMatch": "No matching messages found",
"openInViewer": "Open in CLI Viewer",
"popOutToPage": "Pop out to full page",
"statusBar": "{total} executions | {active} active | {errors} error | {lines} lines",
"copy": "Copy",
"copied": "Copied!",

View File

@@ -8,9 +8,23 @@
"low": "Low"
},
"stats": {
"total": "Total Findings",
"total": "Total",
"dimensions": "Dimensions"
},
"progress": {
"title": "Review Progress",
"totalFindings": "Total Findings",
"critical": "Critical",
"high": "High"
},
"dimensionTabs": {
"all": "All"
},
"filters": {
"severity": "Severity",
"sort": "Sort",
"reset": "Reset"
},
"search": {
"placeholder": "Search findings..."
},
@@ -23,16 +37,38 @@
"count": "{count} selected",
"selectAll": "Select All",
"clearAll": "Clear All",
"clear": "Clear"
"clear": "Clear",
"selectVisible": "Visible",
"selectCritical": "Critical"
},
"export": "Export Fix JSON",
"codeContext": "Code Context",
"rootCause": "Root Cause",
"impact": "Impact",
"recommendations": "Recommendations",
"fixProgress": {
"title": "Fix Progress",
"phase": {
"planning": "PLANNING",
"execution": "EXECUTION",
"completion": "COMPLETION"
},
"stats": {
"total": "Total",
"fixed": "Fixed",
"failed": "Failed",
"pending": "Pending"
},
"activeAgents": "Active Agent",
"activeAgentsPlural": "Active Agents",
"stage": "Stage",
"complete": "{percent}% Complete",
"working": "Working..."
},
"empty": {
"title": "No findings found",
"message": "Try adjusting your filters or search query."
"message": "Try adjusting your filters or search query.",
"noFixProgress": "No fix progress data available"
},
"notFound": {
"title": "Review Session Not Found",

View File

@@ -8,6 +8,15 @@
"archived": "Archived",
"paused": "Paused"
},
"type": {
"workflow": "Workflow",
"review": "Review",
"tdd": "TDD",
"test": "Test",
"docs": "Docs",
"lite-plan": "Lite Plan",
"lite-fix": "Lite Fix"
},
"actions": {
"viewDetails": "View Details",
"archive": "Archive",

View File

@@ -45,5 +45,7 @@
"tokens": "令牌: {count}",
"duration": "时长: {value}",
"model": "模型: {name}",
"user": "用户"
"user": "用户",
"popOutToPage": "在完整页面中打开",
"openInViewer": "在 CLI 查看器中打开"
}

View File

@@ -0,0 +1,51 @@
{
"page": {
"title": "CLI 查看器",
"subtitle": "{count, plural, =0 {暂无活动会话} other {# 个活动会话}}"
},
"layout": {
"title": "布局",
"single": "单窗格",
"splitH": "水平分割",
"splitV": "垂直分割",
"grid": "2x2 网格"
},
"pane": {
"empty": "未选择执行",
"selectExecution": "选择执行",
"loading": "正在加载 {executionId} 的输出...",
"close": "关闭",
"maximize": "最大化",
"minimize": "最小化"
},
"toolbar": {
"refresh": "刷新",
"clearAll": "清空所有",
"settings": "设置"
},
"emptyState": {
"title": "暂无 CLI 执行",
"description": "从侧边栏选择一个执行或启动新的 CLI 会话以在此查看输出。",
"action": "查看执行列表"
},
"tabs": {
"noTabs": "暂无标签页",
"addTab": "添加标签页",
"closeTab": "关闭标签页",
"pinTab": "固定标签页",
"unpinTab": "取消固定"
},
"picker": {
"selectExecution": "选择执行",
"searchExecutions": "搜索执行...",
"noExecutions": "暂无可用执行",
"noMatchingExecutions": "未找到匹配的执行",
"alreadyOpen": "已打开",
"executionCount": "{available}/{total} 个执行可用"
},
"paneActions": {
"splitHorizontal": "水平分割",
"splitVertical": "垂直分割",
"closePane": "关闭窗格"
}
}

View File

@@ -63,12 +63,16 @@
"inactive": "未激活",
"pending": "待处理",
"inProgress": "进行中",
"running": "运行中",
"initializing": "初始化中",
"planning": "规划中",
"completed": "已完成",
"failed": "失败",
"blocked": "已阻塞",
"cancelled": "已取消",
"paused": "已暂停",
"archived": "已归档",
"idle": "空闲",
"unknown": "未知",
"draft": "草稿",
"published": "已发布",
@@ -76,7 +80,7 @@
"deleting": "删除中...",
"label": "状态",
"openIssues": "开放问题",
"enabled": "Enabled",
"enabled": "已启用",
"disabled": "已禁用"
},
"priority": {
@@ -95,7 +99,10 @@
"months": "月",
"years": "年",
"ago": "前",
"justNow": "刚刚"
"justNow": "刚刚",
"minutesAgo": "{count}分钟前",
"hoursAgo": "{count}小时前",
"daysAgo": "{count}天前"
},
"buttons": {
"new": "新建",
@@ -168,7 +175,11 @@
"todayActivity": "今日活动",
"totalCommands": "总命令数",
"totalSkills": "总技能数",
"categories": "分类"
"categories": "分类",
"total": "总计"
},
"labels": {
"progress": "进度"
},
"dialog": {
"createSession": "创建新会话",
@@ -194,6 +205,15 @@
"button": "搜索文档"
}
},
"dashboard": {
"config": {
"title": "部件",
"widgets": "仪表板部件",
"hideAll": "全部隐藏",
"showAll": "全部显示",
"resetLayout": "重置布局"
}
},
"all": "全部",
"yes": "是",
"no": "否",
@@ -246,78 +266,6 @@
"no": "否",
"required": "此问题为必填项"
},
"coordinator": {
"page": {
"title": "协调器",
"status": "状态:{status}",
"startButton": "启动协调器",
"noNodeSelected": "选择节点以查看详细信息"
},
"modal": {
"title": "启动协调器",
"description": "描述您希望协调器执行的任务"
},
"form": {
"taskDescription": "任务描述",
"taskDescriptionPlaceholder": "描述协调器需要执行的任务至少10个字符...",
"parameters": "参数(可选)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "协调器执行的可选JSON参数",
"characterCount": "{current} / {max} 字符(最少:{min}",
"start": "启动协调器",
"starting": "启动中..."
},
"validation": {
"taskDescriptionRequired": "任务描述为必填项",
"taskDescriptionTooShort": "任务描述至少需要10个字符",
"taskDescriptionTooLong": "任务描述不能超过2000个字符",
"parametersInvalidJson": "参数必须是有效的JSON格式",
"answerRequired": "答案为必填项"
},
"success": {
"started": "协调器启动成功"
},
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"skipped": "已跳过"
},
"logs": "日志",
"entries": "条日志",
"error": "错误",
"output": "输出",
"startedAt": "开始时间",
"completedAt": "完成时间",
"retrying": "重试中...",
"retry": "重试",
"skipping": "跳过中...",
"skip": "跳过",
"logLevel": "日志级别",
"level": {
"all": "全部",
"info": "信息",
"warn": "警告",
"error": "错误",
"debug": "调试"
},
"noLogs": "无可用日志",
"question": {
"answer": "答案",
"textPlaceholder": "输入您的答案...",
"selectOne": "选择一个",
"selectMultiple": "选择多个",
"confirm": "确认",
"yes": "是",
"no": "否",
"submitting": "提交中...",
"submit": "提交"
},
"error": {
"submitFailed": "提交答案失败"
}
},
"feedback": {
"error": {
"network": "网络错误,请检查您的连接并重试。",

View File

@@ -0,0 +1,157 @@
{
"page": {
"title": "协调器",
"status": "状态:{status}",
"startButton": "启动协调器",
"noNodeSelected": "选择节点以查看详细信息"
},
"taskDetail": {
"title": "任务详情",
"noSelection": "选择任务以查看执行详情"
},
"emptyState": {
"title": "欢迎使用工作流协调器",
"subtitle": "智能任务编排,实时执行监控,一站式管理复杂工作流",
"startButton": "启动协调器",
"feature1": {
"title": "智能执行",
"description": "依赖管理与并行执行的智能任务编排"
},
"feature2": {
"title": "实时监控",
"description": "流水线可视化,详细日志与执行指标"
},
"feature3": {
"title": "灵活控制",
"description": "支持重试、跳过和暂停的交互式控制"
},
"quickStart": {
"title": "快速开始",
"step1": "点击「启动协调器」按钮开始",
"step2": "用自然语言描述您的工作流任务",
"step3": "监控执行流水线,与运行中的任务交互"
}
},
"modal": {
"title": "启动协调器",
"description": "描述您希望协调器执行的任务"
},
"multiStep": {
"step1": {
"title": "欢迎使用协调器",
"subtitle": "智能工作流编排,助力任务自动化执行",
"feature1": {
"title": "智能执行",
"description": "依赖管理与并行执行的智能任务编排"
},
"feature2": {
"title": "实时监控",
"description": "流水线可视化,详细日志与执行指标"
},
"feature3": {
"title": "灵活控制",
"description": "支持重试、跳过和暂停的交互式控制"
}
},
"step2": {
"title": "配置参数",
"subtitle": "选择模板或自定义参数",
"templateLabel": "选择模板",
"templates": {
"featureDev": "功能开发",
"apiIntegration": "API 集成",
"performanceOptimization": "性能优化",
"documentGeneration": "文档生成"
},
"taskName": "任务名称",
"taskNamePlaceholder": "输入任务名称...",
"taskDescription": "任务描述",
"taskDescriptionPlaceholder": "详细描述您的任务需求...",
"customParameters": "自定义参数"
},
"progress": {
"step": "步骤 {current} / {total}"
},
"actions": {
"next": "下一步",
"back": "返回",
"cancel": "取消",
"submit": "提交"
}
},
"form": {
"taskDescription": "任务描述",
"taskDescriptionPlaceholder": "描述协调器需要执行的任务至少10个字符...",
"parameters": "参数(可选)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "协调器执行的可选JSON参数",
"characterCount": "{current} / {max} 字符(最少:{min}",
"start": "启动协调器",
"starting": "启动中..."
},
"validation": {
"taskDescriptionRequired": "任务描述为必填项",
"taskDescriptionTooShort": "任务描述至少需要10个字符",
"taskDescriptionTooLong": "任务描述不能超过2000个字符",
"parametersInvalidJson": "参数必须是有效的JSON格式",
"answerRequired": "答案为必填项"
},
"success": {
"started": "协调器启动成功"
},
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"skipped": "已跳过"
},
"logs": "日志",
"entries": "条日志",
"error": "错误",
"output": "输出",
"startedAt": "开始时间",
"completedAt": "完成时间",
"retrying": "重试中...",
"retry": "重试",
"skipping": "跳过中...",
"skip": "跳过",
"logLevel": "日志级别",
"level": {
"all": "全部",
"info": "信息",
"warn": "警告",
"error": "错误",
"debug": "调试"
},
"noLogs": "暂无日志",
"question": {
"answer": "回答",
"textPlaceholder": "输入您的回答...",
"selectOne": "单选",
"selectMultiple": "多选",
"confirm": "确认",
"yes": "是",
"no": "否",
"submitting": "提交中...",
"submit": "提交"
},
"taskList": {
"filter": {
"all": "全部任务",
"running": "运行中",
"completed": "已完成",
"failed": "失败"
},
"sort": {
"time": "按时间",
"name": "按名称"
},
"empty": "暂无任务"
},
"taskCard": {
"nodes": "节点",
"started": "开始"
},
"steps": "步"
}

View File

@@ -3,6 +3,9 @@
"title": "执行监控",
"subtitle": "查看实时执行状态和历史记录"
},
"actions": {
"openCliViewer": "CLI 监控"
},
"currentExecution": {
"title": "当前执行",
"noExecution": "当前没有正在执行的工作流",

View File

@@ -17,13 +17,26 @@
"sections": {
"statistics": "统计",
"recentSessions": "最近会话",
"recentTasks": "最近任务",
"activeLoops": "活跃循环",
"openIssues": "开放问题",
"quickActions": "快速操作"
"quickActions": "快速操作",
"taskDetails": "任务详情"
},
"tabs": {
"allSessions": "全部",
"allTasks": "全部",
"workflow": "工作流",
"liteTasks": "轻量任务",
"orchestrator": "编排器",
"inProgress": "进行中",
"planning": "规划中",
"completed": "已完成",
"paused": "已暂停"
},
"widgets": {
"workflowStatus": "工作流状态",
"activity": "活动时间线",
"activity": "活动热图",
"taskTypes": "任务类型"
},
"emptyState": {
@@ -31,6 +44,10 @@
"title": "未找到会话",
"message": "没有符合当前筛选条件的工作流会话。"
},
"noTasks": {
"title": "暂无任务",
"message": "没有符合当前筛选条件的任务。"
},
"noLoops": {
"title": "无活跃循环",
"message": "启动新的开发循环以开始监控进度。"
@@ -81,5 +98,8 @@
"errors": {
"loadFailed": "加载仪表板数据失败",
"retry": "重试"
},
"project": {
"features": "个功能"
}
}

View File

@@ -9,6 +9,7 @@ import sessions from './sessions.json';
import issues from './issues.json';
import home from './home.json';
import orchestrator from './orchestrator.json';
import coordinator from './coordinator.json';
import loops from './loops.json';
import commands from './commands.json';
import memory from './memory.json';
@@ -37,6 +38,7 @@ import notification from './notification.json';
import notifications from './notifications.json';
import workspace from './workspace.json';
import help from './help.json';
import cliViewer from './cli-viewer.json';
/**
* Flattens nested JSON object to dot-separated keys
@@ -69,6 +71,7 @@ export default {
...flattenMessages(issues, 'issues'),
...flattenMessages(home, 'home'),
...flattenMessages(orchestrator, 'orchestrator'),
...flattenMessages(coordinator, 'coordinator'),
...flattenMessages(loops, 'loops'),
...flattenMessages(commands, 'commands'),
...flattenMessages(memory, 'memory'),
@@ -97,4 +100,5 @@ export default {
...flattenMessages(notifications, 'notifications'),
...flattenMessages(workspace, 'workspace'),
...flattenMessages(help, 'help'),
...flattenMessages(cliViewer, 'cliViewer'),
} as Record<string, string>;

View File

@@ -61,5 +61,11 @@
"explorations": "探索",
"context": "上下文",
"diagnoses": "诊断"
},
"status": {
"completed": "已完成",
"inProgress": "进行中",
"blocked": "已阻止",
"pending": "待处理"
}
}

View File

@@ -14,7 +14,9 @@
"project": "项目",
"history": "历史",
"orchestrator": "编排器",
"coordinator": "协调器",
"loops": "循环监控",
"cliViewer": "CLI 查看器",
"issues": "问题",
"issueQueue": "问题队列",
"issueDiscovery": "问题发现",
@@ -75,6 +77,8 @@
"noExecutionsHint": "启动 CLI 命令以查看流式输出",
"noMessages": "等待消息...",
"noMatch": "未找到匹配的消息",
"openInViewer": "在 CLI 查看器中打开",
"popOutToPage": "弹出到全页面",
"statusBar": "{total} 个执行 | {active} 个活跃 | {errors} 个错误 | {lines} 行",
"copy": "复制",
"copied": "已复制!",

View File

@@ -8,6 +8,15 @@
"archived": "已归档",
"paused": "已暂停"
},
"type": {
"workflow": "工作流",
"review": "审查",
"tdd": "TDD",
"test": "测试",
"docs": "文档",
"lite-plan": "轻量计划",
"lite-fix": "轻量修复"
},
"actions": {
"viewDetails": "查看详情",
"archive": "归档",

View File

@@ -10,7 +10,7 @@ import {
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import {
ProviderList,
ProviderModal,
@@ -198,26 +198,21 @@ export function ApiSettingsPage() {
</div>
{/* Tabbed Interface */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)}>
<TabsList>
<TabsTrigger value="providers">
{formatMessage({ id: 'apiSettings.tabs.providers' })}
</TabsTrigger>
<TabsTrigger value="endpoints">
{formatMessage({ id: 'apiSettings.tabs.endpoints' })}
</TabsTrigger>
<TabsTrigger value="cache">
{formatMessage({ id: 'apiSettings.tabs.cache' })}
</TabsTrigger>
<TabsTrigger value="modelPools">
{formatMessage({ id: 'apiSettings.tabs.modelPools' })}
</TabsTrigger>
<TabsTrigger value="cliSettings">
{formatMessage({ id: 'apiSettings.tabs.cliSettings' })}
</TabsTrigger>
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabType)}
tabs={[
{ value: 'providers', label: formatMessage({ id: 'apiSettings.tabs.providers' }) },
{ value: 'endpoints', label: formatMessage({ id: 'apiSettings.tabs.endpoints' }) },
{ value: 'cache', label: formatMessage({ id: 'apiSettings.tabs.cache' }) },
{ value: 'modelPools', label: formatMessage({ id: 'apiSettings.tabs.modelPools' }) },
{ value: 'cliSettings', label: formatMessage({ id: 'apiSettings.tabs.cliSettings' }) },
]}
/>
<TabsContent value="providers">
{/* Tab Content */}
{activeTab === 'providers' && (
<div className="mt-4">
<ProviderList
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
@@ -225,33 +220,41 @@ export function ApiSettingsPage() {
onSyncToCodexLens={handleSyncToCodexLens}
onManageModels={handleManageModels}
/>
</TabsContent>
</div>
)}
<TabsContent value="endpoints">
{activeTab === 'endpoints' && (
<div className="mt-4">
<EndpointList
onAddEndpoint={handleAddEndpoint}
onEditEndpoint={handleEditEndpoint}
/>
</TabsContent>
</div>
)}
<TabsContent value="cache">
{activeTab === 'cache' && (
<div className="mt-4">
<CacheSettings />
</TabsContent>
</div>
)}
<TabsContent value="modelPools">
{activeTab === 'modelPools' && (
<div className="mt-4">
<ModelPoolList
onAddPool={handleAddPool}
onEditPool={handleEditPool}
/>
</TabsContent>
</div>
)}
<TabsContent value="cliSettings">
{activeTab === 'cliSettings' && (
<div className="mt-4">
<CliSettingsList
onAddCliSettings={handleAddCliSettings}
onEditCliSettings={handleEditCliSettings}
/>
</TabsContent>
</Tabs>
</div>
)}
{/* Modals */}
<ProviderModal

View File

@@ -0,0 +1,266 @@
// ========================================
// CLI Viewer Page
// ========================================
// Multi-pane CLI output viewer with configurable layouts
// Integrates with viewerStore for state management
import { useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Terminal,
LayoutGrid,
Columns,
Rows,
Square,
ChevronDown,
RotateCcw,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import { cn } from '@/lib/utils';
import { LayoutContainer } from '@/components/cli-viewer';
import {
useViewerStore,
useViewerLayout,
useViewerPanes,
useFocusedPaneId,
type AllotmentLayout,
} from '@/stores/viewerStore';
// ========================================
// Types
// ========================================
export type LayoutType = 'single' | 'split-h' | 'split-v' | 'grid-2x2';
interface LayoutOption {
id: LayoutType;
icon: React.ElementType;
labelKey: string;
}
// ========================================
// Constants
// ========================================
const LAYOUT_OPTIONS: LayoutOption[] = [
{ id: 'single', icon: Square, labelKey: 'cliViewer.layout.single' },
{ id: 'split-h', icon: Columns, labelKey: 'cliViewer.layout.splitH' },
{ id: 'split-v', icon: Rows, labelKey: 'cliViewer.layout.splitV' },
{ id: 'grid-2x2', icon: LayoutGrid, labelKey: 'cliViewer.layout.grid' },
];
const DEFAULT_LAYOUT: LayoutType = 'split-h';
// ========================================
// Helper Functions
// ========================================
/**
* Detect layout type from AllotmentLayout structure
*/
function detectLayoutType(layout: AllotmentLayout): LayoutType {
const childCount = layout.children.length;
// Empty or single pane
if (childCount === 0 || childCount === 1) {
return 'single';
}
// Two panes at root level
if (childCount === 2) {
const hasNestedGroups = layout.children.some(
(child) => typeof child !== 'string'
);
// If no nested groups, it's a simple split
if (!hasNestedGroups) {
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
}
// Check for grid layout (2x2)
const allNested = layout.children.every(
(child) => typeof child !== 'string'
);
if (allNested) {
return 'grid-2x2';
}
}
// Default to current direction
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
}
/**
* Count total panes in layout
*/
function countPanes(layout: AllotmentLayout): number {
let count = 0;
const traverse = (children: (string | AllotmentLayout)[]) => {
for (const child of children) {
if (typeof child === 'string') {
count++;
} else {
traverse(child.children);
}
}
};
traverse(layout.children);
return count;
}
// ========================================
// Main Component
// ========================================
export function CliViewerPage() {
const { formatMessage } = useIntl();
const [searchParams, setSearchParams] = useSearchParams();
// Store hooks
const layout = useViewerLayout();
const panes = useViewerPanes();
const focusedPaneId = useFocusedPaneId();
const { initializeDefaultLayout, addTab, reset } = useViewerStore();
// Detect current layout type from store
const currentLayoutType = useMemo(() => detectLayoutType(layout), [layout]);
// Count active sessions (tabs across all panes)
const activeSessionCount = useMemo(() => {
return Object.values(panes).reduce((count, pane) => count + pane.tabs.length, 0);
}, [panes]);
// Initialize layout if empty
useEffect(() => {
const paneCount = countPanes(layout);
if (paneCount === 0) {
initializeDefaultLayout(DEFAULT_LAYOUT);
}
}, [layout, initializeDefaultLayout]);
// Handle executionId from URL params
useEffect(() => {
const executionId = searchParams.get('executionId');
if (executionId && focusedPaneId) {
// Add tab to focused pane
addTab(focusedPaneId, executionId, `Execution ${executionId.slice(0, 8)}`);
// Clear the URL param after processing
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('executionId');
return newParams;
});
}
}, [searchParams, focusedPaneId, addTab, setSearchParams]);
// Handle layout change
const handleLayoutChange = useCallback(
(layoutType: LayoutType) => {
initializeDefaultLayout(layoutType);
},
[initializeDefaultLayout]
);
// Handle reset
const handleReset = useCallback(() => {
reset();
initializeDefaultLayout(DEFAULT_LAYOUT);
}, [reset, initializeDefaultLayout]);
// Get current layout option for display
const currentLayoutOption =
LAYOUT_OPTIONS.find((l) => l.id === currentLayoutType) || LAYOUT_OPTIONS[1];
const CurrentLayoutIcon = currentLayoutOption.icon;
return (
<div className="h-full flex flex-col -m-4 md:-m-6">
{/* ======================================== */}
{/* Toolbar */}
{/* ======================================== */}
<div className="flex items-center justify-between gap-3 p-3 bg-card border-b border-border">
{/* Page Title */}
<div className="flex items-center gap-2 min-w-0">
<Terminal className="w-5 h-5 text-primary flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliViewer.page.title' })}
</span>
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'cliViewer.page.subtitle' },
{ count: activeSessionCount }
)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Reset Button */}
<Button
variant="ghost"
size="sm"
onClick={handleReset}
title={formatMessage({ id: 'cliViewer.toolbar.clearAll' })}
>
<RotateCcw className="w-4 h-4" />
</Button>
{/* Layout Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CurrentLayoutIcon className="w-4 h-4" />
<span className="hidden sm:inline">
{formatMessage({ id: currentLayoutOption.labelKey })}
</span>
<ChevronDown className="w-4 h-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{formatMessage({ id: 'cliViewer.layout.title' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.id}
onClick={() => handleLayoutChange(option.id)}
className={cn(
'gap-2',
currentLayoutType === option.id && 'bg-accent'
)}
>
<Icon className="w-4 h-4" />
{formatMessage({ id: option.labelKey })}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ======================================== */}
{/* Layout Container */}
{/* ======================================== */}
<div className="flex-1 min-h-0 bg-background">
<LayoutContainer />
</div>
</div>
);
}
export default CliViewerPage;

View File

@@ -15,7 +15,7 @@ import {
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import {
AlertDialog,
AlertDialogTrigger,
@@ -176,26 +176,21 @@ export function CodexLensManagerPage() {
)}
{/* Tabbed Interface */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">
{formatMessage({ id: 'codexlens.tabs.overview' })}
</TabsTrigger>
<TabsTrigger value="settings">
{formatMessage({ id: 'codexlens.tabs.settings' })}
</TabsTrigger>
<TabsTrigger value="models">
{formatMessage({ id: 'codexlens.tabs.models' })}
</TabsTrigger>
<TabsTrigger value="search">
{formatMessage({ id: 'codexlens.tabs.search' })}
</TabsTrigger>
<TabsTrigger value="advanced">
{formatMessage({ id: 'codexlens.tabs.advanced' })}
</TabsTrigger>
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={setActiveTab}
tabs={[
{ value: 'overview', label: formatMessage({ id: 'codexlens.tabs.overview' }) },
{ value: 'settings', label: formatMessage({ id: 'codexlens.tabs.settings' }) },
{ value: 'models', label: formatMessage({ id: 'codexlens.tabs.models' }) },
{ value: 'search', label: formatMessage({ id: 'codexlens.tabs.search' }) },
{ value: 'advanced', label: formatMessage({ id: 'codexlens.tabs.advanced' }) },
]}
/>
<TabsContent value="overview">
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="mt-4">
<OverviewTab
installed={installed}
status={status}
@@ -203,24 +198,32 @@ export function CodexLensManagerPage() {
isLoading={isLoading}
onRefresh={handleRefresh}
/>
</TabsContent>
</div>
)}
<TabsContent value="settings">
{activeTab === 'settings' && (
<div className="mt-4">
<SettingsTab enabled={installed} />
</TabsContent>
</div>
)}
<TabsContent value="models">
{activeTab === 'models' && (
<div className="mt-4">
<ModelsTab installed={installed} />
</TabsContent>
</div>
)}
<TabsContent value="search">
{activeTab === 'search' && (
<div className="mt-4">
<SearchTab enabled={installed} />
</TabsContent>
</div>
)}
<TabsContent value="advanced">
{activeTab === 'advanced' && (
<div className="mt-4">
<AdvancedTab enabled={installed} />
</TabsContent>
</Tabs>
</div>
)}
{/* Semantic Install Dialog */}
<SemanticInstallDialog

View File

@@ -5,6 +5,7 @@
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import {
Activity,
Clock,
@@ -16,10 +17,12 @@ import {
ListTree,
History,
List,
Monitor,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { ExecutionMonitor } from './orchestrator/ExecutionMonitor';
import { useExecutionStore } from '@/stores/executionStore';
import type { ExecutionStatus } from '@/types/execution';
@@ -86,9 +89,14 @@ function formatDateTime(dateString: string): string {
export function ExecutionMonitorPage() {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const currentExecution = useExecutionStore((state) => state.currentExecution);
const [selectedView, setSelectedView] = useState<'workflow' | 'timeline' | 'list'>('workflow');
const handleOpenCliViewer = () => {
navigate('/cli-viewer');
};
// Calculate statistics
const stats = useMemo(() => {
const total = mockExecutionHistory.length;
@@ -126,14 +134,20 @@ export function ExecutionMonitorPage() {
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Activity className="w-6 h-6" />
{formatMessage({ id: 'executionMonitor.page.title' })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'executionMonitor.page.subtitle' })}
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Activity className="w-6 h-6" />
{formatMessage({ id: 'executionMonitor.page.title' })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'executionMonitor.page.subtitle' })}
</p>
</div>
<Button onClick={handleOpenCliViewer} className="gap-2">
<Monitor className="w-4 h-4" />
{formatMessage({ id: 'executionMonitor.actions.openCliViewer' })}
</Button>
</div>
{/* Current Execution Area */}
@@ -230,24 +244,31 @@ export function ExecutionMonitorPage() {
</CardTitle>
</CardHeader>
<CardContent>
<Tabs value={selectedView} onValueChange={(v) => setSelectedView(v as typeof selectedView)}>
<TabsList>
<TabsTrigger value="workflow">
<ListTree className="w-4 h-4 mr-2" />
{formatMessage({ id: 'executionMonitor.history.tabs.byWorkflow' })}
</TabsTrigger>
<TabsTrigger value="timeline">
<History className="w-4 h-4 mr-2" />
{formatMessage({ id: 'executionMonitor.history.tabs.timeline' })}
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4 mr-2" />
{formatMessage({ id: 'executionMonitor.history.tabs.list' })}
</TabsTrigger>
</TabsList>
<TabsNavigation
value={selectedView}
onValueChange={(v) => setSelectedView(v as typeof selectedView)}
tabs={[
{
value: 'workflow',
label: formatMessage({ id: 'executionMonitor.history.tabs.byWorkflow' }),
icon: <ListTree className="w-4 h-4" />,
},
{
value: 'timeline',
label: formatMessage({ id: 'executionMonitor.history.tabs.timeline' }),
icon: <History className="w-4 h-4" />,
},
{
value: 'list',
label: formatMessage({ id: 'executionMonitor.history.tabs.list' }),
icon: <List className="w-4 h-4" />,
},
]}
/>
{/* By Workflow View */}
<TabsContent value="workflow" className="mt-4">
{/* By Workflow View */}
{selectedView === 'workflow' && (
<div className="mt-4">
{workflowGroups.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{formatMessage({ id: 'executionMonitor.history.empty' })}
@@ -302,10 +323,11 @@ export function ExecutionMonitorPage() {
))}
</div>
)}
</TabsContent>
</div>
)}
{/* Timeline View */}
<TabsContent value="timeline" className="mt-4">
{/* Timeline View */}
{selectedView === 'timeline' && (
<div className="space-y-3">
{mockExecutionHistory.map((exec, index) => (
<div key={exec.execId} className="flex gap-4">
@@ -359,11 +381,11 @@ export function ExecutionMonitorPage() {
</div>
))}
</div>
</TabsContent>
)}
{/* List View */}
<TabsContent value="list" className="mt-4">
<div className="space-y-2">
{/* List View */}
{selectedView === 'list' && (
<div className="space-y-2">
{mockExecutionHistory.map((exec) => (
<Card key={exec.execId} className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
@@ -402,9 +424,8 @@ export function ExecutionMonitorPage() {
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,45 +1,29 @@
// ========================================
// HomePage Component
// ========================================
// Dashboard home page with stat cards and recent sessions
// Dashboard home page with combined stats, workflow status, and activity heatmap
import * as React from 'react';
import { lazy, Suspense } from 'react';
import { useIntl } from 'react-intl';
import { AlertCircle } from 'lucide-react';
import { DashboardHeader } from '@/components/dashboard/DashboardHeader';
import { DashboardGridContainer } from '@/components/dashboard/DashboardGridContainer';
import { DetailedStatsWidget } from '@/components/dashboard/widgets/DetailedStatsWidget';
import { WorkflowTaskWidget } from '@/components/dashboard/widgets/WorkflowTaskWidget';
import { RecentSessionsWidget } from '@/components/dashboard/widgets/RecentSessionsWidget';
import { ChartSkeleton } from '@/components/charts';
import { Button } from '@/components/ui/Button';
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
import { WIDGET_IDS } from '@/components/dashboard/defaultLayouts';
// Code-split chart widgets for better initial load performance
const WorkflowStatusPieChartWidget = lazy(() => import('@/components/dashboard/widgets/WorkflowStatusPieChartWidget'));
const ActivityLineChartWidget = lazy(() => import('@/components/dashboard/widgets/ActivityLineChartWidget'));
const TaskTypeBarChartWidget = lazy(() => import('@/components/dashboard/widgets/TaskTypeBarChartWidget'));
/**
* HomePage component - Dashboard overview with widget-based layout
* HomePage component - Dashboard overview with fixed widget layout
*/
export function HomePage() {
const { formatMessage } = useIntl();
const { resetLayout } = useUserDashboardLayout();
// Track errors from widgets (optional, for future enhancements)
const [hasError, _setHasError] = React.useState(false);
const handleRefresh = () => {
// Trigger refetch by reloading the page or using React Query's invalidateQueries
window.location.reload();
};
const handleResetLayout = () => {
resetLayout();
};
return (
<div className="space-y-6">
{/* Header */}
@@ -47,7 +31,6 @@ export function HomePage() {
titleKey="home.dashboard.title"
descriptionKey="home.dashboard.description"
onRefresh={handleRefresh}
onResetLayout={handleResetLayout}
/>
{/* Error alert (optional, shown if widgets encounter critical errors) */}
@@ -66,29 +49,14 @@ export function HomePage() {
</div>
)}
{/* Dashboard Grid with Widgets */}
<DashboardGridContainer isDraggable={true} isResizable={true}>
{/* Widget 1: Detailed Stats */}
<DetailedStatsWidget key={WIDGET_IDS.STATS} />
{/* Dashboard Widgets - Simple flex layout for dynamic height */}
<div className="flex flex-col gap-4">
{/* Row 1: Combined Stats + Workflow Status + Task Details */}
<WorkflowTaskWidget />
{/* Widget 2: Recent Sessions */}
<RecentSessionsWidget key={WIDGET_IDS.RECENT_SESSIONS} />
{/* Widget 3: Workflow Status Pie Chart (code-split with Suspense fallback) */}
<Suspense fallback={<ChartSkeleton type="pie" height={280} />}>
<WorkflowStatusPieChartWidget key={WIDGET_IDS.WORKFLOW_STATUS} />
</Suspense>
{/* Widget 4: Activity Line Chart (code-split with Suspense fallback) */}
<Suspense fallback={<ChartSkeleton type="line" height={280} />}>
<ActivityLineChartWidget key={WIDGET_IDS.ACTIVITY} />
</Suspense>
{/* Widget 5: Task Type Bar Chart (code-split with Suspense fallback) */}
<Suspense fallback={<ChartSkeleton type="bar" height={280} />}>
<TaskTypeBarChartWidget key={WIDGET_IDS.TASK_TYPES} />
</Suspense>
</DashboardGridContainer>
{/* Row 2: Recent Sessions */}
<RecentSessionsWidget />
</div>
</div>
);
}

View File

@@ -41,7 +41,8 @@ import { Flowchart } from '@/components/shared/Flowchart';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Tabs, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/Collapsible';
import type { LiteTask, LiteTaskSession } from '@/lib/api';
@@ -330,53 +331,68 @@ export function LiteTaskDetailPage() {
{/* Session Type-Specific Tabs */}
{isMultiCli ? (
<Tabs value={multiCliActiveTab} onValueChange={(v) => setMultiCliActiveTab(v as MultiCliTab)}>
<TabsList className="w-full">
<TabsTrigger value="tasks" className="flex-1 gap-1">
<ListTodo className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="discussion" className="flex-1 gap-1">
<MessageSquare className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.discussion' })}
</TabsTrigger>
<TabsTrigger value="context" className="flex-1 gap-1">
<Package className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary" className="flex-1 gap-1">
<FileText className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.summary' })}
</TabsTrigger>
</TabsList>
</Tabs>
<TabsNavigation
value={multiCliActiveTab}
onValueChange={(v) => setMultiCliActiveTab(v as MultiCliTab)}
tabs={[
{
value: 'tasks',
label: formatMessage({ id: 'liteTasksDetail.tabs.tasks' }),
icon: <ListTodo className="h-4 w-4" />,
},
{
value: 'discussion',
label: formatMessage({ id: 'liteTasksDetail.tabs.discussion' }),
icon: <MessageSquare className="h-4 w-4" />,
},
{
value: 'context',
label: formatMessage({ id: 'liteTasksDetail.tabs.context' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'summary',
label: formatMessage({ id: 'liteTasksDetail.tabs.summary' }),
icon: <FileText className="h-4 w-4" />,
},
]}
/>
) : (
<Tabs value={litePlanActiveTab} onValueChange={(v) => setLitePlanActiveTab(v as LitePlanTab)}>
<TabsList className="w-full">
<TabsTrigger value="tasks" className="flex-1 gap-1">
<ListTodo className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="plan" className="flex-1 gap-1">
<Ruler className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.plan' })}
</TabsTrigger>
{isLiteFix && (
<TabsTrigger value="diagnoses" className="flex-1 gap-1">
<Stethoscope className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.diagnoses' })}
</TabsTrigger>
)}
<TabsTrigger value="context" className="flex-1 gap-1">
<Package className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary" className="flex-1 gap-1">
<FileText className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.summary' })}
</TabsTrigger>
</TabsList>
</Tabs>
<TabsNavigation
value={litePlanActiveTab}
onValueChange={(v) => setLitePlanActiveTab(v as LitePlanTab)}
tabs={[
{
value: 'tasks',
label: formatMessage({ id: 'liteTasksDetail.tabs.tasks' }),
icon: <ListTodo className="h-4 w-4" />,
},
{
value: 'plan',
label: formatMessage({ id: 'liteTasksDetail.tabs.plan' }),
icon: <Ruler className="h-4 w-4" />,
},
...(isLiteFix
? [
{
value: 'diagnoses' as const,
label: formatMessage({ id: 'liteTasksDetail.tabs.diagnoses' }),
icon: <Stethoscope className="h-4 w-4" />,
},
]
: []),
{
value: 'context',
label: formatMessage({ id: 'liteTasksDetail.tabs.context' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'summary',
label: formatMessage({ id: 'liteTasksDetail.tabs.summary' }),
icon: <FileText className="h-4 w-4" />,
},
]}
/>
)}
{/* Task List with Multi-Tab Content */}
@@ -390,15 +406,11 @@ export function LiteTaskDetailPage() {
<Card key={taskId} className="overflow-hidden">
{/* Task Header */}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-4">
{/* Left: Task ID, Title, Description */}
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-medium flex items-center gap-2 flex-wrap">
<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={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : 'secondary'}
>
{task.status}
</Badge>
{task.priority && (
<Badge variant="outline" className="text-xs">{task.priority}</Badge>
)}
@@ -414,28 +426,77 @@ export function LiteTaskDetailPage() {
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{task.description}</p>
)}
</div>
{/* Right: Meta Information */}
<div className="flex flex-col items-end gap-2 text-xs text-muted-foreground flex-shrink-0">
{/* Row 1: Status Badge */}
<Badge
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : task.status === 'blocked' ? 'destructive' : 'secondary'}
className="w-fit"
>
{task.status}
</Badge>
{/* Row 2: Metadata */}
<div className="flex items-center gap-3 flex-wrap justify-end">
{/* Dependencies Count */}
{task.context?.depends_on && task.context.depends_on.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.context.depends_on.length}</span>
<span>dep{task.context.depends_on.length > 1 ? 's' : ''}</span>
</span>
)}
{/* Target Files Count */}
{task.flow_control?.target_files && task.flow_control.target_files.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.flow_control.target_files.length}</span>
<span>file{task.flow_control.target_files.length > 1 ? 's' : ''}</span>
</span>
)}
{/* Focus Paths Count */}
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.context.focus_paths.length}</span>
<span>focus</span>
</span>
)}
{/* Acceptance Criteria Count */}
{task.context?.acceptance && task.context.acceptance.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.context.acceptance.length}</span>
<span>criteria</span>
</span>
)}
</div>
</div>
</div>
</CardHeader>
{/* Multi-Tab Content */}
<Tabs
value={activeTaskTab}
onValueChange={(v) => handleTaskTabChange(taskId, v as TaskTabValue)}
className="w-full"
>
<TabsList className="w-full rounded-none border-y border-border bg-muted/50 px-4">
<TabsTrigger value="task" className="flex-1 gap-1.5">
<ListTodo className="h-4 w-4" />
Task
</TabsTrigger>
<TabsTrigger value="context" className="flex-1 gap-1.5">
<Package className="h-4 w-4" />
Context
</TabsTrigger>
</TabsList>
<div className="w-full">
<TabsNavigation
value={activeTaskTab}
onValueChange={(v) => handleTaskTabChange(taskId, v as TaskTabValue)}
tabs={[
{
value: 'task',
label: 'Task',
icon: <ListTodo className="h-4 w-4" />,
},
{
value: 'context',
label: 'Context',
icon: <Package className="h-4 w-4" />,
},
]}
/>
{/* Task Tab - Implementation Details */}
<TabsContent value="task" className="p-4 space-y-4">
{activeTaskTab === 'task' && (
<div className="p-4 space-y-4">
{/* Flowchart */}
{hasFlowchart && task.flow_control && (
<div>
@@ -478,10 +539,12 @@ export function LiteTaskDetailPage() {
</div>
</div>
)}
</TabsContent>
</div>
)}
{/* Context Tab - Planning Context */}
<TabsContent value="context" className="p-4 space-y-4">
{activeTaskTab === 'context' && (
<div className="p-4 space-y-4">
{/* Focus Paths */}
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
<div>
@@ -547,8 +610,9 @@ export function LiteTaskDetailPage() {
</ul>
</div>
)}
</TabsContent>
</Tabs>
</div>
)}
</div>
</Card>
);
})}

View File

@@ -30,12 +30,16 @@ import {
Stethoscope,
FolderOpen,
FileText,
CheckCircle2,
Clock,
AlertCircle,
} from 'lucide-react';
import { useLiteTasks } from '@/hooks/useLiteTasks';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Tabs, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { TaskDrawer } from '@/components/shared/TaskDrawer';
import { fetchLiteSessionContext, type LiteTask, type LiteTaskSession, type LiteSessionContext } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
@@ -482,6 +486,19 @@ export function LiteTasksPage() {
const taskCount = session.tasks?.length || 0;
const isExpanded = expandedSessionId === session.id;
// Calculate task status distribution
const taskStats = React.useMemo(() => {
const tasks = session.tasks || [];
return {
completed: tasks.filter((t) => t.status === 'completed').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
blocked: tasks.filter((t) => t.status === 'blocked').length,
pending: tasks.filter((t) => !t.status || t.status === 'pending').length,
};
}, [session.tasks]);
const firstTask = session.tasks?.[0];
return (
<div key={session.id}>
<Card
@@ -507,6 +524,43 @@ export function LiteTasksPage() {
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
</Badge>
</div>
{/* Task preview - first task title */}
{firstTask?.title && (
<div className="mb-3 pb-3 border-b border-border/50">
<p className="text-sm text-foreground line-clamp-1">{firstTask.title}</p>
</div>
)}
{/* Task status distribution */}
<div className="flex items-center flex-wrap gap-2 mb-3">
{taskStats.completed > 0 && (
<Badge variant="success" className="gap-1 text-xs">
<CheckCircle2 className="h-3 w-3" />
{taskStats.completed} {formatMessage({ id: 'liteTasks.status.completed' })}
</Badge>
)}
{taskStats.inProgress > 0 && (
<Badge variant="warning" className="gap-1 text-xs">
<Clock className="h-3 w-3" />
{taskStats.inProgress} {formatMessage({ id: 'liteTasks.status.inProgress' })}
</Badge>
)}
{taskStats.blocked > 0 && (
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{taskStats.blocked} {formatMessage({ id: 'liteTasks.status.blocked' })}
</Badge>
)}
{taskStats.pending > 0 && (
<Badge variant="secondary" className="gap-1 text-xs">
<Activity className="h-3 w-3" />
{taskStats.pending} {formatMessage({ id: 'liteTasks.status.pending' })}
</Badge>
)}
</div>
{/* Date and task count */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{session.createdAt && (
<span className="flex items-center gap-1">
@@ -546,6 +600,18 @@ export function LiteTasksPage() {
const status = latestSynthesis.status || session.status || 'analyzing';
const createdAt = (metadata.timestamp as string) || session.createdAt || '';
// Calculate task status distribution
const taskStats = React.useMemo(() => {
const tasks = session.tasks || [];
return {
completed: tasks.filter((t) => t.status === 'completed').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
blocked: tasks.filter((t) => t.status === 'blocked').length,
pending: tasks.filter((t) => !t.status || t.status === 'pending').length,
total: tasks.length,
};
}, [session.tasks]);
return (
<Card
key={session.id}
@@ -575,6 +641,37 @@ export function LiteTasksPage() {
<MessageCircle className="h-4 w-4" />
<span className="line-clamp-1">{topicTitle}</span>
</div>
{/* Task status distribution for multi-cli */}
{taskStats.total > 0 && (
<div className="flex items-center flex-wrap gap-2 mb-3">
{taskStats.completed > 0 && (
<Badge variant="success" className="gap-1 text-xs">
<CheckCircle2 className="h-3 w-3" />
{taskStats.completed} {formatMessage({ id: 'liteTasks.status.completed' })}
</Badge>
)}
{taskStats.inProgress > 0 && (
<Badge variant="warning" className="gap-1 text-xs">
<Clock className="h-3 w-3" />
{taskStats.inProgress} {formatMessage({ id: 'liteTasks.status.inProgress' })}
</Badge>
)}
{taskStats.blocked > 0 && (
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{taskStats.blocked} {formatMessage({ id: 'liteTasks.status.blocked' })}
</Badge>
)}
{taskStats.pending > 0 && (
<Badge variant="secondary" className="gap-1 text-xs">
<Activity className="h-3 w-3" />
{taskStats.pending} {formatMessage({ id: 'liteTasks.status.pending' })}
</Badge>
)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{createdAt && (
<span className="flex items-center gap-1">
@@ -651,30 +748,30 @@ export function LiteTasksPage() {
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as LiteTaskTab)}>
<TabsList>
<TabsTrigger value="lite-plan">
<FileEdit className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.plan' })}
<Badge variant="secondary" className="ml-2">
{litePlan.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="lite-fix">
<Wrench className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.fix' })}
<Badge variant="secondary" className="ml-2">
{liteFix.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="multi-cli-plan">
<MessagesSquare className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.multiCli' })}
<Badge variant="secondary" className="ml-2">
{multiCliPlan.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={(v) => setActiveTab(v as LiteTaskTab)}
tabs={[
{
value: 'lite-plan',
label: formatMessage({ id: 'liteTasks.type.plan' }),
icon: <FileEdit className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{litePlan.length}</Badge>,
},
{
value: 'lite-fix',
label: formatMessage({ id: 'liteTasks.type.fix' }),
icon: <Wrench className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{liteFix.length}</Badge>,
},
{
value: 'multi-cli-plan',
label: formatMessage({ id: 'liteTasks.type.multiCli' }),
icon: <MessagesSquare className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{multiCliPlan.length}</Badge>,
},
]}
/>
{/* Search and Sort Toolbar */}
<div className="mt-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
@@ -729,86 +826,91 @@ export function LiteTasksPage() {
</div>
{/* Lite Plan Tab */}
<TabsContent value="lite-plan" className="mt-4">
{litePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLitePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLitePlan.map(renderLiteTaskCard)}</div>
)}
</TabsContent>
{activeTab === 'lite-plan' && (
<div className="mt-4">
{litePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLitePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLitePlan.map(renderLiteTaskCard)}</div>
)}
</div>
)}
{/* Lite Fix Tab */}
<TabsContent value="lite-fix" className="mt-4">
{liteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-fix' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLiteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLiteFix.map(renderLiteTaskCard)}</div>
)}
</TabsContent>
{activeTab === 'lite-fix' && (
<div className="mt-4">
{liteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-fix' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLiteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLiteFix.map(renderLiteTaskCard)}</div>
)}
</div>
)}
{/* Multi-CLI Plan Tab */}
<TabsContent value="multi-cli-plan" className="mt-4">
{multiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'multi-cli-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredMultiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredMultiCliPlan.map(renderMultiCliCard)}</div>
)}
</TabsContent>
</Tabs>
{activeTab === 'multi-cli-plan' && (
<div className="mt-4">
{multiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'multi-cli-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredMultiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredMultiCliPlan.map(renderMultiCliCard)}</div>
)}
</div>
)}
{/* TaskDrawer */}
<TaskDrawer

View File

@@ -228,158 +228,161 @@ export function ProjectOverviewPage() {
const { technologyStack, architecture, keyComponents, developmentIndex, guidelines, metadata } = projectOverview;
return (
<div className="space-y-6">
{/* Project Header */}
<div className="space-y-4">
{/* Project Header + Technology Stack - Combined */}
<Card>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<CardContent className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-4 pb-3 border-b border-border">
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground mb-2">
<h1 className="text-base font-semibold text-foreground mb-1">
{projectOverview.projectName}
</h1>
<p className="text-muted-foreground">
<p className="text-xs text-muted-foreground">
{projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })}
</p>
</div>
<div className="text-sm text-muted-foreground text-right">
<div className="text-xs text-muted-foreground text-right">
<div>
{formatMessage({ id: 'projectOverview.header.initialized' })}:{' '}
{formatDate(projectOverview.initializedAt)}
</div>
{metadata?.analysis_mode && (
<div className="mt-1">
<span className="font-mono text-xs px-2 py-0.5 bg-muted rounded">
<span className="font-mono text-[10px] px-1.5 py-0.5 bg-muted rounded">
{metadata.analysis_mode}
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Technology Stack */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Code2 className="w-5 h-5" />
{/* Technology Stack */}
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Code2 className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.techStack.title' })}
</h3>
{/* Languages */}
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.languages' })}
</h4>
<div className="flex flex-wrap gap-3">
{technologyStack?.languages && technologyStack.languages.length > 0 ? (
technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
<div
key={lang.name}
className={`flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg ${
lang.primary ? 'ring-2 ring-primary' : ''
}`}
>
<span className="font-semibold text-foreground">{lang.name}</span>
<span className="text-xs text-muted-foreground">{lang.file_count} files</span>
{lang.primary && (
<span className="text-xs px-1.5 py-0.5 bg-primary text-primary-foreground rounded">
{formatMessage({ id: 'projectOverview.techStack.primary' })}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Languages */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.languages' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.languages && technologyStack.languages.length > 0 ? (
technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
<div
key={lang.name}
className={`flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded text-xs ${
lang.primary ? 'ring-1 ring-primary' : ''
}`}
>
<span className="font-medium text-foreground">{lang.name}</span>
<span className="text-[10px] text-muted-foreground">{lang.file_count}</span>
{lang.primary && (
<span className="text-[9px] px-1 py-0.5 bg-primary text-primary-foreground rounded">
{formatMessage({ id: 'projectOverview.techStack.primary' })}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground text-xs">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span>
)}
</div>
</div>
</div>
{/* Frameworks */}
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.frameworks' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
technologyStack.frameworks.map((fw: string) => (
<Badge key={fw} variant="success" className="px-3 py-1.5">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span>
)}
{/* Frameworks */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.frameworks' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
technologyStack.frameworks.map((fw: string) => (
<Badge key={fw} variant="success" className="px-2 py-0.5 text-[10px]">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span>
)}
</div>
</div>
</div>
{/* Build Tools */}
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 && (
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{/* Build Tools */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.buildTools' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack.build_tools.map((tool: string) => (
<Badge key={tool} variant="warning" className="px-3 py-1.5">
{tool}
</Badge>
))}
<div className="flex flex-wrap gap-1.5">
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 ? (
technologyStack.build_tools.map((tool: string) => (
<Badge key={tool} variant="warning" className="px-2 py-0.5 text-[10px]">
{tool}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</div>
</div>
)}
{/* Test Frameworks */}
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 && (
{/* Test Frameworks */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.testFrameworks' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack.test_frameworks.map((fw: string) => (
<Badge key={fw} variant="default" className="px-3 py-1.5">
{fw}
</Badge>
))}
<div className="flex flex-wrap gap-1.5">
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 ? (
technologyStack.test_frameworks.map((fw: string) => (
<Badge key={fw} variant="default" className="px-2 py-0.5 text-[10px]">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Architecture */}
{architecture && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Blocks className="w-5 h-5" />
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Blocks className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.architecture.title' })}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Style */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.style' })}
</h4>
<div className="px-3 py-2 bg-background border border-border rounded-lg">
<span className="text-foreground font-medium">{architecture.style}</span>
<div className="px-2 py-1.5 bg-background border border-border rounded">
<span className="text-foreground font-medium text-xs">{architecture.style}</span>
</div>
</div>
{/* Layers */}
{architecture.layers && architecture.layers.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.layers' })}
</h4>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{architecture.layers.map((layer: string) => (
<span key={layer} className="px-2 py-1 bg-muted text-foreground rounded text-sm">
<span key={layer} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]">
{layer}
</span>
))}
@@ -390,12 +393,12 @@ export function ProjectOverviewPage() {
{/* Patterns */}
{architecture.patterns && architecture.patterns.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{architecture.patterns.map((pattern: string) => (
<span key={pattern} className="px-2 py-1 bg-muted text-foreground rounded text-sm">
<span key={pattern} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]">
{pattern}
</span>
))}
@@ -410,33 +413,33 @@ export function ProjectOverviewPage() {
{/* Key Components */}
{keyComponents && keyComponents.length > 0 && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Component className="w-5 h-5" />
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Component className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.components.title' })}
</h3>
<div className="space-y-3">
<div className="space-y-2">
{keyComponents.map((comp: KeyComponent) => {
const importance = comp.importance || 'low';
const importanceColors: Record<string, string> = {
high: 'border-l-4 border-l-destructive bg-destructive/5',
medium: 'border-l-4 border-l-warning bg-warning/5',
low: 'border-l-4 border-l-muted-foreground bg-muted',
high: 'border-l-2 border-l-destructive bg-destructive/5',
medium: 'border-l-2 border-l-warning bg-warning/5',
low: 'border-l-2 border-l-muted-foreground bg-muted',
};
const importanceBadges: Record<string, React.ReactElement> = {
high: (
<Badge variant="destructive" className="text-xs">
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.high' })}
</Badge>
),
medium: (
<Badge variant="warning" className="text-xs">
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.medium' })}
</Badge>
),
low: (
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.low' })}
</Badge>
),
@@ -445,17 +448,17 @@ export function ProjectOverviewPage() {
return (
<div
key={comp.name}
className={`p-4 rounded-lg ${importanceColors[importance] || importanceColors.low}`}
className={`p-2.5 rounded ${importanceColors[importance] || importanceColors.low}`}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-foreground">{comp.name}</h4>
<div className="flex items-start justify-between mb-1">
<h4 className="font-medium text-foreground text-xs">{comp.name}</h4>
{importanceBadges[importance]}
</div>
{comp.description && (
<p className="text-sm text-muted-foreground mb-2">{comp.description}</p>
<p className="text-[10px] text-muted-foreground mb-1">{comp.description}</p>
)}
{comp.responsibility && comp.responsibility.length > 0 && (
<ul className="text-xs text-muted-foreground list-disc list-inside">
<ul className="text-[10px] text-muted-foreground list-disc list-inside">
{comp.responsibility.map((resp: string, i: number) => (
<li key={i}>{resp}</li>
))}
@@ -472,20 +475,20 @@ export function ProjectOverviewPage() {
{/* Development Index */}
{developmentIndex && totalEntries > 0 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<GitBranch className="w-5 h-5" />
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-1.5">
<GitBranch className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.devIndex.title' })}
</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{devIndexCategories.map((cat) => {
const count = devIndexTotals[cat.key];
if (count === 0) return null;
const Icon = cat.icon;
return (
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'}>
<Icon className="w-3 h-3 mr-1" />
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0">
<Icon className="w-2.5 h-2.5 mr-0.5" />
{count}
</Badge>
);
@@ -494,21 +497,21 @@ export function ProjectOverviewPage() {
</div>
<Tabs value={devIndexView} onValueChange={(v) => setDevIndexView(v as DevIndexView)}>
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="category">
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
<div className="flex items-center justify-between mb-3">
<TabsList className="h-7">
<TabsTrigger value="category" className="text-xs px-2 py-1 h-6">
<LayoutGrid className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.categories' })}
</TabsTrigger>
<TabsTrigger value="timeline">
<GitCommitHorizontal className="w-3.5 h-3.5 mr-1" />
<TabsTrigger value="timeline" className="text-xs px-2 py-1 h-6">
<GitCommitHorizontal className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.timeline' })}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="category">
<div className="space-y-4">
<div className="space-y-3">
{devIndexCategories.map((cat) => {
const entries = developmentIndex?.[cat.key] || [];
if (entries.length === 0) return null;
@@ -516,38 +519,38 @@ export function ProjectOverviewPage() {
return (
<div key={cat.key}>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<Icon className="w-4 h-4" />
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
<span>{formatMessage({ id: cat.i18nKey })}</span>
<Badge variant="secondary">{entries.length}</Badge>
<Badge variant="secondary" className="text-[10px] px-1 py-0">{entries.length}</Badge>
</h4>
<div className="space-y-2">
<div className="space-y-1.5">
{entries.slice(0, 5).map((entry: DevelopmentIndexEntry & { type?: string; typeLabel?: string; typeIcon?: React.ElementType; typeColor?: string; date?: string }, i: number) => (
<div
key={i}
className="p-3 bg-background border border-border rounded-lg hover:shadow-sm transition-shadow"
className="p-2 bg-background border border-border rounded hover:shadow-sm transition-shadow"
>
<div className="flex items-start justify-between mb-1">
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<span className="text-xs text-muted-foreground">
<div className="flex items-start justify-between mb-0.5">
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5>
<span className="text-[10px] text-muted-foreground">
{formatDate(entry.archivedAt || entry.date || entry.implemented_at)}
</span>
</div>
{entry.description && (
<p className="text-sm text-muted-foreground mb-1">{entry.description}</p>
<p className="text-[10px] text-muted-foreground mb-1">{entry.description}</p>
)}
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1.5 text-[10px] flex-wrap">
{entry.sessionId && (
<span className="px-2 py-0.5 bg-primary-light text-primary rounded font-mono">
<span className="px-1.5 py-0.5 bg-primary-light text-primary rounded font-mono">
{entry.sessionId}
</span>
)}
{entry.sub_feature && (
<span className="px-2 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
<span className="px-1.5 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
)}
{entry.status && (
<span
className={`px-2 py-0.5 rounded ${
className={`px-1.5 py-0.5 rounded ${
entry.status === 'completed'
? 'bg-success-light text-success'
: 'bg-warning-light text-warning'
@@ -560,7 +563,7 @@ export function ProjectOverviewPage() {
</div>
))}
{entries.length > 5 && (
<div className="text-sm text-muted-foreground text-center py-2">
<div className="text-xs text-muted-foreground text-center py-1">
... and {entries.length - 5} more
</div>
)}
@@ -572,24 +575,24 @@ export function ProjectOverviewPage() {
</TabsContent>
<TabsContent value="timeline">
<div className="space-y-4">
<div className="space-y-3">
{allDevEntries.slice(0, 20).map((entry, i) => {
const Icon = entry.typeIcon;
return (
<div key={i} className="flex gap-4">
<div key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full bg-${entry.typeColor}-light text-${entry.typeColor} flex items-center justify-center`}
className={`w-6 h-6 rounded-full bg-${entry.typeColor}-light text-${entry.typeColor} flex items-center justify-center`}
>
<Icon className="w-4 h-4" />
<Icon className="w-3 h-3" />
</div>
{i < Math.min(allDevEntries.length, 20) - 1 && (
<div className="w-0.5 flex-1 bg-border mt-2" />
<div className="w-0.5 flex-1 bg-border mt-1.5" />
)}
</div>
<div className="flex-1 pb-4">
<div className="flex items-start justify-between mb-1">
<div className="flex items-center gap-2">
<div className="flex-1 pb-3">
<div className="flex items-start justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Badge
variant={
entry.typeColor === 'primary'
@@ -598,31 +601,31 @@ export function ProjectOverviewPage() {
? 'destructive'
: 'secondary'
}
className="text-xs"
className="text-[10px] px-1.5 py-0"
>
{entry.typeLabel}
</Badge>
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDate(entry.date)}
</span>
</div>
{entry.description && (
<p className="text-sm text-muted-foreground mb-2">{entry.description}</p>
<p className="text-[10px] text-muted-foreground mb-1">{entry.description}</p>
)}
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1.5 text-[10px]">
{entry.sessionId && (
<span className="px-2 py-0.5 bg-muted rounded font-mono">
<span className="px-1.5 py-0.5 bg-muted rounded font-mono">
{entry.sessionId}
</span>
)}
{entry.sub_feature && (
<span className="px-2 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
<span className="px-1.5 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
)}
{entry.tags &&
entry.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-accent rounded">
<span key={tag} className="px-1.5 py-0.5 bg-accent rounded">
{tag}
</span>
))}
@@ -632,7 +635,7 @@ export function ProjectOverviewPage() {
);
})}
{allDevEntries.length > 20 && (
<div className="text-sm text-muted-foreground text-center py-4">
<div className="text-xs text-muted-foreground text-center py-2">
... and {allDevEntries.length - 20} more entries
</div>
)}
@@ -646,26 +649,26 @@ export function ProjectOverviewPage() {
{/* Guidelines */}
{guidelines && (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<ScrollText className="w-5 h-5" />
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-1.5">
<ScrollText className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.guidelines.title' })}
</h3>
<div className="flex gap-2">
<div className="flex gap-1.5">
{!isEditMode ? (
<Button variant="outline" size="sm" onClick={handleEditStart}>
<Edit className="w-4 h-4 mr-1" />
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={handleEditStart}>
<Edit className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.guidelines.edit' })}
</Button>
) : (
<>
<Button variant="outline" size="sm" onClick={handleEditCancel} disabled={isUpdating}>
<X className="w-4 h-4 mr-1" />
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={handleEditCancel} disabled={isUpdating}>
<X className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.guidelines.cancel' })}
</Button>
<Button variant="default" size="sm" onClick={handleSave} disabled={isUpdating}>
<Save className="w-4 h-4 mr-1" />
<Button variant="default" size="sm" className="h-7 text-xs px-2" onClick={handleSave} disabled={isUpdating}>
<Save className="w-3 h-3 mr-1" />
{isUpdating ? formatMessage({ id: 'projectOverview.guidelines.saving' }) : formatMessage({ id: 'projectOverview.guidelines.save' })}
</Button>
</>
@@ -673,17 +676,17 @@ export function ProjectOverviewPage() {
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
{!isEditMode ? (
<>
{/* Read-only Mode - Conventions */}
{guidelines.conventions && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<BookMarked className="w-4 h-4" />
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
<BookMarked className="w-3.5 h-3.5" />
<span>{formatMessage({ id: 'projectOverview.guidelines.conventions' })}</span>
</h4>
<div className="space-y-2">
<div className="space-y-1.5">
{Object.entries(guidelines.conventions).map(([key, items]) => {
const itemList = Array.isArray(items) ? items : [];
if (itemList.length === 0) return null;
@@ -692,12 +695,12 @@ export function ProjectOverviewPage() {
{itemList.map((item: string, i: number) => (
<div
key={i}
className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg"
className="flex items-start gap-2 p-2 bg-background border border-border rounded"
>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
<span className="text-[10px] px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
{key}
</span>
<span className="text-sm text-foreground">{item}</span>
<span className="text-xs text-foreground">{item}</span>
</div>
))}
</div>
@@ -710,11 +713,11 @@ export function ProjectOverviewPage() {
{/* Read-only Mode - Constraints */}
{guidelines.constraints && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<ShieldAlert className="w-4 h-4" />
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
<ShieldAlert className="w-3.5 h-3.5" />
<span>{formatMessage({ id: 'projectOverview.guidelines.constraints' })}</span>
</h4>
<div className="space-y-2">
<div className="space-y-1.5">
{Object.entries(guidelines.constraints).map(([key, items]) => {
const itemList = Array.isArray(items) ? items : [];
if (itemList.length === 0) return null;
@@ -723,12 +726,12 @@ export function ProjectOverviewPage() {
{itemList.map((item: string, i: number) => (
<div
key={i}
className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg"
className="flex items-start gap-2 p-2 bg-background border border-border rounded"
>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
<span className="text-[10px] px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
{key}
</span>
<span className="text-sm text-foreground">{item}</span>
<span className="text-xs text-foreground">{item}</span>
</div>
))}
</div>

View File

@@ -1,7 +1,7 @@
// ========================================
// ReviewSessionPage Component
// ========================================
// Review session detail page with findings display and multi-select
// Review session detail page with findings display, multi-select, dimension tabs, and fix progress carousel
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
@@ -17,6 +17,8 @@ import {
Download,
ChevronDown,
ChevronRight,
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
} from 'lucide-react';
import { useReviewSession } from '@/hooks/useReviewSession';
import { Button } from '@/components/ui/Button';
@@ -42,6 +44,229 @@ interface FindingWithSelection {
impact?: string;
}
// Fix Progress Types
interface FixStage {
stage: number;
status: 'completed' | 'in-progress' | 'pending';
groups: string[];
}
interface FixProgressData {
fix_session_id: string;
phase: 'planning' | 'execution' | 'completion';
total_findings: number;
fixed_count: number;
failed_count: number;
in_progress_count: number;
pending_count: number;
percent_complete: number;
current_stage: number;
total_stages: number;
stages: FixStage[];
active_agents: Array<{
agent_id: string;
group_id: string;
current_finding: { finding_title: string } | null;
}>;
}
/**
* Fix Progress Carousel Component
* Displays fix progress with polling and carousel navigation
*/
function FixProgressCarousel({ sessionId }: { sessionId: string }) {
const { formatMessage } = useIntl();
const [fixProgressData, setFixProgressData] = React.useState<FixProgressData | null>(null);
const [currentSlide, setCurrentSlide] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(false);
// Fetch fix progress data
const fetchFixProgress = React.useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`);
if (!response.ok) {
if (response.status === 404) {
setFixProgressData(null);
}
return;
}
const data = await response.json();
setFixProgressData(data);
} catch (err) {
console.error('Failed to fetch fix progress:', err);
} finally {
setIsLoading(false);
}
}, [sessionId]);
// Poll for fix progress updates
React.useEffect(() => {
fetchFixProgress();
// Stop polling if phase is completion
if (fixProgressData?.phase === 'completion') {
return;
}
const interval = setInterval(() => {
fetchFixProgress();
}, 5000);
return () => clearInterval(interval);
}, [fetchFixProgress, fixProgressData?.phase]);
// Navigate carousel
const navigateSlide = (direction: 'prev' | 'next' | number) => {
if (!fixProgressData) return;
const totalSlides = fixProgressData.active_agents.length > 0 ? 3 : 2;
if (typeof direction === 'number') {
setCurrentSlide(direction);
} else if (direction === 'next') {
setCurrentSlide((prev) => (prev + 1) % totalSlides);
} else if (direction === 'prev') {
setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides);
}
};
if (isLoading && !fixProgressData) {
return (
<Card>
<CardContent className="p-4">
<div className="h-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
);
}
if (!fixProgressData) {
return null;
}
const { phase, total_findings, fixed_count, failed_count, in_progress_count, pending_count, percent_complete, current_stage, total_stages, stages, active_agents } = fixProgressData;
const phaseIcon = phase === 'planning' ? '📝' : phase === 'execution' ? '⚡' : '✅';
const totalSlides = active_agents.length > 0 ? 3 : 2;
return (
<Card>
<CardContent className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">🔧</span>
<span className="font-semibold text-sm">{formatMessage({ id: 'reviewSession.fixProgress.title' })}</span>
</div>
{/* Stage Dots */}
<div className="flex gap-1">
{stages.map((stage, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full ${
stage.status === 'completed' ? 'bg-green-500' :
stage.status === 'in-progress' ? 'bg-blue-500' :
'bg-gray-300 dark:bg-gray-600'
}`}
title={`Stage ${i + 1}: ${stage.status}`}
/>
))}
</div>
</div>
{/* Carousel */}
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={{ transform: `translateX(-${currentSlide * 100}%)` }}
>
{/* Slide 1: Overview */}
<div className="w-full flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<Badge variant={phase === 'planning' ? 'secondary' : phase === 'execution' ? 'default' : 'success'}>
{phaseIcon} {formatMessage({ id: `reviewSession.fixProgress.phase.${phase}` })}
</Badge>
<span className="text-xs text-muted-foreground">{fixProgressData.fix_session_id}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${percent_complete}%` }}
/>
</div>
<div className="text-xs text-muted-foreground text-center">
{formatMessage({ id: 'reviewSession.fixProgress.complete' }, { percent: percent_complete.toFixed(0) })} · {formatMessage({ id: 'reviewSession.fixProgress.stage' })} {current_stage}/{total_stages}
</div>
</div>
{/* Slide 2: Stats */}
<div className="w-full flex-shrink-0">
<div className="grid grid-cols-4 gap-2">
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold">{total_findings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.total' })}</div>
</div>
<div className="text-center p-2 bg-green-100 dark:bg-green-900/20 rounded">
<div className="text-lg font-bold text-green-600 dark:text-green-400">{fixed_count}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.fixed' })}</div>
</div>
<div className="text-center p-2 bg-red-100 dark:bg-red-900/20 rounded">
<div className="text-lg font-bold text-red-600 dark:text-red-400">{failed_count}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.failed' })}</div>
</div>
<div className="text-center p-2 bg-yellow-100 dark:bg-yellow-900/20 rounded">
<div className="text-lg font-bold text-yellow-600 dark:text-yellow-400">{pending_count + in_progress_count}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.pending' })}</div>
</div>
</div>
</div>
{/* Slide 3: Active Agents (if any) */}
{active_agents.length > 0 && (
<div className="w-full flex-shrink-0">
<div className="text-sm font-semibold mb-2">
{active_agents.length} {active_agents.length === 1 ? formatMessage({ id: 'reviewSession.fixProgress.activeAgents' }) : formatMessage({ id: 'reviewSession.fixProgress.activeAgentsPlural' })}
</div>
<div className="space-y-2">
{active_agents.slice(0, 2).map((agent, i) => (
<div key={i} className="flex items-center gap-2 p-2 bg-muted rounded">
<span>🤖</span>
<span className="text-sm">{agent.current_finding?.finding_title || formatMessage({ id: 'reviewSession.fixProgress.working' })}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Carousel Navigation */}
{totalSlides > 1 && (
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={() => navigateSlide('prev')}>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex gap-1">
{Array.from({ length: totalSlides }).map((_, i) => (
<button
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
currentSlide === i ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={() => navigateSlide(i)}
/>
))}
</div>
<Button variant="outline" size="sm" onClick={() => navigateSlide('next')}>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
);
}
/**
* ReviewSessionPage component - Display review session findings
*/
@@ -61,11 +286,13 @@ export function ReviewSessionPage() {
const [severityFilter, setSeverityFilter] = React.useState<Set<SeverityFilter>>(
new Set(['critical', 'high', 'medium', 'low'])
);
const [dimensionFilter, setDimensionFilter] = React.useState<string>('all');
const [searchQuery, setSearchQuery] = React.useState('');
const [sortField, setSortField] = React.useState<SortField>('severity');
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
const [selectedFindings, setSelectedFindings] = React.useState<Set<string>>(new Set());
const [expandedFindings, setExpandedFindings] = React.useState<Set<string>>(new Set());
const [selectedFindingId, setSelectedFindingId] = React.useState<string | null>(null);
const handleBack = () => {
navigate('/sessions');
@@ -83,6 +310,12 @@ export function ReviewSessionPage() {
});
};
const resetFilters = () => {
setSeverityFilter(new Set(['critical', 'high', 'medium', 'low']));
setDimensionFilter('all');
setSearchQuery('');
};
const toggleSelectFinding = (findingId: string) => {
setSelectedFindings(prev => {
const next = new Set(prev);
@@ -104,6 +337,22 @@ export function ReviewSessionPage() {
}
};
const selectVisibleFindings = () => {
const validIds = filteredFindings.map(f => f.id).filter((id): id is string => id !== undefined);
setSelectedFindings(new Set(validIds));
};
const selectBySeverity = (severity: FindingWithSelection['severity']) => {
const criticalIds = flattenedFindings
.filter(f => f.severity === severity && f.id !== undefined)
.map(f => f.id!);
setSelectedFindings(prev => {
const next = new Set(prev);
criticalIds.forEach(id => next.add(id));
return next;
});
};
const toggleExpandFinding = (findingId: string) => {
setExpandedFindings(prev => {
const next = new Set(prev);
@@ -116,6 +365,10 @@ export function ReviewSessionPage() {
});
};
const handleFindingClick = (findingId: string) => {
setSelectedFindingId(findingId);
};
const exportSelectedAsJson = () => {
const selected = flattenedFindings.filter(f => f.id !== undefined && selectedFindings.has(f.id));
if (selected.length === 0) return;
@@ -148,12 +401,26 @@ export function ReviewSessionPage() {
// Severity order for sorting
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
// Calculate dimension counts
const dimensionCounts = React.useMemo(() => {
const counts: Record<string, number> = { all: flattenedFindings.length };
flattenedFindings.forEach(f => {
counts[f.dimension] = (counts[f.dimension] || 0) + 1;
});
return counts;
}, [flattenedFindings]);
// Filter and sort findings
const filteredFindings = React.useMemo(() => {
let filtered = flattenedFindings;
// Apply dimension filter
if (dimensionFilter !== 'all') {
filtered = filtered.filter(f => f.dimension === dimensionFilter);
}
// Apply severity filter
if (severityFilter.size > 0 && !severityFilter.has('all' as SeverityFilter)) {
if (severityFilter.size > 0) {
filtered = filtered.filter(f => severityFilter.has(f.severity));
}
@@ -186,7 +453,7 @@ export function ReviewSessionPage() {
});
return filtered;
}, [flattenedFindings, severityFilter, searchQuery, sortField, sortOrder]);
}, [flattenedFindings, severityFilter, dimensionFilter, searchQuery, sortField, sortOrder]);
// Get severity badge props
const getSeverityBadge = (severity: FindingWithSelection['severity']) => {
@@ -256,6 +523,11 @@ export function ReviewSessionPage() {
const dimensions = reviewSession.reviewDimensions || [];
const totalFindings = flattenedFindings.length;
// Determine session status (ACTIVE or ARCHIVED)
const isActive = reviewSession._isActive !== false;
const sessionStatus = isActive ? 'ACTIVE' : 'ARCHIVED';
const phase = reviewSession.phase || 'in-progress';
return (
<div className="space-y-6">
{/* Header */}
@@ -266,65 +538,99 @@ export function ReviewSessionPage() {
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: 'reviewSession.title' })}
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
🔍 {reviewSession.session_id}
</h1>
<p className="text-sm text-muted-foreground">{reviewSession.session_id}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="review">Review</Badge>
<Badge variant={isActive ? "success" : "secondary"} className="text-xs">
{sessionStatus}
</Badge>
</div>
</div>
</div>
<Badge variant="info">
{formatMessage({ id: 'reviewSession.type' })}
</Badge>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{totalFindings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.total' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-destructive">{severityCounts.critical}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.critical' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-warning">{severityCounts.high}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.high' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{dimensions.length}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
</CardContent>
</Card>
</div>
{/* Review Progress Section */}
<Card>
<CardContent className="p-4 space-y-4">
{/* Review Progress Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">📊</span>
<span className="font-semibold">{formatMessage({ id: 'reviewSession.progress.title' })}</span>
</div>
<Badge variant="secondary">{phase.toUpperCase()}</Badge>
</div>
{/* Summary Cards Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<span className="text-2xl">📊</span>
<div>
<div className="text-lg font-bold">{totalFindings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.totalFindings' })}</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-red-100 dark:bg-red-900/20 rounded-lg">
<span className="text-2xl">🔴</span>
<div>
<div className="text-lg font-bold text-red-600 dark:text-red-400">{severityCounts.critical}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.critical' })}</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-orange-100 dark:bg-orange-900/20 rounded-lg">
<span className="text-2xl">🟠</span>
<div>
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{severityCounts.high}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.high' })}</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-blue-100 dark:bg-blue-900/20 rounded-lg">
<span className="text-2xl">🎯</span>
<div>
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{dimensions.length}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Fix Progress Carousel */}
{sessionId && <FixProgressCarousel sessionId={sessionId} />}
{/* Filters and Controls */}
<Card>
<CardContent className="p-4 space-y-4">
{/* Severity Filters */}
<div className="flex flex-wrap gap-2">
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
const isEnabled = severityFilter.has(severity);
const badge = getSeverityBadge(severity);
return (
<Badge
key={severity}
variant={isEnabled ? badge.variant : 'outline'}
className={`cursor-pointer ${isEnabled ? '' : 'opacity-50'}`}
onClick={() => toggleSeverity(severity)}
>
<badge.icon className="h-3 w-3 mr-1" />
{badge.label}: {severityCounts[severity]}
</Badge>
);
})}
{/* Checkbox-style Severity Filters */}
<div className="space-y-3">
<div className="text-sm font-medium">{formatMessage({ id: 'reviewSession.filters.severity' })}</div>
<div className="flex flex-wrap gap-2">
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
const isEnabled = severityFilter.has(severity);
return (
<label
key={severity}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border cursor-pointer transition-colors ${
isEnabled
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-muted'
}`}
>
<input
type="checkbox"
checked={isEnabled}
onChange={() => toggleSeverity(severity)}
className="sr-only"
/>
<span className="text-sm font-medium">
{formatMessage({ id: `reviewSession.severity.${severity}` })}
</span>
</label>
);
})}
</div>
</div>
{/* Search and Sort */}
@@ -355,6 +661,9 @@ export function ReviewSessionPage() {
>
{sortOrder === 'asc' ? '↑' : '↓'}
</Button>
<Button variant="outline" size="sm" onClick={resetFilters}>
{formatMessage({ id: 'reviewSession.filters.reset' })}
</Button>
</div>
{/* Selection Controls */}
@@ -368,6 +677,12 @@ export function ReviewSessionPage() {
? formatMessage({ id: 'reviewSession.selection.clearAll' })
: formatMessage({ id: 'reviewSession.selection.selectAll' })}
</Button>
<Button variant="outline" size="sm" onClick={selectVisibleFindings}>
{formatMessage({ id: 'reviewSession.selection.selectVisible' })}
</Button>
<Button variant="outline" size="sm" onClick={() => selectBySeverity('critical')}>
{formatMessage({ id: 'reviewSession.selection.selectCritical' })}
</Button>
<Button
variant="outline"
size="sm"
@@ -384,12 +699,39 @@ export function ReviewSessionPage() {
className="gap-2"
>
<Download className="h-4 w-4" />
{formatMessage({ id: 'reviewSession.export' })}
🔧 {formatMessage({ id: 'reviewSession.export' })}
</Button>
</div>
</CardContent>
</Card>
{/* Dimension Tabs */}
<div className="flex flex-wrap gap-2">
<button
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
dimensionFilter === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
onClick={() => setDimensionFilter('all')}
>
{formatMessage({ id: 'reviewSession.dimensionTabs.all' })} ({dimensionCounts.all || 0})
</button>
{dimensions.map(dim => (
<button
key={dim.name}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
dimensionFilter === dim.name
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
onClick={() => setDimensionFilter(dim.name)}
>
{dim.name} ({dim.findings?.length || 0})
</button>
))}
</div>
{/* Findings List */}
{filteredFindings.length === 0 ? (
<Card>

View File

@@ -27,7 +27,7 @@ import { ReviewTab } from './session-detail/ReviewTab';
import { TaskDrawer } from '@/components/shared/TaskDrawer';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import type { TaskData } from '@/types/store';
type TabValue = 'tasks' | 'context' | 'summary' | 'impl-plan' | 'conflict' | 'review';
@@ -103,6 +103,43 @@ export function SessionDetailPage() {
const completedTasks = tasks.filter((t) => t.status === 'completed').length;
const hasReview = session.has_review || session.review;
const tabs: TabItem[] = [
{
value: 'tasks',
label: formatMessage({ id: 'sessionDetail.tabs.tasks' }),
icon: <ListChecks className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{tasks.length}</Badge>,
},
{
value: 'context',
label: formatMessage({ id: 'sessionDetail.tabs.context' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'summary',
label: formatMessage({ id: 'sessionDetail.tabs.summary' }),
icon: <FileText className="h-4 w-4" />,
},
{
value: 'impl-plan',
label: formatMessage({ id: 'sessionDetail.tabs.implPlan' }),
icon: <Ruler className="h-4 w-4" />,
},
{
value: 'conflict',
label: formatMessage({ id: 'sessionDetail.tabs.conflict' }),
icon: <Scale className="h-4 w-4" />,
},
];
if (hasReview) {
tabs.push({
value: 'review',
label: formatMessage({ id: 'sessionDetail.tabs.review' }),
icon: <Search className="h-4 w-4" />,
});
}
return (
<div className="space-y-6">
{/* Header */}
@@ -148,65 +185,48 @@ export function SessionDetailPage() {
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
<TabsTrigger value="tasks">
<ListChecks className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.tasks' })}
<Badge variant="secondary" className="ml-2">
{tasks.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="context">
<Package className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.summary' })}
</TabsTrigger>
<TabsTrigger value="impl-plan">
<Ruler className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.implPlan' })}
</TabsTrigger>
<TabsTrigger value="conflict">
<Scale className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.conflict' })}
</TabsTrigger>
{hasReview && (
<TabsTrigger value="review">
<Search className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.review' })}
</TabsTrigger>
)}
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
tabs={tabs}
/>
<TabsContent value="tasks" className="mt-4">
{/* Tab Content */}
{activeTab === 'tasks' && (
<div className="mt-4">
<TaskListTab session={session} onTaskClick={setSelectedTask} />
</TabsContent>
</div>
)}
<TabsContent value="context" className="mt-4">
{activeTab === 'context' && (
<div className="mt-4">
<ContextTab context={context} />
</TabsContent>
</div>
)}
<TabsContent value="summary" className="mt-4">
{activeTab === 'summary' && (
<div className="mt-4">
<SummaryTab summary={summary} summaries={summaries} />
</TabsContent>
</div>
)}
<TabsContent value="impl-plan" className="mt-4">
{activeTab === 'impl-plan' && (
<div className="mt-4">
<ImplPlanTab implPlan={implPlan} />
</TabsContent>
</div>
)}
<TabsContent value="conflict" className="mt-4">
{activeTab === 'conflict' && (
<div className="mt-4">
<ConflictTab conflicts={conflicts as any} />
</TabsContent>
</div>
)}
{hasReview && (
<TabsContent value="review" className="mt-4">
<ReviewTab review={review as any} />
</TabsContent>
)}
</Tabs>
{hasReview && activeTab === 'review' && (
<div className="mt-4">
<ReviewTab review={review as any} />
</div>
)}
{/* Description (if exists) */}
{session.description && (

View File

@@ -40,7 +40,7 @@ import {
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { cn } from '@/lib/utils';
import type { SessionMetadata } from '@/types/store';
@@ -174,13 +174,15 @@ export function SessionsPage() {
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Location tabs */}
<Tabs value={locationFilter} onValueChange={(v) => setLocationFilter(v as LocationFilter)}>
<TabsList>
<TabsTrigger value="active">{formatMessage({ id: 'sessions.filters.active' })}</TabsTrigger>
<TabsTrigger value="archived">{formatMessage({ id: 'sessions.filters.archived' })}</TabsTrigger>
<TabsTrigger value="all">{formatMessage({ id: 'sessions.filters.all' })}</TabsTrigger>
</TabsList>
</Tabs>
<TabsNavigation
value={locationFilter}
onValueChange={(v) => setLocationFilter(v as LocationFilter)}
tabs={[
{ value: 'active', label: formatMessage({ id: 'sessions.filters.active' }) },
{ value: 'archived', label: formatMessage({ id: 'sessions.filters.archived' }) },
{ value: 'all', label: formatMessage({ id: 'sessions.filters.all' }) },
]}
/>
{/* Search input */}
<div className="flex-1 max-w-sm relative">

View File

@@ -1,18 +1,18 @@
// ========================================
// Coordinator Page
// Coordinator Page - Merged Layout
// ========================================
// Page for monitoring and managing coordinator workflow execution with timeline, logs, and node details
// Unified page for task list overview and execution details with timeline, logs, and node details
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Play } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Play, CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
CoordinatorInputModal,
CoordinatorTimeline,
CoordinatorLogStream,
NodeDetailsPanel,
CoordinatorEmptyState,
} from '@/components/coordinator';
import {
useCoordinatorStore,
@@ -21,11 +21,164 @@ import {
selectCoordinatorStatus,
selectIsPipelineLoaded,
} from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========================================
// Types
// ========================================
interface CoordinatorTask {
id: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: {
completed: number;
total: number;
};
startedAt: string;
completedAt?: string;
}
// ========================================
// Mock Data (temporary - will be replaced by store)
// ========================================
const MOCK_TASKS: CoordinatorTask[] = [
{
id: 'task-1',
name: 'Feature Auth',
status: 'running',
progress: { completed: 3, total: 5 },
startedAt: '2026-02-03T14:23:00Z',
},
{
id: 'task-2',
name: 'API Integration',
status: 'completed',
progress: { completed: 8, total: 8 },
startedAt: '2026-02-03T10:00:00Z',
completedAt: '2026-02-03T10:15:00Z',
},
{
id: 'task-3',
name: 'Performance Test',
status: 'failed',
progress: { completed: 2, total: 6 },
startedAt: '2026-02-03T09:00:00Z',
},
];
// ========================================
// Task Card Component (inline)
// ========================================
interface TaskCardProps {
task: CoordinatorTask;
isSelected: boolean;
onClick: () => void;
}
function TaskCard({ task, isSelected, onClick }: TaskCardProps) {
const { formatMessage } = useIntl();
const statusConfig = {
pending: {
icon: Clock,
color: 'text-muted-foreground',
bg: 'bg-muted/50',
},
running: {
icon: Loader2,
color: 'text-blue-500',
bg: 'bg-blue-500/10',
},
completed: {
icon: CheckCircle2,
color: 'text-green-500',
bg: 'bg-green-500/10',
},
failed: {
icon: XCircle,
color: 'text-red-500',
bg: 'bg-red-500/10',
},
};
const config = statusConfig[task.status];
const StatusIcon = config.icon;
const progressPercent = Math.round((task.progress.completed / task.progress.total) * 100);
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex flex-col p-3 rounded-lg border transition-all text-left w-full min-w-[160px] max-w-[200px]',
'hover:border-primary/50 hover:shadow-sm',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-card'
)}
>
{/* Task Name */}
<div className="flex items-center gap-2 mb-2">
<StatusIcon
className={cn(
'w-4 h-4 flex-shrink-0',
config.color,
task.status === 'running' && 'animate-spin'
)}
/>
<span className="text-sm font-medium text-foreground truncate">
{task.name}
</span>
</div>
{/* Status Badge */}
<div
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mb-2 w-fit',
config.bg,
config.color
)}
>
{formatMessage({ id: `coordinator.status.${task.status}` })}
</div>
{/* Progress */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{task.progress.completed}/{task.progress.total}
</span>
<span>{progressPercent}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
task.status === 'completed' && 'bg-green-500',
task.status === 'running' && 'bg-blue-500',
task.status === 'failed' && 'bg-red-500',
task.status === 'pending' && 'bg-muted-foreground'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</button>
);
}
// ========================================
// Main Component
// ========================================
export function CoordinatorPage() {
const { formatMessage } = useIntl();
const [isInputModalOpen, setIsInputModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// Store selectors
const commandChain = useCoordinatorStore(selectCommandChain);
@@ -33,7 +186,11 @@ export function CoordinatorPage() {
const status = useCoordinatorStore(selectCoordinatorStatus);
const isPipelineLoaded = useCoordinatorStore(selectIsPipelineLoaded);
const syncStateFromServer = useCoordinatorStore((state) => state.syncStateFromServer);
const reset = useCoordinatorStore((state) => state.reset);
// Mock tasks (temporary - will be replaced by store)
const tasks = useMemo(() => MOCK_TASKS, []);
const hasTasks = tasks.length > 0;
const selectedTask = tasks.find((t) => t.id === selectedTaskId);
// Sync state on mount (for page refresh scenarios)
useEffect(() => {
@@ -52,12 +209,21 @@ export function CoordinatorPage() {
setSelectedNode(nodeId);
}, []);
// Handle task selection
const handleTaskClick = useCallback((taskId: string) => {
setSelectedTaskId((prev) => (prev === taskId ? null : taskId));
setSelectedNode(null);
}, []);
// Get selected node object
const selectedNodeObject = commandChain.find((node) => node.id === selectedNode) || currentNode || null;
const selectedNodeObject =
commandChain.find((node) => node.id === selectedNode) || currentNode || null;
return (
<div className="h-full flex flex-col -m-4 md:-m-6">
{/* ======================================== */}
{/* Toolbar */}
{/* ======================================== */}
<div className="flex items-center gap-3 p-3 bg-card border-b border-border">
{/* Page Title and Status */}
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -68,9 +234,12 @@ export function CoordinatorPage() {
</span>
{isPipelineLoaded && (
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'coordinator.page.status' }, {
status: formatMessage({ id: `coordinator.status.${status}` }),
})}
{formatMessage(
{ id: 'coordinator.page.status' },
{
status: formatMessage({ id: `coordinator.status.${status}` }),
}
)}
</span>
)}
</div>
@@ -90,43 +259,90 @@ export function CoordinatorPage() {
</div>
</div>
{/* Main Content Area - 3 Panel Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel: Timeline */}
<div className="w-1/3 min-w-[300px] border-r border-border bg-card">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
className="h-full"
{/* ======================================== */}
{/* Main Content Area */}
{/* ======================================== */}
{!hasTasks ? (
/* Empty State - No tasks */
<div className="flex-1 flex overflow-hidden">
<CoordinatorEmptyState
onStart={handleOpenInputModal}
disabled={status === 'running' || status === 'initializing'}
className="w-full"
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* ======================================== */}
{/* Task List Area */}
{/* ======================================== */}
<div className="p-4 border-b border-border bg-background">
<div className="flex gap-3 overflow-x-auto pb-2">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onClick={() => handleTaskClick(task.id)}
/>
))}
</div>
</div>
{/* Center Panel: Log Stream */}
<div className="flex-1 min-w-0 bg-card">
<CoordinatorLogStream />
</div>
{/* ======================================== */}
{/* Task Detail Area (shown when task is selected) */}
{/* ======================================== */}
{selectedTask ? (
<div className="flex-1 flex overflow-hidden">
{/* Left Panel: Timeline */}
<div className="w-1/3 min-w-[300px] border-r border-border bg-card">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
className="h-full"
/>
</div>
{/* Right Panel: Node Details */}
<div className="w-80 min-w-[320px] max-w-[400px] border-l border-border bg-card overflow-y-auto">
{selectedNodeObject ? (
<NodeDetailsPanel
node={selectedNodeObject}
isExpanded={true}
onToggle={(expanded) => {
if (!expanded) {
setSelectedNode(null);
}
}}
/>
{/* Center Panel: Log Stream */}
<div className="flex-1 min-w-0 flex flex-col bg-card">
<div className="flex-1 min-h-0">
<CoordinatorLogStream />
</div>
</div>
{/* Right Panel: Node Details */}
<div className="w-80 min-w-[320px] max-w-[400px] border-l border-border bg-card overflow-y-auto">
{selectedNodeObject ? (
<NodeDetailsPanel
node={selectedNodeObject}
isExpanded={true}
onToggle={(expanded) => {
if (!expanded) {
setSelectedNode(null);
}
}}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm p-4 text-center">
{formatMessage({ id: 'coordinator.page.noNodeSelected' })}
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm p-4 text-center">
{formatMessage({ id: 'coordinator.page.noNodeSelected' })}
/* No task selected - show selection prompt */
<div className="flex-1 flex items-center justify-center bg-muted/30">
<div className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.taskDetail.noSelection' })}
</div>
</div>
)}
</div>
</div>
)}
{/* ======================================== */}
{/* Coordinator Input Modal */}
{/* ======================================== */}
<CoordinatorInputModal
open={isInputModalOpen}
onClose={() => setIsInputModalOpen(false)}

View File

@@ -0,0 +1,6 @@
// ========================================
// Coordinator Page Export
// ========================================
// Barrel export for CoordinatorPage component
export { CoordinatorPage } from './CoordinatorPage';

View File

@@ -27,10 +27,10 @@ export { ReviewSessionPage } from './ReviewSessionPage';
export { McpManagerPage } from './McpManagerPage';
export { EndpointsPage } from './EndpointsPage';
export { InstallationsPage } from './InstallationsPage';
export { ExecutionMonitorPage } from './ExecutionMonitorPage';
export { RulesManagerPage } from './RulesManagerPage';
export { PromptHistoryPage } from './PromptHistoryPage';
export { ExplorerPage } from './ExplorerPage';
export { GraphExplorerPage } from './GraphExplorerPage';
export { CodexLensManagerPage } from './CodexLensManagerPage';
export { ApiSettingsPage } from './ApiSettingsPage';
export { CliViewerPage } from './CliViewerPage';

View File

@@ -257,12 +257,6 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
{/* Row 2: Meta info */}
<div className="flex items-center gap-3 flex-wrap justify-end text-xs text-muted-foreground">
{priority && (
<Badge variant={priority.variant} className="text-xs gap-1">
<Zap className="h-3 w-3" />
{priority.label}
</Badge>
)}
{taskType && (
<span className="bg-muted px-1.5 py-0.5 rounded">{taskType}</span>
)}

View File

@@ -31,7 +31,6 @@ import {
McpManagerPage,
EndpointsPage,
InstallationsPage,
ExecutionMonitorPage,
HookManagerPage,
RulesManagerPage,
PromptHistoryPage,
@@ -39,6 +38,7 @@ import {
GraphExplorerPage,
CodexLensManagerPage,
ApiSettingsPage,
CliViewerPage,
} from '@/pages';
/**
@@ -91,14 +91,14 @@ const routes: RouteObject[] = [
path: 'coordinator',
element: <CoordinatorPage />,
},
{
path: 'executions',
element: <ExecutionMonitorPage />,
},
{
path: 'loops',
element: <LoopMonitorPage />,
},
{
path: 'cli-viewer',
element: <CliViewerPage />,
},
{
path: 'issues',
element: <IssueHubPage />,
@@ -206,8 +206,8 @@ export const ROUTES = {
HISTORY: '/history',
ORCHESTRATOR: '/orchestrator',
COORDINATOR: '/coordinator',
EXECUTIONS: '/executions',
LOOPS: '/loops',
CLI_VIEWER: '/cli-viewer',
ISSUES: '/issues',
// Legacy issue routes - use ISSUES with ?tab parameter instead
ISSUE_QUEUE: '/issues?tab=queue',

View File

@@ -90,6 +90,20 @@ export {
selectIsPipelineLoaded,
} from './coordinatorStore';
// Viewer Store
export {
useViewerStore,
useViewerActions,
useViewerLayout,
useViewerPanes,
useViewerTabs,
useFocusedPaneId,
selectPane,
selectTab,
selectPaneTabs,
selectActiveTab,
} from './viewerStore';
// Re-export types for convenience
export type {
// App Store Types
@@ -143,6 +157,18 @@ export type {
PipelineDetails,
} from './coordinatorStore';
// Viewer Store Types
export type {
PaneId,
CliExecutionId,
TabId,
TabState,
PaneState,
AllotmentLayoutGroup,
AllotmentLayout,
ViewerState,
} from './viewerStore';
// Execution Types
export type {
ExecutionStatus,

View File

@@ -0,0 +1,980 @@
// ========================================
// Viewer Store
// ========================================
// Zustand store for managing CLI Viewer layout and tab state
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// ========== Types ==========
/**
* Unique identifier for a pane in the viewer layout
*/
export type PaneId = string;
/**
* Unique identifier for a CLI execution
*/
export type CliExecutionId = string;
/**
* Unique identifier for a tab
*/
export type TabId = string;
/**
* Tab state representing a single tab in a pane
*/
export interface TabState {
id: TabId;
executionId: CliExecutionId;
title: string;
isPinned: boolean;
order: number;
}
/**
* Pane state representing a container for tabs
*/
export interface PaneState {
id: PaneId;
tabs: TabState[];
activeTabId: TabId | null;
}
/**
* Allotment layout group for nested split layouts
*/
export interface AllotmentLayoutGroup {
direction: 'horizontal' | 'vertical';
sizes?: number[];
children: (PaneId | AllotmentLayoutGroup)[];
}
/**
* Root layout type
*/
export type AllotmentLayout = AllotmentLayoutGroup;
/**
* Viewer state interface
*/
export interface ViewerState {
// Layout state
layout: AllotmentLayout;
panes: Record<PaneId, PaneState>;
tabs: Record<TabId, TabState>;
focusedPaneId: PaneId | null;
// ID counters for generating unique IDs
nextPaneIdCounter: number;
nextTabIdCounter: number;
// Actions
setLayout: (newLayout: AllotmentLayout) => void;
addPane: (parentPaneId?: PaneId, direction?: 'horizontal' | 'vertical') => PaneId;
removePane: (paneId: PaneId) => void;
addTab: (paneId: PaneId, executionId: CliExecutionId, title: string) => TabId;
removeTab: (paneId: PaneId, tabId: TabId) => void;
setActiveTab: (paneId: PaneId, tabId: TabId) => void;
moveTab: (sourcePaneId: PaneId, tabId: TabId, targetPaneId: PaneId, newIndex: number) => void;
togglePinTab: (tabId: TabId) => void;
setFocusedPane: (paneId: PaneId) => void;
initializeDefaultLayout: (layoutName: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => void;
reset: () => void;
}
// ========== Constants ==========
const VIEWER_STORAGE_KEY = 'cli-viewer-storage';
const VIEWER_STORAGE_VERSION = 1;
// ========== Helper Functions ==========
/**
* Generate a unique pane ID
*/
const generatePaneId = (counter: number): PaneId => `pane-${counter}`;
/**
* Generate a unique tab ID
*/
const generateTabId = (counter: number): TabId => `tab-${counter}`;
/**
* Check if a value is a PaneId (string) or AllotmentLayoutGroup
*/
const isPaneId = (value: PaneId | AllotmentLayoutGroup): value is PaneId => {
return typeof value === 'string';
};
/**
* Find a pane ID in the layout tree
*/
const findPaneInLayout = (
layout: AllotmentLayout,
paneId: PaneId
): { found: boolean; parent: AllotmentLayoutGroup | null; index: number } => {
const search = (
group: AllotmentLayoutGroup,
_parent: AllotmentLayoutGroup | null
): { found: boolean; parent: AllotmentLayoutGroup | null; index: number } => {
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i];
if (isPaneId(child)) {
if (child === paneId) {
return { found: true, parent: group, index: i };
}
} else {
const result = search(child, group);
if (result.found) {
return result;
}
}
}
return { found: false, parent: null, index: -1 };
};
return search(layout, null);
};
/**
* Remove a pane from layout and clean up empty groups
*/
const removePaneFromLayout = (layout: AllotmentLayout, paneId: PaneId): AllotmentLayout => {
const removeFromGroup = (group: AllotmentLayoutGroup): AllotmentLayoutGroup | null => {
const newChildren: (PaneId | AllotmentLayoutGroup)[] = [];
for (const child of group.children) {
if (isPaneId(child)) {
if (child !== paneId) {
newChildren.push(child);
}
} else {
const cleanedChild = removeFromGroup(child);
if (cleanedChild && cleanedChild.children.length > 0) {
// If only one child remains, flatten it
if (cleanedChild.children.length === 1) {
newChildren.push(cleanedChild.children[0]);
} else {
newChildren.push(cleanedChild);
}
}
}
}
if (newChildren.length === 0) {
return null;
}
// Update sizes proportionally when removing a child
const newSizes = group.sizes
? group.sizes.filter((_, i) => {
const child = group.children[i];
return !isPaneId(child) || child !== paneId;
})
: undefined;
// Normalize sizes to sum to 100
const normalizedSizes = newSizes
? (() => {
const total = newSizes.reduce((sum, s) => sum + s, 0);
return total > 0 ? newSizes.map((s) => (s / total) * 100) : undefined;
})()
: undefined;
return {
direction: group.direction,
sizes: normalizedSizes,
children: newChildren,
};
};
const result = removeFromGroup(layout);
return result || { direction: 'horizontal', children: [] };
};
/**
* Add a pane to the layout at a specific position
*/
const addPaneToLayout = (
layout: AllotmentLayout,
newPaneId: PaneId,
parentPaneId?: PaneId,
direction: 'horizontal' | 'vertical' = 'horizontal'
): AllotmentLayout => {
if (!parentPaneId) {
// Add to root level
if (layout.children.length === 0) {
return {
...layout,
children: [newPaneId],
sizes: [100],
};
}
// If root direction matches, add as sibling
if (layout.direction === direction) {
const currentSizes = layout.sizes || layout.children.map(() => 100 / layout.children.length);
const totalSize = currentSizes.reduce((sum, s) => sum + s, 0);
const newSize = totalSize / (layout.children.length + 1);
const scaleFactor = (totalSize - newSize) / totalSize;
return {
...layout,
children: [...layout.children, newPaneId],
sizes: [...currentSizes.map((s) => s * scaleFactor), newSize],
};
}
// Wrap entire layout in new group
return {
direction,
sizes: [50, 50],
children: [layout, newPaneId],
};
}
// Add relative to a specific pane
const addRelativeTo = (group: AllotmentLayoutGroup): AllotmentLayoutGroup => {
const newChildren: (PaneId | AllotmentLayoutGroup)[] = [];
let newSizes: number[] | undefined = group.sizes ? [] : undefined;
for (let i = 0; i < group.children.length; i++) {
const child = group.children[i];
const childSize = group.sizes ? group.sizes[i] : undefined;
if (isPaneId(child)) {
if (child === parentPaneId) {
if (group.direction === direction) {
// Same direction, add as sibling
const halfSize = (childSize || 50) / 2;
newChildren.push(child, newPaneId);
if (newSizes) {
newSizes.push(halfSize, halfSize);
}
} else {
// Different direction, wrap in new group
const newGroup: AllotmentLayoutGroup = {
direction,
sizes: [50, 50],
children: [child, newPaneId],
};
newChildren.push(newGroup);
if (newSizes && childSize !== undefined) {
newSizes.push(childSize);
}
}
} else {
newChildren.push(child);
if (newSizes && childSize !== undefined) {
newSizes.push(childSize);
}
}
} else {
// Recursively check nested groups
const result = findPaneInLayout(child, parentPaneId);
if (result.found) {
newChildren.push(addRelativeTo(child));
} else {
newChildren.push(child);
}
if (newSizes && childSize !== undefined) {
newSizes.push(childSize);
}
}
}
return {
...group,
children: newChildren,
sizes: newSizes,
};
};
return addRelativeTo(layout);
};
/**
* Get all pane IDs from layout
*/
const getAllPaneIds = (layout: AllotmentLayout): PaneId[] => {
const paneIds: PaneId[] = [];
const traverse = (group: AllotmentLayoutGroup) => {
for (const child of group.children) {
if (isPaneId(child)) {
paneIds.push(child);
} else {
traverse(child);
}
}
};
traverse(layout);
return paneIds;
};
// ========== Initial State ==========
const createDefaultLayout = (): AllotmentLayout => ({
direction: 'horizontal',
children: [],
});
const initialState: Omit<ViewerState, keyof ViewerStateActions> = {
layout: createDefaultLayout(),
panes: {},
tabs: {},
focusedPaneId: null,
nextPaneIdCounter: 1,
nextTabIdCounter: 1,
};
// Separate actions type for initial state typing
type ViewerStateActions = Pick<
ViewerState,
| 'setLayout'
| 'addPane'
| 'removePane'
| 'addTab'
| 'removeTab'
| 'setActiveTab'
| 'moveTab'
| 'togglePinTab'
| 'setFocusedPane'
| 'initializeDefaultLayout'
| 'reset'
>;
// ========== Store ==========
/**
* Zustand store for CLI Viewer layout and tab management
*
* @remarks
* Manages the split-pane layout, tabs within each pane, and tab state.
* Uses persist middleware to save layout state to localStorage.
*
* @example
* ```tsx
* const { layout, addPane, addTab } = useViewerStore();
* const paneId = addPane();
* const tabId = addTab(paneId, 'exec-123', 'CLI Output');
* ```
*/
export const useViewerStore = create<ViewerState>()(
persist(
devtools(
(set, get) => ({
...initialState,
// ========== Layout Actions ==========
setLayout: (newLayout: AllotmentLayout) => {
set({ layout: newLayout }, false, 'viewer/setLayout');
},
addPane: (parentPaneId?: PaneId, direction: 'horizontal' | 'vertical' = 'horizontal') => {
const state = get();
const newPaneId = generatePaneId(state.nextPaneIdCounter);
const newPane: PaneState = {
id: newPaneId,
tabs: [],
activeTabId: null,
};
const newLayout = addPaneToLayout(state.layout, newPaneId, parentPaneId, direction);
set(
{
layout: newLayout,
panes: {
...state.panes,
[newPaneId]: newPane,
},
focusedPaneId: newPaneId,
nextPaneIdCounter: state.nextPaneIdCounter + 1,
},
false,
'viewer/addPane'
);
return newPaneId;
},
removePane: (paneId: PaneId) => {
const state = get();
const pane = state.panes[paneId];
if (!pane) {
console.warn(`[ViewerStore] Pane not found: ${paneId}`);
return;
}
// Get all remaining panes
const allPaneIds = getAllPaneIds(state.layout);
if (allPaneIds.length <= 1) {
console.warn('[ViewerStore] Cannot remove the last pane');
return;
}
// Remove pane from layout
const newLayout = removePaneFromLayout(state.layout, paneId);
// Remove pane and its tabs
const newPanes = { ...state.panes };
delete newPanes[paneId];
const newTabs = { ...state.tabs };
for (const tab of pane.tabs) {
delete newTabs[tab.id];
}
// Update focused pane if needed
let newFocusedPaneId = state.focusedPaneId;
if (newFocusedPaneId === paneId) {
const remainingPaneIds = getAllPaneIds(newLayout);
newFocusedPaneId = remainingPaneIds.length > 0 ? remainingPaneIds[0] : null;
}
set(
{
layout: newLayout,
panes: newPanes,
tabs: newTabs,
focusedPaneId: newFocusedPaneId,
},
false,
'viewer/removePane'
);
},
// ========== Tab Actions ==========
addTab: (paneId: PaneId, executionId: CliExecutionId, title: string) => {
const state = get();
const pane = state.panes[paneId];
if (!pane) {
console.warn(`[ViewerStore] Pane not found: ${paneId}`);
return '';
}
// Check if tab for this execution already exists in this pane
const existingTab = pane.tabs.find((t) => t.executionId === executionId);
if (existingTab) {
// Just activate the existing tab
set(
{
panes: {
...state.panes,
[paneId]: {
...pane,
activeTabId: existingTab.id,
},
},
focusedPaneId: paneId,
},
false,
'viewer/addTab-existing'
);
return existingTab.id;
}
const newTabId = generateTabId(state.nextTabIdCounter);
const maxOrder = pane.tabs.reduce((max, t) => Math.max(max, t.order), 0);
const newTab: TabState = {
id: newTabId,
executionId,
title,
isPinned: false,
order: maxOrder + 1,
};
const updatedPane: PaneState = {
...pane,
tabs: [...pane.tabs, newTab],
activeTabId: newTabId,
};
set(
{
panes: {
...state.panes,
[paneId]: updatedPane,
},
tabs: {
...state.tabs,
[newTabId]: newTab,
},
focusedPaneId: paneId,
nextTabIdCounter: state.nextTabIdCounter + 1,
},
false,
'viewer/addTab'
);
return newTabId;
},
removeTab: (paneId: PaneId, tabId: TabId) => {
const state = get();
const pane = state.panes[paneId];
if (!pane) {
console.warn(`[ViewerStore] Pane not found: ${paneId}`);
return;
}
const tabIndex = pane.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) {
console.warn(`[ViewerStore] Tab not found: ${tabId}`);
return;
}
const newTabs = pane.tabs.filter((t) => t.id !== tabId);
// Determine new active tab
let newActiveTabId: TabId | null = null;
if (pane.activeTabId === tabId && newTabs.length > 0) {
// Select the tab at the same index, or the last one
const newActiveIndex = Math.min(tabIndex, newTabs.length - 1);
newActiveTabId = newTabs[newActiveIndex].id;
} else if (pane.activeTabId !== tabId) {
newActiveTabId = pane.activeTabId;
}
const updatedPane: PaneState = {
...pane,
tabs: newTabs,
activeTabId: newActiveTabId,
};
const globalTabs = { ...state.tabs };
delete globalTabs[tabId];
set(
{
panes: {
...state.panes,
[paneId]: updatedPane,
},
tabs: globalTabs,
},
false,
'viewer/removeTab'
);
},
setActiveTab: (paneId: PaneId, tabId: TabId) => {
const state = get();
const pane = state.panes[paneId];
if (!pane) {
console.warn(`[ViewerStore] Pane not found: ${paneId}`);
return;
}
const tab = pane.tabs.find((t) => t.id === tabId);
if (!tab) {
console.warn(`[ViewerStore] Tab not found in pane: ${tabId}`);
return;
}
set(
{
panes: {
...state.panes,
[paneId]: {
...pane,
activeTabId: tabId,
},
},
focusedPaneId: paneId,
},
false,
'viewer/setActiveTab'
);
},
moveTab: (
sourcePaneId: PaneId,
tabId: TabId,
targetPaneId: PaneId,
newIndex: number
) => {
const state = get();
const sourcePane = state.panes[sourcePaneId];
const targetPane = state.panes[targetPaneId];
if (!sourcePane || !targetPane) {
console.warn('[ViewerStore] Source or target pane not found');
return;
}
const tabIndex = sourcePane.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) {
console.warn(`[ViewerStore] Tab not found: ${tabId}`);
return;
}
const tab = sourcePane.tabs[tabIndex];
// Remove from source
const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId);
// Determine new active tab for source pane
let newSourceActiveTabId: TabId | null = sourcePane.activeTabId;
if (sourcePane.activeTabId === tabId && newSourceTabs.length > 0) {
const newActiveIndex = Math.min(tabIndex, newSourceTabs.length - 1);
newSourceActiveTabId = newSourceTabs[newActiveIndex].id;
} else if (sourcePane.activeTabId === tabId) {
newSourceActiveTabId = null;
}
// Calculate new order based on target position
let newOrder: number;
if (sourcePaneId === targetPaneId) {
// Moving within the same pane
const sortedTabs = [...newSourceTabs].sort((a, b) => a.order - b.order);
if (newIndex === 0) {
newOrder = sortedTabs.length > 0 ? sortedTabs[0].order - 1 : 1;
} else if (newIndex >= sortedTabs.length) {
newOrder = sortedTabs.length > 0 ? sortedTabs[sortedTabs.length - 1].order + 1 : 1;
} else {
const prevOrder = sortedTabs[newIndex - 1].order;
const nextOrder = sortedTabs[newIndex].order;
newOrder = (prevOrder + nextOrder) / 2;
}
} else {
// Moving to different pane
const sortedTargetTabs = [...targetPane.tabs].sort((a, b) => a.order - b.order);
if (newIndex === 0) {
newOrder = sortedTargetTabs.length > 0 ? sortedTargetTabs[0].order - 1 : 1;
} else if (newIndex >= sortedTargetTabs.length) {
newOrder = sortedTargetTabs.length > 0 ? sortedTargetTabs[sortedTargetTabs.length - 1].order + 1 : 1;
} else {
const prevOrder = sortedTargetTabs[newIndex - 1].order;
const nextOrder = sortedTargetTabs[newIndex].order;
newOrder = (prevOrder + nextOrder) / 2;
}
}
const movedTab: TabState = {
...tab,
order: newOrder,
};
// Add to target
let newTargetTabs: TabState[];
if (sourcePaneId === targetPaneId) {
newTargetTabs = [...newSourceTabs, movedTab];
} else {
newTargetTabs = [...targetPane.tabs, movedTab];
}
const updatedSourcePane: PaneState = {
...sourcePane,
tabs: sourcePaneId === targetPaneId ? newTargetTabs : newSourceTabs,
activeTabId: sourcePaneId === targetPaneId ? tabId : newSourceActiveTabId,
};
const updatedTargetPane: PaneState =
sourcePaneId === targetPaneId
? updatedSourcePane
: {
...targetPane,
tabs: newTargetTabs,
activeTabId: tabId,
};
const newPanes = {
...state.panes,
[sourcePaneId]: updatedSourcePane,
};
if (sourcePaneId !== targetPaneId) {
newPanes[targetPaneId] = updatedTargetPane;
}
set(
{
panes: newPanes,
tabs: {
...state.tabs,
[tabId]: movedTab,
},
focusedPaneId: targetPaneId,
},
false,
'viewer/moveTab'
);
},
togglePinTab: (tabId: TabId) => {
const state = get();
const tab = state.tabs[tabId];
if (!tab) {
console.warn(`[ViewerStore] Tab not found: ${tabId}`);
return;
}
const updatedTab: TabState = {
...tab,
isPinned: !tab.isPinned,
};
// Also update in the pane's tabs array
const newPanes = { ...state.panes };
for (const paneId of Object.keys(newPanes)) {
const pane = newPanes[paneId];
const tabIndex = pane.tabs.findIndex((t) => t.id === tabId);
if (tabIndex !== -1) {
newPanes[paneId] = {
...pane,
tabs: pane.tabs.map((t) => (t.id === tabId ? updatedTab : t)),
};
break;
}
}
set(
{
tabs: {
...state.tabs,
[tabId]: updatedTab,
},
panes: newPanes,
},
false,
'viewer/togglePinTab'
);
},
// ========== Focus Actions ==========
setFocusedPane: (paneId: PaneId) => {
const state = get();
if (!state.panes[paneId]) {
console.warn(`[ViewerStore] Pane not found: ${paneId}`);
return;
}
set({ focusedPaneId: paneId }, false, 'viewer/setFocusedPane');
},
// ========== Layout Initialization ==========
initializeDefaultLayout: (layoutName: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => {
const state = get();
let counter = state.nextPaneIdCounter;
const createPane = (): PaneState => {
const paneId = generatePaneId(counter++);
return {
id: paneId,
tabs: [],
activeTabId: null,
};
};
let layout: AllotmentLayout;
const panes: Record<PaneId, PaneState> = {};
switch (layoutName) {
case 'single': {
const pane = createPane();
panes[pane.id] = pane;
layout = {
direction: 'horizontal',
sizes: [100],
children: [pane.id],
};
break;
}
case 'split-h': {
const pane1 = createPane();
const pane2 = createPane();
panes[pane1.id] = pane1;
panes[pane2.id] = pane2;
layout = {
direction: 'horizontal',
sizes: [50, 50],
children: [pane1.id, pane2.id],
};
break;
}
case 'split-v': {
const pane1 = createPane();
const pane2 = createPane();
panes[pane1.id] = pane1;
panes[pane2.id] = pane2;
layout = {
direction: 'vertical',
sizes: [50, 50],
children: [pane1.id, pane2.id],
};
break;
}
case 'grid-2x2': {
const pane1 = createPane();
const pane2 = createPane();
const pane3 = createPane();
const pane4 = createPane();
panes[pane1.id] = pane1;
panes[pane2.id] = pane2;
panes[pane3.id] = pane3;
panes[pane4.id] = pane4;
layout = {
direction: 'vertical',
sizes: [50, 50],
children: [
{
direction: 'horizontal',
sizes: [50, 50],
children: [pane1.id, pane2.id],
},
{
direction: 'horizontal',
sizes: [50, 50],
children: [pane3.id, pane4.id],
},
],
};
break;
}
default:
return;
}
const firstPaneId = Object.keys(panes)[0] || null;
set(
{
layout,
panes,
tabs: {},
focusedPaneId: firstPaneId,
nextPaneIdCounter: counter,
nextTabIdCounter: 1,
},
false,
'viewer/initializeDefaultLayout'
);
},
// ========== Reset ==========
reset: () => {
set(
{
layout: createDefaultLayout(),
panes: {},
tabs: {},
focusedPaneId: null,
nextPaneIdCounter: 1,
nextTabIdCounter: 1,
},
false,
'viewer/reset'
);
},
}),
{ name: 'ViewerStore' }
),
{
name: VIEWER_STORAGE_KEY,
version: VIEWER_STORAGE_VERSION,
// Persist all layout state
partialize: (state) => ({
layout: state.layout,
panes: state.panes,
tabs: state.tabs,
focusedPaneId: state.focusedPaneId,
nextPaneIdCounter: state.nextPaneIdCounter,
nextTabIdCounter: state.nextTabIdCounter,
}),
}
)
);
// ========== Selectors ==========
/**
* Select the current layout
*/
export const useViewerLayout = () => useViewerStore((state) => state.layout);
/**
* Select all panes
*/
export const useViewerPanes = () => useViewerStore((state) => state.panes);
/**
* Select all tabs
*/
export const useViewerTabs = () => useViewerStore((state) => state.tabs);
/**
* Select the focused pane ID
*/
export const useFocusedPaneId = () => useViewerStore((state) => state.focusedPaneId);
/**
* Select a specific pane by ID
*/
export const selectPane = (state: ViewerState, paneId: PaneId) => state.panes[paneId];
/**
* Select a specific tab by ID
*/
export const selectTab = (state: ViewerState, tabId: TabId) => state.tabs[tabId];
/**
* Select tabs for a specific pane, sorted by order
*/
export const selectPaneTabs = (state: ViewerState, paneId: PaneId): TabState[] => {
const pane = state.panes[paneId];
if (!pane) return [];
return [...pane.tabs].sort((a, b) => a.order - b.order);
};
/**
* Select the active tab for a pane
*/
export const selectActiveTab = (state: ViewerState, paneId: PaneId): TabState | null => {
const pane = state.panes[paneId];
if (!pane || !pane.activeTabId) return null;
return pane.tabs.find((t) => t.id === pane.activeTabId) || null;
};
// ========== Helper Hooks ==========
/**
* Hook to get viewer actions
* Useful for components that only need actions, not the full state
*/
export const useViewerActions = () => {
return useViewerStore((state) => ({
setLayout: state.setLayout,
addPane: state.addPane,
removePane: state.removePane,
addTab: state.addTab,
removeTab: state.removeTab,
setActiveTab: state.setActiveTab,
moveTab: state.moveTab,
togglePinTab: state.togglePinTab,
setFocusedPane: state.setFocusedPane,
initializeDefaultLayout: state.initializeDefaultLayout,
reset: state.reset,
}));
};

View File

@@ -12,6 +12,28 @@ export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionD
export type SessionFilter = 'all' | 'active' | 'archived';
export type LiteTaskType = 'lite-plan' | 'lite-fix' | null;
/**
* Session type identifier matching backend session types.
*
* @remarks
* This type defines all available session types in the CCW workflow system.
* It matches the backend SessionType definition in `ccw/src/types/session.ts`.
*
* **Type descriptions:**
* - `workflow`: Standard workflow execution session
* - `review`: Code review session with dimension-based analysis
* - `tdd`: Test-driven development session
* - `test`: Testing session
* - `docs`: Documentation generation session
* - `lite-plan`: Lightweight planning session
* - `lite-fix`: Lightweight bug fix session
*
* **Backend type definition:** {@link https://github.com/claudews/ccw/blob/main/ccw/src/types/session.ts | session.ts}
*
* @see {@link SessionMetadata.type | SessionMetadata.type} for usage in session metadata
*/
export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs' | 'lite-plan' | 'lite-fix';
export interface AppState {
// Theme
theme: Theme;
@@ -148,6 +170,7 @@ export interface DashboardLayoutActions {
*/
export interface SessionMetadata {
session_id: string;
type?: SessionType;
title?: string;
description?: string;
status: 'planning' | 'in_progress' | 'completed' | 'archived' | 'paused';

View File

@@ -41,9 +41,8 @@ export default defineConfig({
'/docs': {
target: 'http://localhost:3001',
changeOrigin: true,
// Remove /docs prefix when forwarding to Docusaurus
// Example: /docs/getting-started -> http://localhost:3001/getting-started
rewrite: (path) => path.replace(/^\/docs/, ''),
// Preserve /docs prefix to match Docusaurus baseUrl configuration
// Example: /docs/overview -> http://localhost:3001/docs/overview
},
},
},