feat(backstage): tool chip, triage sort, bento metadata tiles

This commit is contained in:
matevip 2026-05-04 14:00:39 +08:00
parent ce5741c45f
commit f5d845ff31
5 changed files with 217 additions and 66 deletions

View File

@ -32,8 +32,13 @@
<div class="focus-subtitle" v-if="run.username">@{{ run.username }}</div> <div class="focus-subtitle" v-if="run.username">@{{ run.username }}</div>
</div> </div>
<!-- The one sentence: what it's doing right now --> <!-- The one sentence: what it's doing right now. The tool name,
when present, sits below as its own chip sentence stays
short and readable; the tool gets monospace prominence. -->
<p class="focus-saying">{{ humanSentence(run) }}</p> <p class="focus-saying">{{ humanSentence(run) }}</p>
<div v-if="run.runningToolName" class="focus-tool-row">
<span class="focus-tool-chip" :title="run.runningToolName">{{ run.runningToolName }}</span>
</div>
<!-- Stuck callout --> <!-- Stuck callout -->
<div v-if="run.stuckReason" class="focus-callout"> <div v-if="run.stuckReason" class="focus-callout">
@ -61,26 +66,30 @@
</div> </div>
</div> </div>
<!-- Secondary metadata --> <!-- Secondary metadata as bento KPI tiles. Each tile is one
<dl class="focus-grid"> glance: big value, small caption. The third tile carries
<div class="focus-row"> the conversation id (full id on hover); the previous list
<dt>{{ t('backstage.detail.lastHeard') }}</dt> form buried these behind label text. -->
<dd>{{ formatAge(run.msSinceLastEvent) }} {{ t('backstage.detail.ago') }}</dd> <div class="focus-tiles">
<div class="focus-tile">
<div class="focus-tile-value">{{ formatAge(run.msSinceLastEvent) }}</div>
<div class="focus-tile-label">{{ t('backstage.detail.lastHeard') }}</div>
</div> </div>
<div class="focus-row"> <div class="focus-tile">
<dt>{{ t('backstage.detail.audience') }}</dt> <div
<dd> class="focus-tile-value"
<span v-if="run.subscriberCount === 0" class="focus-warn"> :class="{ 'focus-tile-warn': run.subscriberCount === 0 }"
{{ t('backstage.detail.noOneListening') }} >{{ run.subscriberCount }}</div>
</span> <div class="focus-tile-label">{{ t('backstage.detail.audience') }}</div>
<span v-else>{{ t('backstage.detail.peopleListening', { n: run.subscriberCount }) }}</span>
</dd>
</div> </div>
<div class="focus-row"> <div class="focus-tile">
<dt>{{ t('backstage.detail.session') }}</dt> <div
<dd class="focus-id" :title="run.conversationId">#{{ shortId(run.conversationId) }}</dd> class="focus-tile-value focus-tile-id"
:title="run.conversationId"
>#{{ shortId(run.conversationId) }}</div>
<div class="focus-tile-label">{{ t('backstage.detail.session') }}</div>
</div> </div>
</dl> </div>
<!-- Helpers (subagents) --> <!-- Helpers (subagents) -->
<div v-if="subagents.length > 0" class="focus-section"> <div v-if="subagents.length > 0" class="focus-section">
@ -321,7 +330,7 @@ html.dark .focus-backdrop {
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
} }
/* ===== The one sentence ===== */ /* ===== The one sentence + tool chip ===== */
.focus-saying { .focus-saying {
font-size: 22px; font-size: 22px;
line-height: 1.45; line-height: 1.45;
@ -329,7 +338,37 @@ html.dark .focus-backdrop {
letter-spacing: -0.015em; letter-spacing: -0.015em;
color: var(--mc-text-primary); color: var(--mc-text-primary);
text-align: center; text-align: center;
margin: 0 0 24px; margin: 0 0 12px;
}
.focus-tool-row {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.focus-tool-chip {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 8px;
background: var(--mc-accent-soft);
color: var(--mc-accent);
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.01em;
border: 1px solid transparent;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html.dark .focus-tool-chip {
background: rgba(92, 166, 157, 0.14);
color: hsl(170, 35%, 72%);
border-color: rgba(92, 166, 157, 0.2);
} }
.focus-callout { .focus-callout {
@ -467,49 +506,75 @@ html.dark .time-ring-warn::before {
font-weight: 500; font-weight: 500;
} }
/* ===== Secondary grid ===== */ /* ===== Secondary metadata as bento KPI tiles =====
.focus-grid { * Three equal tiles: each is one glance big value (mono tabular-nums),
margin: 0 0 8px; * small caption beneath. Replaces the dt/dd list form, which buried the
border-top: 1px solid var(--mc-border-light); * value behind a label and made the section read like a settings panel
* instead of a dashboard.
*/
.focus-tiles {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
} }
.focus-row { .focus-tile {
padding: 14px 10px 12px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
border-radius: 12px;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: baseline; align-items: center;
padding: 13px 0; text-align: center;
border-bottom: 1px solid var(--mc-border-light); min-width: 0;
margin: 0;
} }
.focus-row dt { html.dark .focus-tile {
font-size: 12px; background: rgba(255, 255, 255, 0.04);
color: var(--mc-text-tertiary);
margin: 0;
letter-spacing: 0.01em;
} }
.focus-row dd { .focus-tile-value {
font-size: 13.5px; font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--mc-text-primary); color: var(--mc-text-primary);
margin: 0;
text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
} }
.focus-warn { .focus-tile-id {
color: hsl(265, 50%, 50%);
}
html.dark .focus-warn {
color: hsl(265, 55%, 70%);
}
.focus-id {
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 12px; font-size: 14px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: var(--mc-text-secondary);
font-weight: 500;
cursor: default;
}
.focus-tile-warn {
color: hsl(265, 50%, 52%);
}
html.dark .focus-tile-warn {
color: hsl(265, 55%, 72%);
}
.focus-tile-label {
font-size: 10.5px;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
letter-spacing: 0.06em;
margin-top: 8px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
} }
/* ===== Subagents ===== */ /* ===== Subagents ===== */

View File

@ -61,12 +61,20 @@ export function useBackstageAgent() {
return t('backstage.dotTitle.healthy') return t('backstage.dotTitle.healthy')
} }
/**
* One-sentence status. Deliberately does NOT inline the tool name
* the tool deserves its own monospace chip in the UI, and embedding
* it inside a sentence both gets ellipsis-truncated on narrow cards
* AND duplicates information when the chip is also rendered.
* stuckCallout (used in the focus panel) is the one place that still
* mentions the tool name, because there it lives in a long-form alert.
*/
function humanSentence(run: BackstageRunCard): string { function humanSentence(run: BackstageRunCard): string {
if (run.stuckReason === 'tool_silent') return t('backstage.saying.toolSilent', { tool: run.runningToolName || t('backstage.aTool') }) if (run.stuckReason === 'tool_silent') return t('backstage.saying.toolSilentBare')
if (run.stuckReason === 'idle_silent') return t('backstage.saying.idleSilent') if (run.stuckReason === 'idle_silent') return t('backstage.saying.idleSilent')
if (run.stuckReason === 'hard_cap') return t('backstage.saying.hardCap') if (run.stuckReason === 'hard_cap') return t('backstage.saying.hardCap')
if (run.currentPhase === 'awaiting_approval') return t('backstage.saying.awaitingApproval') if (run.currentPhase === 'awaiting_approval') return t('backstage.saying.awaitingApproval')
if (run.runningToolName) return t('backstage.saying.usingTool', { tool: run.runningToolName }) if (run.runningToolName) return t('backstage.saying.usingToolBare')
if (run.currentPhase === 'executing_tool') return t('backstage.saying.usingSomething') if (run.currentPhase === 'executing_tool') return t('backstage.saying.usingSomething')
if (run.currentPhase === 'summarizing') return t('backstage.saying.wrappingUp') if (run.currentPhase === 'summarizing') return t('backstage.saying.wrappingUp')
if (run.currentPhase === 'planning') return t('backstage.saying.planning') if (run.currentPhase === 'planning') return t('backstage.saying.planning')

View File

@ -346,10 +346,10 @@ export default {
replying: 'Writing the reply...', replying: 'Writing the reply...',
planning: 'Planning the next steps...', planning: 'Planning the next steps...',
summarizing: 'Wrapping up...', summarizing: 'Wrapping up...',
usingTool: 'Using {tool}...', usingToolBare: 'Running',
usingSomething: 'Using a tool...', usingSomething: 'Using a tool...',
awaitingApproval: 'Waiting for your approval.', awaitingApproval: 'Waiting for your approval.',
toolSilent: 'Stuck on {tool}, no progress.', toolSilentBare: 'Stuck on a tool, no progress.',
idleSilent: 'Quiet for too long.', idleSilent: 'Quiet for too long.',
hardCap: 'Has been at this far too long.', hardCap: 'Has been at this far too long.',
}, },

View File

@ -1526,10 +1526,10 @@ export default {
replying: '正在写回复...', replying: '正在写回复...',
planning: '正在规划下一步...', planning: '正在规划下一步...',
summarizing: '正在收尾...', summarizing: '正在收尾...',
usingTool: '正在使用 {tool}...', usingToolBare: '正在使用',
usingSomething: '正在使用某个工具...', usingSomething: '正在使用某个工具...',
awaitingApproval: '在等你审批。', awaitingApproval: '在等你审批。',
toolSilent: '卡在 {tool},没有进展。', toolSilentBare: '卡在工具上,没有进展。',
idleSilent: '安静太久了。', idleSilent: '安静太久了。',
hardCap: '已经跑了太久。', hardCap: '已经跑了太久。',
}, },

View File

@ -99,8 +99,14 @@
</div> </div>
</div> </div>
<!-- Single human sentence --> <!-- Status sentence + (when present) the tool chip standing
<div class="agent-saying">{{ humanSentence(run) }}</div> on its own so a long tool name doesn't get ellipsis-eaten. -->
<div class="agent-saying-row">
<span class="agent-saying">{{ humanSentence(run) }}</span>
<span v-if="run.runningToolName" class="tool-chip" :title="run.runningToolName">
{{ run.runningToolName }}
</span>
</div>
<!-- Meta: just the time + orphan hint (id moved to detail) --> <!-- Meta: just the time + orphan hint (id moved to detail) -->
<div class="agent-meta-row"> <div class="agent-meta-row">
@ -257,14 +263,37 @@ const showFilterRow = computed(() => {
return s.stuck > 0 || s.orphan > 0 return s.stuck > 0 || s.orphan > 0
}) })
/**
* Triage order: stuck first (you have to do something), then orphan (a run
* that nobody is watching), then working (healthy & boring). Within each
* tier, the older run wins a 5-minute stuck run is more urgent than one
* stuck for 8 seconds.
*
* Without this, runs are listed in whatever order the snapshot returned
* them, and the one card you actually need to look at can be 4 rows down.
*/
function tierOf(r: BackstageRunCard): number {
if (r.stuckReason) return 0
if (r.orphan) return 1
return 2
}
const visibleRuns = computed<BackstageRunCard[]>(() => { const visibleRuns = computed<BackstageRunCard[]>(() => {
const runs = snapshot.value?.runs ?? [] const runs = snapshot.value?.runs ?? []
switch (activeFilter.value) { const filtered = (() => {
case 'working': return runs.filter(isWorking) switch (activeFilter.value) {
case 'attention': return runs.filter(isAttention) case 'working': return runs.filter(isWorking)
case 'quiet': return runs.filter(isQuiet) case 'attention': return runs.filter(isAttention)
default: return runs case 'quiet': return runs.filter(isQuiet)
} default: return runs
}
})()
return [...filtered].sort((a, b) => {
const ta = tierOf(a)
const tb = tierOf(b)
if (ta !== tb) return ta - tb
return b.ageMs - a.ageMs
})
}) })
// If the filter the user picked no longer matches any rows (e.g., the stuck // If the filter the user picked no longer matches any rows (e.g., the stuck
@ -867,13 +896,20 @@ html.dark .agent-avatar {
50% { opacity: 0.7; transform: scale(0.85); } 50% { opacity: 0.7; transform: scale(0.85); }
} }
/* ===== Saying ===== */ /* ===== Saying + tool chip ===== */
.agent-saying-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
min-height: 22px;
}
.agent-saying { .agent-saying {
font-size: 14.5px; font-size: 14.5px;
line-height: 1.55; line-height: 1.55;
color: var(--mc-text-secondary); color: var(--mc-text-secondary);
min-height: 22px;
margin-bottom: 16px;
letter-spacing: -0.005em; letter-spacing: -0.005em;
} }
@ -886,6 +922,48 @@ html.dark .agent-card.is-stuck .agent-saying {
color: hsl(28, 80%, 76%); color: hsl(28, 80%, 76%);
} }
/*
* Tool chip monospace, accent-soft tinted. The tool name was previously
* baked into the sentence and got truncated on narrow cards; promoting it
* to its own chip makes it ellipsis-resistant and visually weighty. It also
* stops competing with the sentence for the same line of attention.
*/
.tool-chip {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 6px;
background: var(--mc-accent-soft);
color: var(--mc-accent);
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 11.5px;
font-weight: 500;
letter-spacing: 0.01em;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid transparent;
}
html.dark .tool-chip {
background: rgba(92, 166, 157, 0.14);
color: hsl(170, 35%, 70%);
border-color: rgba(92, 166, 157, 0.18);
}
.agent-card.is-stuck .tool-chip {
background: hsla(20, 100%, 90%, 0.65);
color: hsl(20, 75%, 38%);
border-color: hsla(20, 80%, 55%, 0.25);
}
html.dark .agent-card.is-stuck .tool-chip {
background: hsla(20, 60%, 30%, 0.4);
color: hsl(28, 80%, 78%);
border-color: hsla(20, 70%, 55%, 0.3);
}
/* ===== Meta + bar ===== */ /* ===== Meta + bar ===== */
.agent-meta-row { .agent-meta-row {
display: flex; display: flex;