Add benchmark results for fast3 and fast4, implement KeepAliveLspBridge, and add tests for staged strategies

- Added new benchmark result files: compare_2026-02-09_score_fast3.json and compare_2026-02-09_score_fast4.json.
- Implemented KeepAliveLspBridge to maintain a persistent LSP connection across multiple queries, improving performance.
- Created unit tests for staged clustering strategies in test_staged_stage3_fast_strategies.py, ensuring correct behavior of score and dir_rr strategies.
This commit is contained in:
catlog22
2026-02-09 20:45:29 +08:00
parent c62d26183b
commit 4344e79e68
64 changed files with 6154 additions and 123 deletions

View File

@@ -0,0 +1,444 @@
// ========================================
// Issue Board Panel
// ========================================
// Kanban board view for issues (status-driven) with local ordering.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import type { DropResult } from '@hello-pangea/dnd';
import { AlertCircle, LayoutGrid } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Switch } from '@/components/ui/Switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
import { IssueCard } from '@/components/shared/IssueCard';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { cn } from '@/lib/utils';
import { useIssues, useIssueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { createCliSession, executeInCliSession } from '@/lib/api';
import type { Issue } from '@/lib/api';
type IssueBoardStatus = Issue['status'];
type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
const BOARD_COLUMNS: Array<{ id: IssueBoardStatus; titleKey: string }> = [
{ id: 'open', titleKey: 'issues.status.open' },
{ id: 'in_progress', titleKey: 'issues.status.inProgress' },
{ id: 'resolved', titleKey: 'issues.status.resolved' },
{ id: 'completed', titleKey: 'issues.status.completed' },
{ id: 'closed', titleKey: 'issues.status.closed' },
];
type BoardOrder = Partial<Record<IssueBoardStatus, string[]>>;
function storageKey(projectPath: string | null | undefined): string {
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
return `ccw.issueBoard.order:${base}`;
}
interface AutoStartConfig {
enabled: boolean;
tool: ToolName;
mode: 'analysis' | 'write';
resumeStrategy: ResumeStrategy;
}
function autoStartStorageKey(projectPath: string | null | undefined): string {
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
return `ccw.issueBoard.autoStart:${base}`;
}
function safeParseAutoStart(value: string | null): AutoStartConfig {
const defaults: AutoStartConfig = {
enabled: false,
tool: 'claude',
mode: 'write',
resumeStrategy: 'nativeResume',
};
if (!value) return defaults;
try {
const parsed = JSON.parse(value) as Partial<AutoStartConfig>;
return {
enabled: Boolean(parsed.enabled),
tool: parsed.tool === 'codex' || parsed.tool === 'gemini' ? parsed.tool : 'claude',
mode: parsed.mode === 'analysis' ? 'analysis' : 'write',
resumeStrategy: parsed.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume',
};
} catch {
return defaults;
}
}
function safeParseOrder(value: string | null): BoardOrder {
if (!value) return {};
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object') return {};
return parsed as BoardOrder;
} catch {
return {};
}
}
function buildColumns(
issues: Issue[],
order: BoardOrder,
formatTitle: (statusId: IssueBoardStatus) => string
): KanbanColumn<Issue & KanbanItem>[] {
const byId = new Map(issues.map((i) => [i.id, i]));
const columns: KanbanColumn<Issue & KanbanItem>[] = [];
for (const col of BOARD_COLUMNS) {
const desired = (order[col.id] ?? []).map((id) => byId.get(id)).filter(Boolean) as Issue[];
const desiredIds = new Set(desired.map((i) => i.id));
const remaining = issues
.filter((i) => i.status === col.id && !desiredIds.has(i.id))
.sort((a, b) => {
const at = a.updatedAt || a.createdAt;
const bt = b.updatedAt || b.createdAt;
return bt.localeCompare(at);
});
const items = [...desired, ...remaining].map((issue) => ({
...issue,
id: issue.id,
title: issue.title,
status: issue.status,
}));
columns.push({
id: col.id,
title: formatTitle(col.id),
items,
icon: <LayoutGrid className="w-4 h-4" />,
});
}
return columns;
}
function syncOrderWithIssues(prev: BoardOrder, issues: Issue[]): BoardOrder {
const statusById = new Map(issues.map((i) => [i.id, i.status]));
const next: BoardOrder = {};
for (const { id: status } of BOARD_COLUMNS) {
const existing = prev[status] ?? [];
const filtered = existing.filter((id) => statusById.get(id) === status);
const present = new Set(filtered);
const missing = issues
.filter((i) => i.status === status && !present.has(i.id))
.map((i) => i.id);
next[status] = [...filtered, ...missing];
}
return next;
}
function reorderIds(list: string[], from: number, to: number): string[] {
const next = [...list];
const [moved] = next.splice(from, 1);
if (moved === undefined) return list;
next.splice(to, 0, moved);
return next;
}
function buildIssueAutoPrompt(issue: Issue): string {
const lines: string[] = [];
lines.push(`Issue: ${issue.id}`);
lines.push(`Status: ${issue.status}`);
lines.push(`Priority: ${issue.priority}`);
lines.push('');
lines.push(`Title: ${issue.title}`);
if (issue.context) {
lines.push('');
lines.push('Context:');
lines.push(String(issue.context));
}
if (Array.isArray(issue.solutions) && issue.solutions.length > 0) {
lines.push('');
lines.push('Solutions:');
for (const s of issue.solutions) {
lines.push(`- [${s.status}] ${s.description}`);
if (s.approach) lines.push(` Approach: ${s.approach}`);
}
}
lines.push('');
lines.push('Instruction:');
lines.push(
'Start working on this issue in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
);
return lines.join('\n');
}
export function IssueBoardPanel() {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { issues, isLoading, error } = useIssues();
const { updateIssue } = useIssueMutations();
const [order, setOrder] = useState<BoardOrder>({});
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const [drawerInitialTab, setDrawerInitialTab] = useState<'overview' | 'terminal'>('overview');
const [optimisticError, setOptimisticError] = useState<string | null>(null);
const [autoStart, setAutoStart] = useState<AutoStartConfig>(() => safeParseAutoStart(null));
// Load order when project changes
useEffect(() => {
const key = storageKey(projectPath);
const loaded = safeParseOrder(localStorage.getItem(key));
setOrder(loaded);
}, [projectPath]);
// Load auto-start config when project changes
useEffect(() => {
const key = autoStartStorageKey(projectPath);
setAutoStart(safeParseAutoStart(localStorage.getItem(key)));
}, [projectPath]);
// Keep order consistent with current issues (status moves, deletions, new issues)
useEffect(() => {
setOrder((prev) => syncOrderWithIssues(prev, issues));
}, [issues]);
// Persist order
useEffect(() => {
const key = storageKey(projectPath);
try {
localStorage.setItem(key, JSON.stringify(order));
} catch {
// ignore quota errors
}
}, [order, projectPath]);
// Persist auto-start config
useEffect(() => {
const key = autoStartStorageKey(projectPath);
try {
localStorage.setItem(key, JSON.stringify(autoStart));
} catch {
// ignore quota errors
}
}, [autoStart, projectPath]);
const columns = useMemo(
() =>
buildColumns(issues, order, (statusId) => {
const col = BOARD_COLUMNS.find((c) => c.id === statusId);
if (!col) return statusId;
return formatMessage({ id: col.titleKey });
}),
[issues, order, formatMessage]
);
const idsByStatus = useMemo(() => {
const map: Record<string, string[]> = {};
for (const col of columns) {
map[col.id] = col.items.map((i) => i.id);
}
return map;
}, [columns]);
const handleItemClick = useCallback((issue: Issue) => {
setDrawerInitialTab('overview');
setSelectedIssue(issue);
}, []);
const handleCloseDrawer = useCallback(() => {
setSelectedIssue(null);
setOptimisticError(null);
}, []);
const handleDragEnd = useCallback(
async (result: DropResult, sourceColumn: string, destColumn: string) => {
const issueId = result.draggableId;
const issue = issues.find((i) => i.id === issueId);
if (!issue) return;
setOptimisticError(null);
const sourceStatus = sourceColumn as IssueBoardStatus;
const destStatus = destColumn as IssueBoardStatus;
const sourceIds = idsByStatus[sourceStatus] ?? [];
const destIds = idsByStatus[destStatus] ?? [];
// Update local order first (optimistic)
setOrder((prev) => {
const next = { ...prev };
if (sourceStatus === destStatus) {
next[sourceStatus] = reorderIds(sourceIds, result.source.index, result.destination!.index);
return next;
}
const nextSource = [...sourceIds];
nextSource.splice(result.source.index, 1);
const nextDest = [...destIds];
nextDest.splice(result.destination!.index, 0, issueId);
next[sourceStatus] = nextSource;
next[destStatus] = nextDest;
return next;
});
// Status update
if (sourceStatus !== destStatus) {
try {
await updateIssue(issueId, { status: destStatus });
// Auto action: drag to in_progress opens the drawer on terminal tab.
if (destStatus === 'in_progress' && sourceStatus !== 'in_progress') {
setDrawerInitialTab('terminal');
setSelectedIssue({ ...issue, status: destStatus });
if (autoStart.enabled) {
if (!projectPath) {
setOptimisticError('Auto-start failed: no project path selected');
return;
}
try {
const created = await createCliSession({
workingDir: projectPath,
preferredShell: 'bash',
tool: autoStart.tool,
resumeKey: issueId,
});
await executeInCliSession(created.session.sessionKey, {
tool: autoStart.tool,
prompt: buildIssueAutoPrompt({ ...issue, status: destStatus }),
mode: autoStart.mode,
resumeKey: issueId,
resumeStrategy: autoStart.resumeStrategy,
});
} catch (e) {
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
} catch (e) {
setOptimisticError(e instanceof Error ? e.message : String(e));
}
}
},
[issues, idsByStatus, updateIssue]
);
if (error) {
return (
<Card className="p-12 text-center">
<AlertCircle className="w-16 h-16 mx-auto text-destructive/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">
{formatMessage({ id: 'issues.queue.error.title' })}
</h3>
<p className="mt-2 text-muted-foreground">{error.message}</p>
</Card>
);
}
return (
<>
<div className="mb-3 flex flex-col gap-2">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoStart.enabled}
onCheckedChange={(checked) => setAutoStart((prev) => ({ ...prev, enabled: checked }))}
/>
<div className="text-sm text-foreground">
{formatMessage({ id: 'issues.board.autoStart.label' })}
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={autoStart.tool}
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, tool: v as ToolName }))}
disabled={!autoStart.enabled}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">claude</SelectItem>
<SelectItem value="codex">codex</SelectItem>
<SelectItem value="gemini">gemini</SelectItem>
</SelectContent>
</Select>
<Select
value={autoStart.mode}
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, mode: v as 'analysis' | 'write' }))}
disabled={!autoStart.enabled}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="analysis">analysis</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
<Select
value={autoStart.resumeStrategy}
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, resumeStrategy: v as ResumeStrategy }))}
disabled={!autoStart.enabled}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nativeResume">nativeResume</SelectItem>
<SelectItem value="promptConcat">promptConcat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{optimisticError && (
<div className="text-sm text-destructive">
{optimisticError}
</div>
)}
<KanbanBoard<Issue & KanbanItem>
columns={columns}
onDragEnd={handleDragEnd}
onItemClick={(item) => handleItemClick(item as unknown as Issue)}
isLoading={isLoading}
emptyColumnMessage={formatMessage({ id: 'issues.emptyState.message' })}
className={cn('gap-4', 'grid')}
renderItem={(item, provided) => (
<IssueCard
issue={item as unknown as Issue}
compact
showActions={false}
onClick={(i) => handleItemClick(i)}
innerRef={provided.innerRef}
draggableProps={provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
className="w-full"
/>
)}
/>
<IssueDrawer
issue={selectedIssue}
isOpen={Boolean(selectedIssue)}
onClose={handleCloseDrawer}
initialTab={drawerInitialTab}
/>
</>
);
}
export default IssueBoardPanel;

