feat(client): slice 7 — task driving cabin (intent → followups → task card)

Per docs/product-package/10-frontend-detail-spec.md and the wireframes
in 11-product-prototype-wireframes.md §2-§3, the client's first-class
surface is no longer "code-companion empty state" but a Heicode task
driving cabin: input an idea, answer Heicode's follow-ups, see the
auto-generated task card, hand off to Manager for resource binding /
deployment.

This slice ships the surface as a mock skeleton — the data layer
(useHeicodeTaskStore) is seeded with two demo tasks so the wireframe
can be reviewed end-to-end before backend wiring lands.

New files:
  - stores/heicodeTaskStore.ts
      HeicodeTask shape (id / name / status / status_caption / thread /
      card), HeicodeTaskStatus enum, ChatTurn (user | heicode), and
      FollowupQuestion (with optional 'high-risk' option flag).
      Actions: submitIntent, openTask, closeTask, answerFollowup,
      appendMessage. Two seeded tasks ("小团队任务管理 SaaS" running
      with full task card; "企业微信通知集成" awaiting approval).

  - pages/HeicodeTasksHome.tsx
      Two layouts behind a single route. When currentTaskId is null
      we render the Home (wireframe §2):
        - Header line "当前任务:未选择"
        - Big intent prompt + textarea + Send (⌘/Ctrl+Enter shortcut)
        - Recent tasks grid (status pill + caption + relative time)
        - Manager auxiliary footer hint
      When a task is open we render the Workspace (wireframe §3):
        - Header with back button + task name + status pill
        - Conversation thread (user bubble right-aligned, Heicode
          left-aligned with "H" avatar; follow-up questions render
          as chip groups with high-risk dot indicators)
        - Reply textarea at bottom
        - Right-side TaskCardPanel (lg breakpoint+) with goal /
          scope / generated-artifacts / Manager actions / footer
          buttons (修改目标 / 去 Manager 准备)

Plumbing:
  - tabStore.ts: HEICODE_TASKS_TAB_ID + 'heicode_tasks' TabType,
    treated like settings/scheduled in dedupe rules
  - Sidebar.tsx: new "我的任务" entry between "新建会话" and
    "定时任务" with a target icon
  - ContentRouter.tsx: route 'heicode_tasks' → HeicodeTasksHome
  - i18n: tasks.* (~24 keys per locale) + sidebar.heicodeTasks

