feat(goal): checklist UI — progress ring, hover checklist card, criteria SSE

This commit is contained in:
matevip 2026-06-03 21:19:27 +08:00
parent c5ccce8ebc
commit da332f2fd8
5 changed files with 145 additions and 10 deletions

View File

@ -1277,12 +1277,21 @@ export const triggerApi = {
}) => http.post('/triggers/events', envelope), }) => http.post('/triggers/events', envelope),
} }
// ==================== Persistent goals (RFC 48) ==================== // ==================== Persistent goals ====================
// //
// Snowflake IDs are sent as strings end-to-end — the backend's // Snowflake IDs are sent as strings end-to-end — the backend's
// ToStringSerializer makes responses strings, and request payloads keep // ToStringSerializer makes responses strings, and request payloads keep
// them as strings to dodge JS Number precision loss. See CLAUDE.md // them as strings to dodge JS Number precision loss. See CLAUDE.md
// "ID Handling — Snowflake Precision Convention". // "ID Handling — Snowflake Precision Convention".
/** One checkable item of a goal's exit checklist. */
export interface GoalCriterion {
id: string
text: string
passed: boolean
evidence?: string
}
export interface Goal { export interface Goal {
id: string id: string
conversationId: string conversationId: string
@ -1298,6 +1307,7 @@ export interface Goal {
llmCallBudget: number llmCallBudget: number
agentLlmCallsUsed: number agentLlmCallsUsed: number
evalLlmCallsUsed: number evalLlmCallsUsed: number
totalLlmCallsUsed?: number
progressSummary?: string | null progressSummary?: string | null
completionScore?: number | null completionScore?: number | null
lastEvaluationAt?: string | null lastEvaluationAt?: string | null
@ -1306,6 +1316,8 @@ export interface Goal {
lastFollowupAt?: string | null lastFollowupAt?: string | null
createTime: string createTime: string
updateTime: string updateTime: string
/** Parsed checklist; always an array on the wire (empty when none). */
criteria?: GoalCriterion[]
} }
export interface GoalEvent { export interface GoalEvent {
@ -1329,6 +1341,7 @@ export const goalApi = {
llmCallBudget?: number llmCallBudget?: number
autoFollowupEnabled?: boolean autoFollowupEnabled?: boolean
followupCooldownSeconds?: number followupCooldownSeconds?: number
criteria?: { text: string }[]
}) => http.post<Goal>('/goals', data), }) => http.post<Goal>('/goals', data),
findActive: (conversationId: string) => findActive: (conversationId: string) =>

View File

@ -69,6 +69,14 @@ const tooltip = computed(() => {
} }
return parts.join(' · ') return parts.join(' · ')
}) })
// Checklist for the richer hover card. Empty until a checklist exists.
const criteria = computed(() => goal.value?.criteria ?? [])
const progressLabel = computed(() => {
if (!props.conversationId) return ''
const p = goalStore.criteriaProgress(props.conversationId)
return p ? `${p.passed}/${p.total}` : ''
})
</script> </script>
<template> <template>
@ -107,7 +115,20 @@ const tooltip = computed(() => {
/> />
</svg> </svg>
<span v-if="showFollowupMark" class="followup-mark" :title="$t('goal.autoFollowup')"></span> <span v-if="showFollowupMark" class="followup-mark" :title="$t('goal.autoFollowup')"></span>
<span v-if="goal && tooltip" class="goal-tip">{{ tooltip }}</span> <!-- Checklist card when the goal has criteria; plain one-liner otherwise. -->
<div v-if="goal && criteria.length" class="goal-card">
<div class="goal-card-head">
<span class="goal-card-title">{{ goal.title }}</span>
<span v-if="progressLabel" class="goal-card-count">{{ progressLabel }}</span>
</div>
<ul class="goal-card-list">
<li v-for="c in criteria" :key="c.id" :class="{ done: c.passed }">
<span class="goal-card-mark">{{ c.passed ? '✓' : '○' }}</span>
<span class="goal-card-text">{{ c.text }}</span>
</li>
</ul>
</div>
<span v-else-if="goal && tooltip" class="goal-tip">{{ tooltip }}</span>
</div> </div>
</template> </template>
@ -246,4 +267,78 @@ const tooltip = computed(() => {
opacity: 1; opacity: 1;
transform: translateY(-50%) translateX(2px); transform: translateY(-50%) translateX(2px);
} }
/* Checklist hover card same reveal mechanics as the tooltip, but a
* multi-line block listing each criterion with a done marker. */
.goal-card {
visibility: hidden;
opacity: 0;
position: absolute;
left: calc(100% + 14px);
top: 50%;
transform: translateY(-50%);
width: 280px;
background: var(--mc-text-primary, #1d1612);
color: var(--mc-bg-elevated, #ffffff);
padding: 10px 12px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22);
transition: opacity 150ms ease, transform 150ms ease;
z-index: 10;
pointer-events: none;
}
.avatar-with-ring:hover .goal-card {
visibility: visible;
opacity: 1;
transform: translateY(-50%) translateX(2px);
}
.goal-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.goal-card-title {
font-size: 12px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.goal-card-count {
font-size: 11px;
color: #b6905b;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.goal-card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.goal-card-list li {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.82);
}
.goal-card-list li.done .goal-card-text {
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
}
.goal-card-mark {
flex-shrink: 0;
width: 12px;
text-align: center;
color: #9b7d6c;
}
.goal-card-list li.done .goal-card-mark {
color: #2f8a6d;
}
</style> </style>

View File

@ -34,6 +34,9 @@ async function accept() {
props.agentId, props.agentId,
props.workspaceId, props.workspaceId,
props.suggestedTitle, props.suggestedTitle,
// Default to autonomous continuation when the user opts in from here
// the whole point of accepting is "keep working toward this".
{ autoFollowup: true },
) )
} finally { } finally {
busy.value = false busy.value = false

View File

@ -50,7 +50,7 @@ export type SSEEventType =
| 'delegation_async_spawned' | 'delegation_async_spawned'
// Heartbeat watchdog flagged a sub-agent as making no observable progress // Heartbeat watchdog flagged a sub-agent as making no observable progress
| 'subagent_stale' | 'subagent_stale'
// Persistent goal events (RFC 48) — emitted by GoalEvaluationNode // Persistent goal events — emitted by GoalEvaluationNode
| 'goal_evaluated' | 'goal_evaluated'
| 'goal_followup' | 'goal_followup'
| 'goal_completed' | 'goal_completed'

View File

@ -94,7 +94,7 @@ export const useGoalStore = defineStore('goal', () => {
agentId: string, agentId: string,
workspaceId: string, workspaceId: string,
title: string, title: string,
opts: { description?: string; exitCriteria?: string; autoFollowup?: boolean } = {}, opts: { description?: string; exitCriteria?: string; autoFollowup?: boolean; criteria?: string[] } = {},
): Promise<Goal | null> { ): Promise<Goal | null> {
try { try {
const res: any = await goalApi.create({ const res: any = await goalApi.create({
@ -105,6 +105,7 @@ export const useGoalStore = defineStore('goal', () => {
description: opts.description, description: opts.description,
exitCriteria: opts.exitCriteria, exitCriteria: opts.exitCriteria,
autoFollowupEnabled: opts.autoFollowup, autoFollowupEnabled: opts.autoFollowup,
criteria: opts.criteria?.map((text) => ({ text })),
}) })
const goal: Goal = res?.data const goal: Goal = res?.data
activeGoalByConv.value[conversationId] = goal activeGoalByConv.value[conversationId] = goal
@ -170,11 +171,14 @@ export const useGoalStore = defineStore('goal', () => {
case 'goal_evaluated': { case 'goal_evaluated': {
evaluatingByConv.value[conversationId] = false evaluatingByConv.value[conversationId] = false
lastTerminalEventAtByConv.value[conversationId] = Date.now() lastTerminalEventAtByConv.value[conversationId] = Date.now()
if (goal && data?.score != null) { // Prefer the full goal snapshot (carries the criteria array + score);
goal.completionScore = Number(data.score) // fall back to patching the cached goal for older payload shapes.
} const fresh = data?.goal as Goal | undefined
if (goal && typeof data?.gap === 'string') { if (fresh && typeof fresh.id === 'string') {
goal.progressSummary = data.gap activeGoalByConv.value[conversationId] = fresh
} else if (goal) {
if (data?.score != null) goal.completionScore = Number(data.score)
if (typeof data?.gap === 'string') goal.progressSummary = data.gap
} }
break break
} }
@ -185,6 +189,11 @@ export const useGoalStore = defineStore('goal', () => {
// its evaluating state until message_complete fires for that // its evaluating state until message_complete fires for that
// followup turn — so the user sees breathe → still → breathe. // followup turn — so the user sees breathe → still → breathe.
pendingFollowupByConv.value[conversationId] = true pendingFollowupByConv.value[conversationId] = true
// The followup payload carries the latest criteria progress.
const fresh = data?.goal as Goal | undefined
if (fresh && typeof fresh.id === 'string') {
activeGoalByConv.value[conversationId] = fresh
}
break break
} }
case 'goal_completed': { case 'goal_completed': {
@ -269,10 +278,24 @@ export const useGoalStore = defineStore('goal', () => {
function progressFraction(conversationId: string): number | null { function progressFraction(conversationId: string): number | null {
const g = activeGoal(conversationId) const g = activeGoal(conversationId)
if (!g || g.completionScore == null) return null if (!g) return null
// Prefer the deterministic checklist (passed / total) when present;
// fall back to the evaluator's completion score otherwise.
if (g.criteria && g.criteria.length > 0) {
const passed = g.criteria.filter((c) => c.passed).length
return passed / g.criteria.length
}
if (g.completionScore == null) return null
return Math.max(0, Math.min(1, g.completionScore)) return Math.max(0, Math.min(1, g.completionScore))
} }
/** Checklist progress as { passed, total } when a checklist exists. */
function criteriaProgress(conversationId: string): { passed: number; total: number } | null {
const g = activeGoal(conversationId)
if (!g || !g.criteria || g.criteria.length === 0) return null
return { passed: g.criteria.filter((c) => c.passed).length, total: g.criteria.length }
}
// ==================== Inline prompt + system line helpers ==================== // ==================== Inline prompt + system line helpers ====================
function isPromptDismissed(conversationId: string): boolean { function isPromptDismissed(conversationId: string): boolean {
@ -349,6 +372,7 @@ export const useGoalStore = defineStore('goal', () => {
isEvaluating, isEvaluating,
activeGoal, activeGoal,
progressFraction, progressFraction,
criteriaProgress,
isPromptDismissed, isPromptDismissed,
dismissPrompt, dismissPrompt,
clearDismissedPrompt, clearDismissedPrompt,