View File

@@ -3,23 +3,25 @@
// ========================================
// Right-side issue detail drawer with Overview/Solutions/History tabs
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash } from 'lucide-react';
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash, Terminal } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { cn } from '@/lib/utils';
import type { Issue } from '@/lib/api';
import { IssueTerminalTab } from './IssueTerminalTab';
// ========== Types ==========
export interface IssueDrawerProps {
issue: Issue | null;
isOpen: boolean;
onClose: () => void;
initialTab?: TabValue;
}
type TabValue = 'overview' | 'solutions' | 'history' | 'json';
type TabValue = 'overview' | 'solutions' | 'history' | 'terminal' | 'json';
// ========== Status Configuration ==========
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
@@ -39,20 +41,25 @@ const priorityConfig: Record<string, { label: string; variant: 'default' | 'seco
// ========== Component ==========
export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }: IssueDrawerProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<TabValue>('overview');
const [activeTab, setActiveTab] = useState<TabValue>(initialTab);
// Reset to overview when issue changes
useState(() => {
// Reset to initial tab when opening/switching issues
useEffect(() => {
if (!isOpen || !issue) return;
setActiveTab(initialTab);
}, [initialTab, isOpen, issue?.id]);
// ESC key to close
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
});
}, [isOpen, onClose]);
if (!issue || !isOpen) {
return null;
@@ -126,6 +133,10 @@ export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
<History className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.detail.tabs.history' })}
</TabsTrigger>
<TabsTrigger value="terminal" className="flex-1">
<Terminal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.detail.tabs.terminal' })}
</TabsTrigger>
<TabsTrigger value="json" className="flex-1">
<Hash className="h-4 w-4 mr-2" />
JSON
@@ -213,6 +224,11 @@ export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
)}
</TabsContent>
{/* Terminal Tab */}
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
<IssueTerminalTab issueId={issue.id} />
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="mt-4 pb-6 focus-visible:outline-none">
<div className="text-center py-12 text-muted-foreground">

