feat(plans): Kanban boards in the Agents workspace

Live lifecycle board (grid<->board toggle) plus an assignee-swimlane plan
board that groups follow-up re-runs of one goal into a single xN card.
Custom right-side detail/goal panels with markdown output. Fixes plans
being persisted under the per-run trace id so the board actually populates.

Closes #385
This commit is contained in:
matevip 2026-06-20 17:52:29 +08:00
parent d6001e3e6f
commit c656aff349
19 changed files with 2003 additions and 186 deletions

View File

@ -284,7 +284,11 @@ public class PlanGenerationNode implements NodeAction {
}
String systemPrompt = accessor.systemPrompt();
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
// Persist plans under the real agent id (the same key StepExecutionNode
// reads), NOT the per-run trace id otherwise mate_plan.agent_id holds a
// random trace string and listByAgent never matches, leaving the Plan
// board permanently empty even after plans are generated.
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String conversationId = accessor.conversationId();
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
@ -410,7 +414,7 @@ public class PlanGenerationNode implements NodeAction {
+ "downgrading to single-step plan so tools can execute (goal: {})",
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
List<String> gatedSteps = List.of(goal);
var gatedPlan = planningService.createPlan(agentId, goal, gatedSteps);
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
return PlanStateAccessor.output()
.needsPlanning(true)
@ -451,7 +455,7 @@ public class PlanGenerationNode implements NodeAction {
steps = List.of(goal);
}
var plan = planningService.createPlan(agentId, goal, steps);
var plan = planningService.createPlan(agentId, conversationId, goal, steps);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
@ -495,7 +499,7 @@ public class PlanGenerationNode implements NodeAction {
// answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks.
try {
var plan = planningService.createPlan(agentId, goal, List.of(goal));
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
return PlanStateAccessor.output()
.needsPlanning(true)

View File

@ -23,10 +23,14 @@ public class PlanningController {
private final PlanningService planningService;
@Operation(summary = "获取 Agent 的计划列表")
@Operation(summary = "获取计划列表(带 agentId 则按员工,否则跨员工取最近 N 条)")
@GetMapping
public R<List<PlanEntity>> listByAgent(@RequestParam String agentId) {
return R.ok(planningService.listPlansByAgent(agentId));
public R<List<PlanEntity>> list(@RequestParam(required = false) String agentId,
@RequestParam(required = false, defaultValue = "100") int limit) {
if (agentId != null && !agentId.isBlank()) {
return R.ok(planningService.listPlansByAgent(agentId));
}
return R.ok(planningService.listRecentPlans(limit));
}
@Operation(summary = "获取计划详情(含步骤)")

View File

@ -21,6 +21,9 @@ public class PlanEntity {
/** 关联的 Agent ID字符串 */
private String agentId;
/** 产生该计划的对话/运行 ID可空历史行为 null。用于把计划绑定到具体运行、支持跨员工/协同分组。 */
private String conversationId;
/** 任务目标 */
private String goal;

View File

@ -34,8 +34,18 @@ public class PlanningService {
*/
@Transactional
public PlanEntity createPlan(String agentId, String goal, List<String> steps) {
return createPlan(agentId, null, goal, steps);
}
/**
* 创建执行计划并绑定到产生它的对话/运行
* conversationId 可空历史调用方便于把计划归到某次运行支撑跨员工/协同看板
*/
@Transactional
public PlanEntity createPlan(String agentId, String conversationId, String goal, List<String> steps) {
PlanEntity plan = new PlanEntity();
plan.setAgentId(agentId);
plan.setConversationId(conversationId);
plan.setGoal(goal);
plan.setStatus("running");
plan.setTotalSteps(steps.size());
@ -111,6 +121,17 @@ public class PlanningService {
.orderByDesc(PlanEntity::getCreateTime));
}
/**
* 跨员工获取最近的计划列表用于团队/泳道看板
* 按创建时间倒序limit 兜底防止全表拉取
*/
public List<PlanEntity> listRecentPlans(int limit) {
int capped = limit <= 0 ? 100 : Math.min(limit, 500);
return planMapper.selectList(new LambdaQueryWrapper<PlanEntity>()
.orderByDesc(PlanEntity::getCreateTime)
.last("LIMIT " + capped));
}
/**
* 获取计划详情含子计划
*/

View File

@ -0,0 +1,11 @@
-- V155: Link a plan to the conversation/run that produced it.
--
-- mate_plan previously carried only agent_id, so a plan could not be tied to a
-- specific conversation or delegation run — every listByAgent query mixed all of
-- an agent's plans across all conversations, and a multi-agent collaboration
-- could not be reconstructed. conversation_id makes plans groupable by run and
-- is the foundation for the cross-agent / assignee-swimlane plan board.
--
-- Nullable: legacy rows (and any plan created before this column existed) keep
-- a NULL conversation_id and simply don't participate in run-level grouping.
ALTER TABLE mate_plan ADD COLUMN IF NOT EXISTS conversation_id VARCHAR(64);

View File

@ -0,0 +1,3 @@
-- V155: Link a plan to the conversation/run that produced it (see H2 file for
-- context). KingbaseES (PostgreSQL-compatible) supports ADD COLUMN IF NOT EXISTS.
ALTER TABLE mate_plan ADD COLUMN IF NOT EXISTS conversation_id VARCHAR(64);

View File

@ -0,0 +1,15 @@
-- V155: Link a plan to the conversation/run that produced it (see H2 file for
-- context). MySQL 8.0 doesn't support `ADD COLUMN IF NOT EXISTS`, so the
-- existence check goes through INFORMATION_SCHEMA + a prepared statement.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_plan'
AND COLUMN_NAME = 'conversation_id'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_plan ADD COLUMN conversation_id VARCHAR(64) NULL',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -66,10 +66,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://<your-deployment>' })
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/stream` | API Key | SSE streaming chat (issues visitorToken); the body may include an optional `agentId` to override the channel's bound agent (must be in the same workspace as the channel) |
| POST | `/stream` | API Key | SSE streaming chat (issues visitorToken) |
| GET | `/config` | API Key | Get channel config (title/placeholder/...) |
| GET | `/skills` | + visitorToken | List skills visible to this agent (for building your own slash picker UI) |
| GET | `/wiki/pages` | + visitorToken | List wiki pages visible to this agent (for building your own `[[slug]]` reference picker UI) |
| POST | `/sessions` | API Key | Explicitly create an empty session thread |
| GET | `/sessions` | + visitorToken | List sessions (excludes archived by default) |
| GET | `/sessions/page` | + visitorToken | Paginated + keyword search |
@ -201,89 +199,6 @@ curl -X POST https://mate.example.com/api/v1/admin/webchat/revoked-visitor \
After revocation, all of that visitor's management endpoints return 401 (`/stream` is unaffected and can re-issue a fresh token). Revocation state is briefly cached, so under a multi-instance deployment it takes up to ~10 minutes to fully propagate. Un-revoke via `DELETE` on the same endpoint.
## Skill invocation (slash picker)
The admin-console chat input shows a skill picker when you type `/`. This is a **pure frontend affordance** — selecting a skill rewrites the input box into a directive:
- English: `Use the "<skill-name>" skill: <user message>`
- Chinese: `使用「<技能名>」技能:<用户消息>`
The directive goes out as a regular user message on `/stream`, and the LLM voluntarily calls the `load_skill` meta-tool when it sees it (see [skills.md](./skills.md#the-slash-menu)). The backend **does no `/` parsing**; webchat uses the exact same agent runtime as the admin console, so this path works out of the box for webchat callers.
To build your own picker, first list the skills via the new endpoint:
```bash
curl https://mate.example.com/api/v1/channels/webchat/skills?visitorId=v1 \
-H "X-MC-Key: your-api-key" \
-H "X-MC-Visitor-Token: <token>"
# returns [{"id":..., "name":"news-summary", "nameZh":"News Summary", "description":"...", "icon":"..."}]
```
The `agentId` query parameter is optional (falls back to the channel's bound agent). Only skills **explicitly bound to the agent AND enabled** are returned, sorted by slug. The response carries display-level metadata only — **no** SKILL.md content, configJson, or security scan results (those stay admin-console-only).
After a user picks a skill, construct the message:
```bash
curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \
-H "X-MC-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"visitorId":"v1","message":"Use the \"news-summary\" skill: summarize the top 3 AI stories today"}'
```
> Note: the directive text relies on the LLM "obeying" and calling `load_skill`. Under complex tasks it occasionally drifts; for production, bind the target skill to the agent and reinforce the system prompt in `AGENTS.md`.
## Wiki knowledge-base reference (`[[slug]]` picker)
The knowledge base has a picker parallel to the slash-skill one — using the **Obsidian / Wikipedia wikilink convention `[[slug]]`**. The user types `[[` in the input box to open a picker, selects a page, and a `[[<slug>]]` token is inserted. On submit the input is rewritten into a directive text, and the LLM calls `wiki_read_page(slug=...)` to read the referenced page before answering. The backend **does no `[[` parsing** — webchat uses the exact same agent runtime as the admin console.
Directive text format (**exact**):
- English: `Reference the wiki page [[<slug>]]: <user message>`
- Chinese: `参考知识库页面 [[<slug>]]<用户消息>`
Multiple references are supported naturally (just list them):
```
Reference the wiki pages [[auth-design]], [[webchat-integration]]: how do these two work together?
```
To build your own picker, first list the pages via the new endpoint:
```bash
curl "https://mate.example.com/api/v1/channels/webchat/wiki/pages?visitorId=v1" \
-H "X-MC-Key: your-api-key" \
-H "X-MC-Visitor-Token: <token>"
# returns [{"kbId":1,"kbName":"MateClaw Docs","slug":"webchat-integration",
# "title":"WebChat Integration Guide","summary":"...","pageType":"source"}, ...]
```
Optional query parameters:
| Parameter | Required | Notes |
|---|---|---|
| `visitorId` | yes | Visitor ID |
| `agentId` | no | Override the channel's bound agent; must be in the same workspace as the channel |
| `keyword` | no | Filter, matches `slug` OR `title` (LIKE) |
Behavior:
- **Scope**: KBs explicitly bound to the agent (`mate_agent_wiki_kb`); with no bindings, falls back to every KB in the workspace (mirrors the wiki-tool default)
- **Page filter**: excludes `pageType=synthesis` (LLM intermediate artifacts)
- **100-page cap**: when exceeded (and no `keyword`), returns `422` asking the caller to narrow with a keyword
- **Returned fields**: only `kbId / kbName / slug / title / summary / pageType`; **no** content, embedding, sourceRawIds, or outgoingLinks (admin-console-only)
- **Ordering**: by `slug` ascending
After a user picks a page, construct the message (English directive example):
```bash
curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \
-H "X-MC-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"visitorId":"v1","message":"Reference the wiki page [[webchat-integration]]: summarize the integration flow"}'
```
> Note: `[[slug]]` is a convention hint for the LLM (documented in the `wiki_read_page` `@Tool` description), but the LLM can still drift under complex tasks. For production, reinforce the system prompt in `AGENTS.md`, or bind the target KB to a dedicated agent to narrow the retrieval space.
## curl examples
**Step 1: send the first message**

View File

@ -66,10 +66,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://<你的部署地址>' })
| 方法 | 路径 | 鉴权 | 用途 |
|---|---|---|---|
| POST | `/stream` | API Key | SSE 流式对话(签发 visitorToken);请求体可选传 `agentId` 覆盖渠道绑定的 agent(必须与渠道同 workspace) |
| POST | `/stream` | API Key | SSE 流式对话(签发 visitorToken) |
| GET | `/config` | API Key | 拿渠道配置(title/placeholder/...) |
| GET | `/skills` | + visitorToken | 列出该 agent 绑定的可见技能(供下游自建 slash picker UI) |
| GET | `/wiki/pages` | + visitorToken | 列出该 agent 可见的 wiki 页面(供下游自建 `[[slug]]` 引用 picker UI) |
| POST | `/sessions` | API Key | 显式创建空会话线程 |
| GET | `/sessions` | + visitorToken | 列出会话(默认排除 archived) |
| GET | `/sessions/page` | + visitorToken | 分页 + 关键词搜索 |
@ -206,89 +204,6 @@ curl -X POST https://mate.example.com/api/v1/admin/webchat/revoked-visitor \
撤销后该 visitor 的所有管理端点调用返回 401(`/stream` 不受影响,可重新签发新 token)。撤销状态带短时缓存,多实例下最长约 10 分钟生效。取消撤销用 `DELETE` 同一端点。
## 技能调用(slash picker)
主控台前端在输入框键入 `/` 会弹出技能选择菜单。这是**纯前端 affordance**——选中后输入框被改写成指令文本:
- 中文:`使用「<技能名>」技能:<用户消息>`
- 英文:`Use the "<skill-name>" skill: <用户消息>`
指令文本作为普通 user message 发到 `/stream`,LLM 收到后调用 `load_skill` 元工具(详见 [skills.md](./skills.md#slash-菜单))。后端**不做 `/` 解析**,webchat 走的是和主控台完全一样的 agent runtime,所以这条路径对 webchat 调用方**开箱即用**。
下游集成方要自建 picker UI,先用新端点拿清单:
```bash
curl https://mate.example.com/api/v1/channels/webchat/skills?visitorId=v1 \
-H "X-MC-Key: your-api-key" \
-H "X-MC-Visitor-Token: <token>"
# 返回 [{"id":..., "name":"news-summary", "nameZh":"新闻摘要", "description":"...", "icon":"..."}]
```
可选 `agentId` 参数(默认回落到渠道绑定的 agent);只返回该 agent **显式绑定且 enabled** 的技能,按 slug 字母序排序。返回字段是展示级元数据,**不包含** SKILL.md 正文、configJson、安全扫描结果——这些只走管理控制台。
选中后构造消息:
```bash
curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \
-H "X-MC-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"visitorId":"v1","message":"使用「新闻摘要」技能:总结今天最重要的 3 条 AI 新闻"}'
```
> 注意:指令文本依赖 LLM "听话"调用 `load_skill`。复杂任务下偶发漂移,生产环境建议把目标技能**绑定**到 agent(`agentId` 对应的)并在 `AGENTS.md` 里强化系统提示。
## Wiki 知识库引用(`[[slug]]` picker)
跟技能调用一样,wiki 知识库也可以通过 picker 显式指代——用 **Obsidian / Wikipedia 风格的 `[[slug]]` 链接语法**。用户在输入框敲 `[[` 触发 picker,选中后插入 `[[<slug>]]` token,发送时改写为指令文本,LLM 收到后调 `wiki_read_page(slug=...)` 读取该页面再做答。后端**不做任何 `[[` 解析**,webchat 走的是和主控台完全一样的 agent runtime。
指令文本格式(**精确**):
- 中文:`参考知识库页面 [[<slug>]]:<用户消息>`
- 英文:`Reference the wiki page [[<slug>]]: <user message>`
多引用天然支持(并列写即可):
```
参考知识库页面 [[auth-design]]、[[webchat-integration]]:这两套怎么协同?
```
下游集成方要自建 picker UI,先用端点拿页面清单:
```bash
curl "https://mate.example.com/api/v1/channels/webchat/wiki/pages?visitorId=v1" \
-H "X-MC-Key: your-api-key" \
-H "X-MC-Visitor-Token: <token>"
# 返回 [{"kbId":1,"kbName":"MateClaw 文档","slug":"webchat-integration",
# "title":"WebChat 接入指南","summary":"...","pageType":"source"}, ...]
```
可选 query 参数:
| 参数 | 必填 | 说明 |
|---|---|---|
| `visitorId` | 是 | 访客 ID |
| `agentId` | 否 | 显式指定 agent,缺省回落到渠道绑定;必须与渠道同 workspace |
| `keyword` | 否 | 关键词过滤,匹配 `slug``title`(LIKE) |
行为约束:
- **可见范围**:agent 显式绑定的 KB(`mate_agent_wiki_kb` 表);无绑定时回落到该 workspace 全部 KB(跟 wiki 工具的默认行为一致)
- **页面过滤**:排除 `pageType=synthesis`(LLM 中间产物,对终端访客无意义)
- **100 页上限**:超出时(且未传 `keyword`)返回 `422`,要求传 `keyword` 收窄
- **返回字段**:仅 `kbId / kbName / slug / title / summary / pageType`;**不包含**正文、embedding、sourceRawIds、outgoingLinks(这些只走管理控制台)
- **排序**:按 `slug` 字母序
选中后构造消息(中文 directive 示例):
```bash
curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \
-H "X-MC-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"visitorId":"v1","message":"参考知识库页面 [[webchat-integration]]:总结这套接入流程"}'
```
> 注意:`[[slug]]` 是给 LLM 看的约定提示(`wiki_read_page` 的 `@Tool` description 里写明了),但 LLM 仍可能漂移——复杂任务下建议同时在 `AGENTS.md` 里强化提示,或把目标 KB **绑定**到专用 agent 收窄检索空间。
## curl 示例
**第一步:发首条消息**

View File

@ -522,6 +522,8 @@ export const mcpApi = {
// ==================== Plan ====================
export const planApi = {
listByAgent: (agentId: string) => http.get(`/plans?agentId=${agentId}`),
/** Cross-agent recent plans for the team / swimlane board. */
listAll: (limit = 100) => http.get('/plans', { params: { limit } }),
get: (id: string | number) => http.get(`/plans/${id}`),
}

View File

@ -0,0 +1,274 @@
<template>
<!--
Active-goals panel same right-anchored slide-in shell as PlanDetailPanel
(backdrop blur + mc-surface-card + round close), replacing the default
el-drawer chrome so both board popups share one design language.
-->
<Teleport to="body">
<Transition name="pd-slide">
<div v-if="open" class="pd-backdrop" @click.self="$emit('close')">
<aside class="pd-panel mc-surface-card" role="dialog" aria-modal="true">
<button class="pd-close" type="button" :aria-label="t('live.actions.close')" @click="$emit('close')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6 L18 18 M18 6 L6 18"/></svg>
</button>
<div class="gp-head">
<h2 class="gp-head__title">{{ t('plans.activeGoals') }}</h2>
</div>
<div v-if="loading" class="pd-loading">{{ t('common.loading') }}</div>
<el-empty v-else-if="!goals.length" :description="t('plans.noGoals')" />
<div v-else class="gp-list">
<div v-for="goal in goals" :key="goal.id" class="gp-goal">
<div class="gp-goal__title">{{ cleanGoal(goal.title) }}</div>
<p v-if="showDesc(goal)" class="gp-goal__desc">{{ cleanGoal(goal.description) }}</p>
<div class="gp-goal__score" v-if="goal.completionScore != null">
<div class="gp-progress">
<div class="gp-progress__fill" :style="{ width: pct(goal) + '%' }"></div>
</div>
<span>{{ pct(goal) }}%</span>
</div>
<ul v-if="goal.criteria?.length" class="gp-criteria">
<li v-for="c in goal.criteria" :key="c.id" :class="{ 'is-passed': c.passed }">
<span class="gp-check">
<svg v-if="c.passed" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>
</span>
<span>{{ c.text }}</span>
</li>
</ul>
<p v-if="goal.progressSummary" class="gp-goal__gap">{{ goal.progressSummary }}</p>
</div>
</div>
</aside>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Goal } from '@/api'
const props = defineProps<{
open: boolean
goals: Goal[]
loading: boolean
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
// Strip the appended "[Follow-up guidance] ..." block so titles/descriptions
// read as the original objective, not the internal re-prompt.
function cleanGoal(text?: string | null): string {
if (!text) return ''
const i = text.indexOf('[Follow-up guidance]')
return (i >= 0 ? text.slice(0, i) : text).trim()
}
// The description often duplicates the title (both seeded from the same goal
// text). Only show it when it adds something the title doesn't.
function showDesc(goal: Goal): boolean {
const title = cleanGoal(goal.title)
const desc = cleanGoal(goal.description)
return !!desc && desc !== title
}
function pct(goal: Goal): number {
return Math.round((goal.completionScore || 0) * 100)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) emit('close')
}
watch(() => props.open, (open) => {
if (typeof document === 'undefined') return
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
<style scoped>
/* ===== Shared right-panel shell (mirrors PlanDetailPanel) ===== */
.pd-backdrop {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
justify-content: flex-end;
background: rgba(20, 14, 10, 0.42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
html.dark .pd-backdrop {
background: rgba(0, 0, 0, 0.5);
}
.pd-slide-enter-active,
.pd-slide-leave-active {
transition: opacity 0.22s ease, backdrop-filter 0.22s ease, -webkit-backdrop-filter 0.22s ease;
}
.pd-slide-enter-active .pd-panel,
.pd-slide-leave-active .pd-panel {
transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.pd-slide-enter-from,
.pd-slide-leave-to {
opacity: 0;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
}
.pd-slide-enter-from .pd-panel,
.pd-slide-leave-to .pd-panel {
transform: translateX(24px);
}
.pd-panel {
position: relative;
width: 480px;
max-width: 100%;
height: 100vh;
overflow-y: auto;
padding: 28px 28px 32px;
border-radius: 24px 0 0 24px;
box-shadow: -28px 0 80px -24px rgba(0, 0, 0, 0.35);
}
.pd-close {
position: absolute;
top: 18px;
right: 18px;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: var(--mc-bg-muted);
color: var(--mc-text-tertiary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.18s ease, color 0.18s ease;
}
.pd-close:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.pd-loading {
font-size: 13px;
color: var(--mc-text-tertiary);
}
/* ===== Header ===== */
.gp-head {
margin: 2px 36px 20px 0;
}
.gp-head__title {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--mc-text-primary);
}
/* ===== Goal cards ===== */
.gp-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.gp-goal {
padding: 16px;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: var(--mc-radius-lg);
}
.gp-goal__title {
font-size: 14px;
font-weight: 700;
color: var(--mc-text-primary);
line-height: 1.45;
}
.gp-goal__desc {
margin: 7px 0 0;
font-size: 12.5px;
color: var(--mc-text-secondary);
line-height: 1.55;
}
.gp-goal__score {
display: flex;
align-items: center;
gap: 9px;
margin: 14px 0;
font-size: 12px;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
}
.gp-progress {
flex: 1;
height: 5px;
border-radius: var(--mc-radius-full);
background: var(--mc-bg-muted);
overflow: hidden;
}
.gp-progress__fill {
height: 100%;
background: var(--mc-primary);
transition: width 0.3s;
}
.gp-criteria {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.gp-criteria li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12.5px;
color: var(--mc-text-tertiary);
line-height: 1.45;
}
.gp-criteria li.is-passed {
color: var(--mc-text-secondary);
}
.gp-check {
display: inline-flex;
align-items: center;
color: var(--mc-text-quaternary);
flex-shrink: 0;
margin-top: 1px;
}
.gp-criteria li.is-passed .gp-check {
color: var(--mc-success);
}
.gp-goal__gap {
margin: 12px 0 0;
padding-top: 12px;
border-top: 1px dashed var(--mc-border);
font-size: 12px;
color: var(--mc-text-tertiary);
line-height: 1.5;
}
@media (max-width: 600px) {
.pd-panel {
width: 100%;
border-radius: 0;
padding: 24px 20px 28px;
}
}
</style>

View File

@ -0,0 +1,587 @@
<template>
<!--
Team plan board: every employee's recent plans laid out as assignee swimlanes
(rows = employee, columns = plan status). Cross-agent by default the picker
is a filter, not a requirement. Click a plan to open its step detail. Read-only;
status is execution-driven.
-->
<div class="plan-board">
<div class="pb-toolbar mc-surface-card">
<div class="pb-agent-select">
<AgentPickerDialog
block
clearable
:model-value="filterAgentId || null"
:agents="agentStore.agents"
:placeholder="t('plans.allEmployees')"
@change="onFilterChange"
/>
</div>
<div class="pb-toolbar__spacer"></div>
<button
class="pb-btn"
:class="{ 'is-active': goalDrawerOpen }"
@click="openGoalDrawer"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span>{{ t('plans.goals') }}</span>
<span v-if="activeGoals.length" class="pb-btn__badge">{{ activeGoals.length }}</span>
</button>
<button class="pb-btn pb-btn--icon" :title="t('common.refresh')" :disabled="loading" @click="reload">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'pb-spin': loading }"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
</div>
<div v-if="loading" class="pb-blank mc-surface-card">
<span class="pb-hint">{{ t('common.loading') }}</span>
</div>
<div v-else-if="!lanes.length" class="pb-blank mc-surface-card">
<el-empty :description="t('plans.noPlansAll')" />
</div>
<!-- Swimlanes -->
<div v-else class="pb-swim mc-surface-card">
<div class="pb-swim__head">
<div class="pb-corner"></div>
<div class="pb-cols">
<div v-for="col in columns" :key="col.key" class="pb-colhead">
<span class="pb-dot" :class="`is-${col.key}`"></span>
<span>{{ col.label }}</span>
</div>
</div>
</div>
<div v-for="lane in lanes" :key="lane.key" class="pb-lane">
<div class="pb-lane__label">
<div class="pb-avatar">
<SkillIcon v-if="lane.icon" :value="lane.icon" :size="20" fallback="🤖" />
<span v-else class="pb-avatar__letter">{{ laneLetter(lane.name) }}</span>
</div>
<div class="pb-lane__id">
<div class="pb-lane__name">{{ lane.name }}</div>
<div class="pb-lane__count">{{ lane.plans.length }} {{ t('plans.plans') }}</div>
</div>
</div>
<div class="pb-cols">
<div v-for="col in columns" :key="col.key" class="pb-cell">
<article
v-for="group in visibleGroups(lane, col.key)"
:key="group.key"
class="pb-card"
@click="openDetail(group.latest)"
>
<div class="pb-card__goal">{{ group.goal }}</div>
<div class="pb-card__foot">
<div class="pb-progress">
<div class="pb-progress__bar" :class="`is-${group.latest.status}`" :style="{ width: progressPct(group.latest) + '%' }"></div>
</div>
<span class="pb-card__steps">{{ group.latest.completedSteps }}/{{ group.latest.totalSteps }}</span>
<span v-if="group.plans.length > 1" class="pb-card__runs" :title="t('plans.runs', { n: group.plans.length })">×{{ group.plans.length }}</span>
</div>
</article>
<button
v-if="!expandedCells.has(cellKey(lane, col.key)) && hiddenCount(lane, col.key) > 0"
class="pb-more"
@click="toggleCell(lane, col.key)"
>{{ t('plans.more', { n: hiddenCount(lane, col.key) }) }}</button>
<button
v-else-if="expandedCells.has(cellKey(lane, col.key)) && groupsIn(lane, col.key).length > GROUP_LIMIT"
class="pb-more"
@click="toggleCell(lane, col.key)"
>{{ t('plans.collapse') }}</button>
<div v-if="!groupsIn(lane, col.key).length" class="pb-cell__empty"></div>
</div>
</div>
</div>
</div>
<!-- Plan detail: custom right-side slide-in panel (matches focus-panel) -->
<PlanDetailPanel
:open="detailOpen"
:plan="detailPlan"
:loading="loadingDetail"
:assignee-name="detailAssignee.name"
:assignee-icon="detailAssignee.icon"
@close="detailOpen = false"
/>
<!-- Active goals: custom right-side panel (matches plan detail) -->
<GoalsPanel
:open="goalDrawerOpen"
:goals="activeGoals"
:loading="loadingGoals"
@close="goalDrawerOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAgentStore } from '@/stores/useAgentStore'
import AgentPickerDialog from '@/components/common/AgentPickerDialog.vue'
import SkillIcon from '@/components/common/SkillIcon.vue'
import PlanDetailPanel from '@/components/agents/PlanDetailPanel.vue'
import GoalsPanel from '@/components/agents/GoalsPanel.vue'
import { planApi, goalApi, type Goal } from '@/api'
import type { Plan } from '@/types'
const { t } = useI18n()
const agentStore = useAgentStore()
type PlanStatus = 'pending' | 'running' | 'completed' | 'failed'
interface Lane { key: string; name: string; icon?: string | null; plans: Plan[]; latest: number }
/** A column card: one cleaned goal, with every plan that re-ran it (latest first). */
interface PlanGroup { key: string; goal: string; plans: Plan[]; latest: Plan }
// Goal follow-up re-enters planning each turn, so a single objective can spawn
// many near-identical plans. Collapse them per column into one card (×N badge)
// instead of a wall of duplicates closer to a "task with N runs" than N tasks.
const GROUP_LIMIT = 6
const plans = ref<Plan[]>([])
const loading = ref(false)
const filterAgentId = ref<string>('')
const expandedCells = ref<Set<string>>(new Set())
const detailOpen = ref(false)
const detailPlan = ref<Plan | null>(null)
const loadingDetail = ref(false)
const detailAssignee = computed<{ name: string; icon?: string | null }>(() => {
const p = detailPlan.value
if (!p) return { name: '' }
const meta = agentById.value.get(String(p.agentId))
return { name: meta?.name || t('plans.unknownAgent'), icon: meta?.icon }
})
const goalDrawerOpen = ref(false)
const loadingGoals = ref(false)
const activeGoals = ref<Goal[]>([])
const columns = computed<{ key: PlanStatus; label: string }[]>(() => [
{ key: 'pending', label: t('plans.col.pending') },
{ key: 'running', label: t('plans.col.running') },
{ key: 'completed', label: t('plans.col.completed') },
{ key: 'failed', label: t('plans.col.failed') },
])
const agentById = computed(() => {
const m = new Map<string, { name: string; icon?: string | null }>()
for (const a of agentStore.agents) m.set(String(a.id), { name: a.name, icon: a.icon })
return m
})
// Group plans into assignee lanes; only employees with at least one plan get a
// lane. Optional picker narrows to a single employee. Lanes ordered by most
// recent plan activity so the busy ones surface first.
const lanes = computed<Lane[]>(() => {
const src = filterAgentId.value
? plans.value.filter((p) => String(p.agentId) === filterAgentId.value)
: plans.value
const map = new Map<string, Lane>()
for (const p of src) {
const key = String(p.agentId)
let lane = map.get(key)
if (!lane) {
const meta = agentById.value.get(key)
lane = { key, name: meta?.name || t('plans.unknownAgent'), icon: meta?.icon, plans: [], latest: 0 }
map.set(key, lane)
}
lane.plans.push(p)
const ts = p.createTime ? new Date(p.createTime).getTime() : 0
if (ts > lane.latest) lane.latest = ts
}
return [...map.values()].sort((a, b) => b.latest - a.latest)
})
function planTs(p: Plan): number {
return p.createTime ? new Date(p.createTime).getTime() : 0
}
// Drop the appended "[Follow-up guidance] ..." block so re-runs of one objective
// share a title and therefore a group.
function cleanGoal(goal?: string): string {
if (!goal) return ''
const i = goal.indexOf('[Follow-up guidance]')
return (i >= 0 ? goal.slice(0, i) : goal).trim()
}
// Group a column's plans by cleaned goal, newest run first; groups ordered by
// most-recent activity.
function groupsIn(lane: Lane, status: PlanStatus): PlanGroup[] {
const map = new Map<string, Plan[]>()
for (const p of lane.plans) {
if (p.status !== status) continue
const key = cleanGoal(p.goal) || String(p.id)
const arr = map.get(key)
if (arr) arr.push(p)
else map.set(key, [p])
}
const groups: PlanGroup[] = [...map.entries()].map(([key, ps]) => {
const sorted = [...ps].sort((a, b) => planTs(b) - planTs(a))
return { key, goal: cleanGoal(sorted[0].goal) || t('plans.untitled'), plans: sorted, latest: sorted[0] }
})
return groups.sort((a, b) => planTs(b.latest) - planTs(a.latest))
}
function cellKey(lane: Lane, status: PlanStatus): string {
return `${lane.key}:${status}`
}
function visibleGroups(lane: Lane, status: PlanStatus): PlanGroup[] {
const all = groupsIn(lane, status)
return expandedCells.value.has(cellKey(lane, status)) ? all : all.slice(0, GROUP_LIMIT)
}
function hiddenCount(lane: Lane, status: PlanStatus): number {
return Math.max(0, groupsIn(lane, status).length - GROUP_LIMIT)
}
function toggleCell(lane: Lane, status: PlanStatus) {
const key = cellKey(lane, status)
const next = new Set(expandedCells.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedCells.value = next
}
function progressPct(plan: Plan): number {
if (!plan.totalSteps) return 0
return Math.round((plan.completedSteps / plan.totalSteps) * 100)
}
function laneLetter(name: string): string {
return (name || '?').trim().charAt(0).toUpperCase()
}
async function reload() {
loading.value = true
try {
if (!agentStore.agents.length) await agentStore.fetchAgents()
const res: any = await planApi.listAll(200)
plans.value = (res?.data ?? []) as Plan[]
} finally {
loading.value = false
}
}
function onFilterChange(value: string | number | null) {
filterAgentId.value = value == null ? '' : String(value)
}
async function openDetail(plan: Plan) {
detailPlan.value = plan
detailOpen.value = true
loadingDetail.value = true
try {
const res: any = await planApi.get(String(plan.id))
detailPlan.value = (res?.data ?? plan) as Plan
} finally {
loadingDetail.value = false
}
}
async function openGoalDrawer() {
goalDrawerOpen.value = true
await loadGoals()
}
async function loadGoals() {
loadingGoals.value = true
try {
const res: any = await goalApi.list({ status: 'active', limit: 100 })
const all = (res?.data ?? []) as Goal[]
activeGoals.value = filterAgentId.value
? all.filter((g) => String(g.agentId) === filterAgentId.value)
: all
} finally {
loadingGoals.value = false
}
}
onMounted(reload)
</script>
<style scoped>
.plan-board {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Toolbar */
.pb-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
}
.pb-agent-select {
width: 220px;
}
.pb-toolbar__spacer {
flex: 1;
}
.pb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
border-radius: var(--mc-radius-md);
font-size: 13.5px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.pb-btn:hover:not(:disabled) {
border-color: var(--mc-border-strong);
}
.pb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pb-btn.is-active {
border-color: var(--mc-primary);
color: var(--mc-primary);
}
.pb-btn--icon {
padding: 8px 11px;
}
.pb-btn__badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--mc-radius-full);
background: var(--mc-primary);
color: #fff;
font-size: 11px;
line-height: 18px;
text-align: center;
}
.pb-spin {
animation: pb-spin 0.8s linear infinite;
}
@keyframes pb-spin {
to { transform: rotate(360deg); }
}
/* Blank */
.pb-blank {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 24px;
}
.pb-hint {
padding: 16px;
font-size: 13px;
color: var(--mc-text-tertiary);
}
/* Swimlanes */
.pb-swim {
padding: 8px 8px 12px;
overflow-x: auto;
}
.pb-swim__head,
.pb-lane {
display: flex;
gap: 12px;
min-width: 760px;
}
.pb-corner,
.pb-lane__label {
width: 168px;
flex-shrink: 0;
}
.pb-cols {
display: flex;
flex: 1;
gap: 12px;
min-width: 0;
}
.pb-colhead {
flex: 1;
min-width: 150px;
display: flex;
align-items: center;
gap: 7px;
padding: 10px 12px;
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
}
/* Lane */
.pb-lane {
border-top: 1px solid var(--mc-border-light);
padding: 12px 0;
}
.pb-lane__label {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px;
}
.pb-avatar {
width: 34px;
height: 34px;
border-radius: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
font-weight: 700;
font-size: 14px;
}
.pb-avatar :deep(.skill-icon) { width: 100% !important; height: 100% !important; display: flex; align-items: center; justify-content: center; }
.pb-avatar :deep(.skill-icon__img) { width: 62%; height: 62%; object-fit: contain; }
.pb-avatar :deep(.skill-icon__glyph) { font-size: 18px; line-height: 1; }
.pb-lane__id {
min-width: 0;
}
.pb-lane__name {
font-size: 13.5px;
font-weight: 600;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pb-lane__count {
font-size: 11px;
color: var(--mc-text-tertiary);
margin-top: 2px;
}
/* Cell */
.pb-cell {
flex: 1;
min-width: 150px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: var(--mc-radius-md);
}
.pb-cell__empty {
text-align: center;
color: var(--mc-text-quaternary);
font-size: 13px;
padding: 6px 0;
}
/* Status dots + tags */
.pb-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--mc-text-tertiary);
}
.pb-dot.is-pending { background: var(--mc-text-tertiary); }
.pb-dot.is-running { background: var(--mc-primary); }
.pb-dot.is-completed { background: var(--mc-success); }
.pb-dot.is-failed { background: var(--mc-danger); }
/* Plan card */
.pb-card {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: var(--mc-radius-md);
padding: 10px 11px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.pb-card:hover {
border-color: var(--mc-border-strong);
box-shadow: var(--mc-shadow-soft);
}
.pb-card__goal {
font-size: 12.5px;
line-height: 1.45;
color: var(--mc-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pb-card__runs {
flex-shrink: 0;
padding: 0 6px;
height: 16px;
line-height: 16px;
border-radius: var(--mc-radius-full);
background: var(--mc-bg-muted);
color: var(--mc-text-tertiary);
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.pb-more {
margin-top: 2px;
padding: 6px 8px;
border: 1px dashed var(--mc-border);
border-radius: var(--mc-radius-md);
background: transparent;
color: var(--mc-text-tertiary);
font-size: 11.5px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.pb-more:hover {
background: var(--mc-bg-hover);
color: var(--mc-text-secondary);
border-color: var(--mc-border-strong);
}
.pb-card__foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 9px;
}
.pb-card__steps {
font-size: 11px;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* Progress */
.pb-progress {
flex: 1;
height: 4px;
border-radius: var(--mc-radius-full);
background: var(--mc-bg-muted);
overflow: hidden;
}
.pb-progress__bar {
height: 100%;
background: var(--mc-success);
transition: width 0.3s;
}
.pb-progress__bar.is-running { background: var(--mc-primary); }
.pb-progress__bar.is-pending { background: var(--mc-text-tertiary); }
.pb-progress__bar.is-failed { background: var(--mc-danger); }
@media (max-width: 900px) {
.pb-agent-select {
width: 160px;
}
}
</style>

View File

@ -0,0 +1,444 @@
<template>
<!--
Plan (task) detail a right-anchored slide-in panel matching the project's
custom focus-panel language (backdrop blur + mc-surface-card + round close),
NOT the default el-drawer chrome. Content is laid out like a kanban task
card: assignee + status header, KPI tiles, output, and a step timeline.
-->
<Teleport to="body">
<Transition name="pd-slide">
<div v-if="open && plan" class="pd-backdrop" @click.self="$emit('close')">
<aside class="pd-panel mc-surface-card" role="dialog" aria-modal="true">
<button class="pd-close" type="button" :aria-label="t('live.actions.close')" @click="$emit('close')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6 L18 18 M18 6 L6 18"/></svg>
</button>
<!-- Header: assignee + status -->
<div class="pd-head">
<div class="pd-assignee">
<div class="pd-avatar">
<SkillIcon v-if="assigneeIcon" :value="assigneeIcon" :size="20" fallback="🤖" />
<span v-else class="pd-avatar__letter">{{ letter(assigneeName) }}</span>
</div>
<div class="pd-assignee__id">
<div class="pd-assignee__name">{{ assigneeName || t('plans.unknownAgent') }}</div>
<div class="pd-assignee__cap">{{ t('plans.assignee') }}</div>
</div>
</div>
<span class="pd-status" :class="`is-${plan.status}`">{{ statusLabel(plan.status) }}</span>
</div>
<!-- Title -->
<h2 class="pd-title">{{ cleanGoal(plan.goal) }}</h2>
<!-- KPI tiles -->
<div class="pd-tiles">
<div class="pd-tile">
<div class="pd-tile__value">{{ plan.completedSteps }}/{{ plan.totalSteps }}</div>
<div class="pd-tile__label">{{ t('plans.steps') }}</div>
</div>
<div class="pd-tile">
<div class="pd-tile__value">{{ pct }}%</div>
<div class="pd-tile__label">{{ t('plans.progress') }}</div>
</div>
<div class="pd-tile">
<div class="pd-tile__value pd-tile__date">{{ shortDate(plan.createTime) }}</div>
<div class="pd-tile__label">{{ t('plans.created') }}</div>
</div>
</div>
<!-- Output / summary -->
<section v-if="plan.summary" class="pd-section">
<div class="pd-section__title">{{ t('plans.output') }}</div>
<div class="pd-output markdown-body" v-html="summaryHtml"></div>
</section>
<!-- Steps timeline -->
<section class="pd-section">
<div class="pd-section__title">{{ t('plans.steps') }}</div>
<div v-if="loading" class="pd-loading">{{ t('common.loading') }}</div>
<ol v-else class="pd-timeline">
<li
v-for="step in (plan.steps ?? [])"
:key="String(step.id)"
class="pd-step"
:class="{ 'is-open': expanded === String(step.id), 'has-result': !!step.result }"
@click="toggle(step)"
>
<span class="pd-step__dot" :class="`is-${step.status}`"></span>
<div class="pd-step__body">
<div class="pd-step__title"><b>{{ step.stepIndex + 1 }}.</b> {{ step.description }}</div>
<div v-if="step.result && expanded === String(step.id)" class="pd-step__result markdown-body" v-html="resultHtml"></div>
<div v-else-if="step.result" class="pd-step__hint">{{ t('plans.viewResult') }}</div>
</div>
</li>
</ol>
</section>
</aside>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import type { Plan, SubPlan } from '@/types'
const props = defineProps<{
open: boolean
plan: Plan | null
loading: boolean
assigneeName?: string
assigneeIcon?: string | null
}>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const expanded = ref<string>('')
// Render the plan output and the expanded step result as markdown, reusing the
// same renderer the chat uses. `streaming = false` full-fidelity one-shot
// render (code highlight + cache), since this content is already complete.
const expandedResultText = computed(() => {
if (!expanded.value) return ''
return (props.plan?.steps ?? []).find((s) => String(s.id) === expanded.value)?.result ?? ''
})
const { html: summaryHtml } = useStreamingMarkdown(() => props.plan?.summary ?? '', () => false)
const { html: resultHtml } = useStreamingMarkdown(() => expandedResultText.value, () => false)
const pct = computed(() => {
const p = props.plan
if (!p || !p.totalSteps) return 0
return Math.round((p.completedSteps / p.totalSteps) * 100)
})
function statusLabel(status: string): string {
return t(`plans.col.${status}`, status)
}
// The persisted goal can carry an appended "[Follow-up guidance] ..." block
// (added when a goal follow-up re-enters planning). Strip it for display so the
// title reads as the original task, not the internal re-prompt.
function cleanGoal(goal: string): string {
if (!goal) return ''
const i = goal.indexOf('[Follow-up guidance]')
return (i >= 0 ? goal.slice(0, i) : goal).trim()
}
function letter(name?: string): string {
return (name || '?').trim().charAt(0).toUpperCase()
}
function shortDate(ts?: string): string {
if (!ts) return '—'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return '—'
return `${d.getMonth() + 1}/${d.getDate()}`
}
function toggle(step: SubPlan) {
if (!step.result) return
expanded.value = expanded.value === String(step.id) ? '' : String(step.id)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) emit('close')
}
// Reset the expanded step whenever a different plan is shown.
watch(() => props.plan?.id, () => { expanded.value = '' })
watch(() => props.open, (open) => {
if (typeof document === 'undefined') return
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
<style scoped>
.pd-backdrop {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
justify-content: flex-end;
background: rgba(20, 14, 10, 0.42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
html.dark .pd-backdrop {
background: rgba(0, 0, 0, 0.5);
}
/* Slide-in from the right */
.pd-slide-enter-active,
.pd-slide-leave-active {
transition: opacity 0.22s ease, backdrop-filter 0.22s ease, -webkit-backdrop-filter 0.22s ease;
}
.pd-slide-enter-active .pd-panel,
.pd-slide-leave-active .pd-panel {
transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.pd-slide-enter-from,
.pd-slide-leave-to {
opacity: 0;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
}
.pd-slide-enter-from .pd-panel,
.pd-slide-leave-to .pd-panel {
transform: translateX(24px);
}
.pd-panel {
position: relative;
width: 480px;
max-width: 100%;
height: 100vh;
overflow-y: auto;
padding: 28px 28px 32px;
border-radius: 24px 0 0 24px;
box-shadow: -28px 0 80px -24px rgba(0, 0, 0, 0.35);
}
.pd-close {
position: absolute;
top: 18px;
right: 18px;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: var(--mc-bg-muted);
color: var(--mc-text-tertiary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.18s ease, color 0.18s ease;
}
.pd-close:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
/* Header */
.pd-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 36px 18px 0;
}
.pd-assignee {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.pd-avatar {
width: 38px;
height: 38px;
border-radius: 11px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
font-weight: 700;
font-size: 15px;
}
.pd-avatar :deep(.skill-icon) { width: 100% !important; height: 100% !important; display: flex; align-items: center; justify-content: center; }
.pd-avatar :deep(.skill-icon__img) { width: 62%; height: 62%; object-fit: contain; }
.pd-avatar :deep(.skill-icon__glyph) { font-size: 20px; line-height: 1; }
.pd-assignee__name {
font-size: 14px;
font-weight: 600;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pd-assignee__cap {
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mc-text-tertiary);
margin-top: 2px;
}
.pd-status {
flex-shrink: 0;
padding: 4px 11px;
border-radius: var(--mc-radius-full);
font-size: 12px;
font-weight: 600;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
}
.pd-status.is-running { background: var(--mc-primary-bg); color: var(--mc-primary); }
.pd-status.is-completed { background: var(--mc-success); color: #fff; }
.pd-status.is-failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
/* Title */
.pd-title {
margin: 0 0 18px;
font-size: 18px;
font-weight: 700;
line-height: 1.45;
letter-spacing: -0.01em;
color: var(--mc-text-primary);
}
/* KPI tiles */
.pd-tiles {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 22px;
}
.pd-tile {
padding: 13px 10px 11px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
border-radius: 12px;
text-align: center;
min-width: 0;
}
html.dark .pd-tile {
background: rgba(255, 255, 255, 0.04);
}
.pd-tile__value {
font-size: 19px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--mc-text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.pd-tile__date {
font-size: 16px;
}
.pd-tile__label {
font-size: 10.5px;
color: var(--mc-text-tertiary);
letter-spacing: 0.05em;
margin-top: 7px;
}
/* Sections */
.pd-section {
margin-top: 22px;
}
.pd-section__title {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mc-text-tertiary);
font-weight: 600;
margin-bottom: 12px;
}
.pd-output {
padding: 14px 16px;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: var(--mc-radius-md);
font-size: 13px;
line-height: 1.7;
color: var(--mc-text-secondary);
word-break: break-word;
}
.pd-output :deep(> :first-child) { margin-top: 0; }
.pd-output :deep(> :last-child) { margin-bottom: 0; }
.pd-loading {
font-size: 13px;
color: var(--mc-text-tertiary);
}
/* Timeline */
.pd-timeline {
list-style: none;
margin: 0;
padding: 0;
}
.pd-step {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 16px;
}
.pd-step::before {
content: '';
position: absolute;
left: 5px;
top: 16px;
bottom: 0;
width: 2px;
background: var(--mc-border-light);
}
.pd-step:last-child { padding-bottom: 0; }
.pd-step:last-child::before { display: none; }
.pd-step.has-result { cursor: pointer; }
.pd-step__dot {
position: relative;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 3px;
background: var(--mc-text-tertiary);
box-shadow: 0 0 0 3px var(--mc-bg-surface);
}
.pd-step__dot.is-pending { background: var(--mc-text-tertiary); }
.pd-step__dot.is-running { background: var(--mc-primary); }
.pd-step__dot.is-completed { background: var(--mc-success); }
.pd-step__dot.is-failed { background: var(--mc-danger); }
.pd-step__body {
min-width: 0;
flex: 1;
}
.pd-step__title {
font-size: 13px;
line-height: 1.5;
color: var(--mc-text-primary);
word-break: break-word;
}
.pd-step__result {
margin-top: 8px;
padding: 10px 12px;
background: var(--mc-bg-sunken);
border-radius: var(--mc-radius-md);
font-size: 12px;
line-height: 1.6;
color: var(--mc-text-secondary);
word-break: break-word;
max-height: 280px;
overflow-y: auto;
}
.pd-step__result :deep(> :first-child) { margin-top: 0; }
.pd-step__result :deep(> :last-child) { margin-bottom: 0; }
.pd-step__hint {
margin-top: 5px;
font-size: 11.5px;
color: var(--mc-primary);
}
@media (max-width: 600px) {
.pd-panel {
width: 100%;
border-radius: 0;
padding: 24px 20px 28px;
}
}
</style>

View File

@ -0,0 +1,397 @@
<template>
<!--
Live lifecycle board: the same real-time snapshot the grid renders, laid out
across run/goal lifecycle columns instead of a flat grid. Running cards carry
a goal-completion overlay (goal linked by conversationId); terminal goals
populate the Done / Unmet columns. Read-only status is execution-driven.
-->
<div class="live-board">
<section v-for="col in columns" :key="col.key" class="lb-col" :class="`is-${col.key}`">
<header class="lb-col__head">
<span class="lb-dot" :class="`is-${col.key}`"></span>
<span class="lb-col__title">{{ col.label }}</span>
<span class="lb-col__count">{{ col.items.length }}</span>
<span v-if="col.key === 'running' && (summary?.queued ?? 0) > 0" class="lb-col__sub">
{{ t('live.board.queuedHint', { n: summary!.queued }) }}
</span>
</header>
<div class="lb-col__body">
<!-- Run cards (running / attention) -->
<template v-if="col.kind === 'run'">
<article
v-for="run in (col.items as LiveRunCard[])"
:key="run.conversationId"
class="lb-card lb-card--run"
:class="{ 'is-stuck': !!run.stuckReason, 'is-orphan': run.orphan && !run.stuckReason }"
@click="$emit('open', run)"
>
<div class="lb-card__top">
<div class="lb-avatar-wrap" :class="ringClass(run)">
<div class="lb-avatar" :style="avatarBgStyle(run)">
<SkillIcon v-if="run.agentIcon" :value="run.agentIcon" :size="22" fallback="🤖" />
<span v-else class="lb-avatar__letter">{{ avatarLetter(run) }}</span>
</div>
</div>
<div class="lb-card__id">
<div class="lb-card__name">{{ run.agentName || t('live.unknownAgent') }}</div>
<div class="lb-card__age">{{ formatAge(run.ageMs) }}</div>
</div>
</div>
<div class="lb-card__saying">
{{ humanSentence(run) }}
<span v-if="run.runningToolName" class="lb-tool" :title="run.runningToolName">{{ run.runningToolName }}</span>
</div>
<!-- Goal overlay: the conversation's goal becomes a visible bar -->
<div v-if="goalFor(run)" class="lb-goal">
<div class="lb-goal__bar">
<div class="lb-goal__fill" :style="{ width: goalPct(goalFor(run)) + '%' }"></div>
</div>
<span class="lb-goal__pct">{{ goalPct(goalFor(run)) }}%</span>
</div>
<div class="lb-card__foot">
<div class="lb-subs" v-if="run.subagentCount > 0" :title="t('live.subagentsBadge', { n: run.subagentCount })">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
{{ run.subagentCount }}
</div>
<div class="lb-actions">
<button class="lb-act" @click.stop="$emit('stop', run)">{{ t('live.actions.stop') }}</button>
<button v-if="run.stuckReason" class="lb-act lb-act--strong" @click.stop="$emit('recycle', run)">{{ t('live.actions.endIt') }}</button>
</div>
</div>
</article>
</template>
<!-- Goal cards (done / failed) -->
<template v-else>
<article
v-for="goal in (col.items as Goal[])"
:key="goal.id"
class="lb-card lb-card--goal"
:class="`is-${col.key}`"
>
<div class="lb-card__name">{{ goal.title || t('live.board.noGoal') }}</div>
<div v-if="goal.completionScore != null" class="lb-goal">
<div class="lb-goal__bar">
<div class="lb-goal__fill" :class="`is-${col.key}`" :style="{ width: goalPct(goal) + '%' }"></div>
</div>
<span class="lb-goal__pct">{{ goalPct(goal) }}%</span>
</div>
<p v-if="goal.progressSummary" class="lb-card__gap">{{ goal.progressSummary }}</p>
</article>
</template>
<div v-if="!col.items.length" class="lb-col__empty">{{ t('live.board.emptyCol') }}</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { useLiveAgent } from '@/composables/useLiveAgent'
import type { LiveRunCard, LiveSummary, Goal } from '@/api'
const props = defineProps<{
runs: LiveRunCard[]
summary: LiveSummary | null
goalByConv: Record<string, Goal>
doneGoals: Goal[]
failedGoals: Goal[]
}>()
defineEmits<{
(e: 'open', run: LiveRunCard): void
(e: 'stop', run: LiveRunCard): void
(e: 'recycle', run: LiveRunCard): void
}>()
const { t } = useI18n()
const { avatarLetter, avatarBgStyle, ringClass, humanSentence, formatAge } = useLiveAgent()
const runningRuns = computed(() => props.runs.filter((r) => !r.stuckReason && !r.orphan))
const attentionRuns = computed(() =>
props.runs
.filter((r) => !!r.stuckReason || r.orphan)
// Stuck (actionable) before orphan (merely unwatched).
.sort((a, b) => (a.stuckReason ? 0 : 1) - (b.stuckReason ? 0 : 1)),
)
const columns = computed(() => [
{ key: 'running', kind: 'run', label: t('live.board.running'), items: runningRuns.value },
{ key: 'attention', kind: 'run', label: t('live.board.attention'), items: attentionRuns.value },
{ key: 'done', kind: 'goal', label: t('live.board.done'), items: props.doneGoals },
{ key: 'failed', kind: 'goal', label: t('live.board.failed'), items: props.failedGoals },
] as { key: string; kind: 'run' | 'goal'; label: string; items: LiveRunCard[] | Goal[] }[])
function goalFor(run: LiveRunCard): Goal | undefined {
return props.goalByConv[run.conversationId]
}
function goalPct(goal?: Goal): number {
if (!goal || goal.completionScore == null) return 0
return Math.round((goal.completionScore || 0) * 100)
}
</script>
<style scoped>
.live-board {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 14px;
}
.lb-col {
display: flex;
flex-direction: column;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: var(--mc-radius-lg);
min-height: 140px;
}
.lb-col__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
font-size: 12.5px;
font-weight: 600;
color: var(--mc-text-secondary);
border-bottom: 1px solid var(--mc-border-light);
}
.lb-col__count {
min-width: 20px;
padding: 0 6px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: var(--mc-radius-full);
background: var(--mc-bg-muted);
color: var(--mc-text-tertiary);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lb-col__sub {
margin-left: auto;
font-size: 11px;
font-weight: 500;
color: var(--mc-text-tertiary);
}
.lb-col__body {
flex: 1;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
max-height: calc(100vh - 360px);
}
.lb-col__empty {
text-align: center;
color: var(--mc-text-quaternary);
font-size: 12.5px;
padding: 14px 0;
}
/* Column accent dots */
.lb-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.lb-dot.is-running { background: hsl(155, 55%, 50%); }
.lb-dot.is-attention { background: hsl(20, 80%, 55%); }
.lb-dot.is-done { background: var(--mc-success); }
.lb-dot.is-failed { background: var(--mc-danger); }
/* Cards */
.lb-card {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: var(--mc-radius-md);
padding: 11px 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.lb-card--run {
cursor: pointer;
}
.lb-card--run:hover {
border-color: var(--mc-border-strong);
box-shadow: var(--mc-shadow-soft);
}
.lb-card--run.is-stuck {
border-color: hsla(20, 80%, 55%, 0.45);
}
.lb-card--run.is-orphan {
border-color: hsla(265, 50%, 60%, 0.3);
}
.lb-card__top {
display: flex;
align-items: center;
gap: 10px;
}
.lb-avatar-wrap {
position: relative;
flex-shrink: 0;
padding: 3px;
margin: -3px;
}
.lb-avatar-wrap::before {
content: '';
position: absolute;
inset: 1px;
border-radius: 12px;
border: 2px solid transparent;
pointer-events: none;
}
.lb-avatar-wrap.ring-healthy::before { border-color: hsla(155, 55%, 50%, 0.7); }
.lb-avatar-wrap.ring-stuck::before { border-color: hsla(20, 80%, 55%, 0.8); }
.lb-avatar-wrap.ring-orphan::before { border-color: hsla(265, 50%, 60%, 0.6); }
.lb-avatar-wrap.ring-thinking::before { border-color: hsla(155, 55%, 55%, 0.5); }
.lb-avatar {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-weight: 700;
font-size: 14px;
}
.lb-avatar :deep(.skill-icon) {
width: 100% !important;
height: 100% !important;
display: flex;
align-items: center;
justify-content: center;
}
.lb-avatar :deep(.skill-icon__img) { width: 62%; height: 62%; object-fit: contain; }
.lb-avatar :deep(.skill-icon__glyph) { font-size: 18px; line-height: 1; }
.lb-card__id {
min-width: 0;
flex: 1;
}
.lb-card__name {
font-size: 13px;
font-weight: 600;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-card__age {
font-size: 11px;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.lb-card__saying {
margin-top: 9px;
font-size: 12.5px;
line-height: 1.5;
color: var(--mc-text-secondary);
}
.lb-tool {
display: inline-block;
margin-left: 4px;
padding: 1px 6px;
border-radius: var(--mc-radius-sm);
background: var(--mc-accent-soft);
color: var(--mc-accent);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
/* Goal overlay / goal cards */
.lb-goal {
display: flex;
align-items: center;
gap: 7px;
margin-top: 9px;
}
.lb-goal__bar {
flex: 1;
height: 4px;
border-radius: var(--mc-radius-full);
background: var(--mc-bg-muted);
overflow: hidden;
}
.lb-goal__fill {
height: 100%;
background: var(--mc-primary);
transition: width 0.3s;
}
.lb-goal__fill.is-done { background: var(--mc-success); }
.lb-goal__fill.is-failed { background: var(--mc-danger); }
.lb-goal__pct {
font-size: 11px;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
}
.lb-card__gap {
margin: 8px 0 0;
font-size: 11.5px;
color: var(--mc-text-tertiary);
line-height: 1.45;
}
.lb-card__foot {
display: flex;
align-items: center;
margin-top: 10px;
}
.lb-subs {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
}
.lb-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
.lb-act {
padding: 4px 10px;
font-size: 11.5px;
border-radius: var(--mc-radius-full);
border: 1px solid var(--mc-border-light);
background: transparent;
color: var(--mc-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.lb-act:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
border-color: var(--mc-border);
}
.lb-act--strong {
border-color: hsla(20, 80%, 55%, 0.5);
color: hsl(20, 75%, 45%);
}
.lb-act--strong:hover {
background: hsla(20, 80%, 55%, 0.12);
}
@media (max-width: 1100px) {
.live-board {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
}
</style>

View File

@ -15,7 +15,7 @@
<span>{{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }}</span>
</button>
<div v-if="showFilterRow" class="filter-row">
<div v-if="showFilterRow && layout === 'grid'" class="filter-row">
<button
v-for="opt in filterOptions"
:key="opt.key"
@ -30,6 +30,27 @@
<div class="toolbar-spacer"></div>
<div class="layout-toggle" role="group">
<button
class="layout-seg"
:class="{ 'is-active': layout === 'grid' }"
:title="t('live.layout.gridHint')"
@click="setLayout('grid')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
<span>{{ t('live.layout.grid') }}</span>
</button>
<button
class="layout-seg"
:class="{ 'is-active': layout === 'board' }"
:title="t('live.layout.boardHint')"
@click="setLayout('board')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="6" height="18" rx="1"/><rect x="10" y="3" width="6" height="11" rx="1"/><rect x="17" y="3" width="4" height="7" rx="1"/></svg>
<span>{{ t('live.layout.board') }}</span>
</button>
</div>
<button
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
class="chip-btn chip-btn-warm"
@ -51,6 +72,19 @@
</div>
</div>
<!-- Lifecycle board: runs + goals across status columns -->
<LiveBoard
v-else-if="layout === 'board'"
:runs="snapshot?.runs ?? []"
:summary="snapshot?.summary ?? null"
:goal-by-conv="goalByConv"
:done-goals="doneGoals"
:failed-goals="failedGoals"
@open="openDetail"
@stop="confirmStop"
@recycle="confirmRecycle"
/>
<!-- Empty: nothing to see -->
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
<div class="empty-orb"></div>
@ -158,9 +192,10 @@ import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import SkillIcon from '@/components/common/SkillIcon.vue'
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
import LiveBoard from '@/components/live/LiveBoard.vue'
import { useLiveAgent } from '@/composables/useLiveAgent'
import { mcConfirm } from '@/components/common/useConfirm'
import { liveApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard } from '@/api'
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
const { t } = useI18n()
const {
@ -182,6 +217,42 @@ const detail = ref<LiveRunCard | null>(null)
const activeFilter = ref<FilterKey>('all')
let timer: ReturnType<typeof setInterval> | null = null
// ===== Lifecycle board mode =====
// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched
// lazily (only once the board is shown) and refreshed alongside the snapshot.
const layout = ref<'grid' | 'board'>('grid')
const goalsActive = ref<Goal[]>([])
const doneGoals = ref<Goal[]>([])
const failedGoals = ref<Goal[]>([])
const goalByConv = computed<Record<string, Goal>>(() => {
const map: Record<string, Goal> = {}
for (const g of goalsActive.value) map[g.conversationId] = g
return map
})
function setLayout(next: 'grid' | 'board') {
if (layout.value === next) return
layout.value = next
if (next === 'board') loadGoals()
}
async function loadGoals() {
// Best-effort: the board still renders its run columns without goals.
try {
const [active, done, failed] = await Promise.all([
goalApi.list({ status: 'active', limit: 100 }),
goalApi.list({ status: 'completed', limit: 50 }),
goalApi.list({ status: 'exhausted', limit: 50 }),
])
goalsActive.value = ((active as any)?.data ?? []) as Goal[]
doneGoals.value = ((done as any)?.data ?? []) as Goal[]
failedGoals.value = ((failed as any)?.data ?? []) as Goal[]
} catch {
/* leave whatever we had; columns degrade to empty */
}
}
function isWorking(r: LiveRunCard): boolean {
return !r.stuckReason && !r.orphan
}
@ -347,6 +418,8 @@ async function refresh() {
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
if (fresh) detail.value = fresh
}
// Keep the board's goal columns fresh on the same cadence as the snapshot.
if (layout.value === 'board') loadGoals()
} catch (e: any) {
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
} finally {
@ -469,6 +542,43 @@ onBeforeUnmount(() => {
min-width: 0;
}
/* ===== Grid / board layout toggle ===== */
.layout-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
}
.layout-seg {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--mc-text-tertiary);
font-size: 12.5px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.layout-seg:hover {
color: var(--mc-text-primary);
}
.layout-seg.is-active {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
box-shadow: var(--mc-shadow-soft);
}
/* ===== Filter chip row (kanban-inspired, soft) ===== */
.filter-row {
display: flex;

View File

@ -416,6 +416,38 @@ export default {
docs: {
title: 'Docs',
},
plans: {
title: 'Plan Board',
subtitle: "Lay out an employee's plans and steps as a board by status",
selectAgent: 'Select an employee',
allEmployees: 'All employees',
unknownAgent: 'Unknown employee',
noPlansAll: 'No plans across the team yet. Once a plan-execute employee runs a multi-step task, it shows up here.',
detail: 'Plan detail',
assignee: 'Assignee',
progress: 'Progress',
created: 'Created',
output: 'Output',
runs: 'Ran {n}× for the same goal',
more: 'Show {n} more',
collapse: 'Collapse',
goals: 'Goals',
activeGoals: 'Active Goals',
noGoals: 'No active goals',
plans: 'Plans',
noPlans: 'No plans for this employee yet',
noAgent: 'Select an employee to begin',
selectPlan: 'Pick a plan on the left to view its step board',
untitled: 'Untitled plan',
steps: 'steps',
viewResult: 'Click to view result',
col: {
pending: 'To Do',
running: 'In Progress',
completed: 'Done',
failed: 'Failed',
},
},
nav: {
dashboard: 'Dashboard',
chat: 'Chat',
@ -569,6 +601,22 @@ export default {
errors: {
loadFailed: 'Could not load runtime status.',
},
layout: {
grid: 'Grid',
board: 'Board',
gridHint: 'Grid view',
boardHint: 'Lifecycle board view',
},
board: {
running: 'Running',
attention: 'Needs Attention',
done: 'Done',
failed: 'Unmet',
queuedHint: '{n} queued',
goalLabel: 'Goal',
emptyCol: 'Empty',
noGoal: 'No goal',
},
},
doctor: {
title: 'System Diagnostics',
@ -1173,6 +1221,7 @@ export default {
views: {
roster: 'Roster',
live: 'Live',
plans: 'Plans',
},
templates: {
title: 'Choose a Role',

View File

@ -416,6 +416,38 @@ export default {
docs: {
title: '帮助文档',
},
plans: {
title: '计划看板',
subtitle: '把员工的计划与步骤按状态铺成看板',
selectAgent: '选择员工',
allEmployees: '全部员工',
unknownAgent: '未知员工',
noPlansAll: '团队暂无任何计划。让计划执行型员工跑一个多步任务后,这里就会出现。',
detail: '计划详情',
assignee: '受派员工',
progress: '进度',
created: '创建',
output: '产出',
runs: '同一目标运行 {n} 次',
more: '展开 {n} 项',
collapse: '收起',
goals: '目标',
activeGoals: '活跃目标',
noGoals: '暂无活跃目标',
plans: '计划',
noPlans: '该员工暂无计划',
noAgent: '请选择一个员工',
selectPlan: '从左侧选择一个计划,查看其步骤看板',
untitled: '未命名计划',
steps: '步骤',
viewResult: '点击查看结果',
col: {
pending: '待执行',
running: '执行中',
completed: '已完成',
failed: '失败',
},
},
nav: {
dashboard: '仪表盘',
chat: '对话',
@ -1064,6 +1096,7 @@ export default {
views: {
roster: '花名册',
live: '现场',
plans: '计划看板',
},
templates: {
title: '选择岗位',
@ -2104,6 +2137,22 @@ export default {
errors: {
loadFailed: '无法加载运行时状态。',
},
layout: {
grid: '网格',
board: '看板',
gridHint: '网格视图',
boardHint: '生命周期看板视图',
},
board: {
running: '运行中',
attention: '需关注',
done: '已完成',
failed: '未达成',
queuedHint: '排队 {n}',
goalLabel: '目标',
emptyCol: '暂无',
noGoal: '未设目标',
},
},
doctor: {
title: '系统诊断',

View File

@ -723,6 +723,8 @@ export interface SubPlan {
export interface Plan {
id: string | number
agentId: string
/** Conversation/run that produced the plan (may be absent on legacy rows). */
conversationId?: string
goal: string
status: 'pending' | 'running' | 'completed' | 'failed'
totalSteps: number

View File

@ -30,6 +30,11 @@
:class="{ warn: liveStuck > 0 }"
>{{ liveRunning }}</span>
</button>
<button
class="view-seg"
:class="{ 'is-active': view === 'plans' }"
@click="setView('plans')"
>{{ t('agents.views.plans') }}</button>
</div>
<button class="btn-secondary" style="display:inline-flex;align-items:center;gap:6px;" @click="router.push('/agents/create')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -162,7 +167,10 @@
</template>
<!-- Live: what the team is doing right now -->
<LivePanel v-else />
<LivePanel v-else-if="view === 'live'" />
<!-- Plans: the team's plans as a step board -->
<PlanBoard v-else-if="view === 'plans'" />
</div>
</div>
@ -707,6 +715,7 @@ import type { Agent } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
import LivePanel from '@/components/live/LivePanel.vue'
import PlanBoard from '@/components/agents/PlanBoard.vue'
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
import {
emptyProfile,
@ -1133,16 +1142,19 @@ const filteredAgents = computed(() => {
// Roster Live view switch admin only. The running/stuck counts feed the
// segmented control's pulse + badge so you know whether Live is worth a look.
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
const view = ref<'roster' | 'live'>(
route.query.view === 'live' && isAdminRole.value ? 'live' : 'roster',
type AgentView = 'roster' | 'live' | 'plans'
const view = ref<AgentView>(
isAdminRole.value && (route.query.view === 'live' || route.query.view === 'plans')
? (route.query.view as AgentView)
: 'roster',
)
const liveRunning = ref(0)
const liveStuck = ref(0)
let livePollTimer: ReturnType<typeof setInterval> | null = null
function setView(next: 'roster' | 'live') {
function setView(next: AgentView) {
view.value = next
router.replace({ query: next === 'live' ? { view: 'live' } : {} })
router.replace({ query: next === 'roster' ? {} : { view: next } })
}
async function refreshLiveCounts() {