mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
feat: Implement terminal panel for command execution and monitoring
- Added TerminalPanel component with navigation and main area for terminal interactions. - Integrated terminal session management with CLI execution output display. - Enhanced SolutionDrawer to open terminal panel on command execution. - Updated localization files for terminal panel strings in English and Chinese. - Introduced hooks for terminal panel state management. - Created JSON schemas for plan overview and fix plan types.
This commit is contained in:
@@ -294,5 +294,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"deprecated": true,
|
||||
"deprecated_message": "Migrated to plan-overview-fix-schema.json (extends base) + task-schema.json.",
|
||||
"migration_guide": {
|
||||
"plan_level": "→ plan-overview-fix-schema.json (继承 base,含 fix_context)",
|
||||
"task_level": "→ .task/FIX-*.json (task-schema.json)",
|
||||
"field_mapping": {
|
||||
"root_cause": "→ fix_context.root_cause",
|
||||
"strategy": "→ fix_context.strategy",
|
||||
"severity": "→ fix_context.severity",
|
||||
"risk_level": "→ fix_context.risk_level",
|
||||
"test_strategy": "→ plan-overview-fix-schema.test_strategy",
|
||||
"rollback_plan": "→ plan-overview-fix-schema.rollback_plan",
|
||||
"tasks[].verification[]": "→ convergence.criteria[]",
|
||||
"tasks[].risk": "→ risks[] (结构化)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,5 +440,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"deprecated": true,
|
||||
"deprecated_message": "Migrated to plan-overview-base-schema.json + task-schema.json (PLANNING 区块).",
|
||||
"migration_guide": {
|
||||
"plan_level": "→ plan-overview-base-schema.json",
|
||||
"task_level": "→ .task/TASK-*.json (task-schema.json)",
|
||||
"field_mapping": {
|
||||
"tasks[].modification_points": "→ files[].change (合并入 files)",
|
||||
"tasks[].reference": "→ reference (直接迁移)",
|
||||
"tasks[].rationale": "→ rationale (直接迁移)",
|
||||
"tasks[].verification": "→ test.manual_checks + test.success_metrics (合并入 test)",
|
||||
"tasks[].risks": "→ risks (直接迁移)",
|
||||
"tasks[].code_skeleton": "→ code_skeleton (直接迁移)",
|
||||
"tasks[].acceptance": "→ convergence.criteria (已删除别名,直接用 convergence)",
|
||||
"tasks[].cli_execution_id": "→ cli_execution.id (已存在)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "plan-overview-base-schema.json",
|
||||
"title": "Plan Overview Base Schema",
|
||||
"description": "计划概览基础 schema — 所有计划类型共享字段。Feature 类型直接使用此 schema,其他类型 (fix/tdd/review) 通过 allOf 继承并扩展。",
|
||||
"type": "object",
|
||||
"required": ["summary", "approach", "task_ids", "task_count", "_metadata"],
|
||||
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "2-3 句计划概述"
|
||||
},
|
||||
"approach": {
|
||||
"type": "string",
|
||||
"description": "高层实施策略和方法论"
|
||||
},
|
||||
"task_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1,
|
||||
"description": "引用 .task/ 下的任务 ID 列表 (如 ['TASK-001', 'TASK-002'])"
|
||||
},
|
||||
"task_count": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "任务数量 (应等于 task_ids.length)"
|
||||
},
|
||||
|
||||
"complexity": {
|
||||
"type": "string",
|
||||
"enum": ["Low", "Medium", "High"],
|
||||
"description": "计划复杂度"
|
||||
},
|
||||
"estimated_time": {
|
||||
"type": "string",
|
||||
"description": "总估算时间 (如 '30 minutes', '2 hours')"
|
||||
},
|
||||
"recommended_execution": {
|
||||
"type": "string",
|
||||
"enum": ["Agent", "Codex"],
|
||||
"description": "推荐执行方式"
|
||||
},
|
||||
|
||||
"data_flow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "ASCII/文本形式的数据流图 (如 'A → B → C')"
|
||||
},
|
||||
"stages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["stage", "input", "output", "component"],
|
||||
"properties": {
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"description": "阶段名称"
|
||||
},
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "输入数据格式/类型"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "输出数据格式/类型"
|
||||
},
|
||||
"component": {
|
||||
"type": "string",
|
||||
"description": "处理该阶段的组件/模块"
|
||||
},
|
||||
"transformations": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "该阶段的数据转换"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "详细数据流阶段"
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "外部依赖或数据源"
|
||||
}
|
||||
},
|
||||
"description": "全局数据流设计"
|
||||
},
|
||||
|
||||
"design_decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["decision", "rationale"],
|
||||
"properties": {
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"description": "设计决策"
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"description": "决策原因"
|
||||
},
|
||||
"tradeoff": {
|
||||
"type": "string",
|
||||
"description": "权衡取舍"
|
||||
},
|
||||
"alternatives": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "考虑过的替代方案"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "影响整个计划的全局设计决策"
|
||||
},
|
||||
|
||||
|
||||
"_metadata": {
|
||||
"type": "object",
|
||||
"required": ["timestamp", "source", "plan_type"],
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 规划时间戳"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["cli-lite-planning-agent", "direct-planning"],
|
||||
"description": "规划来源"
|
||||
},
|
||||
"planning_mode": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "agent-based"],
|
||||
"description": "规划执行模式"
|
||||
},
|
||||
"plan_type": {
|
||||
"type": "string",
|
||||
"enum": ["feature", "fix", "tdd", "review", "collaborative", "requirement"],
|
||||
"description": "计划类型 — 消费端据此选择对应的扩展 schema 校验"
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"default": "2.0",
|
||||
"description": "Schema 版本"
|
||||
},
|
||||
"exploration_angles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "用于上下文的探索角度"
|
||||
},
|
||||
"duration_seconds": {
|
||||
"type": "integer",
|
||||
"description": "规划耗时 (秒)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"additionalProperties": true,
|
||||
|
||||
"_extension_guide": {
|
||||
"description": "扩展指南 — 通过 allOf + $ref 继承此 base schema",
|
||||
"available_extensions": {
|
||||
"fix": "plan-overview-fix-schema.json — fix_context, test_strategy, rollback_plan",
|
||||
"tdd": "(未来) plan-overview-tdd-schema.json — tdd_cycles, coverage_targets, phase_requirements",
|
||||
"review": "(未来) plan-overview-review-schema.json — review_dimensions, finding_summary"
|
||||
},
|
||||
"feature_note": "feature 类型直接使用此 base schema,无需额外扩展"
|
||||
},
|
||||
|
||||
"_field_migration_from_plan_json": {
|
||||
"summary": "直接迁移",
|
||||
"approach": "直接迁移",
|
||||
"tasks": "→ task_ids[] (引用 .task/ 下的独立文件)",
|
||||
"complexity": "直接迁移",
|
||||
"estimated_time": "直接迁移",
|
||||
"recommended_execution": "直接迁移",
|
||||
"data_flow": "直接迁移",
|
||||
"design_decisions": "直接迁移",
|
||||
"flow_control": "→ 删除 (由引擎从 task depends_on + parallel_group 自动推断)",
|
||||
"focus_paths": "→ 删除 (由引擎从 task focus_paths / files[].path 聚合)",
|
||||
"_metadata": "扩展 (新增 plan_type, schema_version)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "plan-overview-fix-schema.json",
|
||||
"title": "Fix Plan Overview Schema",
|
||||
"description": "Fix 计划概览 — 继承 base + fix 专属字段。_metadata.plan_type 必须为 'fix'。",
|
||||
"allOf": [
|
||||
{ "$ref": "plan-overview-base-schema.json" }
|
||||
],
|
||||
"required": ["fix_context"],
|
||||
"properties": {
|
||||
"fix_context": {
|
||||
"type": "object",
|
||||
"required": ["root_cause", "strategy", "severity", "risk_level"],
|
||||
"properties": {
|
||||
"root_cause": {
|
||||
"type": "string",
|
||||
"description": "根因描述 (从 fix-plan-json-schema.root_cause 迁移)"
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["immediate_patch", "comprehensive_fix", "refactor"],
|
||||
"description": "修复策略 (从 fix-plan-json-schema.strategy 迁移)"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["Low", "Medium", "High", "Critical"],
|
||||
"description": "Bug 严重等级 (从 fix-plan-json-schema.severity 迁移)"
|
||||
},
|
||||
"risk_level": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"description": "修复风险等级 (从 fix-plan-json-schema.risk_level 迁移)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Fix 专属上下文 — 整合原 fix-plan-json-schema 的顶层 fix 字段"
|
||||
},
|
||||
|
||||
"test_strategy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["unit", "integration", "e2e", "smoke", "full"],
|
||||
"description": "修复后的测试范围"
|
||||
},
|
||||
"specific_tests": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "需运行的特定测试文件或模式"
|
||||
},
|
||||
"manual_verification": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "手动验证步骤 (无自动化测试时)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "修复验证测试策略 (从 fix-plan-json-schema.test_strategy 迁移)"
|
||||
},
|
||||
|
||||
"rollback_plan": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["git_revert", "feature_flag", "manual"],
|
||||
"description": "回滚策略"
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "回滚步骤"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "回滚计划 (从 fix-plan-json-schema.rollback_plan 迁移)"
|
||||
},
|
||||
|
||||
"_metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diagnosis_angles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "诊断角度 (fix 特有,从 fix-plan-json-schema._metadata.diagnosis_angles 迁移)"
|
||||
}
|
||||
},
|
||||
"description": "Fix 扩展的 metadata 字段 (与 base _metadata 合并)"
|
||||
}
|
||||
},
|
||||
|
||||
"_field_migration_from_fix_plan_json": {
|
||||
"root_cause": "→ fix_context.root_cause",
|
||||
"strategy": "→ fix_context.strategy",
|
||||
"severity": "→ fix_context.severity",
|
||||
"risk_level": "→ fix_context.risk_level",
|
||||
"test_strategy": "直接迁移",
|
||||
"rollback_plan": "直接迁移",
|
||||
"tasks": "→ task_ids[] (引用 .task/ 下的独立文件)",
|
||||
"_metadata.diagnosis_angles": "直接迁移"
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,10 @@
|
||||
"items": { "type": "string" },
|
||||
"description": "修改描述列表"
|
||||
},
|
||||
"change": {
|
||||
"type": "string",
|
||||
"description": "单条变更描述 (精确修改说明,合并自 modification_points.change)"
|
||||
},
|
||||
"conflict_risk": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
@@ -163,6 +167,16 @@
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"description": "覆盖率目标 (%)"
|
||||
},
|
||||
"manual_checks": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "手动验证步骤 (合并自 verification_detail)"
|
||||
},
|
||||
"success_metrics": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "量化成功指标 (如 '响应时间 <200ms', '覆盖率 >80%',合并自 verification_detail)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -174,6 +188,132 @@
|
||||
"description": "回归检查点"
|
||||
},
|
||||
|
||||
"_comment_PLANNING": "PLANNING 区块 (可选) — 规划详情 (reference + rationale + risks + code_skeleton)",
|
||||
"reference": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "参考模式名称"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "参考文件路径"
|
||||
},
|
||||
"examples": {
|
||||
"type": "string",
|
||||
"description": "参考指南或示例"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "参考实现资料"
|
||||
},
|
||||
"rationale": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chosen_approach": {
|
||||
"type": "string",
|
||||
"description": "选定方案及原因"
|
||||
},
|
||||
"alternatives_considered": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "被考虑但未选择的替代方案"
|
||||
},
|
||||
"decision_factors": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "影响决策的关键因素 (性能、可维护性、成本等)"
|
||||
},
|
||||
"tradeoffs": {
|
||||
"type": "string",
|
||||
"description": "选定方案的已知权衡"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "设计决策原因 (Medium/High complexity 时使用)"
|
||||
},
|
||||
"risks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["description", "probability", "impact", "mitigation"],
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "风险描述"
|
||||
},
|
||||
"probability": {
|
||||
"type": "string",
|
||||
"enum": ["Low", "Medium", "High"],
|
||||
"description": "发生概率"
|
||||
},
|
||||
"impact": {
|
||||
"type": "string",
|
||||
"enum": ["Low", "Medium", "High"],
|
||||
"description": "影响程度"
|
||||
},
|
||||
"mitigation": {
|
||||
"type": "string",
|
||||
"description": "缓解策略"
|
||||
},
|
||||
"fallback": {
|
||||
"type": "string",
|
||||
"description": "缓解失败时的替代方案"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "结构化风险评估"
|
||||
},
|
||||
"code_skeleton": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interfaces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"definition": { "type": "string" },
|
||||
"purpose": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"description": "关键接口/类型定义"
|
||||
},
|
||||
"key_functions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"signature": { "type": "string" },
|
||||
"purpose": { "type": "string" },
|
||||
"returns": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"description": "关键函数签名"
|
||||
},
|
||||
"classes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"purpose": { "type": "string" },
|
||||
"methods": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "关键类结构"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "代码骨架 (High complexity 时使用)"
|
||||
},
|
||||
|
||||
"_comment_EXECUTION": "EXECUTION 区块 (可选) — 执行策略",
|
||||
"meta": {
|
||||
"type": "object",
|
||||
@@ -268,11 +408,6 @@
|
||||
"type": "array",
|
||||
"description": "支撑证据"
|
||||
},
|
||||
"risk_items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "风险项"
|
||||
},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
@@ -356,7 +491,8 @@
|
||||
"_field_usage_by_producer": {
|
||||
"workflow-plan": "IDENTITY + CLASSIFICATION + SCOPE + DEPENDENCIES + CONVERGENCE + FILES + EXECUTION(meta+cli_execution) + CONTEXT(context_package_path)",
|
||||
"lite-plan": "IDENTITY + CLASSIFICATION + DEPENDENCIES + CONVERGENCE + FILES",
|
||||
"req-plan": "IDENTITY + CLASSIFICATION + SCOPE + DEPENDENCIES + CONVERGENCE + CONTEXT(inputs/outputs/risk_items)",
|
||||
"lite-plan (v2)": "IDENTITY + CLASSIFICATION + SCOPE + DEPENDENCIES + CONVERGENCE + FILES(+change) + IMPLEMENTATION(+manual_checks +success_metrics) + PLANNING(reference + rationale + risks + code_skeleton)",
|
||||
"req-plan": "IDENTITY + CLASSIFICATION + SCOPE + DEPENDENCIES + CONVERGENCE + PLANNING(risks) + CONTEXT(inputs/outputs)",
|
||||
"collaborative-plan": "IDENTITY + CLASSIFICATION + SCOPE + DEPENDENCIES + CONVERGENCE + FILES + CONTEXT(source)",
|
||||
"issue-resolve": "IDENTITY + CLASSIFICATION + SCOPE + DEPENDENCIES + CONVERGENCE + FILES(with target) + IMPLEMENTATION + CONTEXT(commit/source)",
|
||||
"review-cycle": "IDENTITY + CLASSIFICATION + FILES + CONVERGENCE + IMPLEMENTATION + CONTEXT(source/evidence)",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useIssues, useIssueMutations } from '@/hooks';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { createCliSession, executeInCliSession } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
|
||||
type IssueBoardStatus = Issue['status'];
|
||||
type ToolName = 'claude' | 'codex' | 'gemini';
|
||||
@@ -318,6 +319,8 @@ export function IssueBoardPanel() {
|
||||
resumeKey: issueId,
|
||||
resumeStrategy: autoStart.resumeStrategy,
|
||||
}, projectPath);
|
||||
// Auto-open terminal panel to show execution output
|
||||
useTerminalPanelStore.getState().openTerminal(created.session.sessionKey);
|
||||
} catch (e) {
|
||||
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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';
|
||||
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
|
||||
|
||||
// ========== Types ==========
|
||||
export interface IssueDrawerProps {
|
||||
@@ -43,6 +43,7 @@ const priorityConfig: Record<string, { label: string; variant: 'default' | 'seco
|
||||
|
||||
export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }: IssueDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const openTerminal = useOpenTerminalPanel();
|
||||
const [activeTab, setActiveTab] = useState<TabValue>(initialTab);
|
||||
|
||||
// Reset to initial tab when opening/switching issues
|
||||
@@ -224,9 +225,21 @@ export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }:
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Terminal Tab */}
|
||||
{/* Terminal Tab - Link to Terminal Panel */}
|
||||
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<IssueTerminalTab issueId={issue.id} />
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-sm mb-4">{formatMessage({ id: 'home.terminalPanel.openInPanel' })}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
openTerminal(issue.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type QueueItem,
|
||||
} from '@/lib/api';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
|
||||
type ToolName = 'claude' | 'codex' | 'gemini';
|
||||
type ResumeStrategy = 'nativeResume' | 'promptConcat';
|
||||
@@ -170,6 +171,8 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
|
||||
resumeStrategy,
|
||||
}, projectPath);
|
||||
setLastExecution({ executionId: result.executionId, command: result.command });
|
||||
// Auto-open terminal panel to show execution output
|
||||
useTerminalPanelStore.getState().openTerminal(sessionKey);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
|
||||
import { QueueSendToOrchestrator } from '@/components/issue/queue/QueueSendToOrchestrator';
|
||||
import { IssueTerminalTab } from '@/components/issue/hub/IssueTerminalTab';
|
||||
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
|
||||
import { useIssueQueue } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
@@ -39,6 +39,7 @@ const statusConfig: Record<string, { label: string; variant: 'default' | 'second
|
||||
|
||||
export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const openTerminal = useOpenTerminalPanel();
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('overview');
|
||||
const { data: queue } = useIssueQueue();
|
||||
const itemId = item?.item_id;
|
||||
@@ -257,9 +258,21 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Terminal Tab */}
|
||||
{/* Terminal Tab - Link to Terminal Panel */}
|
||||
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<IssueTerminalTab issueId={issueId} />
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-sm mb-4">{formatMessage({ id: 'home.terminalPanel.openInPanel' })}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
openTerminal(issueId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON Tab */}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Sidebar } from './Sidebar';
|
||||
import { MainContent } from './MainContent';
|
||||
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
|
||||
import { NotificationPanel } from '@/components/notification';
|
||||
import { TerminalPanel } from '@/components/terminal-panel';
|
||||
import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui';
|
||||
import { BackgroundImage } from '@/components/shared/BackgroundImage';
|
||||
import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores';
|
||||
@@ -200,6 +201,9 @@ export function AppShell({
|
||||
onClose={handleNotificationPanelClose}
|
||||
/>
|
||||
|
||||
{/* Terminal Panel - Global Drawer */}
|
||||
<TerminalPanel />
|
||||
|
||||
{/* Ask Question Dialog - For ask_question MCP tool (legacy) */}
|
||||
{currentQuestion && (
|
||||
<AskQuestionDialog
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
// ========================================
|
||||
// TerminalMainArea Component
|
||||
// ========================================
|
||||
// Main display area for the terminal panel.
|
||||
// Shows header with session info, tab switcher (terminal/queue), and
|
||||
// embedded xterm.js terminal with command input. Reuses the xterm rendering
|
||||
// pattern from IssueTerminalTab (init, FitAddon, output streaming, PTY input).
|
||||
// Main content area inside TerminalPanel.
|
||||
// Renders terminal output (xterm.js) or queue view based on panelView.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, Terminal as TerminalIcon } from 'lucide-react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { X, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import type { PanelView } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import {
|
||||
fetchCliSessionBuffer,
|
||||
sendCliSessionText,
|
||||
@@ -26,48 +23,45 @@ import {
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface TerminalMainAreaProps {
|
||||
interface TerminalMainAreaProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
|
||||
const { formatMessage } = useIntl();
|
||||
const panelView = useTerminalPanelStore((s) => s.panelView);
|
||||
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
|
||||
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
|
||||
|
||||
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
||||
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
||||
|
||||
const activeSession = activeTerminalId ? sessionsByKey[activeTerminalId] : null;
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const activeSession: CliSessionMeta | undefined = activeTerminalId
|
||||
? sessions[activeTerminalId]
|
||||
: undefined;
|
||||
|
||||
// ========== xterm State ==========
|
||||
|
||||
// xterm refs
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const lastChunkIndexRef = useRef<number>(0);
|
||||
|
||||
// Input batching refs (same pattern as IssueTerminalTab)
|
||||
// PTY input batching
|
||||
const pendingInputRef = useRef<string>('');
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const activeSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Keep ref in sync with activeTerminalId for closures
|
||||
useEffect(() => {
|
||||
activeSessionKeyRef.current = activeTerminalId;
|
||||
}, [activeTerminalId]);
|
||||
// Command execution
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const flushInput = async () => {
|
||||
const sessionKey = activeSessionKeyRef.current;
|
||||
const flushInput = useCallback(async () => {
|
||||
const sessionKey = activeTerminalId;
|
||||
if (!sessionKey) return;
|
||||
const pending = pendingInputRef.current;
|
||||
pendingInputRef.current = '';
|
||||
@@ -77,18 +71,19 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
} catch {
|
||||
// Ignore transient failures
|
||||
}
|
||||
};
|
||||
}, [activeTerminalId, projectPath]);
|
||||
|
||||
const scheduleFlush = () => {
|
||||
const scheduleFlush = useCallback(() => {
|
||||
if (flushTimerRef.current !== null) return;
|
||||
flushTimerRef.current = window.setTimeout(async () => {
|
||||
flushTimerRef.current = null;
|
||||
await flushInput();
|
||||
}, 30);
|
||||
};
|
||||
}, [flushInput]);
|
||||
|
||||
// ========== xterm Initialization ==========
|
||||
// ========== xterm Lifecycle ==========
|
||||
|
||||
// Init xterm instance
|
||||
useEffect(() => {
|
||||
if (!terminalHostRef.current) return;
|
||||
if (xtermRef.current) return;
|
||||
@@ -107,7 +102,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
|
||||
// Forward keystrokes to backend (batched)
|
||||
term.onData((data) => {
|
||||
if (!activeSessionKeyRef.current) return;
|
||||
if (!activeTerminalId) return;
|
||||
pendingInputRef.current += data;
|
||||
scheduleFlush();
|
||||
});
|
||||
@@ -126,8 +121,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ========== Attach to Active Session ==========
|
||||
|
||||
// Attach to selected session: clear terminal and load buffer
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
@@ -152,8 +146,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
});
|
||||
}, [activeTerminalId, projectPath, setBuffer, clearOutput]);
|
||||
|
||||
// ========== Stream Output Chunks ==========
|
||||
|
||||
// Stream new output chunks into xterm
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
if (!term) return;
|
||||
@@ -169,8 +162,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
lastChunkIndexRef.current = chunks.length;
|
||||
}, [outputChunks, activeTerminalId]);
|
||||
|
||||
// ========== Resize Observer ==========
|
||||
|
||||
// Resize observer -> fit + resize backend
|
||||
useEffect(() => {
|
||||
const host = terminalHostRef.current;
|
||||
const term = xtermRef.current;
|
||||
@@ -179,11 +171,10 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
|
||||
const resize = () => {
|
||||
fitAddon.fit();
|
||||
const sessionKey = activeSessionKeyRef.current;
|
||||
if (sessionKey) {
|
||||
if (activeTerminalId) {
|
||||
void (async () => {
|
||||
try {
|
||||
await resizeCliSession(sessionKey, { cols: term.cols, rows: term.rows }, projectPath || undefined);
|
||||
await resizeCliSession(activeTerminalId, { cols: term.cols, rows: term.rows }, projectPath || undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -194,32 +185,30 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, [projectPath]);
|
||||
}, [activeTerminalId, projectPath]);
|
||||
|
||||
// ========== Execute Command ==========
|
||||
// ========== Command Execution ==========
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!activeTerminalId) return;
|
||||
if (!prompt.trim()) return;
|
||||
if (!activeTerminalId || !prompt.trim()) return;
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
const sessionTool = (activeSession?.tool || 'claude') as 'claude' | 'codex' | 'gemini';
|
||||
try {
|
||||
await executeInCliSession(activeTerminalId, {
|
||||
tool: activeSession?.tool || 'claude',
|
||||
tool: sessionTool,
|
||||
prompt: prompt.trim(),
|
||||
mode: 'analysis',
|
||||
category: 'user',
|
||||
}, projectPath || undefined);
|
||||
setPrompt('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} catch {
|
||||
// Error shown in terminal output
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Ctrl+Enter or Cmd+Enter to execute
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleExecute();
|
||||
@@ -232,92 +221,82 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">
|
||||
{activeSession
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<TerminalIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-semibold text-foreground truncate">
|
||||
{panelView === 'queue'
|
||||
? formatMessage({ id: 'home.terminalPanel.executionQueue' })
|
||||
: activeSession
|
||||
? `${activeSession.tool || 'cli'} - ${activeSession.sessionKey}`
|
||||
: 'Terminal Panel'}
|
||||
</h3>
|
||||
{activeSession?.tool && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
: formatMessage({ id: 'home.terminalPanel.title' })}
|
||||
</span>
|
||||
{activeSession?.workingDir && panelView === 'terminal' && (
|
||||
<span className="text-xs text-muted-foreground truncate hidden sm:inline">
|
||||
{activeSession.workingDir}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 hover:bg-secondary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={panelView}
|
||||
onValueChange={(v) => setPanelView(v as PanelView)}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div className="px-4 pt-2 bg-card">
|
||||
<TabsList>
|
||||
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Content */}
|
||||
{panelView === 'queue' ? (
|
||||
/* Queue View - Placeholder */
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}</p>
|
||||
</div>
|
||||
|
||||
{/* Terminal View */}
|
||||
<TabsContent value="terminal" className="flex-1 flex flex-col min-h-0 mt-0 p-0">
|
||||
{activeTerminalId ? (
|
||||
</div>
|
||||
) : activeTerminalId ? (
|
||||
/* Terminal View */
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* xterm container */}
|
||||
<div className="flex-1 min-h-0 bg-black/90">
|
||||
<div ref={terminalHostRef} className="h-full w-full" />
|
||||
<div className="flex-1 min-h-0">
|
||||
<div
|
||||
ref={terminalHostRef}
|
||||
className="h-full w-full bg-black/90 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Command input area */}
|
||||
{/* Command Input */}
|
||||
<div className="border-t border-border p-3 bg-card">
|
||||
{error && (
|
||||
<div className="text-xs text-destructive mb-2">{error}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter command... (Ctrl+Enter to execute)"
|
||||
placeholder={formatMessage({ id: 'home.terminalPanel.commandPlaceholder' })}
|
||||
className={cn(
|
||||
'flex-1 min-h-[60px] max-h-[120px] p-2 bg-background border border-input rounded-md text-sm resize-none',
|
||||
'w-full min-h-[60px] p-2 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
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={!activeTerminalId || isExecuting || !prompt.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
Execute
|
||||
{formatMessage({ id: 'home.terminalPanel.execute' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No terminal selected</p>
|
||||
<div className="text-center">
|
||||
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.noTerminalSelected' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Queue View (placeholder for Phase 2) */}
|
||||
<TabsContent value="queue" className="flex-1 flex items-center justify-center mt-0 p-0">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">Execution Queue Management</p>
|
||||
<p className="text-xs mt-1">Coming in Phase 2</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,139 +1,122 @@
|
||||
// ========================================
|
||||
// TerminalNavBar Component
|
||||
// ========================================
|
||||
// Left navigation bar for the terminal panel.
|
||||
// Shows queue entry icon at top, separator, and dynamic terminal session icons
|
||||
// with status badges. Reads session data from cliSessionStore and panel state
|
||||
// from terminalPanelStore.
|
||||
// Left-side icon navigation bar (w-16) inside TerminalPanel.
|
||||
// Shows fixed queue entry icon + dynamic terminal icons with status badges.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
ClipboardList,
|
||||
Terminal,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Circle,
|
||||
} from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ClipboardList, Terminal, Loader2, CheckCircle, XCircle, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useCliSessionStore, type CliSessionMeta, type CliSessionOutputChunk } from '@/stores/cliSessionStore';
|
||||
|
||||
// ========== Status Badge Configuration ==========
|
||||
// ========== Status Badge Mapping ==========
|
||||
|
||||
type SessionStatus = 'running' | 'completed' | 'failed' | 'idle';
|
||||
|
||||
interface StatusBadgeConfig {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
colorClass: string;
|
||||
}
|
||||
/** Activity detection threshold in milliseconds */
|
||||
const ACTIVITY_THRESHOLD_MS = 10_000;
|
||||
|
||||
const statusBadgeMap: Record<SessionStatus, StatusBadgeConfig> = {
|
||||
running: { icon: Loader2, colorClass: 'bg-blue-500' },
|
||||
completed: { icon: CheckCircle, colorClass: 'bg-green-500' },
|
||||
failed: { icon: XCircle, colorClass: 'bg-red-500' },
|
||||
idle: { icon: Circle, colorClass: 'bg-gray-500' },
|
||||
};
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Derive a simple session status from the session metadata.
|
||||
* This is a heuristic based on available data - the shellKind and updatedAt fields
|
||||
* provide indirect clues about activity. A more precise status would require
|
||||
* backend support for explicit session state tracking.
|
||||
*/
|
||||
function deriveSessionStatus(_sessionKey: string, _shellKind: string): SessionStatus {
|
||||
// For now, default to idle. In Phase 2 we can refine this
|
||||
// based on active execution tracking from the backend.
|
||||
function getSessionStatus(
|
||||
session: CliSessionMeta | undefined,
|
||||
chunks: CliSessionOutputChunk[] | undefined,
|
||||
): SessionStatus {
|
||||
if (!session) return 'idle';
|
||||
if (!chunks || chunks.length === 0) return 'idle';
|
||||
const lastChunk = chunks[chunks.length - 1];
|
||||
if (Date.now() - lastChunk.timestamp < ACTIVITY_THRESHOLD_MS) return 'running';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
const statusStyles: Record<SessionStatus, string> = {
|
||||
running: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
failed: 'bg-red-500',
|
||||
idle: 'bg-gray-500',
|
||||
};
|
||||
|
||||
const StatusIcon: Record<SessionStatus, React.ComponentType<{ className?: string }>> = {
|
||||
running: Loader2,
|
||||
completed: CheckCircle,
|
||||
failed: XCircle,
|
||||
idle: Circle,
|
||||
};
|
||||
|
||||
export function TerminalNavBar() {
|
||||
const panelView = useTerminalPanelStore((s) => s.panelView);
|
||||
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
|
||||
const terminalOrder = useTerminalPanelStore((s) => s.terminalOrder);
|
||||
const setActiveTerminal = useTerminalPanelStore((s) => s.setActiveTerminal);
|
||||
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
|
||||
const setActiveTerminal = useTerminalPanelStore((s) => s.setActiveTerminal);
|
||||
|
||||
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||
|
||||
// Build ordered list of sessions that exist in the store
|
||||
const orderedSessions = useMemo(() => {
|
||||
return terminalOrder
|
||||
.map((key) => sessionsByKey[key])
|
||||
.filter(Boolean);
|
||||
}, [terminalOrder, sessionsByKey]);
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleQueueClick = () => {
|
||||
setPanelView('queue');
|
||||
};
|
||||
|
||||
const handleTerminalClick = (sessionKey: string) => {
|
||||
setPanelView('terminal');
|
||||
setActiveTerminal(sessionKey);
|
||||
setPanelView('terminal');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-16 flex-shrink-0 flex flex-col border-r border-border bg-muted/30">
|
||||
{/* Queue entry icon */}
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<div className="w-16 flex-shrink-0 border-r border-border bg-card flex flex-col items-center py-2">
|
||||
{/* Queue Entry - Fixed at top */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQueueClick}
|
||||
className={cn(
|
||||
'w-10 h-10 flex items-center justify-center rounded-md transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
panelView === 'queue' && 'bg-accent text-accent-foreground'
|
||||
'w-10 h-10 rounded-md flex items-center justify-center transition-colors hover:bg-accent',
|
||||
panelView === 'queue' && 'bg-accent'
|
||||
)}
|
||||
title="Execution Queue"
|
||||
onClick={handleQueueClick}
|
||||
title={formatMessage({ id: 'home.terminalPanel.executionQueue' })}
|
||||
>
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
<div className="w-8 border-t border-border my-2" />
|
||||
|
||||
{/* Terminal session icons (scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-1">
|
||||
{orderedSessions.map((session) => {
|
||||
const isActive = activeTerminalId === session.sessionKey && panelView === 'terminal';
|
||||
const status = deriveSessionStatus(session.sessionKey, session.shellKind);
|
||||
const badge = statusBadgeMap[status];
|
||||
const BadgeIcon = badge.icon;
|
||||
{/* Dynamic Terminal Icons */}
|
||||
<div className="flex-1 overflow-y-auto flex flex-col items-center gap-1 w-full px-1">
|
||||
{terminalOrder.map((sessionKey) => {
|
||||
const session = sessions[sessionKey];
|
||||
const status = getSessionStatus(session, outputChunks[sessionKey]);
|
||||
const StatusIconComp = StatusIcon[status];
|
||||
const isActive = activeTerminalId === sessionKey && panelView === 'terminal';
|
||||
const label = session
|
||||
? `${session.tool || 'cli'} - ${session.sessionKey}`
|
||||
: sessionKey;
|
||||
|
||||
return (
|
||||
<div key={session.sessionKey} className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTerminalClick(session.sessionKey)}
|
||||
key={sessionKey}
|
||||
className={cn(
|
||||
'relative w-10 h-10 flex items-center justify-center rounded-md transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive && 'bg-accent text-accent-foreground'
|
||||
'relative w-10 h-10 rounded-md flex items-center justify-center transition-colors hover:bg-accent',
|
||||
isActive && 'bg-accent'
|
||||
)}
|
||||
title={`${session.tool || 'cli'} - ${session.sessionKey}`}
|
||||
onClick={() => handleTerminalClick(sessionKey)}
|
||||
title={label}
|
||||
>
|
||||
<Terminal className="w-5 h-5" />
|
||||
{/* Status badge overlay */}
|
||||
<Terminal className="h-5 w-5 text-muted-foreground" />
|
||||
|
||||
{/* Status Badge - bottom-right overlay */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0.5 right-0.5 w-3.5 h-3.5 rounded-full flex items-center justify-center',
|
||||
badge.colorClass
|
||||
statusStyles[status]
|
||||
)}
|
||||
>
|
||||
<BadgeIcon
|
||||
<StatusIconComp
|
||||
className={cn(
|
||||
'w-2 h-2 text-white',
|
||||
'h-2 w-2 text-white',
|
||||
status === 'running' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// ========================================
|
||||
// TerminalPanel Component
|
||||
// ========================================
|
||||
// Right-side overlay panel for terminal monitoring.
|
||||
// Follows the IssueDrawer pattern: fixed overlay + translate-x slide animation.
|
||||
// Contains TerminalNavBar (left icon strip) and TerminalMainArea (main content).
|
||||
// All state is read from terminalPanelStore - no props needed.
|
||||
// Right-side sliding panel for terminal monitoring.
|
||||
// Follows IssueDrawer pattern: overlay + fixed panel + translate-x animation.
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -12,8 +10,6 @@ import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { TerminalNavBar } from './TerminalNavBar';
|
||||
import { TerminalMainArea } from './TerminalMainArea';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalPanel() {
|
||||
const isPanelOpen = useTerminalPanelStore((s) => s.isPanelOpen);
|
||||
const closePanel = useTerminalPanelStore((s) => s.closePanel);
|
||||
@@ -32,10 +28,6 @@ export function TerminalPanel() {
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isPanelOpen, handleClose]);
|
||||
|
||||
if (!isPanelOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
@@ -51,19 +43,17 @@ export function TerminalPanel() {
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50',
|
||||
'flex flex-row transition-transform duration-300 ease-in-out',
|
||||
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-row transition-transform duration-300 ease-in-out',
|
||||
isPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Terminal Panel"
|
||||
style={{ minWidth: '400px', maxWidth: '800px' }}
|
||||
>
|
||||
{/* Left navigation bar */}
|
||||
{/* Left: Icon Navigation */}
|
||||
<TerminalNavBar />
|
||||
|
||||
{/* Main display area */}
|
||||
{/* Right: Main Content */}
|
||||
<TerminalMainArea onClose={handleClose} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// ========================================
|
||||
// Terminal Panel - Barrel Exports
|
||||
// Terminal Panel - Barrel Export
|
||||
// ========================================
|
||||
// Re-exports all terminal panel components for convenient imports.
|
||||
|
||||
export { TerminalPanel } from './TerminalPanel';
|
||||
export { TerminalNavBar } from './TerminalNavBar';
|
||||
|
||||
@@ -105,5 +105,22 @@
|
||||
},
|
||||
"project": {
|
||||
"features": "features"
|
||||
},
|
||||
"terminalPanel": {
|
||||
"title": "Terminal Monitor",
|
||||
"executionQueue": "Execution Queue",
|
||||
"executionQueueDesc": "Execution Queue Management",
|
||||
"executionQueuePhase2": "Coming in Phase 2",
|
||||
"noTerminalSelected": "No terminal selected",
|
||||
"selectTerminalHint": "Select a terminal from the sidebar",
|
||||
"commandPlaceholder": "Enter command... (Ctrl+Enter to execute)",
|
||||
"execute": "Execute",
|
||||
"openInPanel": "Open in Terminal Panel",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"idle": "Idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,5 +105,22 @@
|
||||
},
|
||||
"project": {
|
||||
"features": "个功能"
|
||||
},
|
||||
"terminalPanel": {
|
||||
"title": "终端监控",
|
||||
"executionQueue": "执行队列",
|
||||
"executionQueueDesc": "执行队列管理",
|
||||
"executionQueuePhase2": "将在 Phase 2 实现",
|
||||
"noTerminalSelected": "未选择终端",
|
||||
"selectTerminalHint": "从侧边栏选择一个终端",
|
||||
"commandPlaceholder": "输入命令... (Ctrl+Enter 执行)",
|
||||
"execute": "执行",
|
||||
"openInPanel": "在终端面板中查看",
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"idle": "空闲"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,3 +145,11 @@ export const selectActiveTerminalId = (state: TerminalPanelStore) => state.activ
|
||||
export const selectPanelView = (state: TerminalPanelStore) => state.panelView;
|
||||
export const selectTerminalOrder = (state: TerminalPanelStore) => state.terminalOrder;
|
||||
export const selectTerminalCount = (state: TerminalPanelStore) => state.terminalOrder.length;
|
||||
|
||||
// ========== Convenience Hooks ==========
|
||||
|
||||
/** Hook that returns the openTerminal action for use in event handlers */
|
||||
export function useOpenTerminalPanel() {
|
||||
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
|
||||
return openTerminal;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user