View File

@@ -4,9 +4,9 @@
// Dynamic header component for IssueHub
import { useIntl } from 'react-intl';
import { AlertCircle, Radar, ListTodo } from 'lucide-react';
import { AlertCircle, Radar, ListTodo, LayoutGrid } from 'lucide-react';
type IssueTab = 'issues' | 'queue' | 'discovery';
type IssueTab = 'issues' | 'board' | 'queue' | 'discovery';
interface IssueHubHeaderProps {
currentTab: IssueTab;
@@ -22,6 +22,11 @@ export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
title: formatMessage({ id: 'issues.title' }),
description: formatMessage({ id: 'issues.description' }),
},
board: {
icon: <LayoutGrid className="w-6 h-6 text-primary" />,
title: formatMessage({ id: 'issues.board.pageTitle' }),
description: formatMessage({ id: 'issues.board.description' }),
},
queue: {
icon: <ListTodo className="w-6 h-6 text-primary" />,
title: formatMessage({ id: 'issues.queue.pageTitle' }),

View File

@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
export type IssueTab = 'issues' | 'queue' | 'discovery';
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery';
interface IssueHubTabsProps {
currentTab: IssueTab;
@@ -19,6 +19,7 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
const tabs: Array<{ value: IssueTab; label: string }> = [
{ value: 'issues', label: formatMessage({ id: 'issues.hub.tabs.issues' }) },
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
];

View File

@@ -0,0 +1,402 @@
// ========================================
// IssueTerminalTab
// ========================================
// Embedded xterm.js terminal for PTY-backed CLI sessions.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { Plus, RefreshCw, XCircle } from 'lucide-react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { Button } from '@/components/ui/Button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
closeCliSession,
createCliSession,
executeInCliSession,
fetchCliSessionBuffer,
fetchCliSessions,
resizeCliSession,
sendCliSessionText,
type CliSession,
} from '@/lib/api';
import { useCliSessionStore } from '@/stores/cliSessionStore';
type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
export function IssueTerminalTab({ issueId }: { issueId: string }) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const sessionsByKey = useCliSessionStore((s) => s.sessions);
const outputChunks = useCliSessionStore((s) => s.outputChunks);
const setSessions = useCliSessionStore((s) => s.setSessions);
const upsertSession = useCliSessionStore((s) => s.upsertSession);
const setBuffer = useCliSessionStore((s) => s.setBuffer);
const clearOutput = useCliSessionStore((s) => s.clearOutput);
const sessions = useMemo(() => Object.values(sessionsByKey).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), [sessionsByKey]);
const [selectedSessionKey, setSelectedSessionKey] = useState<string>('');
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tool, setTool] = useState<ToolName>('claude');
const [mode, setMode] = useState<'analysis' | 'write'>('analysis');
const [resumeKey, setResumeKey] = useState(issueId);
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false);
const terminalHostRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const lastChunkIndexRef = useRef<number>(0);
const pendingInputRef = useRef<string>('');
const flushTimerRef = useRef<number | null>(null);
const flushInput = async () => {
const sessionKey = selectedSessionKey;
if (!sessionKey) return;
const pending = pendingInputRef.current;
pendingInputRef.current = '';
if (!pending) return;
try {
await sendCliSessionText(sessionKey, { text: pending, appendNewline: false });
} catch (e) {
// Ignore transient failures (WS output still shows process state)
}
};
const scheduleFlush = () => {
if (flushTimerRef.current !== null) return;
flushTimerRef.current = window.setTimeout(async () => {
flushTimerRef.current = null;
await flushInput();
}, 30);
};
useEffect(() => {
setIsLoadingSessions(true);
setError(null);
fetchCliSessions()
.then((r) => {
setSessions(r.sessions as unknown as CliSession[]);
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setIsLoadingSessions(false));
}, [setSessions]);
// Auto-select a session if none selected yet
useEffect(() => {
if (selectedSessionKey) return;
if (sessions.length === 0) return;
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
}, [sessions, selectedSessionKey]);
// Init xterm
useEffect(() => {
if (!terminalHostRef.current) return;
if (xtermRef.current) return;
const term = new XTerm({
convertEol: true,
cursorBlink: true,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
scrollback: 5000,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalHostRef.current);
fitAddon.fit();
// Forward keystrokes to backend (batched)
term.onData((data) => {
if (!selectedSessionKey) return;
pendingInputRef.current += data;
scheduleFlush();
});
xtermRef.current = term;
fitAddonRef.current = fitAddon;
return () => {
try {
term.dispose();
} finally {
xtermRef.current = null;
fitAddonRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Attach to selected session: clear terminal and load buffer
useEffect(() => {
const term = xtermRef.current;
const fitAddon = fitAddonRef.current;
if (!term || !fitAddon) return;
lastChunkIndexRef.current = 0;
term.reset();
term.clear();
if (!selectedSessionKey) return;
clearOutput(selectedSessionKey);
fetchCliSessionBuffer(selectedSessionKey)
.then(({ buffer }) => {
setBuffer(selectedSessionKey, buffer || '');
})
.catch(() => {
// ignore
})
.finally(() => {
fitAddon.fit();
});
}, [selectedSessionKey, setBuffer, clearOutput]);
// Stream new output chunks into xterm
useEffect(() => {
const term = xtermRef.current;
if (!term) return;
if (!selectedSessionKey) return;
const chunks = outputChunks[selectedSessionKey] ?? [];
const start = lastChunkIndexRef.current;
if (start >= chunks.length) return;
for (let i = start; i < chunks.length; i++) {
term.write(chunks[i].data);
}
lastChunkIndexRef.current = chunks.length;
}, [outputChunks, selectedSessionKey]);
// Resize observer -> fit + resize backend
useEffect(() => {
const host = terminalHostRef.current;
const term = xtermRef.current;
const fitAddon = fitAddonRef.current;
if (!host || !term || !fitAddon) return;
const resize = () => {
fitAddon.fit();
if (selectedSessionKey) {
void (async () => {
try {
await resizeCliSession(selectedSessionKey, { cols: term.cols, rows: term.rows });
} catch {
// ignore
}
})();
}
};
const ro = new ResizeObserver(resize);
ro.observe(host);
return () => ro.disconnect();
}, [selectedSessionKey]);
const handleCreateSession = async () => {
setIsCreating(true);
setError(null);
try {
const created = await createCliSession({
workingDir: projectPath || undefined,
preferredShell: 'bash',
cols: xtermRef.current?.cols,
rows: xtermRef.current?.rows,
tool,
model: undefined,
resumeKey,
});
upsertSession(created.session as unknown as CliSession);
setSelectedSessionKey(created.session.sessionKey);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsCreating(false);
}
};
const handleCloseSession = async () => {
if (!selectedSessionKey) return;
setIsClosing(true);
setError(null);
try {
await closeCliSession(selectedSessionKey);
setSelectedSessionKey('');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsClosing(false);
}
};
const handleExecute = async () => {
if (!selectedSessionKey) return;
if (!prompt.trim()) return;
setIsExecuting(true);
setError(null);
try {
await executeInCliSession(selectedSessionKey, {
tool,
prompt: prompt.trim(),
mode,
resumeKey: resumeKey.trim() || undefined,
resumeStrategy,
category: 'user',
});
setPrompt('');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsExecuting(false);
}
};
const handleRefreshSessions = async () => {
setIsLoadingSessions(true);
setError(null);
try {
const r = await fetchCliSessions();
setSessions(r.sessions as unknown as CliSession[]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoadingSessions(false);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="min-w-[240px] flex-1">
<Select value={selectedSessionKey} onValueChange={setSelectedSessionKey}>
<SelectTrigger>
<SelectValue placeholder={formatMessage({ id: 'issues.terminal.session.select' })} />
</SelectTrigger>
<SelectContent>
{sessions.map((s) => (
<SelectItem key={s.sessionKey} value={s.sessionKey}>
{(s.tool || 'cli') + ' · ' + s.sessionKey}
</SelectItem>
))}
{sessions.length === 0 && (
<SelectItem value="__none__" disabled>
{formatMessage({ id: 'issues.terminal.session.none' })}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={handleRefreshSessions} disabled={isLoadingSessions}>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.terminal.session.refresh' })}
</Button>
<Button onClick={handleCreateSession} disabled={isCreating}>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.terminal.session.new' })}
</Button>
<Button
variant="destructive"
onClick={handleCloseSession}
disabled={!selectedSessionKey || isClosing}
>
<XCircle className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.terminal.session.close' })}
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.tool' })}</div>
<Select value={tool} onValueChange={(v) => setTool(v as ToolName)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">claude</SelectItem>
<SelectItem value="codex">codex</SelectItem>
<SelectItem value="gemini">gemini</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.mode' })}</div>
<Select value={mode} onValueChange={(v) => setMode(v as 'analysis' | 'write')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="analysis">analysis</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.resumeKey' })}</div>
<Input value={resumeKey} onChange={(e) => setResumeKey(e.target.value)} placeholder={issueId} />
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
</div>
<Select value={resumeStrategy} onValueChange={(v) => setResumeStrategy(v as ResumeStrategy)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nativeResume">nativeResume</SelectItem>
<SelectItem value="promptConcat">promptConcat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.prompt.label' })}</div>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={formatMessage({ id: 'issues.terminal.exec.prompt.placeholder' })}
className={cn(
'w-full min-h-[90px] p-3 bg-background border border-input rounded-md text-sm resize-none',
'focus:outline-none focus:ring-2 focus:ring-primary'
)}
/>
<div className="flex justify-end">
<Button onClick={handleExecute} disabled={!selectedSessionKey || isExecuting || !prompt.trim()}>
{formatMessage({ id: 'issues.terminal.exec.run' })}
</Button>
</div>
</div>
{error && (
<div className="text-sm text-destructive">
{error}
</div>
)}
<div className="rounded-md border border-border bg-black/90 overflow-hidden">
<div ref={terminalHostRef} className="h-[420px] w-full" />
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
// ========================================
// Content panel for Queue tab in IssueHub
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import {
AlertCircle,
@@ -14,9 +14,12 @@ import {
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { QueueCard } from '@/components/issue/queue/QueueCard';
import { QueueBoard } from '@/components/issue/queue/QueueBoard';
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
import { useIssueQueue, useQueueMutations } from '@/hooks';
import { useIssueQueue, useQueueHistory, useQueueMutations } from '@/hooks';
import type { QueueItem } from '@/lib/api';
// ========== Loading Skeleton ==========
@@ -73,6 +76,7 @@ export function QueuePanel() {
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
const { data: queueData, isLoading, error } = useIssueQueue();
const { data: historyIndex } = useQueueHistory();
const {
activateQueue,
deactivateQueue,
@@ -93,6 +97,16 @@ export function QueuePanel() {
const conflictCount = queue?.conflicts?.length || 0;
const groupCount = Object.keys(queue?.grouped_items || {}).length;
const totalItems = taskCount + solutionCount;
const activeQueueId = historyIndex?.active_queue_id || null;
const activeQueueIds = historyIndex?.active_queue_ids || [];
const queueId = queue?.id;
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
// Keep selector in sync with active queue id
useEffect(() => {
if (activeQueueId) setSelectedQueueId(activeQueueId);
else if (queueId) setSelectedQueueId(queueId);
}, [activeQueueId, queueId]);
const handleActivate = async (queueId: string) => {
try {
@@ -164,11 +178,62 @@ export function QueuePanel() {
return <QueueEmptyState />;
}
// Check if queue is active (has items and no conflicts)
const isActive = totalItems > 0 && conflictCount === 0;
// Check if queue is active (multi-queue index preferred)
const isActive = queueId ? activeQueueIds.includes(queueId) : totalItems > 0 && conflictCount === 0;
return (
<div className="space-y-6">
{/* Queue History / Active Queue Selector */}
{historyIndex && (
<Card className="p-4">
<div className="flex flex-col md:flex-row md:items-center gap-3 justify-between">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'issues.queue.history.title' })}
</div>
<div className="text-xs text-muted-foreground mt-1 font-mono">
{formatMessage({ id: 'issues.queue.history.active' })}:{' '}
{activeQueueId || '—'}
</div>
</div>
<div className="flex items-center gap-2">
<Select value={selectedQueueId} onValueChange={(v) => setSelectedQueueId(v)}>
<SelectTrigger className="w-[260px]">
<SelectValue placeholder={formatMessage({ id: 'issues.queue.history.select' })} />
</SelectTrigger>
<SelectContent>
{(historyIndex.queues || []).length === 0 ? (
<SelectItem value="" disabled>
{formatMessage({ id: 'issues.queue.history.empty' })}
</SelectItem>
) : (
historyIndex.queues.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.id}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={!selectedQueueId || isActivating}
onClick={() => activateQueue(selectedQueueId)}
>
{formatMessage({ id: 'issues.queue.history.activate' })}
</Button>
<Button
variant="outline"
disabled={isDeactivating}
onClick={() => deactivateQueue()}
>
{formatMessage({ id: 'issues.queue.actions.deactivate' })}
</Button>
</div>
</div>
</Card>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
@@ -226,25 +291,26 @@ export function QueuePanel() {
</Card>
)}
{/* Queue Card */}
<div className="grid gap-4 md:grid-cols-2">
<QueueCard
key="current"
queue={queue}
isActive={isActive}
onActivate={handleActivate}
onDeactivate={handleDeactivate}
onDelete={handleDelete}
onMerge={handleMerge}
onSplit={handleSplit}
onItemClick={handleItemClick}
isActivating={isActivating}
isDeactivating={isDeactivating}
isDeleting={isDeleting}
isMerging={isMerging}
isSplitting={isSplitting}
/>
</div>
{/* Queue Card (actions + summary) */}
<QueueCard
key={queue.id || 'legacy'}
queue={queue}
isActive={isActive}
onActivate={handleActivate}
onDeactivate={handleDeactivate}
onDelete={handleDelete}
onMerge={handleMerge}
onSplit={handleSplit}
onItemClick={handleItemClick}
isActivating={isActivating}
isDeactivating={isDeactivating}
isDeleting={isDeleting}
isMerging={isMerging}
isSplitting={isSplitting}
/>
{/* Queue Board (DnD reorder/move) */}
<QueueBoard queue={queue} onItemClick={handleItemClick} />
{/* Solution Detail Drawer */}
<SolutionDrawer

View File

@@ -33,6 +33,7 @@ import type { IssueQueue, QueueItem } from '@/lib/api';
export interface QueueActionsProps {
queue: IssueQueue;
queueId?: string;
isActive?: boolean;
onActivate?: (queueId: string) => void;
onDeactivate?: () => void;
@@ -50,6 +51,7 @@ export interface QueueActionsProps {
export function QueueActions({
queue,
queueId: queueIdProp,
isActive = false,
onActivate,
onDeactivate,
@@ -69,20 +71,20 @@ export function QueueActions({
const [mergeTargetId, setMergeTargetId] = useState('');
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
// Use "current" as the queue ID for single-queue model
// This matches the API pattern where deactivate works on the current queue
const queueId = 'current';
const queueId = queueIdProp;
// Get all items from grouped_items for split dialog
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();
const handleDelete = () => {
if (!queueId) return;
onDelete?.(queueId);
setIsDeleteOpen(false);
};
const handleMerge = () => {
if (mergeTargetId.trim()) {
if (!queueId) return;
onMerge?.(queueId, mergeTargetId.trim());
setIsMergeOpen(false);
setMergeTargetId('');
@@ -91,6 +93,7 @@ export function QueueActions({
const handleSplit = () => {
if (selectedItemIds.length > 0 && selectedItemIds.length < allItems.length) {
if (!queueId) return;
onSplit?.(queueId, selectedItemIds);
setIsSplitOpen(false);
setSelectedItemIds([]);
@@ -128,7 +131,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => onActivate(queueId)}
disabled={isActivating}
disabled={isActivating || !queueId}
title={formatMessage({ id: 'issues.queue.actions.activate' })}
>
{isActivating ? (
@@ -161,7 +164,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsMergeOpen(true)}
disabled={isMerging}
disabled={isMerging || !queueId}
title={formatMessage({ id: 'issues.queue.actions.merge' })}
>
{isMerging ? (
@@ -178,7 +181,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsSplitOpen(true)}
disabled={isSplitting}
disabled={isSplitting || !queueId}
title={formatMessage({ id: 'issues.queue.actions.split' })}
>
{isSplitting ? (
@@ -195,7 +198,7 @@ export function QueueActions({
size="sm"
className="h-8 w-8 p-0"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
disabled={isDeleting || !queueId}
title={formatMessage({ id: 'issues.queue.actions.delete' })}
>
{isDeleting ? (

View File

@@ -0,0 +1,171 @@
// ========================================
// QueueBoard
// ========================================
// Kanban-style view of queue execution groups with DnD reordering/moving.
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { DropResult } from '@hello-pangea/dnd';
import { useIntl } from 'react-intl';
import { LayoutGrid } from 'lucide-react';
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import { useQueueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import type { IssueQueue, QueueItem } from '@/lib/api';
type QueueBoardItem = QueueItem & KanbanItem;
function groupSortKey(groupId: string): [number, string] {
const n = parseInt(groupId.match(/\d+/)?.[0] || '999');
return [Number.isFinite(n) ? n : 999, groupId];
}
function buildColumns(queue: IssueQueue): KanbanColumn<QueueBoardItem>[] {
const entries = Object.entries(queue.grouped_items || {});
entries.sort(([a], [b]) => {
const [an, aid] = groupSortKey(a);
const [bn, bid] = groupSortKey(b);
if (an !== bn) return an - bn;
return aid.localeCompare(bid);
});
return entries.map(([groupId, items]) => {
const sorted = [...(items || [])].sort((a, b) => (a.execution_order || 0) - (b.execution_order || 0));
const mapped = sorted.map((it) => ({
...it,
id: it.item_id,
title: `${it.issue_id} · ${it.solution_id}`,
status: it.status,
}));
return {
id: groupId,
title: groupId,
items: mapped,
icon: <LayoutGrid className="w-4 h-4" />,
};
});
}
function applyDrag(columns: KanbanColumn<QueueBoardItem>[], result: DropResult): KanbanColumn<QueueBoardItem>[] {
if (!result.destination) return columns;
const { source, destination, draggableId } = result;
const next = columns.map((c) => ({ ...c, items: [...c.items] }));
const src = next.find((c) => c.id === source.droppableId);
const dst = next.find((c) => c.id === destination.droppableId);
if (!src || !dst) return columns;
const srcIndex = src.items.findIndex((i) => i.id === draggableId);
if (srcIndex === -1) return columns;
const [moved] = src.items.splice(srcIndex, 1);
if (!moved) return columns;
dst.items.splice(destination.index, 0, moved);
return next;
}
export function QueueBoard({
queue,
onItemClick,
className,
}: {
queue: IssueQueue;
onItemClick?: (item: QueueItem) => void;
className?: string;
}) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { reorderQueueGroup, moveQueueItem, isReordering, isMoving } = useQueueMutations();
const baseColumns = useMemo(() => buildColumns(queue), [queue]);
const [columns, setColumns] = useState<KanbanColumn<QueueBoardItem>[]>(baseColumns);
useEffect(() => {
setColumns(baseColumns);
}, [baseColumns]);
const handleDragEnd = useCallback(
async (result: DropResult, sourceColumn: string, destColumn: string) => {
if (!projectPath) return;
if (!result.destination) return;
if (sourceColumn === destColumn && result.source.index === result.destination.index) return;
try {
const nextColumns = applyDrag(columns, result);
setColumns(nextColumns);
const itemId = result.draggableId;
if (sourceColumn === destColumn) {
const column = nextColumns.find((c) => c.id === sourceColumn);
const nextOrder = (column?.items ?? []).map((i) => i.item_id);
await reorderQueueGroup(sourceColumn, nextOrder);
} else {
await moveQueueItem(itemId, destColumn, result.destination.index);
}
} catch (e) {
// Revert by resetting to server-derived columns
setColumns(baseColumns);
}
},
[baseColumns, columns, moveQueueItem, projectPath, reorderQueueGroup]
);
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="gap-1">
{formatMessage({ id: 'issues.queue.stats.executionGroups' })}
</Badge>
{(isReordering || isMoving) && (
<span>
{formatMessage({ id: 'common.status.running' })}
</span>
)}
</div>
<KanbanBoard<QueueBoardItem>
columns={columns}
onDragEnd={handleDragEnd}
onItemClick={(item) => onItemClick?.(item as unknown as QueueItem)}
emptyColumnMessage={formatMessage({ id: 'issues.queue.empty' })}
renderItem={(item, provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onClick={() => onItemClick?.(item as unknown as QueueItem)}
className={cn(
'p-3 bg-card border border-border rounded-lg shadow-sm cursor-pointer',
'hover:shadow-md hover:border-primary/50 transition-all',
item.status === 'blocked' && 'border-destructive/50 bg-destructive/5',
item.status === 'failed' && 'border-destructive/50 bg-destructive/5',
item.status === 'executing' && 'border-primary/40'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-mono text-muted-foreground">{item.item_id}</div>
<div className="text-sm font-medium text-foreground truncate">
{item.issue_id} · {item.solution_id}
</div>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{formatMessage({ id: `issues.queue.status.${item.status}` })}
</Badge>
</div>
{item.depends_on?.length ? (
<div className="mt-2 text-xs text-muted-foreground">
{formatMessage({ id: 'issues.solution.overview.dependencies' })}: {item.depends_on.length}
</div>
) : null}
</div>
)}
/>
</div>
);
}
export default QueueBoard;

View File

@@ -51,8 +51,7 @@ export function QueueCard({
}: QueueCardProps) {
const { formatMessage } = useIntl();
// Use "current" for queue ID display
const queueId = 'current';
const queueId = queue.id;
// Calculate item counts
const taskCount = queue.tasks?.length || 0;
@@ -88,7 +87,7 @@ export function QueueCard({
{formatMessage({ id: 'issues.queue.title' })}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{queueId.substring(0, 20)}{queueId.length > 20 ? '...' : ''}
{(queueId || 'legacy').substring(0, 20)}{(queueId || 'legacy').length > 20 ? '...' : ''}
</p>
</div>
</div>
@@ -102,6 +101,7 @@ export function QueueCard({
<QueueActions
queue={queue}
queueId={queueId}
isActive={isActive}
onActivate={onActivate}
onDeactivate={onDeactivate}

View File

@@ -0,0 +1,290 @@
// ========================================
// QueueExecuteInSession
// ========================================
// Minimal “execution plane” for queue items:
// pick/create a PTY session and submit a generated prompt to it.
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { Plus, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { useIssues } from '@/hooks';
import {
createCliSession,
executeInCliSession,
fetchCliSessions,
type CliSession,
type QueueItem,
} from '@/lib/api';
import { useCliSessionStore } from '@/stores/cliSessionStore';
type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
function buildQueueItemPrompt(item: QueueItem, issue: any | undefined): string {
const lines: string[] = [];
lines.push(`Queue Item: ${item.item_id}`);
lines.push(`Issue: ${item.issue_id}`);
lines.push(`Solution: ${item.solution_id}`);
if (item.task_id) lines.push(`Task: ${item.task_id}`);
lines.push('');
if (issue) {
if (issue.title) lines.push(`Title: ${issue.title}`);
if (issue.context) {
lines.push('');
lines.push('Context:');
lines.push(String(issue.context));
}
const solution = Array.isArray(issue.solutions)
? issue.solutions.find((s: any) => s?.id === item.solution_id)
: undefined;
if (solution) {
lines.push('');
lines.push('Solution Description:');
if (solution.description) lines.push(String(solution.description));
if (solution.approach) {
lines.push('');
lines.push('Approach:');
lines.push(String(solution.approach));
}
// Best-effort: if the solution has embedded tasks, include the matched task.
const tasks = Array.isArray(solution.tasks) ? solution.tasks : [];
const task = item.task_id ? tasks.find((t: any) => t?.id === item.task_id) : undefined;
if (task) {
lines.push('');
lines.push('Task:');
if (task.title) lines.push(`- ${task.title}`);
if (task.description) lines.push(String(task.description));
}
}
}
lines.push('');
lines.push('Instruction:');
lines.push(
'Implement the above queue item in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
);
return lines.join('\n');
}
export function QueueExecuteInSession({ item, className }: { item: QueueItem; className?: string }) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { issues } = useIssues();
const issue = useMemo(() => issues.find((i) => i.id === item.issue_id) as any, [issues, item.issue_id]);
const sessionsByKey = useCliSessionStore((s) => s.sessions);
const setSessions = useCliSessionStore((s) => s.setSessions);
const upsertSession = useCliSessionStore((s) => s.upsertSession);
const sessions = useMemo(
() => Object.values(sessionsByKey).sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
[sessionsByKey]
);
const [selectedSessionKey, setSelectedSessionKey] = useState<string>('');
const [tool, setTool] = useState<ToolName>('claude');
const [mode, setMode] = useState<'analysis' | 'write'>('write');
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
const [isLoading, setIsLoading] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastExecution, setLastExecution] = useState<{ executionId: string; command: string } | null>(null);
const refreshSessions = async () => {
setIsLoading(true);
setError(null);
try {
const r = await fetchCliSessions();
setSessions(r.sessions as unknown as CliSession[]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void refreshSessions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (selectedSessionKey) return;
if (sessions.length === 0) return;
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
}, [sessions, selectedSessionKey]);
const ensureSession = async (): Promise<string> => {
if (selectedSessionKey) return selectedSessionKey;
if (!projectPath) throw new Error('No project path selected');
const created = await createCliSession({
workingDir: projectPath,
preferredShell: 'bash',
resumeKey: item.issue_id,
});
upsertSession(created.session as unknown as CliSession);
setSelectedSessionKey(created.session.sessionKey);
return created.session.sessionKey;
};
const handleCreateSession = async () => {
setError(null);
try {
if (!projectPath) throw new Error('No project path selected');
const created = await createCliSession({
workingDir: projectPath,
preferredShell: 'bash',
resumeKey: item.issue_id,
});
upsertSession(created.session as unknown as CliSession);
setSelectedSessionKey(created.session.sessionKey);
await refreshSessions();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleExecute = async () => {
setIsExecuting(true);
setError(null);
setLastExecution(null);
try {
const sessionKey = await ensureSession();
const prompt = buildQueueItemPrompt(item, issue);
const result = await executeInCliSession(sessionKey, {
tool,
prompt,
mode,
workingDir: projectPath,
category: 'user',
resumeKey: item.issue_id,
resumeStrategy,
});
setLastExecution({ executionId: result.executionId, command: result.command });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsExecuting(false);
}
};
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'issues.queue.exec.title' })}
</h3>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refreshSessions}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
{formatMessage({ id: 'issues.terminal.session.refresh' })}
</Button>
<Button variant="outline" size="sm" onClick={handleCreateSession} className="gap-2">
<Plus className="h-4 w-4" />
{formatMessage({ id: 'issues.terminal.session.new' })}
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.session.select' })}
</label>
<Select value={selectedSessionKey} onValueChange={(v) => setSelectedSessionKey(v)}>
<SelectTrigger>
<SelectValue placeholder={formatMessage({ id: 'issues.terminal.session.none' })} />
</SelectTrigger>
<SelectContent>
{sessions.length === 0 ? (
<SelectItem value="" disabled>
{formatMessage({ id: 'issues.terminal.session.none' })}
</SelectItem>
) : (
sessions.map((s) => (
<SelectItem key={s.sessionKey} value={s.sessionKey}>
{(s.tool || 'cli') + ' · ' + s.sessionKey}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.tool' })}
</label>
<Select value={tool} onValueChange={(v) => setTool(v as ToolName)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">claude</SelectItem>
<SelectItem value="codex">codex</SelectItem>
<SelectItem value="gemini">gemini</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.mode' })}
</label>
<Select value={mode} onValueChange={(v) => setMode(v as 'analysis' | 'write')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="analysis">analysis</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
</label>
<Select value={resumeStrategy} onValueChange={(v) => setResumeStrategy(v as ResumeStrategy)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nativeResume">nativeResume</SelectItem>
<SelectItem value="promptConcat">promptConcat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{lastExecution && (
<div className="text-xs text-muted-foreground font-mono break-all">
{lastExecution.executionId}
</div>
)}
<div className="flex items-center justify-end">
<Button onClick={handleExecute} disabled={isExecuting || !projectPath} className="gap-2">
{formatMessage({ id: 'issues.terminal.exec.run' })}
</Button>
</div>
</div>
);
}

View File

@@ -5,10 +5,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle } from 'lucide-react';
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle, Terminal } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
import { IssueTerminalTab } from '@/components/issue/hub/IssueTerminalTab';
import { useIssueQueue } from '@/hooks';
import { cn } from '@/lib/utils';
import type { QueueItem } from '@/lib/api';
@@ -20,7 +22,7 @@ export interface SolutionDrawerProps {
onClose: () => void;
}
type TabValue = 'overview' | 'tasks' | 'json';
type TabValue = 'overview' | 'tasks' | 'terminal' | 'json';
// ========== Status Configuration ==========
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
@@ -134,6 +136,10 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
<CheckCircle className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.solution.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="terminal" className="flex-1">
<Terminal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.solution.tabs.terminal' })}
</TabsTrigger>
<TabsTrigger value="json" className="flex-1">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'issues.solution.tabs.json' })}
@@ -170,6 +176,9 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
</div>
</div>
{/* Execute in Session */}
<QueueExecuteInSession item={item} />
{/* Dependencies */}
{item.depends_on && item.depends_on.length > 0 && (
<div>
@@ -244,6 +253,11 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
)}
</TabsContent>
{/* Terminal Tab */}
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
<IssueTerminalTab issueId={issueId} />
</TabsContent>
{/* JSON Tab */}
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">