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:
@@ -5,6 +5,7 @@ import { ActiveSession } from '../../pages/ActiveSession'
|
||||
import { ScheduledTasks } from '../../pages/ScheduledTasks'
|
||||
import { Settings } from '../../pages/Settings'
|
||||
import { TerminalSettings } from '../../pages/TerminalSettings'
|
||||
import { HeicodeTasksHome } from '../../pages/HeicodeTasksHome'
|
||||
|
||||
export function ContentRouter() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
@@ -19,6 +20,8 @@ export function ContentRouter() {
|
||||
page = <Settings />
|
||||
} else if (activeTabType === 'scheduled') {
|
||||
page = <ScheduledTasks />
|
||||
} else if (activeTabType === 'heicode_tasks') {
|
||||
page = <HeicodeTasksHome />
|
||||
} else if (activeTabType !== 'terminal') {
|
||||
page = <ActiveSession />
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useTranslation } from '../../i18n'
|
||||
import { ProjectFilter } from './ProjectFilter'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
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 { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
|
||||
|
||||
@@ -186,6 +191,19 @@ export function Sidebar() {
|
||||
>
|
||||
{t('sidebar.newSession')}
|
||||
</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
|
||||
active={activeTabId === SCHEDULED_TAB_ID}
|
||||
collapsed={!sidebarOpen}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const en = {
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': 'New session',
|
||||
'sidebar.heicodeTasks': 'My tasks',
|
||||
'sidebar.scheduled': 'Scheduled',
|
||||
'sidebar.terminal': 'Terminal',
|
||||
'sidebar.settings': 'Settings',
|
||||
@@ -989,6 +990,36 @@ export const en = {
|
||||
'approval.action.postpone': 'Remind me later',
|
||||
'approval.action.approve': 'Approve for {minutes} min',
|
||||
'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.tags.recommended': 'Recommended',
|
||||
'login.tags.comingSoon': 'Coming soon',
|
||||
|
||||
@@ -21,6 +21,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
'sidebar.newSession': '新建会话',
|
||||
'sidebar.heicodeTasks': '我的任务',
|
||||
'sidebar.scheduled': '定时任务',
|
||||
'sidebar.terminal': '终端',
|
||||
'sidebar.settings': '设置',
|
||||
@@ -991,6 +992,36 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'approval.action.postpone': '稍后提醒',
|
||||
'approval.action.approve': '批准 {minutes} 分钟',
|
||||
'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.tags.recommended': '推荐',
|
||||
'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,
|
||||
),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
@@ -5,9 +5,15 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
|
||||
|
||||
export const SETTINGS_TAB_ID = '__settings__'
|
||||
export const SCHEDULED_TAB_ID = '__scheduled__'
|
||||
export const HEICODE_TASKS_TAB_ID = '__heicode_tasks__'
|
||||
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 = {
|
||||
sessionId: string
|
||||
@@ -162,13 +168,13 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
const validTabs: Tab[] = data.openTabs
|
||||
.filter((t) => {
|
||||
// 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
|
||||
// Session tabs must exist on server
|
||||
return existingIds.has(t.sessionId)
|
||||
})
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user