From da332f2fd8831a4ab4e1f7f40d3e1cdcb0ad5589 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 3 Jun 2026 21:19:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(goal):=20checklist=20UI=20=E2=80=94=20prog?= =?UTF-8?q?ress=20ring,=20hover=20checklist=20card,=20criteria=20SSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mateclaw-ui/src/api/index.ts | 15 ++- .../src/components/goal/GoalAvatarRing.vue | 97 ++++++++++++++++++- .../components/goal/GoalSetInlinePrompt.vue | 3 + mateclaw-ui/src/composables/chat/useStream.ts | 2 +- mateclaw-ui/src/stores/useGoalStore.ts | 38 ++++++-- 5 files changed, 145 insertions(+), 10 deletions(-) diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index b57354f1..34628a1f 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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('/goals', data), findActive: (conversationId: string) => diff --git a/mateclaw-ui/src/components/goal/GoalAvatarRing.vue b/mateclaw-ui/src/components/goal/GoalAvatarRing.vue index 23dabe3d..1a97dc21 100644 --- a/mateclaw-ui/src/components/goal/GoalAvatarRing.vue +++ b/mateclaw-ui/src/components/goal/GoalAvatarRing.vue @@ -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}` : '' +}) @@ -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; +} diff --git a/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue b/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue index b36f2e60..1732e6a6 100644 --- a/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue +++ b/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue @@ -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 diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 3741bd3b..3dbcb251 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -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' diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts index 137adc5e..63f0f27d 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -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 { 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,