fix: merge project workflow into project view

This commit is contained in:
Yoilun
2026-05-26 11:16:12 +08:00
parent dd834378af
commit 573d36bb60
6 changed files with 414 additions and 34 deletions

View File

@@ -1,7 +1,6 @@
<script setup>
import { computed, ref } from 'vue'
import ProjectView from './views/ProjectView.vue'
import WorkflowView from './views/WorkflowView.vue'
import AgentView from './views/AgentView.vue'
import DraftsView from './views/DraftsView.vue'
import SettingsView from './views/SettingsView.vue'
@@ -9,7 +8,6 @@ import StatusBadge from './components/StatusBadge.vue'
const tabs = [
{ id: 'projects', label: '项目视图', component: ProjectView },
{ id: 'workflow', label: '工作流视图', component: WorkflowView },
{ id: 'agents', label: '智能体视图', component: AgentView },
{ id: 'drafts', label: '草稿', component: DraftsView },
{ id: 'settings', label: '设置', component: SettingsView },
@@ -30,7 +28,7 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
<div class="connection-card" aria-label="连接状态">
<StatusBadge label="安全接口" status="complete" confidence="中" source="计划文件" />
<strong>按需读取和确认写回</strong>
<span>项目运行线程和工作流保持只读智能体草稿仅在校验备份和确认后单文件写回</span>
<span>项目运行线程和项目内工作流保持只读智能体草稿仅在校验备份和确认后单文件写回</span>
</div>
</header>

View File

@@ -195,6 +195,11 @@ export function normalizeRuntime(payload = {}) {
source: source.label,
confidence: source.confidenceLabel,
lastActivity: thread.updatedAt || thread.createdAt || '没有时间记录',
displayName: actorDisplayName({
name: thread.agentNickname,
role: thread.agentRole || thread.role,
}),
phaseName: extractPhaseName([goalText, taskSummary, thread.title, thread.preview, thread.agentPath]) || '未标注阶段',
}
})
const source = normalizeSource(payload.source)
@@ -243,9 +248,12 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath
const threadIds = new Set(agents.map((agent) => agent.id))
const agentByID = new Map(agents.map((agent) => [agent.id, agent]))
const threadByID = new Map(runtime.threads.map((thread) => [thread.id, thread]))
const projectThreadIds = new Set(runtime.threads.filter((thread) => threadCanJoinProjectFlow(thread, targetPath)).map((thread) => thread.id))
const projectThreadIds = new Set([
...threadIds,
...runtime.threads.filter((thread) => threadBelongsToProject(thread, targetPath)).map((thread) => thread.id),
])
const threads = runtime.threads.filter((thread) => threadIds.has(thread.id))
const goals = runtime.goals.filter((goal) => threadIds.has(goal.threadId))
const goals = runtime.goals.filter((goal) => threadIds.has(goal.threadId) || projectThreadIds.has(goal.threadId))
const edges = runtime.edges.filter((edge) =>
(threadIds.has(edge.fromThreadId) || threadIds.has(edge.toThreadId)) &&
projectThreadIds.has(edge.fromThreadId) &&
@@ -253,6 +261,8 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath
)
const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source }))
const phaseGroups = buildPhaseGroups(agents)
const phaseHandoffs = buildPhaseHandoffs(handoffs, phaseGroups)
const supervision = buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals })
return {
...runtime,
@@ -262,6 +272,8 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath
edges,
handoffs,
phaseGroups,
phaseHandoffs,
supervision,
isEmpty: agents.length === 0,
emptyTitle: '这个项目没有运行线程',
emptyText: projectPath
@@ -273,14 +285,22 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath
function normalizeProjectHandoff(edge, context) {
const source = normalizeSource(edge.source, context.source)
const status = normalizeSpawnStatus(edge.reason || edge.status) || 'unknown'
const fromAgent = context.agentByID.get(edge.fromThreadId)
const toAgent = context.agentByID.get(edge.toThreadId)
const fromIsMain = isMainThread(context.threadByID.get(edge.fromThreadId))
const toIsMain = isMainThread(context.threadByID.get(edge.toThreadId))
const directionLabel = handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain })
return {
id: `${edge.fromThreadId || 'unknown'}-${edge.toThreadId || 'unknown'}-${edge.createdAt || edge.reason || edge.status || 'event'}`,
from: runtimeNodeName(edge.fromThreadId, context),
to: runtimeNodeName(edge.toThreadId, context),
summary: formatStatus(status),
directionLabel,
status,
time: edge.createdAt || '后端事件',
source: source.label,
confidence: source.confidenceLabel,
phaseName: handoffPhaseName({ fromAgent, toAgent, toIsMain }),
}
}
@@ -290,19 +310,50 @@ function runtimeNodeName(threadID, { agentByID, threadByID }) {
}
const agent = agentByID.get(threadID)
if (agent) {
return agent.name
return agent.displayName
}
const thread = threadByID.get(threadID)
if (thread?.threadSource === 'user' || (!thread?.agentNickname && !thread?.agentRole && !thread?.agentPath && !thread?.role)) {
return '主线程'
if (isMainThread(thread)) {
return '主线程 / 主智能体监管'
}
return thread?.agentNickname || thread?.agentRole || thread?.role || '未知线程'
return actorDisplayName({
name: thread?.agentNickname,
role: thread?.agentRole || thread?.role,
fallbackName: '未知线程',
fallbackRole: '角色未知',
})
}
function handoffPhaseName({ fromAgent, toAgent, toIsMain }) {
if (toIsMain) {
return fromAgent?.phaseName || '未标注阶段'
}
if (toAgent?.phaseName) {
return toAgent.phaseName
}
return fromAgent?.phaseName || '未标注阶段'
}
function handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain }) {
if (fromIsMain && toAgent) {
return '主线程派发'
}
if (fromAgent && toIsMain) {
return '回到主线程'
}
if (fromAgent && toAgent && fromAgent.phaseName !== toAgent.phaseName) {
return '跨阶段交接'
}
if (fromAgent && toAgent) {
return '子智能体交接'
}
return '线程交接'
}
function buildPhaseGroups(agents) {
const groups = new Map()
for (const agent of agents) {
const phaseName = extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段'
const phaseName = agent.phaseName || extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段'
if (!groups.has(phaseName)) {
groups.set(phaseName, [])
}
@@ -322,6 +373,54 @@ function buildPhaseGroups(agents) {
.sort((a, b) => phaseSortKey(a.name) - phaseSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN'))
}
function buildPhaseHandoffs(handoffs, phaseGroups) {
const groups = new Map(phaseGroups.map((phase) => [phase.name, { ...phase, handoffs: [] }]))
for (const handoff of handoffs) {
const phaseName = handoff.phaseName || '未标注阶段'
if (!groups.has(phaseName)) {
groups.set(phaseName, {
name: phaseName,
status: handoff.status,
statusZh: formatStatus(handoff.status),
roles: [],
agents: [],
handoffs: [],
})
}
groups.get(phaseName).handoffs.push(handoff)
}
return [...groups.values()]
.filter((phase) => phase.agents.length > 0 || phase.handoffs.length > 0)
.map((phase) => ({
...phase,
status: phaseStatus([...phase.agents, ...phase.handoffs]),
statusZh: formatStatus(phaseStatus([...phase.agents, ...phase.handoffs])),
}))
.sort((a, b) => phaseSortKey(a.name) - phaseSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN'))
}
function buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) {
const mainThread = [...projectThreadIds]
.map((threadID) => threadByID.get(threadID))
.find((thread) => isMainThread(thread))
const orchestrator = agents.find((agent) => /编排|主智能体|监管/.test(`${agent.role} ${agent.name} ${agent.goal}`))
const supervisorID = mainThread?.id || orchestrator?.id || ''
const supervisorGoal = goals.find((goal) => goal.threadId === supervisorID) || goals.find((goal) => projectThreadIds.has(goal.threadId))
const status = mainThread?.status || orchestrator?.status || supervisorGoal?.status || (handoffs.length > 0 ? 'recent' : 'unknown')
const source = normalizeSource(mainThread?.source || orchestrator?.sourceDetail, runtime.source)
return {
actor: mainThread ? '主线程 / 主智能体监管' : orchestrator?.displayName || '主线程 / 主智能体监管',
status,
statusZh: formatStatus(status),
goal: supervisorGoal?.goal || supervisorGoal?.objective || orchestrator?.goal || '没有目标记录',
lastActivity: mainThread?.updatedAt || mainThread?.createdAt || supervisorGoal?.updatedAt || orchestrator?.lastActivity || '没有时间记录',
handoffCount: handoffs.length,
agentCount: agents.length,
source: source.label,
confidence: source.confidenceLabel,
}
}
function phaseStatus(agents) {
if (agents.some((agent) => agent.status === 'running' || agent.status === 'active')) {
return 'running'
@@ -347,6 +446,21 @@ function extractPhaseName(values) {
return ''
}
function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRole = '未记录角色' } = {}) {
const cleanName = String(name || '').trim()
const cleanRole = String(role || '').trim()
if (cleanName && cleanRole) {
return `${cleanName} / ${cleanRole}`
}
if (cleanRole) {
return cleanRole
}
if (cleanName) {
return `${cleanName} / ${fallbackRole}`
}
return fallbackName ? `${fallbackName} / ${fallbackRole}` : `未知线程 / ${fallbackRole}`
}
function phaseSortKey(name) {
const match = String(name).match(/阶段\s*([0-9]+)/)
if (!match) {
@@ -528,11 +642,14 @@ function threadBelongsToProject(thread, targetPath) {
}
function threadCanJoinProjectFlow(thread, targetPath) {
if (threadBelongsToProject(thread, targetPath)) {
return true
return threadBelongsToProject(thread, targetPath)
}
const cwd = normalizePath(thread?.cwd)
return Boolean(cwd && targetPath.startsWith(`${cwd}/`))
function isMainThread(thread) {
if (!thread || thread.threadSource === 'subagent') {
return false
}
return Boolean(thread.threadSource === 'user' || (!thread.agentNickname && !thread.agentRole && !thread.agentPath && !thread.role))
}
function normalizeSpawnStatus(status) {

View File

@@ -202,6 +202,124 @@ test('filters runtime to selected project and displays agent names from Codex me
assert.equal(projectRuntime.agents[0].statusZh, '运行中')
})
test('agent display names include role without inventing a nickname', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'role-only',
cwd: '/repo/a',
title: '阶段 1产品规划',
agentRole: '产品经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'name-only',
cwd: '/repo/a',
title: '阶段 1补充资料',
agentNickname: 'Nash',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
assert.deepEqual(projectRuntime.agents.map((agent) => agent.displayName), [
'产品经理',
'Nash / 未记录角色',
])
})
test('supervision only uses project-proven main threads', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'wrong-main',
cwd: '/Users/yoilun',
title: '普通对话,没有项目路径',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'right-main',
cwd: '/Users/yoilun',
title: '监管 /Users/yoilun/Code/codex-agent-manager 项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'coder',
cwd: '/Users/yoilun',
title: '阶段 2实现 /Users/yoilun/Code/codex-agent-manager',
agentNickname: 'Ada',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
goals: [
{ threadId: 'wrong-main', goal: '监管别的项目', status: 'active' },
{ threadId: 'right-main', goal: '监管 codex-agent-manager 项目', status: 'active' },
],
edges: [
{ fromThreadId: 'wrong-main', toThreadId: 'coder', reason: 'open' },
{ fromThreadId: 'right-main', toThreadId: 'coder', reason: 'open' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.from), ['主线程 / 主智能体监管'])
assert.equal(projectRuntime.supervision.goal, '监管 codex-agent-manager 项目')
})
test('bare subagent threads are not treated as main supervision threads', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'main',
cwd: '/repo/a',
title: '监管 /repo/a 项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'bare-sub',
cwd: '/repo/a',
title: '阶段 3没有角色元数据的子智能体',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'reviewer',
cwd: '/repo/a',
title: '阶段 3审查',
agentNickname: 'Rawls',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
goals: [
{ threadId: 'main', goal: '监管真实项目', status: 'active' },
{ threadId: 'bare-sub', goal: '裸子智能体目标', status: 'active' },
],
edges: [
{ fromThreadId: 'bare-sub', toThreadId: 'reviewer', reason: 'open' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
assert.equal(projectRuntime.supervision.goal, '监管真实项目')
assert.equal(projectRuntime.handoffs[0].directionLabel, '子智能体交接')
})
test('does not turn ordinary conversation threads into agents', () => {
const runtime = normalizeRuntime({
items: [
@@ -275,6 +393,15 @@ test('builds project handoffs and phase groups from runtime edges and agent task
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'publisher',
cwd: '/repo/a',
title: '阶段 7发布变更',
agentNickname: 'Noether',
agentRole: '发布经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'other-main',
cwd: '/repo/b',
@@ -287,8 +414,13 @@ test('builds project handoffs and phase groups from runtime edges and agent task
{ fromThreadId: 'main', toThreadId: 'coder', reason: 'closed' },
{ fromThreadId: 'coder', toThreadId: 'reviewer', reason: 'open' },
{ fromThreadId: 'main', toThreadId: 'pm', reason: 'closed' },
{ fromThreadId: 'reviewer', toThreadId: 'main', reason: 'closed' },
{ fromThreadId: 'reviewer', toThreadId: 'publisher', reason: 'open' },
{ fromThreadId: 'other-main', toThreadId: 'coder', reason: 'open' },
],
goals: [
{ threadId: 'main', goal: '监管 /repo/a 项目内智能体流程', status: 'active', updatedAt: '2026-05-26T09:00:00Z' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
@@ -298,27 +430,69 @@ test('builds project handoffs and phase groups from runtime edges and agent task
'已完成',
'运行中',
'已完成',
'已完成',
'运行中',
])
assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 -> Averroes',
'Averroes -> Rawls',
'主线程 -> Zeno',
'主线程 / 主智能体监管 -> Averroes / 后端架构师',
'Averroes / 后端架构师 -> Rawls / 代码审查员',
'主线程 / 主智能体监管 -> Zeno / 高级项目经理',
'Rawls / 代码审查员 -> 主线程 / 主智能体监管',
'Rawls / 代码审查员 -> Noether / 发布经理',
])
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.directionLabel), [
'主线程派发',
'子智能体交接',
'主线程派发',
'回到主线程',
'跨阶段交接',
])
assert.ok(projectRuntime.handoffs.every((handoff) => handoff.time === '后端事件'))
assert.deepEqual(projectRuntime.phaseGroups.map((phase) => phase.name), ['阶段 6', '阶段 7'])
assert.equal(projectRuntime.phaseGroups[0].status, 'running')
assert.deepEqual(projectRuntime.phaseGroups[0].roles, ['代码审查员', '后端架构师'])
assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.name), ['Averroes', 'Rawls'])
assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.displayName), ['Averroes / 后端架构师', 'Rawls / 代码审查员'])
assert.deepEqual(projectRuntime.phaseHandoffs.map((phase) => phase.name), ['阶段 6', '阶段 7'])
assert.deepEqual(projectRuntime.phaseHandoffs[0].handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 / 主智能体监管 -> Averroes / 后端架构师',
'Averroes / 后端架构师 -> Rawls / 代码审查员',
'Rawls / 代码审查员 -> 主线程 / 主智能体监管',
])
assert.deepEqual(projectRuntime.phaseHandoffs[1].handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 / 主智能体监管 -> Zeno / 高级项目经理',
'Rawls / 代码审查员 -> Noether / 发布经理',
])
assert.equal(projectRuntime.supervision.actor, '主线程 / 主智能体监管')
assert.equal(projectRuntime.supervision.goal, '监管 /repo/a 项目内智能体流程')
assert.equal(projectRuntime.supervision.handoffCount, 5)
assert.equal(projectRuntime.supervision.agentCount, 4)
})
test('project view does not include sample runtime fallbacks', async () => {
test('project and app views keep workflow inside the selected project view', async () => {
const thisFile = fileURLToPath(import.meta.url)
const viewPath = new URL('../views/ProjectView.vue', `file://${thisFile}`)
const source = await readFile(viewPath, 'utf8')
const appPath = new URL('../App.vue', `file://${thisFile}`)
const viewSource = await readFile(viewPath, 'utf8')
const appSource = await readFile(appPath, 'utf8')
assert.doesNotMatch(source, /sampleProjects/)
assert.doesNotMatch(source, /sampleAgentMatrix/)
assert.match(source, /goalSource/)
assert.doesNotMatch(viewSource, /sampleProjects/)
assert.doesNotMatch(viewSource, /sampleAgentMatrix/)
assert.doesNotMatch(appSource, /WorkflowView/)
assert.doesNotMatch(appSource, /工作流视图/)
assert.match(viewSource, /selectedAgentId/)
assert.match(viewSource, /selectAgent/)
assert.match(viewSource, /phaseHandoffs/)
assert.match(viewSource, /supervision/)
assert.match(viewSource, /goalSource/)
})
test('handoff timeline uses normalized id as stable key', async () => {
const thisFile = fileURLToPath(import.meta.url)
const componentPath = new URL('../components/HandoffTimeline.vue', `file://${thisFile}`)
const source = await readFile(componentPath, 'utf8')
assert.match(source, /:key="item\.id \|\|/)
})
test('keeps project threads when Codex cwd is an ancestor but metadata names the project path', () => {

View File

@@ -6,12 +6,12 @@ defineProps({
<template>
<ol class="handoff-timeline">
<li v-for="item in items" :key="`${item.from}-${item.to}-${item.summary}`">
<li v-for="item in items" :key="item.id || `${item.from}-${item.to}-${item.summary}`">
<div class="timeline-marker" aria-hidden="true"></div>
<div>
<p class="timeline-title">{{ item.from }} {{ item.to }}</p>
<p>{{ item.summary }}</p>
<span>{{ item.time }} · 来源 {{ item.source }} · 置信度 {{ item.confidence }}</span>
<span>{{ item.directionLabel || '线程交接' }} · {{ item.time }} · 来源 {{ item.source }} · 置信度 {{ item.confidence }}</span>
</div>
</li>
</ol>

View File

@@ -295,6 +295,19 @@ button {
font-weight: 700;
}
.matrix-row.clickable {
cursor: pointer;
}
.matrix-row.clickable:hover,
.matrix-row.clickable.selected {
background: color-mix(in srgb, var(--green-soft) 62%, var(--panel));
}
.matrix-row.clickable.selected {
box-shadow: inset 4px 0 0 var(--green);
}
.matrix-row > span {
min-width: 0;
padding: 14px;
@@ -438,6 +451,7 @@ button {
}
.project-item[role="button"]:focus-visible,
.matrix-row[tabindex]:focus-visible,
.agent-list-item:focus-visible,
.tab-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--terracotta) 48%, transparent);
@@ -550,6 +564,36 @@ button {
font-weight: 700;
}
.phase-handoff-groups {
display: grid;
gap: 14px;
}
.phase-handoff-group {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.phase-handoff-group:first-child {
padding-top: 0;
border-top: 0;
}
.phase-handoff-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.muted-line {
margin: 0;
color: var(--muted);
line-height: 1.58;
}
.graph-list {
display: grid;
gap: 12px;

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import HandoffTimeline from '../components/HandoffTimeline.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { apiClient } from '../api/client'
@@ -10,13 +10,34 @@ const error = ref('')
const projectState = ref(normalizeProjects())
const runtimeState = ref(normalizeRuntime())
const selectedProjectId = ref('')
const selectedAgentId = ref('')
const selectedProject = computed(() => projectState.value.projects.find((project) => project.id === selectedProjectId.value))
const projectRuntime = computed(() => filterRuntimeByProject(runtimeState.value, selectedProject.value?.path || ''))
const selectedAgent = computed(() => projectRuntime.value.agents[0])
const selectedAgent = computed(() =>
projectRuntime.value.agents.find((agent) => agent.id === selectedAgentId.value) ?? projectRuntime.value.agents[0],
)
onMounted(loadReadonlyData)
watch(
() => projectRuntime.value.agents.map((agent) => agent.id).join('|'),
() => {
if (!projectRuntime.value.agents.some((agent) => agent.id === selectedAgentId.value)) {
selectedAgentId.value = projectRuntime.value.agents[0]?.id ?? ''
}
},
)
function selectProject(projectId) {
selectedProjectId.value = projectId
selectedAgentId.value = ''
}
function selectAgent(agent) {
selectedAgentId.value = agent.id
}
async function loadReadonlyData() {
loading.value = true
error.value = ''
@@ -28,6 +49,7 @@ async function loadReadonlyData() {
projectState.value = normalizeProjects(projectsPayload)
runtimeState.value = normalizeRuntime(runtimePayload)
selectedProjectId.value = projectState.value.projects[0]?.id ?? ''
selectedAgentId.value = projectRuntime.value.agents[0]?.id ?? ''
} catch (err) {
error.value = err?.message || '连接后端接口失败'
} finally {
@@ -60,8 +82,8 @@ async function loadReadonlyData() {
:class="{ selected: project.id === selectedProjectId }"
role="button"
tabindex="0"
@click="selectedProjectId = project.id"
@keyup.enter="selectedProjectId = project.id"
@click="selectProject(project.id)"
@keyup.enter="selectProject(project.id)"
>
<div>
<strong>{{ project.name }}</strong>
@@ -110,10 +132,19 @@ async function loadReadonlyData() {
<span role="columnheader">进程</span>
<span role="columnheader">最近活动</span>
</div>
<div v-for="agent in projectRuntime.agents" :key="agent.id" class="matrix-row" role="row">
<div
v-for="agent in projectRuntime.agents"
:key="agent.id"
class="matrix-row clickable"
:class="{ selected: agent.id === selectedAgentId }"
role="row"
tabindex="0"
@click="selectAgent(agent)"
@keyup.enter="selectAgent(agent)"
>
<span role="cell">
<strong>{{ agent.name }}</strong>
<small>{{ agent.role }}</small>
<strong>{{ agent.displayName }}</strong>
<small>{{ agent.phaseName }}</small>
</span>
<span role="cell">
<StatusBadge :label="agent.statusZh" :status="agent.status" :source="agent.source" :confidence="agent.confidence" />
@@ -142,7 +173,7 @@ async function loadReadonlyData() {
<div>
<strong>{{ phase.name }}</strong>
<p>{{ phase.roles.join('、') || '未记录角色分类' }}</p>
<span>{{ phase.agents.map((agent) => agent.name).join('、') }}</span>
<span>{{ phase.agents.map((agent) => agent.displayName).join('、') }}</span>
</div>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" />
</li>
@@ -161,7 +192,16 @@ async function loadReadonlyData() {
</div>
<span class="read-only-chip">{{ projectRuntime.handoffs.length }} 条交接</span>
</div>
<HandoffTimeline v-if="projectRuntime.handoffs.length > 0" :items="projectRuntime.handoffs" />
<div v-if="projectRuntime.phaseHandoffs.length > 0" class="phase-handoff-groups">
<section v-for="phase in projectRuntime.phaseHandoffs" :key="phase.name" class="phase-handoff-group">
<div class="phase-handoff-heading">
<strong>{{ phase.name }}</strong>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" />
</div>
<HandoffTimeline v-if="phase.handoffs.length > 0" :items="phase.handoffs" />
<p v-else class="muted-line">这个阶段暂时没有交接边</p>
</section>
</div>
<div v-else class="empty-state compact">
<strong>没有交接记录</strong>
<p>当前项目没有匹配到主线程与智能体之间的交接边</p>
@@ -174,7 +214,7 @@ async function loadReadonlyData() {
<aside class="panel detail-panel" aria-label="详情面板">
<div class="panel-heading">
<p class="eyebrow">详情</p>
<h2>{{ selectedAgent?.name || selectedProject?.name || '只读详情' }}</h2>
<h2>{{ selectedAgent?.displayName || selectedProject?.name || '只读详情' }}</h2>
</div>
<StatusBadge
:label="selectedAgent?.statusZh || selectedProject?.statusZh || (error ? '连接失败' : '等待数据')"
@@ -187,6 +227,11 @@ async function loadReadonlyData() {
<p>{{ selectedAgent?.goal || '选择项目或等待运行线程后显示真实目标。' }}</p>
<span v-if="selectedAgent?.goalSource">{{ selectedAgent.goalSource }}</span>
</div>
<div class="detail-block">
<h3>主智能体监管</h3>
<p>{{ projectRuntime.supervision.actor }}{{ projectRuntime.supervision.goal }}</p>
<span>{{ projectRuntime.supervision.statusZh }} · 最近活动 {{ projectRuntime.supervision.lastActivity }}</span>
</div>
<div class="detail-block">
<h3>证据说明</h3>
<p>数据来自只读接口连接失败时保持错误状态不使用示例数据伪装真实状态</p>
@@ -194,6 +239,8 @@ async function loadReadonlyData() {
<div class="detail-grid">
<span>项目数</span><strong>{{ projectState.projects.length }}</strong>
<span>当前项目线程数</span><strong>{{ projectRuntime.threads.length }}</strong>
<span>子智能体数</span><strong>{{ projectRuntime.supervision.agentCount }}</strong>
<span>交接记录</span><strong>{{ projectRuntime.supervision.handoffCount }}</strong>
<span>接口</span><strong>{{ error ? '连接失败' : '只读连接' }}</strong>
</div>
</aside>