Backend wiring TODO: when mcp-server publishes the task-orchestration
contract, swap submitIntent / answerFollowup / appendMessage for real
calls and keep the same shapes. Status updates can come in via SSE
or polling and be merged onto useHeicodeTaskStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:42:42 +08:00
co-authored by Claude Opus 4.7
parent f08119fd33
commit a268079751
7 changed files with 815 additions and 4 deletions
@@ -5,6 +5,7 @@ import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks' import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Settings } from '../../pages/Settings' import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings' import { TerminalSettings } from '../../pages/TerminalSettings'
import { HeicodeTasksHome } from '../../pages/HeicodeTasksHome'
export function ContentRouter() { export function ContentRouter() {
const activeTabId = useTabStore((s) => s.activeTabId) const activeTabId = useTabStore((s) => s.activeTabId)
@@ -19,6 +20,8 @@ export function ContentRouter() {
page = <Settings /> page = <Settings />
} else if (activeTabType === 'scheduled') { } else if (activeTabType === 'scheduled') {
page = <ScheduledTasks /> page = <ScheduledTasks />
} else if (activeTabType === 'heicode_tasks') {
page = <HeicodeTasksHome />
} else if (activeTabType !== 'terminal') { } else if (activeTabType !== 'terminal') {
page = <ActiveSession /> page = <ActiveSession />
} }
@@ -5,7 +5,12 @@ import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter' import { ProjectFilter } from './ProjectFilter'
import { ConfirmDialog } from '../shared/ConfirmDialog' import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session' import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import {
useTabStore,
SETTINGS_TAB_ID,
SCHEDULED_TAB_ID,
HEICODE_TASKS_TAB_ID,
} from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore' import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
@@ -186,6 +191,19 @@ export function Sidebar() {
> >
{t('sidebar.newSession')} {t('sidebar.newSession')}
</NavItem> </NavItem>
<NavItem
active={activeTabId === HEICODE_TASKS_TAB_ID}
collapsed={!sidebarOpen}
label={t('sidebar.heicodeTasks')}
onClick={() =>
useTabStore
.getState()
.openTab(HEICODE_TASKS_TAB_ID, t('sidebar.heicodeTasks'), 'heicode_tasks')
}
icon={<span className="material-symbols-outlined text-[18px]">target</span>}
>
{t('sidebar.heicodeTasks')}
</NavItem>
<NavItem <NavItem
active={activeTabId === SCHEDULED_TAB_ID} active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!sidebarOpen} collapsed={!sidebarOpen}
+31
View File
@@ -19,6 +19,7 @@ export const en = {
// ─── Sidebar ────────────────────────────────────── // ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session', 'sidebar.newSession': 'New session',
'sidebar.heicodeTasks': 'My tasks',
'sidebar.scheduled': 'Scheduled', 'sidebar.scheduled': 'Scheduled',
'sidebar.terminal': 'Terminal', 'sidebar.terminal': 'Terminal',
'sidebar.settings': 'Settings', 'sidebar.settings': 'Settings',
@@ -989,6 +990,36 @@ export const en = {
'approval.action.postpone': 'Remind me later', 'approval.action.postpone': 'Remind me later',
'approval.action.approve': 'Approve for {minutes} min', 'approval.action.approve': 'Approve for {minutes} min',
'approval.queue.more': '{count} more pending approvals queued', 'approval.queue.more': '{count} more pending approvals queued',
// ─── Task driving cabin (wireframe §2 / §3) ────────────
'tasks.home.currentLabel': 'Current task',
'tasks.home.currentNone': 'None selected',
'tasks.home.intentPrompt': 'What idea do you want to turn into shippable software?',
'tasks.home.intentPlaceholder': 'A small-team task SaaS — login, tasks, comments, notifications, deploy to Azure.',
'tasks.home.intentHint': '⌘/Ctrl + Enter to send',
'tasks.home.send': 'Send',
'tasks.home.recentTitle': 'Recent tasks',
'tasks.home.recentEmpty': 'No tasks yet. Start your first from the box above.',
'tasks.home.managerHint': 'Manager auxiliary: bind resources, deploy Agnet, view status.',
'tasks.workspace.back': 'Back',
'tasks.workspace.replyPlaceholder': 'Continue with more details…',
'tasks.card.title': 'Task card',
'tasks.card.goal': 'Goal',
'tasks.card.scope': 'First-cut scope',
'tasks.card.generated': 'Heicode will generate',
'tasks.card.managerActions': 'Needs Manager assist',
'tasks.card.editGoal': 'Edit goal',
'tasks.card.openManager': 'Open Manager',
'tasks.status.draft': 'Draft',
'tasks.status.configuring': 'Configuring',
'tasks.status.running': 'Running',
'tasks.status.awaiting_approval': 'Awaiting approval',
'tasks.status.completed': 'Completed',
'tasks.status.failed': 'Failed',
'tasks.status.paused': 'Paused',
'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.', 'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.',
'login.tags.recommended': 'Recommended', 'login.tags.recommended': 'Recommended',
'login.tags.comingSoon': 'Coming soon', 'login.tags.comingSoon': 'Coming soon',
+31
View File
@@ -21,6 +21,7 @@ export const zh: Record<TranslationKey, string> = {
// ─── Sidebar ────────────────────────────────────── // ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话', 'sidebar.newSession': '新建会话',
'sidebar.heicodeTasks': '我的任务',
'sidebar.scheduled': '定时任务', 'sidebar.scheduled': '定时任务',
'sidebar.terminal': '终端', 'sidebar.terminal': '终端',
'sidebar.settings': '设置', 'sidebar.settings': '设置',
@@ -991,6 +992,36 @@ export const zh: Record<TranslationKey, string> = {
'approval.action.postpone': '稍后提醒', 'approval.action.postpone': '稍后提醒',
'approval.action.approve': '批准 {minutes} 分钟', 'approval.action.approve': '批准 {minutes} 分钟',
'approval.queue.more': '另有 {count} 个高危请求等待处理', 'approval.queue.more': '另有 {count} 个高危请求等待处理',
// ─── 任务驾驶舱(wireframe §2 / §3)────────────────────
'tasks.home.currentLabel': '当前任务',
'tasks.home.currentNone': '未选择',
'tasks.home.intentPrompt': '你想把什么想法变成可以上线的软件?',
'tasks.home.intentPlaceholder': '做一个小团队任务管理 SaaS,需要登录、任务、评论、通知,部署到 Azure。',
'tasks.home.intentHint': '⌘/Ctrl + Enter 发送',
'tasks.home.send': '发送',
'tasks.home.recentTitle': '最近任务',
'tasks.home.recentEmpty': '还没有任务。从上面的输入框开始第一个吧。',
'tasks.home.managerHint': '辅助:打开 Manager 绑定资源、部署 Agnet、查看状态。',
'tasks.workspace.back': '返回',
'tasks.workspace.replyPlaceholder': '继续补充你的要求…',
'tasks.card.title': '任务卡',
'tasks.card.goal': '目标',
'tasks.card.scope': '第一版范围',
'tasks.card.generated': 'Heicode 将自动生成',
'tasks.card.managerActions': '需要 Manager 辅助',
'tasks.card.editGoal': '修改目标',
'tasks.card.openManager': '去 Manager 准备',
'tasks.status.draft': '草案',
'tasks.status.configuring': '准备中',
'tasks.status.running': '运行中',
'tasks.status.awaiting_approval': '待审批',
'tasks.status.completed': '已完成',
'tasks.status.failed': '失败',
'tasks.status.paused': '已暂停',
'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。', 'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。',
'login.tags.recommended': '推荐', 'login.tags.recommended': '推荐',
'login.tags.comingSoon': '即将开放', 'login.tags.comingSoon': '即将开放',
@@ -0,0 +1,456 @@
// desktop/src/pages/HeicodeTasksHome.tsx
//
// Heicode 任务驾驶舱(首页 → 工作台)。覆盖 wireframe §2 (intent input
// + 最近任务) 和 §3 (追问对话 + 任务卡)。Slice 7 是 mock skeleton,
// 数据来自 useHeicodeTaskStore 的 seed。
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from '../i18n'
import {
useHeicodeTaskStore,
type ChatTurn,
type FollowupQuestion,
type HeicodeTask,
type HeicodeTaskStatus,
} from '../stores/heicodeTaskStore'
const STATUS_TONE: Record<HeicodeTaskStatus, string> = {
draft:
'border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
configuring:
'border-[var(--color-secondary)]/30 bg-[var(--color-secondary)]/10 text-[var(--color-secondary)]',
running:
'border-[var(--color-primary)]/30 bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
awaiting_approval:
'border-[var(--color-error)]/30 bg-[var(--color-error-container)] text-[var(--color-error)]',
completed:
'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]',
failed:
'border-[var(--color-error)]/40 bg-[var(--color-error)]/10 text-[var(--color-error)]',
paused:
'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]',
}
export function HeicodeTasksHome() {
const t = useTranslation()
const tasks = useHeicodeTaskStore((s) => s.tasks)
const currentTaskId = useHeicodeTaskStore((s) => s.currentTaskId)
const submitIntent = useHeicodeTaskStore((s) => s.submitIntent)
const openTask = useHeicodeTaskStore((s) => s.openTask)
const currentTask = useMemo(
() => (currentTaskId ? tasks.find((x) => x.id === currentTaskId) ?? null : null),
[tasks, currentTaskId],
)
if (currentTask) {
return <TaskWorkspace task={currentTask} />
}
return (
<Home
tasks={tasks}
onSubmit={(intent) => {
submitIntent(intent)
}}
onOpenTask={openTask}
tStatus={(s) => t(`tasks.status.${s}` as const)}
tCopy={t}
/>
)
}
// ─── Home view (wireframe §2) ─────────────────────────────────
function Home({
tasks,
onSubmit,
onOpenTask,
tStatus,
tCopy,
}: {
tasks: HeicodeTask[]
onSubmit: (intent: string) => void
onOpenTask: (id: string) => void
tStatus: (s: HeicodeTaskStatus) => string
tCopy: ReturnType<typeof useTranslation>
}) {
const [intent, setIntent] = useState('')
const taRef = useRef<HTMLTextAreaElement>(null)
const handleSend = () => {
const t = intent.trim()
if (!t) return
onSubmit(t)
setIntent('')
}
return (
<div className="relative flex flex-1 flex-col overflow-auto bg-[var(--color-surface)]">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-8 py-12">
{/* Header */}
<header>
<p className="text-[10px] uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
{tCopy('tasks.home.currentLabel')}
</p>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
{tCopy('tasks.home.currentNone')}
</p>
</header>
{/* Intent input */}
<section className="flex flex-col gap-4">
<h1
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{tCopy('tasks.home.intentPrompt')}
</h1>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-4 shadow-[var(--shadow-dropdown)] focus-within:border-[var(--color-primary)] focus-within:shadow-[var(--shadow-focus-ring)]">
<textarea
ref={taRef}
value={intent}
onChange={(e) => setIntent(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
handleSend()
}
}}
placeholder={tCopy('tasks.home.intentPlaceholder')}
rows={3}
className="w-full resize-none bg-transparent text-base leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
<div className="mt-3 flex items-center justify-between">
<p className="text-[10px] text-[var(--color-text-tertiary)]">
{tCopy('tasks.home.intentHint')}
</p>
<button
type="button"
onClick={handleSend}
disabled={!intent.trim()}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-1.5 text-sm font-medium text-[var(--color-on-primary)] transition-colors hover:bg-[var(--color-primary-fixed-dim)] disabled:cursor-not-allowed disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)] disabled:border-[var(--color-border)]"
>
{tCopy('tasks.home.send')}
</button>
</div>
</div>
</section>
{/* Recent tasks */}
<section className="flex flex-col gap-4">
<h2 className="text-xs font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{tCopy('tasks.home.recentTitle')}
</h2>
{tasks.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)]">
{tCopy('tasks.home.recentEmpty')}
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{tasks.slice(0, 6).map((task) => (
<button
key={task.id}
type="button"
onClick={() => onOpenTask(task.id)}
className="group flex flex-col gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-4 text-left transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))]"
>
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-[var(--color-text-primary)] line-clamp-2">
{task.name}
</div>
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${STATUS_TONE[task.status]}`}
>
{tStatus(task.status)}
</span>
</div>
{task.status_caption ? (
<div className="text-xs text-[var(--color-text-secondary)]">
{task.status_caption}
</div>
) : null}
<div className="mt-1 text-[10px] text-[var(--color-text-tertiary)]">
{formatRelativeTime(task.updated_at)}
</div>
</button>
))}
</div>
)}
</section>
{/* Manager footer hint */}
<footer className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 text-xs text-[var(--color-text-tertiary)]">
{tCopy('tasks.home.managerHint')}
</footer>
</div>
</div>
)
}
// ─── Workspace (wireframe §3 + task card) ────────────────────
function TaskWorkspace({ task }: { task: HeicodeTask }) {
const t = useTranslation()
const closeTask = useHeicodeTaskStore((s) => s.closeTask)
const answerFollowup = useHeicodeTaskStore((s) => s.answerFollowup)
const appendMessage = useHeicodeTaskStore((s) => s.appendMessage)
const [draft, setDraft] = useState('')
const handleSend = () => {
const text = draft.trim()
if (!text) return
appendMessage(task.id, text)
setDraft('')
}
return (
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Workspace header */}
<header className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-6 py-3">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={closeTask}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-2 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
>
← {t('tasks.workspace.back')}
</button>
<h1 className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{task.name}
</h1>
</div>
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${STATUS_TONE[task.status]}`}
>
{t(`tasks.status.${task.status}` as const)}
</span>
</header>
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Conversation thread */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto flex max-w-2xl flex-col gap-6">
{task.thread.map((turn, i) => (
<ThreadTurn
key={i}
turn={turn}
onAnswer={(qid, oid) => answerFollowup(task.id, qid, oid)}
/>
))}
</div>
</div>
{/* Reply box */}
<div className="border-t border-[var(--color-border)] px-6 py-4">
<div className="mx-auto flex max-w-2xl flex-col gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-3 focus-within:border-[var(--color-primary)] focus-within:shadow-[var(--shadow-focus-ring)]">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
handleSend()
}
}}
placeholder={t('tasks.workspace.replyPlaceholder')}
rows={2}
className="w-full resize-none bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleSend}
disabled={!draft.trim()}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1 text-xs font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)] disabled:cursor-not-allowed disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)] disabled:border-[var(--color-border)]"
>
{t('tasks.home.send')}
</button>
</div>
</div>
</div>
</div>
{/* Task card sidebar */}
{task.card ? <TaskCardPanel task={task} /> : null}
</div>
</div>
)
}
function ThreadTurn({
turn,
onAnswer,
}: {
turn: ChatTurn
onAnswer: (questionId: string, optionId: string) => void
}) {
if (turn.kind === 'user') {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-[var(--radius-lg)] rounded-tr-[4px] border border-[var(--color-border)] bg-[var(--color-surface-user-msg)] px-4 py-3 text-sm text-[var(--color-text-primary)]">
{turn.text}
</div>
</div>
)
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container)] text-[10px] font-bold tracking-wider uppercase text-[var(--color-brand)]">
H
</div>
<div className="flex-1 text-sm leading-relaxed text-[var(--color-text-primary)]">
{turn.text}
</div>
</div>
{turn.followups && turn.followups.length > 0 ? (
<div className="ml-10 flex flex-col gap-3">
{turn.followups.map((q, idx) => (
<FollowupBlock key={q.id} index={idx + 1} q={q} onAnswer={onAnswer} />
))}
</div>
) : null}
</div>
)
}
function FollowupBlock({
index,
q,
onAnswer,
}: {
index: number
q: FollowupQuestion
onAnswer: (questionId: string, optionId: string) => void
}) {
return (
<div className="flex flex-col gap-2">
<p className="text-sm text-[var(--color-text-secondary)]">
<span className="mr-1 text-[var(--color-text-tertiary)]">{index}.</span>
{q.question}
</p>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => {
const picked = q.answer === opt.id
return (
<button
key={opt.id}
type="button"
onClick={() => onAnswer(q.id, opt.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
picked
? 'border-[var(--color-primary)]/40 bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]'
}`}
>
{opt.label}
{opt.risk === 'high-risk' ? (
<span
className="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--color-error)]"
aria-label="high-risk"
/>
) : null}
</button>
)
})}
</div>
</div>
)
}
function TaskCardPanel({ task }: { task: HeicodeTask }) {
const t = useTranslation()
if (!task.card) return null
const { goal, scope, generated_artifacts, manager_actions } = task.card
return (
<aside className="hidden w-80 shrink-0 overflow-auto border-l border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5 lg:block">
<h2
className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('tasks.card.title')}
</h2>
<Section label={t('tasks.card.goal')}>
<p className="text-sm leading-relaxed text-[var(--color-text-primary)]">{goal}</p>
</Section>
<Section label={t('tasks.card.scope')}>
<ul className="ml-4 list-disc space-y-1 text-sm text-[var(--color-text-secondary)]">
{scope.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</Section>
<Section label={t('tasks.card.generated')}>
<p className="text-xs leading-relaxed text-[var(--color-text-secondary)]">
{generated_artifacts.join(' / ')}
</p>
</Section>
<Section label={t('tasks.card.managerActions')}>
<div className="flex flex-wrap gap-1.5">
{manager_actions.map((a) => (
<button
key={a.label}
type="button"
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)] hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)]"
title={`Manager · ${a.deeplink}`}
>
{a.label}
</button>
))}
</div>
</Section>
<div className="mt-6 flex items-center justify-between gap-2">
<button
type="button"
className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:underline underline-offset-2"
>
{t('tasks.card.editGoal')}
</button>
<button
type="button"
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-3 py-1.5 text-xs font-medium text-[var(--color-on-primary)] hover:bg-[var(--color-primary-fixed-dim)]"
>
{t('tasks.card.openManager')}
</button>
</div>
</aside>
)
}
function Section({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="mt-4">
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{label}
</div>
{children}
</div>
)
}
// ─── Helpers ──────────────────────────────────────────────────
function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts
const min = 60_000
const h = 60 * min
const d = 24 * h
if (diff < min) return '刚刚'
if (diff < h) return `${Math.floor(diff / min)} 分钟前`
if (diff < d) return `${Math.floor(diff / h)} 小时前`
if (diff < 30 * d) return `${Math.floor(diff / d)} 天前`
return new Date(ts).toLocaleDateString()
}
@@ -0,0 +1,266 @@
// desktop/src/stores/heicodeTaskStore.ts
//
// Heicode tasks (high-level user goals). A task represents one
// "想法 → 追问 → 上下文授权 → 执行 → 审批 → 交付" loop, not a one-shot
// chat. See docs/product-package/10-frontend-detail-spec.md and the
// wireframes in 11-product-prototype-wireframes.md §2-§3.
//
// Slice 7 ships the surface with seeded mock data so the UI can be reviewed
// without backend wiring. When mcp-server publishes the task-orchestration
// API, swap submitIntent / answerFollowup / appendMessage for real calls
// and keep the same shapes.
import { create } from 'zustand'
export type HeicodeTaskStatus =
| 'draft'
| 'configuring'
| 'running'
| 'awaiting_approval'
| 'completed'
| 'failed'
| 'paused'
export type TaskCard = {
goal: string
scope: string[]
generated_artifacts: string[]
manager_actions: Array<{ label: string; deeplink: string }>
}
export type FollowupQuestion = {
id: string
question: string
options: Array<{
id: string
label: string
risk?: 'high-risk'
}>
answer?: string
}
export type ChatTurn =
| { kind: 'user'; text: string; at: number }
| { kind: 'heicode'; text: string; at: number; followups?: FollowupQuestion[] }
export type HeicodeTask = {
id: string
name: string
status: HeicodeTaskStatus
status_caption?: string
created_at: number
updated_at: number
intent: string
thread: ChatTurn[]
card?: TaskCard
}
type State = {
tasks: HeicodeTask[]
currentTaskId: string | null
submitIntent: (intent: string) => string
openTask: (taskId: string) => void
closeTask: () => void
answerFollowup: (taskId: string, questionId: string, optionId: string) => void
appendMessage: (taskId: string, text: string) => void
}
const now = Date.now()
const SEEDED: HeicodeTask[] = [
{
id: 'task-saas-001',
name: '小团队任务管理 SaaS',
status: 'running',
status_caption: '后端实现中',
created_at: now - 4 * 3600_000,
updated_at: now - 15 * 60_000,
intent:
'做一个小团队任务管理 SaaS,需要登录、任务、评论、通知,部署到 Azure。',
thread: [
{
kind: 'user',
text: '做一个小团队任务管理 SaaS,需要登录、任务、评论、通知,部署到 Azure。',
at: now - 4 * 3600_000,
},
{
kind: 'heicode',
text: '我可以开始整理任务。还需要确认 3 件事:',
at: now - 4 * 3600_000 + 4_000,
followups: [
{
id: 'q1',
question: '基于已有仓库还是从零开始?',
options: [
{ id: 'fresh', label: '从零开始' },
{ id: 'existing', label: '选择已有仓库' },
],
answer: 'fresh',
},
{
id: 'q2',
question: '第一版是否需要部署?',
options: [
{ id: 'no', label: '暂不部署' },
{ id: 'azure-test', label: 'Azure 测试环境' },
{ id: 'azure-prod', label: '生产环境,需审批', risk: 'high-risk' },
],
answer: 'azure-test',
},
{
id: 'q3',
question: '执行风格?',
options: [
{ id: 'conservative', label: '保守' },
{ id: 'balanced', label: '平衡' },
{ id: 'proactive', label: '主动' },
],
answer: 'balanced',
},
],
},
],
card: {
goal: '做一个面向小团队的任务协作 SaaS。',
scope: [
'登录和团队空间',
'项目、任务、评论',
'通知能力',
'Azure 测试环境部署',
],
generated_artifacts: [
'产品说明',
'原型描述',
'开发任务',
'检查清单',
'部署步骤',
],
manager_actions: [
{ label: '绑定 Git', deeplink: '/resources/git' },
{ label: '绑定云资源', deeplink: '/resources/cloud' },
{ label: '部署 Agnet', deeplink: '/agnet/deploy' },
{ label: '查看预算与审计', deeplink: '/audit' },
],
},
},
{
id: 'task-feishu-002',
name: '企业微信通知集成',
status: 'awaiting_approval',
status_caption: '等待生产部署审批',
created_at: now - 26 * 3600_000,
updated_at: now - 30 * 60_000,
intent: '在我们的告警系统里加一个企业微信群通知通道,按严重度分级。',
thread: [
{
kind: 'user',
text: '在我们的告警系统里加一个企业微信群通知通道,按严重度分级。',
at: now - 26 * 3600_000,
},
],
},
]
const seedFollowups = (): FollowupQuestion[] => [
{
id: 'q1',
question: '基于已有仓库还是从零开始?',
options: [
{ id: 'fresh', label: '从零开始' },
{ id: 'existing', label: '选择已有仓库' },
],
},
{
id: 'q2',
question: '第一版是否需要部署?',
options: [
{ id: 'no', label: '暂不部署' },
{ id: 'azure-test', label: 'Azure 测试环境' },
{ id: 'azure-prod', label: '生产环境,需审批', risk: 'high-risk' },
],
},
{
id: 'q3',
question: '执行风格?',
options: [
{ id: 'conservative', label: '保守' },
{ id: 'balanced', label: '平衡' },
{ id: 'proactive', label: '主动' },
],
},
]
export const useHeicodeTaskStore = create<State>((set) => ({
tasks: SEEDED,
currentTaskId: null,
submitIntent: (intent) => {
const trimmed = intent.trim()
if (!trimmed) return ''
const id = `task-${Date.now().toString(36)}`
const t: HeicodeTask = {
id,
name: trimmed.length > 28 ? trimmed.slice(0, 26) + '…' : trimmed,
status: 'draft',
status_caption: '草案 · 等你回答追问',
created_at: Date.now(),
updated_at: Date.now(),
intent: trimmed,
thread: [
{ kind: 'user', text: trimmed, at: Date.now() },
{
kind: 'heicode',
text: '我可以开始整理任务。还需要确认 3 件事:',
at: Date.now() + 1_000,
followups: seedFollowups(),
},
],
}
set((s) => ({ tasks: [t, ...s.tasks], currentTaskId: id }))
return id
},
openTask: (taskId) => set({ currentTaskId: taskId }),
closeTask: () => set({ currentTaskId: null }),
answerFollowup: (taskId, questionId, optionId) =>
set((s) => ({
tasks: s.tasks.map((t) => {
if (t.id !== taskId) return t
return {
...t,
updated_at: Date.now(),
thread: t.thread.map((turn) =>
turn.kind === 'heicode' && turn.followups
? {
...turn,
followups: turn.followups.map((q) =>
q.id === questionId ? { ...q, answer: optionId } : q,
),
}
: turn,
),
}
}),
})),
appendMessage: (taskId, text) => {
const trimmed = text.trim()
if (!trimmed) return
set((s) => ({
tasks: s.tasks.map((t) =>
t.id === taskId
? {
...t,
updated_at: Date.now(),
thread: [
...t.thread,
{ kind: 'user', text: trimmed, at: Date.now() },
],
}
: t,
),
}))
},
}))
+9 -3
View File
@@ -5,9 +5,15 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
export const SETTINGS_TAB_ID = '__settings__' export const SETTINGS_TAB_ID = '__settings__'
export const SCHEDULED_TAB_ID = '__scheduled__' export const SCHEDULED_TAB_ID = '__scheduled__'
export const HEICODE_TASKS_TAB_ID = '__heicode_tasks__'
export const TERMINAL_TAB_PREFIX = '__terminal__' export const TERMINAL_TAB_PREFIX = '__terminal__'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' export type TabType =
| 'session'
| 'settings'
| 'scheduled'
| 'heicode_tasks'
| 'terminal'
export type Tab = { export type Tab = {
sessionId: string sessionId: string
@@ -162,13 +168,13 @@ export const useTabStore = create<TabStore>((set, get) => ({
const validTabs: Tab[] = data.openTabs const validTabs: Tab[] = data.openTabs
.filter((t) => { .filter((t) => {
// Special tabs are always valid // Special tabs are always valid
if (t.type === 'settings' || t.type === 'scheduled') return true if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'heicode_tasks') return true
if (t.type === 'terminal') return false if (t.type === 'terminal') return false
// Session tabs must exist on server // Session tabs must exist on server
return existingIds.has(t.sessionId) return existingIds.has(t.sessionId)
}) })
.map((t) => { .map((t) => {
if (t.type === 'settings' || t.type === 'scheduled') { if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'heicode_tasks') {
return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const } return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const }
} }
return { return {