mateclaw/mateclaw-ui/src/components/agents/PlanDetailPanel.vue
matevip c656aff349 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
2026-06-20 17:52:29 +08:00

445 lines
13 KiB
Vue

<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>