feat(queue): implement queue scheduler service and API routes

- Added QueueSchedulerService to manage task queue lifecycle, including state machine, dependency resolution, and session management.
- Implemented HTTP API endpoints for queue scheduling:
  - POST /api/queue/execute: Submit items to the scheduler.
  - GET /api/queue/scheduler/state: Retrieve full scheduler state.
  - POST /api/queue/scheduler/start: Start scheduling loop with items.
  - POST /api/queue/scheduler/pause: Pause scheduling.
  - POST /api/queue/scheduler/stop: Graceful stop of the scheduler.
  - POST /api/queue/scheduler/config: Update scheduler configuration.
- Introduced types for queue items, scheduler state, and WebSocket messages to ensure type safety and compatibility with the backend.
- Added static model lists for LiteLLM as a fallback for available models.
This commit is contained in:
catlog22
2026-02-27 20:53:46 +08:00
parent 5b54f38aa3
commit 75173312c1
47 changed files with 3813 additions and 307 deletions

View File

@@ -164,7 +164,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
setCliProvider(p);
setMode('direct');
setProviderId('');
setModel(p === 'claude' ? 'sonnet' : '');
setModel('');
setSettingsFile('');
setAuthToken('');
setBaseUrl('');
@@ -291,7 +291,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
}
settings = {
env,
model: model || 'sonnet',
model: model || undefined,
settingsFile: settingsFile.trim() || undefined,
availableModels,
tags,
@@ -505,7 +505,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
<div className="space-y-2">
<Label htmlFor="model-pb">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
<Input id="model-pb" value={model} onChange={(e) => setModel(e.target.value)} placeholder="sonnet" />
<Input id="model-pb" value={model} onChange={(e) => setModel(e.target.value)} placeholder="" />
</div>
</TabsContent>
@@ -545,7 +545,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
<div className="space-y-2">
<Label htmlFor="model-direct">{formatMessage({ id: 'apiSettings.cliSettings.model' })}</Label>
<Input id="model-direct" value={model} onChange={(e) => setModel(e.target.value)} placeholder="sonnet" />
<Input id="model-direct" value={model} onChange={(e) => setModel(e.target.value)} placeholder="" />
</div>
</TabsContent>
</Tabs>
@@ -644,7 +644,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="codex-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gpt-5.2"
placeholder=""
/>
<p className="text-xs text-muted-foreground">
使 config.toml
@@ -711,7 +711,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="codex-configtoml"
value={configToml}
onChange={(e) => setConfigToml(e.target.value)}
placeholder={'model = "gpt-5.2"\nmodel_reasoning_effort = "xhigh"'}
placeholder=""
className="font-mono text-sm"
rows={6}
/>
@@ -778,7 +778,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="gemini-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemini-2.5-flash"
placeholder=""
/>
</div>
@@ -803,7 +803,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="gemini-settingsjson"
value={geminiSettingsJson}
onChange={(e) => setGeminiSettingsJson(e.target.value)}
placeholder='{"model": "gemini-2.5-flash", ...}'
placeholder="{}"
className="font-mono text-sm"
rows={8}
readOnly

View File

@@ -295,7 +295,7 @@ export function EndpointModal({ open, onClose, endpoint }: EndpointModalProps) {
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gpt-4o"
placeholder=""
className="font-mono"
/>
) : (

View File

@@ -16,7 +16,6 @@ import {
LogOut,
Terminal,
Bell,
Clock,
Monitor,
SquareTerminal,
} from 'lucide-react';
@@ -86,19 +85,6 @@ export function Header({
{/* Right side - Actions */}
<div className="flex items-center gap-2">
{/* History entry */}
<Button
variant="ghost"
size="sm"
asChild
className="gap-2"
>
<Link to="/history" className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.main.history' })}</span>
</Link>
</Button>
{/* CLI Monitor button */}
<Button
variant="ghost"

View File

@@ -27,6 +27,7 @@ import {
Users,
FileSearch,
ScrollText,
Clock,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -78,6 +79,7 @@ const navGroupDefinitions: NavGroupDef[] = [
items: [
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
{ path: '/analysis', labelKey: 'navigation.main.analysis', icon: FileSearch },
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },

View File

@@ -5,11 +5,9 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react';
import { CcwToolsMcpCard } from './CcwToolsMcpCard';
import { updateCcwConfig, updateCcwConfigForCodex } from '@/lib/api';
vi.mock('@/lib/api', () => ({
const apiMock = vi.hoisted(() => ({
installCcwMcp: vi.fn(),
uninstallCcwMcp: vi.fn(),
updateCcwConfig: vi.fn(),
@@ -18,11 +16,24 @@ vi.mock('@/lib/api', () => ({
updateCcwConfigForCodex: vi.fn(),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
success: vi.fn(),
error: vi.fn(),
}),
vi.mock('@/lib/api', () => apiMock);
const notificationsMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}));
// Avoid importing the full hooks barrel in this component test (it has heavy deps and
// side effects that aren't relevant here).
vi.mock('@/hooks', () => ({
mcpServersKeys: { all: ['mcpServers'] },
useNotifications: () => notificationsMock,
}));
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: (selector: (state: { projectPath: string }) => string) =>
selector({ projectPath: '' }),
selectProjectPath: (state: { projectPath: string }) => state.projectPath,
}));
describe('CcwToolsMcpCard', () => {
@@ -31,7 +42,8 @@ describe('CcwToolsMcpCard', () => {
});
it('preserves enabledTools when saving config (Codex)', async () => {
const updateCodexMock = vi.mocked(updateCcwConfigForCodex);
const { CcwToolsMcpCard } = await import('./CcwToolsMcpCard');
const updateCodexMock = vi.mocked(apiMock.updateCcwConfigForCodex);
updateCodexMock.mockResolvedValue({
isInstalled: true,
enabledTools: [],
@@ -51,22 +63,36 @@ describe('CcwToolsMcpCard', () => {
);
const user = userEvent.setup();
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
await user.click(
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
);
await act(async () => {
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
});
const saveButton = screen.getByRole('button', {
name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i,
});
expect(saveButton).toBeEnabled();
await act(async () => {
await user.click(saveButton);
});
await waitFor(() => {
expect(updateCodexMock).toHaveBeenCalledWith(
expect.objectContaining({
enabledTools: ['write_file', 'read_many_files'],
})
);
expect(updateCodexMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(notificationsMock.success).toHaveBeenCalled();
});
const [payload] = updateCodexMock.mock.calls[0] ?? [];
expect(payload).toEqual(
expect.objectContaining({
enabledTools: ['write_file', 'read_many_files'],
})
);
});
it('preserves enabledTools when saving config (Claude)', async () => {
const updateClaudeMock = vi.mocked(updateCcwConfig);
const { CcwToolsMcpCard } = await import('./CcwToolsMcpCard');
const updateClaudeMock = vi.mocked(apiMock.updateCcwConfig);
updateClaudeMock.mockResolvedValue({
isInstalled: true,
enabledTools: [],
@@ -85,18 +111,30 @@ describe('CcwToolsMcpCard', () => {
);
const user = userEvent.setup();
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
await user.click(
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
);
await act(async () => {
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
});
const saveButton = screen.getByRole('button', {
name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i,
});
expect(saveButton).toBeEnabled();
await act(async () => {
await user.click(saveButton);
});
await waitFor(() => {
expect(updateClaudeMock).toHaveBeenCalledWith(
expect.objectContaining({
enabledTools: ['write_file', 'smart_search'],
})
);
expect(updateClaudeMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(notificationsMock.success).toHaveBeenCalled();
});
const [payload] = updateClaudeMock.mock.calls[0] ?? [];
expect(payload).toEqual(
expect.objectContaining({
enabledTools: ['write_file', 'smart_search'],
})
);
});
});

View File

@@ -260,7 +260,7 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
{/* Main Panel */}
<div
className={cn(
'fixed top-0 right-0 h-full w-[640px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
'fixed top-0 right-0 h-full w-[1568px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
role="dialog"

View File

@@ -2,11 +2,13 @@
// AssistantMessage Component
// ========================================
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Bot, ChevronDown, Copy, Check } from 'lucide-react';
import { Bot, ChevronDown, Copy, Check, FileJson } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { JsonCard } from '../components/JsonCard';
import { detectJsonInLine } from '../utils/jsonDetector';
// Status indicator component
interface StatusIndicatorProps {
@@ -94,6 +96,11 @@ export function AssistantMessage({
const [isExpanded, setIsExpanded] = useState(true);
const [copied, setCopied] = useState(false);
// Detect JSON in content
const jsonDetection = useMemo(() => {
return detectJsonInLine(content);
}, [content]);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 2000);
@@ -126,6 +133,14 @@ export function AssistantMessage({
{modelName}
</span>
{/* JSON indicator badge */}
{jsonDetection?.isJson && (
<span className="flex items-center gap-0.5 text-[9px] text-violet-500 dark:text-violet-400 bg-violet-200/50 dark:bg-violet-800/50 px-1 rounded">
<FileJson className="h-2.5 w-2.5" />
JSON
</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<StatusIndicator status={status} duration={duration} />
<ChevronDown
@@ -141,11 +156,21 @@ export function AssistantMessage({
{isExpanded && (
<>
<div className="px-2.5 py-2 bg-violet-50/40 dark:bg-violet-950/30">
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
{content}
{jsonDetection?.isJson && jsonDetection.parsed ? (
/* JSON detected - use collapsible JsonCard */
<JsonCard
data={jsonDetection.parsed}
type="stdout"
onCopy={handleCopy}
/>
) : (
/* Plain text content */
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
{content}
</div>
</div>
</div>
)}
</div>
{/* Metadata Footer - simplified */}

View File

@@ -17,7 +17,7 @@ export function MessageExample() {
<SystemMessage
title="Session Started"
timestamp={Date.now()}
metadata="gemini-2.5-pro | Context: 28 files"
metadata="Context: 28 files"
content="CLI execution started: gemini (analysis mode)"
/>

View File

@@ -19,6 +19,7 @@ import {
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
import { useNativeSession } from '@/hooks/useNativeSession';
import type { ConversationRecord, ConversationTurn, NativeSessionTurn, NativeTokenInfo, NativeToolCall } from '@/lib/api';
import { getToolVariant } from '@/lib/cli-tool-theme';
type ViewMode = 'per-turn' | 'concatenated' | 'native';
type ConcatFormat = 'plain' | 'yaml' | 'json';
@@ -69,16 +70,12 @@ function getStatusInfo(status: string) {
}
/**
* Get badge variant for tool name
* Ensure prompt is a string (handle legacy object data)
*/
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
gemini: 'info',
codex: 'success',
qwen: 'warning',
opencode: 'secondary',
};
return variants[tool] || 'secondary';
function ensureString(value: unknown): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value);
return String(value ?? '');
}
/**
@@ -95,7 +92,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
for (const turn of turns) {
parts.push(`--- Turn ${turn.turn} ---`);
parts.push('USER:');
parts.push(turn.prompt);
parts.push(ensureString(turn.prompt));
parts.push('');
parts.push('ASSISTANT:');
parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' }));
@@ -118,7 +115,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
yaml.push(` - turn: ${turn.turn}`);
yaml.push(` timestamp: ${turn.timestamp}`);
yaml.push(` prompt: |`);
turn.prompt.split('\n').forEach(line => {
ensureString(turn.prompt).split('\n').forEach(line => {
yaml.push(` ${line}`);
});
yaml.push(` response: |`);
@@ -140,7 +137,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
turns.map((t) => ({
turn: t.turn,
timestamp: t.timestamp,
prompt: t.prompt,
prompt: ensureString(t.prompt),
response: t.output.stdout || '',
})),
null,
@@ -212,7 +209,7 @@ function TurnSection({ turn, isLatest, isExpanded, onToggle }: TurnSectionProps)
{formatMessage({ id: 'cli-manager.streamPanel.userPrompt' })}
</h4>
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
{turn.prompt}
{ensureString(turn.prompt)}
</pre>
</div>
@@ -462,7 +459,7 @@ function NativeTurnCard({ turn, isLatest, isExpanded, onToggle }: NativeTurnCard
<div className="p-4 space-y-3">
{turn.content && (
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-80 overflow-y-auto">
{turn.content}
{ensureString(turn.content)}
</pre>
)}

View File

@@ -198,7 +198,9 @@ export function ConversationCard({
{/* Prompt preview */}
<p className="text-sm text-foreground line-clamp-2 mb-2">
{execution.prompt_preview}
{typeof execution.prompt_preview === 'string'
? execution.prompt_preview
: JSON.stringify(execution.prompt_preview)}
</p>
{/* Meta info */}

View File

@@ -24,6 +24,7 @@ import {
} from '@/components/ui/Dialog';
import { useNativeSession } from '@/hooks/useNativeSession';
import { SessionTimeline } from './SessionTimeline';
import { getToolVariant } from '@/lib/cli-tool-theme';
// ========== Types ==========
@@ -35,19 +36,6 @@ export interface NativeSessionPanelProps {
// ========== Helpers ==========
/**
* Get badge variant for tool name
*/
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
gemini: 'info',
codex: 'success',
qwen: 'warning',
opencode: 'secondary',
};
return variants[tool?.toLowerCase()] || 'secondary';
}
/**
* Truncate a string to a max length with ellipsis
*/

View File

@@ -54,6 +54,15 @@ interface ToolCallPanelProps {
// ========== Helpers ==========
/**
* Ensure content is a string (handle legacy object data like {text: "..."})
*/
function ensureString(value: unknown): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value);
return String(value ?? '');
}
/**
* Format token number with compact notation
*/
@@ -319,7 +328,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
{turn.content && (
<div className="p-4">
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
{turn.content}
{ensureString(turn.content)}
</pre>
</div>
)}

View File

@@ -5,7 +5,7 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
import { FolderOpen, RefreshCw } from 'lucide-react';
import { ChevronDown, FolderOpen, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -52,8 +52,6 @@ export interface CliConfigModalProps {
onCreateSession: (config: CliSessionConfig) => Promise<void>;
}
const AUTO_MODEL_VALUE = '__auto__';
/**
* Generate a tag name: {tool}-{HHmmss}
* Example: gemini-143052
@@ -95,6 +93,12 @@ export function CliConfigModal({
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// Model combobox state
const [modelInputValue, setModelInputValue] = React.useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = React.useState(false);
const modelContainerRef = React.useRef<HTMLDivElement>(null);
const modelInputRef = React.useRef<HTMLInputElement>(null);
// CLI Settings integration (for all tools)
const { cliSettings } = useCliSettings({ enabled: true });
@@ -162,13 +166,30 @@ export function CliConfigModal({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when tool changes, reading tag intentionally stale
}, [tool]);
// Sync initial model when tool/modelOptions change
// Sync model input display when model state changes (e.g., tool change)
React.useEffect(() => {
if (modelOptions.length > 0 && (!model || !modelOptions.includes(model))) {
setModel(modelOptions[0]);
setModelInputValue(model ?? '');
}, [model]);
// Filter model suggestions based on typed input
const filteredModelOptions = React.useMemo(() => {
const query = modelInputValue.toLowerCase();
if (!query) return modelOptions;
return modelOptions.filter((m) => m.toLowerCase().includes(query));
}, [modelOptions, modelInputValue]);
// Close model dropdown on outside click
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (modelContainerRef.current && !modelContainerRef.current.contains(e.target as Node)) {
setIsModelDropdownOpen(false);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when modelOptions changes
}, [modelOptions]);
if (isModelDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isModelDropdownOpen]);
const handleToolChange = (nextTool: string) => {
setTool(nextTool as CliTool);
@@ -276,30 +297,80 @@ export function CliConfigModal({
</Select>
</div>
{/* Model */}
{/* Model - Combobox (input + dropdown suggestions) */}
<div className="space-y-2">
<Label htmlFor="cli-config-model">
{formatMessage({ id: 'terminalDashboard.cliConfig.model' })}
</Label>
<Select
value={model ?? AUTO_MODEL_VALUE}
onValueChange={(v) => setModel(v === AUTO_MODEL_VALUE ? undefined : v)}
disabled={isSubmitting}
>
<SelectTrigger id="cli-config-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_MODEL_VALUE}>
{formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto' })}
</SelectItem>
{modelOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<div ref={modelContainerRef} className="relative">
<div className="flex">
<input
ref={modelInputRef}
id="cli-config-model"
value={modelInputValue}
onChange={(e) => {
const v = e.target.value;
setModelInputValue(v);
setModel(v || undefined);
if (!isModelDropdownOpen) setIsModelDropdownOpen(true);
}}
onFocus={() => setIsModelDropdownOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Escape') setIsModelDropdownOpen(false);
if (e.key === 'Enter') setIsModelDropdownOpen(false);
}}
placeholder={formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto', defaultMessage: 'Auto' })}
disabled={isSubmitting}
className="flex h-10 w-full rounded-l-md border border-r-0 border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
type="button"
onClick={() => {
setIsModelDropdownOpen(!isModelDropdownOpen);
if (!isModelDropdownOpen) modelInputRef.current?.focus();
}}
disabled={isSubmitting}
className="flex items-center justify-center h-10 w-9 shrink-0 rounded-r-md border border-input bg-background hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown className="h-4 w-4 opacity-50" />
</button>
</div>
{isModelDropdownOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-card shadow-md max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setModel(undefined);
setModelInputValue('');
setIsModelDropdownOpen(false);
}}
className={cn(
'flex w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
!model && 'bg-accent/50'
)}
>
{formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto', defaultMessage: 'Auto' })}
</button>
{filteredModelOptions.map((m) => (
<button
key={m}
type="button"
onClick={() => {
setModel(m);
setModelInputValue(m);
setIsModelDropdownOpen(false);
}}
className={cn(
'flex w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground font-mono',
model === m && 'bg-accent/50'
)}
>
{m}
</button>
))}
</div>
)}
</div>
</div>
</div>

View File

@@ -22,6 +22,7 @@ import {
Minimize2,
Activity,
Plus,
Gauge,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
@@ -36,11 +37,12 @@ import { toast } from '@/stores/notificationStore';
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
import { useConfigStore } from '@/stores/configStore';
import { useQueueSchedulerStore, selectQueueSchedulerStatus } from '@/stores/queueSchedulerStore';
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
// ========== Types ==========
export type PanelId = 'issues' | 'queue' | 'inspector' | 'execution';
export type PanelId = 'issues' | 'queue' | 'inspector' | 'execution' | 'scheduler';
interface DashboardToolbarProps {
activePanel: PanelId | null;
@@ -95,6 +97,10 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
// Execution monitor count
const executionCount = useExecutionMonitorStore(selectActiveExecutionCount);
// Scheduler status for badge indicator
const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
const isSchedulerActive = schedulerStatus !== 'idle';
// Feature flags for panel visibility
const featureFlags = useConfigStore((s) => s.featureFlags);
const showQueue = featureFlags.dashboardQueuePanelEnabled;
@@ -244,6 +250,13 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
badge={executionCount > 0 ? executionCount : undefined}
/>
)}
<ToolbarButton
icon={Gauge}
label={formatMessage({ id: 'terminalDashboard.toolbar.scheduler', defaultMessage: 'Scheduler' })}
isActive={activePanel === 'scheduler'}
onClick={() => onTogglePanel('scheduler')}
dot={isSchedulerActive}
/>
<ToolbarButton
icon={FolderOpen}
label={formatMessage({ id: 'terminalDashboard.toolbar.files', defaultMessage: 'Files' })}

View File

@@ -17,6 +17,7 @@ import {
Check,
Send,
X,
ListPlus,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import {
@@ -37,6 +38,7 @@ import { executeInCliSession } from '@/lib/api';
import type { Issue } from '@/lib/api';
import { useTerminalGridStore, selectTerminalGridFocusedPaneId, selectTerminalGridPanes } from '@/stores/terminalGridStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { useQueueSchedulerStore } from '@/stores/queueSchedulerStore';
import { toast } from '@/stores/notificationStore';
// ========== Execution Method Type ==========
@@ -51,6 +53,13 @@ const PROMPT_TEMPLATES: Record<ExecutionMethod, (idStr: string) => string> = {
'direct-send': (idStr) => `根据@.workflow/issues/issues.jsonl中的 ${idStr} 需求,进行开发`,
};
// ========== Queue Prompt Builder ==========
function buildQueuePrompt(issues: Issue[]): string {
const ids = issues.map((i) => i.id).join(' ');
return `根据@.workflow/issues/issues.jsonl中的 ${ids} 需求,进行开发`;
}
// ========== Priority Badge ==========
const PRIORITY_STYLES: Record<Issue['priority'], { variant: 'destructive' | 'warning' | 'info' | 'secondary'; label: string }> = {
@@ -203,6 +212,13 @@ export function IssuePanel() {
const [customPrompt, setCustomPrompt] = useState('');
const sentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Queue state
const [isAddingToQueue, setIsAddingToQueue] = useState(false);
const [justQueued, setJustQueued] = useState(false);
const [queueMode, setQueueMode] = useState<'write' | 'analysis' | 'auto'>('write');
const queuedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const submitItems = useQueueSchedulerStore((s) => s.submitItems);
// Terminal refs
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
const panes = useTerminalGridStore(selectTerminalGridPanes);
@@ -234,10 +250,11 @@ export function IssuePanel() {
}
}, [availableMethods, executionMethod]);
// Cleanup sent feedback timer on unmount
// Cleanup feedback timers on unmount
useEffect(() => {
return () => {
if (sentTimerRef.current) clearTimeout(sentTimerRef.current);
if (queuedTimerRef.current) clearTimeout(queuedTimerRef.current);
};
}, []);
@@ -349,6 +366,43 @@ export function IssuePanel() {
}
}, [sessionKey, selectedIds, projectPath, executionMethod, sessionCliTool, customPrompt]);
const handleAddToQueue = useCallback(async () => {
if (selectedIds.size === 0) return;
setIsAddingToQueue(true);
try {
const selectedIssues = sortedIssues.filter((i) => selectedIds.has(i.id));
const now = new Date().toISOString();
const items = selectedIssues.map((issue, index) => ({
item_id: `Q-${issue.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
issue_id: issue.id,
status: 'pending' as const,
tool: sessionCliTool || 'gemini',
prompt: buildQueuePrompt([issue]),
mode: queueMode,
depends_on: [] as string[],
execution_order: index + 1,
execution_group: 'default',
createdAt: now,
metadata: { issueTitle: issue.title, issuePriority: issue.priority },
}));
await submitItems(items);
toast.success(
formatMessage({ id: 'terminalDashboard.issuePanel.addedToQueue', defaultMessage: 'Added to queue' }),
`${items.length} item(s)`
);
setJustQueued(true);
if (queuedTimerRef.current) clearTimeout(queuedTimerRef.current);
queuedTimerRef.current = setTimeout(() => setJustQueued(false), 2000);
} catch (err) {
toast.error(
formatMessage({ id: 'terminalDashboard.issuePanel.addToQueueFailed', defaultMessage: 'Failed to add to queue' }),
err instanceof Error ? err.message : String(err)
);
} finally {
setIsAddingToQueue(false);
}
}, [selectedIds, sortedIssues, sessionCliTool, submitItems, formatMessage]);
// Loading state
if (isLoading) {
return (
@@ -512,22 +566,55 @@ export function IssuePanel() {
Clear
</button>
</div>
<button
type="button"
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
justSent
? 'bg-green-600 text-white'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!sessionKey || isSending}
onClick={isSendConfigOpen ? handleSendToTerminal : handleOpenSendConfig}
title={!sessionKey ? 'No terminal session focused' : `Send via ${executionMethod}`}
>
{isSending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : justSent ? <Check className="w-3.5 h-3.5" /> : <Terminal className="w-3.5 h-3.5" />}
{justSent ? 'Sent!' : `Send (${selectedIds.size})`}
</button>
<div className="flex items-center gap-1.5">
{/* Queue mode selector */}
<select
className="h-6 text-[10px] rounded border border-border bg-background px-1 text-muted-foreground"
value={queueMode}
onChange={(e) => setQueueMode(e.target.value as 'write' | 'analysis' | 'auto')}
title={formatMessage({ id: 'terminalDashboard.issuePanel.queueModeHint', defaultMessage: 'Execution mode for queued items' })}
>
<option value="write">write</option>
<option value="analysis">analysis</option>
<option value="auto">auto</option>
</select>
{/* Add to Queue button */}
<button
type="button"
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
justQueued
? 'bg-green-600 text-white'
: 'bg-muted text-foreground hover:bg-muted/80',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={isAddingToQueue}
onClick={handleAddToQueue}
title={formatMessage({ id: 'terminalDashboard.issuePanel.addToQueueHint', defaultMessage: 'Add selected issues to the execution queue' })}
>
{isAddingToQueue ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : justQueued ? <Check className="w-3.5 h-3.5" /> : <ListPlus className="w-3.5 h-3.5" />}
{justQueued
? formatMessage({ id: 'terminalDashboard.issuePanel.addedToQueue', defaultMessage: 'Queued!' })
: formatMessage({ id: 'terminalDashboard.issuePanel.addToQueue', defaultMessage: 'Queue' })}
</button>
{/* Send to Terminal button */}
<button
type="button"
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
justSent
? 'bg-green-600 text-white'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={!sessionKey || isSending}
onClick={isSendConfigOpen ? handleSendToTerminal : handleOpenSendConfig}
title={!sessionKey ? 'No terminal session focused' : `Send via ${executionMethod}`}
>
{isSending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : justSent ? <Check className="w-3.5 h-3.5" /> : <Terminal className="w-3.5 h-3.5" />}
{justSent ? 'Sent!' : `Send (${selectedIds.size})`}
</button>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,330 @@
// ========================================
// QueueListColumn Component
// ========================================
// Queue items list for embedding in the Issues dual-column panel.
// Unified data source: queueSchedulerStore only.
// Includes inline scheduler controls at the bottom.
import { useMemo, useCallback, useState } from 'react';
import { useIntl } from 'react-intl';
import {
ListChecks,
Loader2,
CheckCircle,
XCircle,
Zap,
Ban,
Square,
Terminal,
Timer,
Clock,
Play,
Pause,
StopCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { cn } from '@/lib/utils';
import {
useIssueQueueIntegrationStore,
selectAssociationChain,
} from '@/stores/issueQueueIntegrationStore';
import {
useQueueExecutionStore,
selectByQueueItem,
} from '@/stores/queueExecutionStore';
import {
useQueueSchedulerStore,
selectQueueSchedulerStatus,
selectQueueItems,
selectSchedulerProgress,
selectCurrentConcurrency,
selectSchedulerConfig,
} from '@/stores/queueSchedulerStore';
import type { QueueItem, QueueItemStatus } from '@/types/queue-frontend-types';
// ========== Status Config ==========
const STATUS_CONFIG: Record<QueueItemStatus, {
variant: 'info' | 'success' | 'destructive' | 'secondary' | 'warning' | 'outline';
icon: typeof Clock;
label: string;
}> = {
pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
queued: { variant: 'info', icon: Timer, label: 'Queued' },
ready: { variant: 'info', icon: Zap, label: 'Ready' },
blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
failed: { variant: 'destructive', icon: XCircle, label: 'Failed' },
cancelled: { variant: 'secondary', icon: Square, label: 'Cancelled' },
};
// ========== Scheduler Status Styles ==========
const SCHEDULER_STATUS_STYLE: Record<string, string> = {
idle: 'bg-muted text-muted-foreground',
running: 'bg-blue-500/15 text-blue-600',
paused: 'bg-yellow-500/15 text-yellow-600',
stopping: 'bg-orange-500/15 text-orange-600',
completed: 'bg-green-500/15 text-green-600',
failed: 'bg-red-500/15 text-red-600',
};
// ========== Item Row ==========
function QueueItemRow({
item,
isHighlighted,
onSelect,
}: {
item: QueueItem;
isHighlighted: boolean;
onSelect: () => void;
}) {
const { formatMessage } = useIntl();
const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending;
const StatusIcon = config.icon;
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
const sessionKey = item.sessionKey ?? activeExec?.sessionKey;
const isExecuting = item.status === 'executing';
const isBlocked = item.status === 'blocked';
// Show issue_id if available (for items added from IssuePanel)
const displayId = item.issue_id ? `${item.issue_id}` : item.item_id;
return (
<button
type="button"
className={cn(
'w-full text-left px-2.5 py-1.5 rounded-md transition-colors',
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30',
isExecuting && 'border-l-2 border-l-blue-500'
)}
onClick={onSelect}
>
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<StatusIcon
className={cn(
'w-3 h-3 shrink-0',
isExecuting && 'animate-spin'
)}
/>
<span className="text-xs font-medium text-foreground truncate font-mono">
{displayId}
</span>
</div>
<Badge variant={config.variant} className="text-[10px] px-1 py-0 shrink-0">
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
</Badge>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground pl-4">
{item.execution_group && <span>{item.execution_group}</span>}
{sessionKey && (
<>
<span className="text-border">|</span>
<span className="flex items-center gap-0.5">
<Terminal className="w-2.5 h-2.5" />
{sessionKey}
</span>
</>
)}
</div>
{isBlocked && item.depends_on.length > 0 && (
<div className="mt-0.5 text-[10px] text-orange-500/80 pl-4 truncate">
{formatMessage(
{ id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' },
{ deps: item.depends_on.join(', ') }
)}
</div>
)}
</button>
);
}
// ========== Inline Scheduler Controls ==========
function SchedulerBar() {
const { formatMessage } = useIntl();
const status = useQueueSchedulerStore(selectQueueSchedulerStatus);
const progress = useQueueSchedulerStore(selectSchedulerProgress);
const concurrency = useQueueSchedulerStore(selectCurrentConcurrency);
const config = useQueueSchedulerStore(selectSchedulerConfig);
const startQueue = useQueueSchedulerStore((s) => s.startQueue);
const pauseQueue = useQueueSchedulerStore((s) => s.pauseQueue);
const stopQueue = useQueueSchedulerStore((s) => s.stopQueue);
const items = useQueueSchedulerStore(selectQueueItems);
const canStart = status === 'idle' && items.length > 0;
const canPause = status === 'running';
const canResume = status === 'paused';
const canStop = status === 'running' || status === 'paused';
const isActive = status !== 'idle';
const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false);
const handleStart = useCallback(() => {
if (canResume) {
startQueue();
} else if (canStart) {
startQueue(items);
}
}, [canResume, canStart, startQueue, items]);
return (
<div className="border-t border-border px-2.5 py-1.5 shrink-0">
<div className="flex items-center justify-between gap-2">
{/* Status badge */}
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', SCHEDULER_STATUS_STYLE[status])}
>
{formatMessage({ id: `terminalDashboard.queuePanel.scheduler.status.${status}`, defaultMessage: status })}
</Badge>
{/* Progress + Concurrency */}
{isActive && (
<span className="text-[10px] text-muted-foreground">
{progress}% | {concurrency}/{config.maxConcurrentSessions}
</span>
)}
{/* Controls */}
<div className="flex items-center gap-0.5">
{(canStart || canResume) && (
<button
type="button"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleStart}
title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Start' })}
>
<Play className="w-3 h-3" />
</button>
)}
{canPause && (
<button
type="button"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={pauseQueue}
title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.pause', defaultMessage: 'Pause' })}
>
<Pause className="w-3 h-3" />
</button>
)}
{canStop && (
<button
type="button"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={() => setIsStopConfirmOpen(true)}
title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
>
<StopCircle className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* Progress bar */}
{isActive && (
<div className="mt-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Stop confirmation dialog */}
<AlertDialog open={isStopConfirmOpen} onOpenChange={setIsStopConfirmOpen}>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle className="text-base">
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started.' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="h-8 text-xs">
{formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })}
</AlertDialogCancel>
<AlertDialogAction
className="h-8 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { stopQueue(); setIsStopConfirmOpen(false); }}
>
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ========== Main Component ==========
export function QueueListColumn() {
const { formatMessage } = useIntl();
const items = useQueueSchedulerStore(selectQueueItems);
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.execution_order - b.execution_order),
[items]
);
const handleSelect = useCallback(
(queueItemId: string) => {
buildAssociationChain(queueItemId, 'queue');
},
[buildAssociationChain]
);
return (
<div className="flex flex-col h-full">
{/* Item list */}
{sortedItems.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground p-3">
<div className="text-center">
<ListChecks className="h-5 w-5 mx-auto mb-1 opacity-30" />
<p className="text-xs">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</p>
<p className="text-[10px] mt-0.5 opacity-70">
{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc', defaultMessage: 'Select issues and click Queue to add items' })}
</p>
</div>
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
{sortedItems.map((item) => (
<QueueItemRow
key={item.item_id}
item={item}
isHighlighted={associationChain?.queueItemId === item.item_id}
onSelect={() => handleSelect(item.item_id)}
/>
))}
</div>
)}
{/* Inline scheduler controls */}
<SchedulerBar />
</div>
);
}

View File

@@ -2,9 +2,10 @@
// QueuePanel Component
// ========================================
// Queue list panel for the terminal dashboard with tab switching.
// Tab 1 (Queue): Issue queue items from useIssueQueue() hook.
// Tab 1 (Queue): Issue queue items from queueSchedulerStore (with useIssueQueue() fallback).
// Tab 2 (Orchestrator): Active orchestration plans from orchestratorStore.
// Integrates with issueQueueIntegrationStore for association chain.
// Note: Scheduler controls are in the standalone SchedulerPanel.
import { useState, useMemo, useCallback, memo } from 'react';
import { useIntl } from 'react-intl';
@@ -27,6 +28,7 @@ import {
Square,
RotateCcw,
AlertCircle,
Timer,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -40,6 +42,11 @@ import {
useQueueExecutionStore,
selectByQueueItem,
} from '@/stores/queueExecutionStore';
import {
useQueueSchedulerStore,
selectQueueSchedulerStatus,
selectQueueItems,
} from '@/stores/queueSchedulerStore';
import {
useOrchestratorStore,
selectActivePlans,
@@ -47,54 +54,77 @@ import {
type OrchestrationRunState,
} from '@/stores/orchestratorStore';
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
import type { QueueItem } from '@/lib/api';
import type { QueueItem as ApiQueueItem } from '@/lib/api';
import type { QueueItem as SchedulerQueueItem, QueueItemStatus as SchedulerQueueItemStatus } from '@/types/queue-frontend-types';
// ========== Tab Type ==========
type QueueTab = 'queue' | 'orchestrator';
// ========== Queue Tab: Unified Item Type ==========
/**
* Unified queue item type that works with both the legacy API QueueItem
* and the new scheduler QueueItem. The scheduler type is a superset.
*/
type UnifiedQueueItem = ApiQueueItem | SchedulerQueueItem;
/**
* Combined status type covering both scheduler statuses and legacy API statuses.
*/
type CombinedQueueItemStatus = SchedulerQueueItemStatus | ApiQueueItem['status'];
// ========== Queue Tab: Status Config ==========
type QueueItemStatus = QueueItem['status'];
const STATUS_CONFIG: Record<QueueItemStatus, {
const STATUS_CONFIG: Record<CombinedQueueItemStatus, {
variant: 'info' | 'success' | 'destructive' | 'secondary' | 'warning' | 'outline';
icon: typeof Clock;
label: string;
}> = {
pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
queued: { variant: 'info', icon: Timer, label: 'Queued' },
ready: { variant: 'info', icon: Zap, label: 'Ready' },
blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
failed: { variant: 'destructive', icon: XCircle, label: 'Failed' },
blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
cancelled: { variant: 'secondary', icon: Square, label: 'Cancelled' },
};
// ========== Queue Tab: Item Row ==========
// ========== Queue Tab: Content ==========
function QueueItemRow({
item,
isHighlighted,
onSelect,
}: {
item: QueueItem;
item: UnifiedQueueItem;
isHighlighted: boolean;
onSelect: () => void;
}) {
const { formatMessage } = useIntl();
const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending;
const statusKey = item.status as CombinedQueueItemStatus;
const config = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.pending;
const StatusIcon = config.icon;
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
// Session key: prefer scheduler QueueItem.sessionKey, fallback to execution store
const schedulerItem = item as SchedulerQueueItem;
const sessionKey = schedulerItem.sessionKey ?? activeExec?.sessionKey;
const isExecuting = item.status === 'executing';
const isBlocked = item.status === 'blocked';
return (
<button
type="button"
className={cn(
'w-full text-left px-3 py-2 rounded-md transition-colors',
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30'
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30',
isExecuting && 'border-l-2 border-l-blue-500'
)}
onClick={onSelect}
>
@@ -103,7 +133,7 @@ function QueueItemRow({
<StatusIcon
className={cn(
'w-3.5 h-3.5 shrink-0',
item.status === 'executing' && 'animate-spin'
isExecuting && 'animate-spin'
)}
/>
<span className="text-sm font-medium text-foreground truncate font-mono">
@@ -111,7 +141,7 @@ function QueueItemRow({
</span>
</div>
<Badge variant={config.variant} className="text-[10px] px-1.5 py-0 shrink-0">
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}` })}
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
</Badge>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground pl-5">
@@ -125,17 +155,25 @@ function QueueItemRow({
</span>
<span className="text-border">|</span>
<span>{item.execution_group}</span>
{activeExec?.sessionKey && (
{sessionKey && (
<>
<span className="text-border">|</span>
<span className="flex items-center gap-0.5">
<Terminal className="w-3 h-3" />
{activeExec.sessionKey}
{sessionKey}
</span>
</>
)}
</div>
{item.depends_on.length > 0 && (
{isBlocked && item.depends_on.length > 0 && (
<div className="mt-0.5 text-[10px] text-orange-500/80 pl-5 truncate">
{formatMessage(
{ id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' },
{ deps: item.depends_on.join(', ') }
)}
</div>
)}
{!isBlocked && item.depends_on.length > 0 && (
<div className="mt-0.5 text-[10px] text-muted-foreground/70 pl-5 truncate">
{formatMessage(
{ id: 'terminalDashboard.queuePanel.dependsOn' },
@@ -151,14 +189,24 @@ function QueueItemRow({
function QueueTabContent(_props: { embedded?: boolean }) {
const { formatMessage } = useIntl();
// Scheduler store data
const schedulerItems = useQueueSchedulerStore(selectQueueItems);
const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
// Legacy API data (fallback)
const queueQuery = useIssueQueue();
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
const allItems = useMemo(() => {
// Use scheduler items when scheduler has data, otherwise fall back to legacy API
const useSchedulerData = schedulerItems.length > 0 || schedulerStatus !== 'idle';
const legacyItems = useMemo(() => {
if (!queueQuery.data) return [];
const grouped = queueQuery.data.grouped_items ?? {};
const items: QueueItem[] = [];
const items: ApiQueueItem[] = [];
for (const group of Object.values(grouped)) {
items.push(...group);
}
@@ -166,6 +214,13 @@ function QueueTabContent(_props: { embedded?: boolean }) {
return items;
}, [queueQuery.data]);
const allItems: UnifiedQueueItem[] = useMemo(() => {
if (useSchedulerData) {
return [...schedulerItems].sort((a, b) => a.execution_order - b.execution_order);
}
return legacyItems;
}, [useSchedulerData, schedulerItems, legacyItems]);
const handleSelect = useCallback(
(queueItemId: string) => {
buildAssociationChain(queueItemId, 'queue');
@@ -173,7 +228,8 @@ function QueueTabContent(_props: { embedded?: boolean }) {
[buildAssociationChain]
);
if (queueQuery.isLoading) {
// Show loading only for legacy mode
if (!useSchedulerData && queueQuery.isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
@@ -181,7 +237,7 @@ function QueueTabContent(_props: { embedded?: boolean }) {
);
}
if (queueQuery.error) {
if (!useSchedulerData && queueQuery.error) {
return (
<div className="flex-1 flex items-center justify-center text-destructive p-4">
<div className="text-center">
@@ -446,8 +502,23 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
const [activeTab, setActiveTab] = useState<QueueTab>('queue');
const orchestratorCount = useOrchestratorStore(selectActivePlanCount);
// Scheduler store data for active count
const schedulerItems = useQueueSchedulerStore(selectQueueItems);
const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
const useSchedulerData = schedulerItems.length > 0 || schedulerStatus !== 'idle';
// Legacy API data for active count fallback
const queueQuery = useIssueQueue();
const queueActiveCount = useMemo(() => {
if (useSchedulerData) {
return schedulerItems.filter(
(item) =>
item.status === 'pending' ||
item.status === 'queued' ||
item.status === 'executing'
).length;
}
if (!queueQuery.data) return 0;
const grouped = queueQuery.data.grouped_items ?? {};
let count = 0;
@@ -457,7 +528,7 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
).length;
}
return count;
}, [queueQuery.data]);
}, [useSchedulerData, schedulerItems, queueQuery.data]);
return (
<div className="flex flex-col h-full">

View File

@@ -0,0 +1,262 @@
// ========================================
// SchedulerPanel Component
// ========================================
// Standalone queue scheduler control panel.
// Shows scheduler status, start/pause/stop controls, concurrency config,
// progress bar, and session pool overview.
import { useCallback, useState } from 'react';
import { useIntl } from 'react-intl';
import {
Play,
Pause,
Square,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { cn } from '@/lib/utils';
import {
useQueueSchedulerStore,
selectQueueSchedulerStatus,
selectSchedulerProgress,
selectSchedulerConfig,
selectSessionPool,
selectCurrentConcurrency,
selectSchedulerError,
} from '@/stores/queueSchedulerStore';
import type { QueueSchedulerStatus } from '@/types/queue-frontend-types';
// ========== Status Badge Config ==========
const SCHEDULER_STATUS_CLASS: Record<QueueSchedulerStatus, string> = {
idle: 'bg-muted text-muted-foreground border-border',
running: 'bg-primary/10 text-primary border-primary/50',
paused: 'bg-amber-500/10 text-amber-500 border-amber-500/50',
stopping: 'bg-orange-500/10 text-orange-500 border-orange-500/50',
completed: 'bg-green-500/10 text-green-500 border-green-500/50',
failed: 'bg-destructive/10 text-destructive border-destructive/50',
};
// ========== Component ==========
export function SchedulerPanel() {
const { formatMessage } = useIntl();
const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
const progress = useQueueSchedulerStore(selectSchedulerProgress);
const config = useQueueSchedulerStore(selectSchedulerConfig);
const sessionPool = useQueueSchedulerStore(selectSessionPool);
const concurrency = useQueueSchedulerStore(selectCurrentConcurrency);
const error = useQueueSchedulerStore(selectSchedulerError);
const startQueue = useQueueSchedulerStore((s) => s.startQueue);
const pauseQueue = useQueueSchedulerStore((s) => s.pauseQueue);
const stopQueue = useQueueSchedulerStore((s) => s.stopQueue);
const updateConfig = useQueueSchedulerStore((s) => s.updateConfig);
const isIdle = schedulerStatus === 'idle';
const isRunning = schedulerStatus === 'running';
const isPaused = schedulerStatus === 'paused';
const isStopping = schedulerStatus === 'stopping';
const isTerminal = schedulerStatus === 'completed' || schedulerStatus === 'failed';
const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false);
const handleConcurrencyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 10) {
updateConfig({ maxConcurrentSessions: value });
}
},
[updateConfig]
);
const sessionEntries = Object.entries(sessionPool);
return (
<div className="flex flex-col h-full">
{/* Status + Controls */}
<div className="px-3 py-3 border-b border-border space-y-3 shrink-0">
{/* Status badge */}
<div className="flex items-center gap-2">
<span
className={cn(
'px-2.5 py-1 rounded text-xs font-medium border',
SCHEDULER_STATUS_CLASS[schedulerStatus]
)}
>
{formatMessage({
id: `terminalDashboard.queuePanel.scheduler.status.${schedulerStatus}`,
defaultMessage: schedulerStatus,
})}
</span>
<span className="text-xs text-muted-foreground ml-auto tabular-nums">
{concurrency}/{config.maxConcurrentSessions}
</span>
</div>
{/* Control buttons */}
<div className="flex items-center gap-2">
{(isIdle || isTerminal) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 flex-1"
onClick={() => startQueue()}
>
<Play className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Start' })}
</Button>
)}
{isPaused && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 flex-1"
onClick={() => startQueue()}
>
<Play className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Resume' })}
</Button>
)}
{isRunning && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 flex-1"
onClick={pauseQueue}
>
<Pause className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.pause', defaultMessage: 'Pause' })}
</Button>
)}
{(isRunning || isPaused) && (
<Button
variant="destructive"
size="sm"
className="h-7 text-xs gap-1.5"
disabled={isStopping}
onClick={() => setIsStopConfirmOpen(true)}
>
{isStopping ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Square className="w-3.5 h-3.5" />}
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
</Button>
)}
</div>
{/* Progress bar (visible when not idle) */}
{!isIdle && (
<div className="flex items-center gap-2">
<Progress
value={progress}
className="h-1.5 flex-1"
indicatorClassName={cn(
schedulerStatus === 'failed' && 'bg-destructive',
schedulerStatus === 'completed' && 'bg-green-500',
schedulerStatus === 'paused' && 'bg-amber-500',
(schedulerStatus === 'running' || schedulerStatus === 'stopping') && 'bg-primary',
)}
/>
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
{formatMessage(
{ id: 'terminalDashboard.queuePanel.scheduler.progress', defaultMessage: '{percent}%' },
{ percent: progress }
)}
</span>
</div>
)}
</div>
{/* Concurrency Config */}
<div className="px-3 py-2.5 border-b border-border shrink-0">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.concurrency', defaultMessage: 'Concurrency' })}
</span>
<input
type="number"
min={1}
max={10}
value={config.maxConcurrentSessions}
onChange={handleConcurrencyChange}
className="w-14 h-6 text-xs text-center rounded border border-border bg-background px-1"
/>
</div>
</div>
{/* Session Pool */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-3 py-2">
<h4 className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-2">
{formatMessage({ id: 'terminalDashboard.schedulerPanel.sessionPool', defaultMessage: 'Session Pool' })}
</h4>
{sessionEntries.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-2">
{formatMessage({ id: 'terminalDashboard.schedulerPanel.noSessions', defaultMessage: 'No active sessions' })}
</p>
) : (
<div className="space-y-1">
{sessionEntries.map(([resumeKey, binding]) => (
<div
key={resumeKey}
className="flex items-center justify-between px-2 py-1.5 rounded bg-muted/30 text-xs"
>
<div className="min-w-0">
<span className="font-mono text-foreground truncate block">{binding.sessionKey}</span>
<span className="text-[10px] text-muted-foreground truncate block">{resumeKey}</span>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 ml-2 tabular-nums">
{new Date(binding.lastUsed).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Error display */}
{error && (
<div className="px-3 py-2 border-t border-destructive/30 bg-destructive/5 shrink-0">
<p className="text-xs text-destructive break-words">{error}</p>
</div>
)}
{/* Stop confirmation dialog */}
<AlertDialog open={isStopConfirmOpen} onOpenChange={setIsStopConfirmOpen}>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle className="text-base">
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started. Pending items will remain in the queue.' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="h-8 text-xs">
{formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })}
</AlertDialogCancel>
<AlertDialogAction
className="h-8 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { stopQueue(); setIsStopConfirmOpen(false); }}
>
{formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}