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

View File

@ -69,6 +69,14 @@ const tooltip = computed(() => {
}
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>
<template>
@ -107,7 +115,20 @@ const tooltip = computed(() => {
/>
</svg>
<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>
</template>
@ -246,4 +267,78 @@ const tooltip = computed(() => {
opacity: 1;
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>

View File

@ -34,6 +34,9 @@ async function accept() {
props.agentId,
props.workspaceId,
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 {
busy.value = false

View File

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

View File

@ -94,7 +94,7 @@ export const useGoalStore = defineStore('goal', () => {
agentId: string,
workspaceId: string,
title: string,
opts: { description?: string; exitCriteria?: string; autoFollowup?: boolean } = {},
opts: { description?: string; exitCriteria?: string; autoFollowup?: boolean; criteria?: string[] } = {},
): Promise<Goal | null> {
try {
const res: any = await goalApi.create({
@ -105,6 +105,7 @@ export const useGoalStore = defineStore('goal', () => {
description: opts.description,
exitCriteria: opts.exitCriteria,
autoFollowupEnabled: opts.autoFollowup,
criteria: opts.criteria?.map((text) => ({ text })),
})
const goal: Goal = res?.data
activeGoalByConv.value[conversationId] = goal
@ -170,11 +171,14 @@ export const useGoalStore = defineStore('goal', () => {
case 'goal_evaluated': {
evaluatingByConv.value[conversationId] = false
lastTerminalEventAtByConv.value[conversationId] = Date.now()
if (goal && data?.score != null) {
goal.completionScore = Number(data.score)
}
if (goal && typeof data?.gap === 'string') {
goal.progressSummary = data.gap
// Prefer the full goal snapshot (carries the criteria array + score);
// fall back to patching the cached goal for older payload shapes.
const fresh = data?.goal as Goal | undefined
if (fresh && typeof fresh.id === 'string') {
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
}
@ -185,6 +189,11 @@ export const useGoalStore = defineStore('goal', () => {
// its evaluating state until message_complete fires for that
// followup turn — so the user sees breathe → still → breathe.
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
}
case 'goal_completed': {
@ -269,10 +278,24 @@ export const useGoalStore = defineStore('goal', () => {
function progressFraction(conversationId: string): number | null {
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))
}
/** 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 ====================
function isPromptDismissed(conversationId: string): boolean {
@ -349,6 +372,7 @@ export const useGoalStore = defineStore('goal', () => {
isEvaluating,
activeGoal,
progressFraction,
criteriaProgress,
isPromptDismissed,
dismissPrompt,
clearDismissedPrompt,