diff --git a/cc-haha/desktop/src/components/tasks/TaskRightPanels.tsx b/cc-haha/desktop/src/components/tasks/TaskRightPanels.tsx new file mode 100644 index 0000000..8268e4c --- /dev/null +++ b/cc-haha/desktop/src/components/tasks/TaskRightPanels.tsx @@ -0,0 +1,558 @@ +// desktop/src/components/tasks/TaskRightPanels.tsx +// +// Right-side panels for the task workspace, switched by status: +// running / awaiting_approval → ExecutionFeedbackPanel (wireframe §8) +// completed → DeliveryResultPanel (wireframe §10) +// draft / configuring → falls back to TaskCardPanel (in HeicodeTasksHome) +// +// Plus TaskDetailDrawer for usage / resources / approvals / security +// (wireframe §audit + 10-frontend §任务用量与审计). All data is mock — +// shapes match what mcp-server / agent-manager are expected to deliver +// (see Heicode-对接进度与待办.md §7.5). + +import { useEffect, useState } from 'react' +import { useTranslation } from '../../i18n' +import { openManagerLink } from '../../lib/managerLink' +import { + useHeicodeTaskStore, + type Artifact, + type Deliverable, + type ExecutionEvent, + type HeicodeTask, + type QualityCheck, + type SkToolCall, + type SubStep, + type TaskAudit, +} from '../../stores/heicodeTaskStore' + +// ─── Execution feedback (Slice 8) ───────────────────────────── + +export function ExecutionFeedbackPanel({ task }: { task: HeicodeTask }) { + const t = useTranslation() + const ex = task.execution + const loadExecution = useHeicodeTaskStore((s) => s.loadExecution) + + // Slice 12 lazy-load: when the task is real (live mode + non-seed id), + // fetch /api/user/tasks/{id}/execution; the store gate skips this for + // mock seed rows so the demo data stays untouched. + useEffect(() => { + void loadExecution(task.id) + }, [task.id, loadExecution]) + + return ( + + ) +} + +const SUB_STEP_TONE: Record = { + done: 'border-[var(--color-success)]/40 bg-[var(--color-success)]/10 text-[var(--color-success)]', + running: 'border-[var(--color-primary)]/40 bg-[var(--color-primary)]/10 text-[var(--color-primary)]', + waiting: 'border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-tertiary)]', + failed: 'border-[var(--color-error)]/40 bg-[var(--color-error)]/10 text-[var(--color-error)]', + skipped: 'border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-tertiary)]', +} + +const SUB_STEP_DOT: Record = { + done: 'bg-[var(--color-success)]', + running: 'bg-[var(--color-primary)] animate-pulse', + waiting: 'bg-[var(--color-text-tertiary)]/40', + failed: 'bg-[var(--color-error)]', + skipped: 'bg-[var(--color-text-tertiary)]/40', +} + +function SubStepRow({ step }: { step: SubStep }) { + const t = useTranslation() + return ( + + + + + {step.label} + + {t(`tasks.subStep.${step.status}` as const)} + + + {step.caption ? ( + + {step.caption} + + ) : null} + + + ) +} + +const SK_CALL_TONE: Record = { + running: 'text-[var(--color-primary)]', + ok: 'text-[var(--color-success)]', + failed: 'text-[var(--color-error)]', +} + +function SkCallRow({ call }: { call: SkToolCall }) { + const t = useTranslation() + return ( + + + + {call.tool} + + {call.caption ? ( + + {call.caption} + + ) : null} + + + {t(`tasks.skCall.${call.status}` as const)} + + + ) +} + +function EventRow({ ev }: { ev: ExecutionEvent }) { + return ( + + + {formatHm(ev.at)} + + {ev.text} + + ) +} + +const ARTIFACT_DOT: Record = { + doc: 'bg-[var(--color-secondary)]', + api: 'bg-[var(--color-primary)]', + diff: 'bg-[var(--color-warning)]', + report: 'bg-[var(--color-success)]', +} + +function ArtifactChip({ a }: { a: Artifact }) { + return ( + + + {a.label} + + ) +} + +// ─── Delivery result (Slice 9) ──────────────────────────────── + +export function DeliveryResultPanel({ task }: { task: HeicodeTask }) { + const t = useTranslation() + const d = task.delivery + const loadDelivery = useHeicodeTaskStore((s) => s.loadDelivery) + + // Slice 13 lazy-load on mount; gate inside the store skips for seed rows. + useEffect(() => { + void loadDelivery(task.id) + }, [task.id, loadDelivery]) + + if (!d) return null + + return ( + + ) +} + +const DELIVERABLE_KIND_TONE: Record = { + 'product-spec': 'border-[var(--color-secondary)]/30', + 'code-diff': 'border-[var(--color-warning)]/30', + 'test-env': 'border-[var(--color-success)]/30', + 'prod-env': 'border-[var(--color-error)]/30', +} + +function DeliverableCard({ d }: { d: Deliverable }) { + return ( + + {d.label} + {d.hint ? ( + {d.hint} + ) : null} + + {d.primary_action ? ( + openManagerLink(d.primary_action!.deeplink)} + className="rounded-[var(--radius-sm)] border border-[var(--color-primary)]/30 bg-[var(--color-primary)]/10 px-2 py-0.5 text-[10px] text-[var(--color-primary)] hover:bg-[var(--color-primary)]/20" + title={d.primary_action.deeplink} + > + {d.primary_action.label} + + ) : null} + {d.secondary_action ? ( + openManagerLink(d.secondary_action!.deeplink)} + className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]" + title={d.secondary_action.deeplink} + > + {d.secondary_action.label} + + ) : null} + + + ) +} + +const QUALITY_TONE: Record = { + pass: 'text-[var(--color-success)]', + fixed: 'text-[var(--color-warning)]', + pending: 'text-[var(--color-text-tertiary)]', + failed: 'text-[var(--color-error)]', +} + +function QualityRow({ q }: { q: QualityCheck }) { + const t = useTranslation() + return ( + + + {q.label} + {q.detail ? ( + + {q.detail} + + ) : null} + + + {t(`tasks.quality.${q.status}` as const)} + + + ) +} + +// ─── Detail drawer (Slice 10) ───────────────────────────────── + +type DetailTab = 'usage' | 'resources' | 'approvals' | 'security' + +export function TaskDetailDrawer({ + task, + open, + onClose, +}: { + task: HeicodeTask + open: boolean + onClose: () => void +}) { + const t = useTranslation() + const [tab, setTab] = useState('usage') + const audit = task.audit + const loadAudit = useHeicodeTaskStore((s) => s.loadAudit) + + // Slice 14 lazy-load. Re-fetches on tab change so heavy lists (logs, + // approvals) come back filtered server-side rather than dumping the + // whole bundle. The store gate skips for seed rows. + useEffect(() => { + if (!open) return + void loadAudit(task.id, tab) + }, [open, task.id, tab, loadAudit]) + + if (!open) return null + + return ( + + + + + + + {t('tasks.detail.title')} + + + {task.name} + + + + {t('tasks.detail.close')} + + + + + {(['usage', 'resources', 'approvals', 'security'] as DetailTab[]).map((k) => ( + setTab(k)} + className={`relative px-3 py-2 text-xs ${ + tab === k + ? 'text-[var(--color-text-primary)]' + : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]' + }`} + > + {t(`tasks.detail.tab.${k}` as const)} + {tab === k ? ( + + ) : null} + + ))} + + + + {!audit ? ( + {t('tasks.detail.empty')} + ) : ( + + )} + + + + ) +} + +function DrawerBody({ tab, audit }: { tab: DetailTab; audit: TaskAudit }) { + const t = useTranslation() + if (tab === 'usage') { + if (audit.usage.length === 0) + return {t('tasks.detail.empty')} + return ( + + {audit.usage.map((u) => ( + + + {u.caption} + + {u.model} + · + {formatHm(u.at)} + + + + {u.cost} + + + ))} + + ) + } + if (tab === 'resources') { + if (audit.resources.length === 0) + return {t('tasks.detail.empty')} + return ( + + {audit.resources.map((r) => ( + + {r.resource} + + {r.action} + · + {r.role} + · + {formatHm(r.at)} + + + ))} + + ) + } + if (tab === 'approvals') { + if (audit.approvals.length === 0) + return {t('tasks.detail.empty')} + return ( + + {audit.approvals.map((a) => ( + + + {a.operation} + + {formatHm(a.at)} + {a.ttl_minutes + ? ' · ' + t('tasks.detail.approval.ttl').replace('{minutes}', String(a.ttl_minutes)) + : ''} + + + + {t(`tasks.detail.approval.${a.decision}` as const)} + + + ))} + + ) + } + // security + if (audit.security.length === 0) + return {t('tasks.detail.empty')} + return ( + + {audit.security.map((s) => ( + + + {t(`tasks.detail.security.${s.kind}` as const)} + · + {formatHm(s.at)} + + + {s.text} + + + ))} + + ) +} + +// ─── Shared bits ────────────────────────────────────────────── + +function PanelHeader({ + title, + rightCaption, +}: { + title: string + rightCaption?: string +}) { + return ( + + + {title} + + {rightCaption ? ( + {rightCaption} + ) : null} + + ) +} + +function Section({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( + + + {label} + + {children} + + ) +} + +function formatHm(ts: number): string { + const d = new Date(ts) + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + return `${hh}:${mm}` +} diff --git a/cc-haha/desktop/src/i18n/locales/en.ts b/cc-haha/desktop/src/i18n/locales/en.ts index 1dea9a7..1c88965 100644 --- a/cc-haha/desktop/src/i18n/locales/en.ts +++ b/cc-haha/desktop/src/i18n/locales/en.ts @@ -1020,6 +1020,51 @@ export const en = { 'tasks.status.completed': 'Completed', 'tasks.status.failed': 'Failed', 'tasks.status.paused': 'Paused', + + // Slice 8: execution feedback panel (wireframe §8) + 'tasks.execution.title': 'Execution feedback', + 'tasks.execution.subSteps': 'Current Agnet sub-steps', + 'tasks.execution.skTools': 'Active SK tool calls', + 'tasks.execution.events': 'Latest events', + 'tasks.execution.artifacts': 'Current artifacts', + 'tasks.execution.spend': 'This run', + 'tasks.execution.openDetail': 'View detail', + 'tasks.execution.empty': 'No execution feedback yet — waiting for Agnet to deploy.', + 'tasks.subStep.done': 'Done', + 'tasks.subStep.running': 'Running', + 'tasks.subStep.waiting': 'Waiting', + 'tasks.subStep.failed': 'Failed', + 'tasks.subStep.skipped': 'Skipped', + 'tasks.skCall.running': 'Running', + 'tasks.skCall.ok': 'OK', + 'tasks.skCall.failed': 'Failed', + + // Slice 9: delivery result panel (wireframe §10) + 'tasks.delivery.title': 'Delivery', + 'tasks.delivery.deliverables': 'Deliverables', + 'tasks.delivery.quality': 'Quality results', + 'tasks.delivery.nextActions': 'Next actions', + 'tasks.quality.pass': 'Pass', + 'tasks.quality.fixed': 'Fixed', + 'tasks.quality.pending': 'Pending', + 'tasks.quality.failed': 'Failed', + + // Slice 10: detail drawer + 'tasks.detail.open': 'View detail', + 'tasks.detail.close': 'Close', + 'tasks.detail.title': 'Task detail', + 'tasks.detail.tab.usage': 'Model usage', + 'tasks.detail.tab.resources': 'Resource access', + 'tasks.detail.tab.approvals': 'Approvals', + 'tasks.detail.tab.security': 'Security log', + 'tasks.detail.empty': 'No records yet.', + 'tasks.detail.approval.approve': 'Approved', + 'tasks.detail.approval.reject': 'Rejected', + 'tasks.detail.approval.expired': 'Expired', + 'tasks.detail.approval.ttl': '{minutes} min TTL', + 'tasks.detail.security.lease': 'Short-lived credential issued', + 'tasks.detail.security.revoke': 'Credential revoked', + 'tasks.detail.security.rotate': 'Key rotated', 'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.', 'login.tags.recommended': 'Recommended', 'login.tags.comingSoon': 'Coming soon', diff --git a/cc-haha/desktop/src/i18n/locales/zh.ts b/cc-haha/desktop/src/i18n/locales/zh.ts index 4140f7d..41dbea4 100644 --- a/cc-haha/desktop/src/i18n/locales/zh.ts +++ b/cc-haha/desktop/src/i18n/locales/zh.ts @@ -1022,6 +1022,51 @@ export const zh: Record = { 'tasks.status.completed': '已完成', 'tasks.status.failed': '失败', 'tasks.status.paused': '已暂停', + + // ─── Slice 8: 执行反馈面板(wireframe §8)────────────── + 'tasks.execution.title': '执行反馈', + 'tasks.execution.subSteps': '当前 Agnet 子环节', + 'tasks.execution.skTools': '正在调用的能力', + 'tasks.execution.events': '最新动态', + 'tasks.execution.artifacts': '当前产物', + 'tasks.execution.spend': '本次消耗', + 'tasks.execution.openDetail': '查看详情', + 'tasks.execution.empty': '尚无执行反馈,等待 Agnet 部署完成。', + 'tasks.subStep.done': '已完成', + 'tasks.subStep.running': '进行中', + 'tasks.subStep.waiting': '等待', + 'tasks.subStep.failed': '失败', + 'tasks.subStep.skipped': '已跳过', + 'tasks.skCall.running': '调用中', + 'tasks.skCall.ok': '完成', + 'tasks.skCall.failed': '失败', + + // ─── Slice 9: 交付结果面板(wireframe §10)───────────── + 'tasks.delivery.title': '交付结果', + 'tasks.delivery.deliverables': '交付物', + 'tasks.delivery.quality': '质量结果', + 'tasks.delivery.nextActions': '继续动作', + 'tasks.quality.pass': '通过', + 'tasks.quality.fixed': '已修复', + 'tasks.quality.pending': '待确认', + 'tasks.quality.failed': '失败', + + // ─── Slice 10: 任务详情抽屉(用量 / 资源 / 审批 / 安全) + 'tasks.detail.open': '查看详情', + 'tasks.detail.close': '关闭', + 'tasks.detail.title': '任务详情', + 'tasks.detail.tab.usage': '模型用量', + 'tasks.detail.tab.resources': '资源访问', + 'tasks.detail.tab.approvals': '审批记录', + 'tasks.detail.tab.security': '安全记录', + 'tasks.detail.empty': '暂无记录。', + 'tasks.detail.approval.approve': '批准', + 'tasks.detail.approval.reject': '拒绝', + 'tasks.detail.approval.expired': '已过期', + 'tasks.detail.approval.ttl': '{minutes} 分钟有效', + 'tasks.detail.security.lease': '短期凭证签发', + 'tasks.detail.security.revoke': '凭证撤销', + 'tasks.detail.security.rotate': '密钥轮换', 'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。', 'login.tags.recommended': '推荐', 'login.tags.comingSoon': '即将开放', diff --git a/cc-haha/desktop/src/lib/heicodeTasksApi.ts b/cc-haha/desktop/src/lib/heicodeTasksApi.ts new file mode 100644 index 0000000..3dd2b17 --- /dev/null +++ b/cc-haha/desktop/src/lib/heicodeTasksApi.ts @@ -0,0 +1,115 @@ +// desktop/src/lib/heicodeTasksApi.ts +// +// Thin typed client for the local /api/heicode-tasks/* proxy, which forwards +// to mcp-server §6 task-orchestration endpoints (Heicode-接口契约文档.md §6). +// +// Field shapes are the canonical HeicodeTask model from heicodeTaskStore.ts. +// We keep the API surface tiny (5 methods, 1 per upstream endpoint) so the +// store can swap mock seeds for real data without touching component code. + +import type { + DeliveryResult, + ExecutionState, + HeicodeTask, + TaskAudit, +} from '../stores/heicodeTaskStore' + +const BASE = '/api/heicode-tasks' + +interface Envelope { + success: boolean + message?: string + data?: T +} + +async function request( + method: 'GET' | 'POST', + path: string, + body?: unknown, +): Promise { + const res = await fetch(BASE + path, { + method, + headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + let env: Envelope + try { + env = (await res.json()) as Envelope + } catch { + throw new Error(`heicode-tasks ${method} ${path}: HTTP ${res.status} (non-JSON)`) + } + if (!res.ok || env.success === false) { + throw new Error(env.message ?? `HTTP ${res.status}`) + } + if (env.data === undefined) { + throw new Error('heicode-tasks: response missing data') + } + return env.data +} + +export const heicodeTasksApi = { + submitIntent(intent: string, name?: string): Promise { + return request('POST', '/intent', { intent, ...(name && { name }) }) + }, + + list(opts?: { + status?: HeicodeTask['status'] + limit?: number + offset?: number + }): Promise<{ items: HeicodeTask[]; total: number; offset: number; limit: number }> { + const qs = new URLSearchParams() + if (opts?.status) qs.set('status', opts.status) + if (opts?.limit !== undefined) qs.set('limit', String(opts.limit)) + if (opts?.offset !== undefined) qs.set('offset', String(opts.offset)) + const suffix = qs.toString() ? '?' + qs.toString() : '' + return request('GET', '/list' + suffix) + }, + + get(id: string): Promise { + return request('GET', '/' + encodeURIComponent(id)) + }, + + answer(id: string, questionId: string, optionId: string): Promise { + return request( + 'POST', + '/' + encodeURIComponent(id) + '/answer', + { question_id: questionId, option_id: optionId }, + ) + }, + + appendMessage(id: string, text: string): Promise { + return request( + 'POST', + '/' + encodeURIComponent(id) + '/messages', + { text }, + ) + }, + + // Slice 12 — wireframe §8 execution feedback (mcp-server §7.8.2 端点 1) + execution(id: string): Promise { + return request( + 'GET', + '/' + encodeURIComponent(id) + '/execution', + ) + }, + + // Slice 13 — wireframe §10 delivery result (mcp-server §7.8.2 端点 2) + delivery(id: string): Promise { + return request( + 'GET', + '/' + encodeURIComponent(id) + '/delivery', + ) + }, + + // Slice 14 — wireframe §audit (mcp-server §7.8.2 端点 3, supports ?tab=) + audit( + id: string, + tab?: 'usage' | 'resources' | 'approvals' | 'security', + ): Promise { + const qs = tab ? '?tab=' + tab : '' + return request( + 'GET', + '/' + encodeURIComponent(id) + '/audit' + qs, + ) + }, +} diff --git a/cc-haha/desktop/src/lib/managerLink.ts b/cc-haha/desktop/src/lib/managerLink.ts new file mode 100644 index 0000000..312f5cf --- /dev/null +++ b/cc-haha/desktop/src/lib/managerLink.ts @@ -0,0 +1,31 @@ +// desktop/src/lib/managerLink.ts +// +// Resolve a Heicode Manager deeplink (path-only or absolute URL) into a real +// URL and open it in the user's browser. +// +// Why a helper: mcp-server team's §7.8.5 reminder — task-card / deliverable +// `deeplink` fields are intentionally path-only (`/manager/resources?from=task`, +// `/agnet/deployments/portal-test`); the Manager domain (currently +// `code.xinghanlab.com`) may change. Keep all path→URL composition here so +// flipping the base URL is a one-line change. +// +// Future hook: read base from a runtime setting / env once Manager domain +// becomes user-configurable. For now hard-coded to production. + +const MANAGER_BASE_URL = 'https://code.xinghanlab.com' + +export function resolveManagerUrl(deeplink: string): string { + if (!deeplink) return MANAGER_BASE_URL + if (/^https?:\/\//i.test(deeplink)) return deeplink + const path = deeplink.startsWith('/') ? deeplink : '/' + deeplink + return MANAGER_BASE_URL + path +} + +export function openManagerLink(deeplink: string): void { + const url = resolveManagerUrl(deeplink) + void import('@tauri-apps/plugin-shell') + .then(({ open }) => open(url)) + .catch(() => { + window.open(url, '_blank', 'noopener,noreferrer') + }) +} diff --git a/cc-haha/desktop/src/pages/HeicodeTasksHome.tsx b/cc-haha/desktop/src/pages/HeicodeTasksHome.tsx index a7f5f74..dc1cf50 100644 --- a/cc-haha/desktop/src/pages/HeicodeTasksHome.tsx +++ b/cc-haha/desktop/src/pages/HeicodeTasksHome.tsx @@ -4,7 +4,7 @@ // + 最近任务) 和 §3 (追问对话 + 任务卡)。Slice 7 是 mock skeleton, // 数据来自 useHeicodeTaskStore 的 seed。 -import { useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from '../i18n' import { useHeicodeTaskStore, @@ -13,6 +13,12 @@ import { type HeicodeTask, type HeicodeTaskStatus, } from '../stores/heicodeTaskStore' +import { + DeliveryResultPanel, + ExecutionFeedbackPanel, + TaskDetailDrawer, +} from '../components/tasks/TaskRightPanels' +import { openManagerLink } from '../lib/managerLink' const STATUS_TONE: Record = { draft: @@ -37,6 +43,12 @@ export function HeicodeTasksHome() { const currentTaskId = useHeicodeTaskStore((s) => s.currentTaskId) const submitIntent = useHeicodeTaskStore((s) => s.submitIntent) const openTask = useHeicodeTaskStore((s) => s.openTask) + const loadTasks = useHeicodeTaskStore((s) => s.loadTasks) + + // Hydrate from mcp-server §6 on mount; fail-soft (mock seeds stay visible). + useEffect(() => { + void loadTasks() + }, [loadTasks]) const currentTask = useMemo( () => (currentTaskId ? tasks.find((x) => x.id === currentTaskId) ?? null : null), @@ -180,14 +192,46 @@ function Home({ {/* Manager footer hint */} -
{t('tasks.detail.empty')}