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>
<!-- 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>
<div v-if="run.runningToolName" class="focus-tool-row">
<span class="focus-tool-chip" :title="run.runningToolName">{{ run.runningToolName }}</span>
</div>
<!-- Stuck callout -->
<div v-if="run.stuckReason" class="focus-callout">
@ -61,26 +66,30 @@
</div>
</div>
<!-- Secondary metadata -->
<dl class="focus-grid">
<div class="focus-row">
<dt>{{ t('backstage.detail.lastHeard') }}</dt>
<dd>{{ formatAge(run.msSinceLastEvent) }} {{ t('backstage.detail.ago') }}</dd>
<!-- Secondary metadata as bento KPI tiles. Each tile is one
glance: big value, small caption. The third tile carries
the conversation id (full id on hover); the previous list
form buried these behind label text. -->
<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 class="focus-row">
<dt>{{ t('backstage.detail.audience') }}</dt>
<dd>
<span v-if="run.subscriberCount === 0" class="focus-warn">
{{ t('backstage.detail.noOneListening') }}
</span>
<span v-else>{{ t('backstage.detail.peopleListening', { n: run.subscriberCount }) }}</span>
</dd>
<div class="focus-tile">
<div
class="focus-tile-value"
:class="{ 'focus-tile-warn': run.subscriberCount === 0 }"
>{{ run.subscriberCount }}</div>
<div class="focus-tile-label">{{ t('backstage.detail.audience') }}</div>
</div>
<div class="focus-row">
<dt>{{ t('backstage.detail.session') }}</dt>
<dd class="focus-id" :title="run.conversationId">#{{ shortId(run.conversationId) }}</dd>
<div class="focus-tile">
<div
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>
</dl>
</div>
<!-- Helpers (subagents) -->
<div v-if="subagents.length > 0" class="focus-section">
@ -321,7 +330,7 @@ html.dark .focus-backdrop {
color: var(--mc-text-tertiary);
}
/* ===== The one sentence ===== */
/* ===== The one sentence + tool chip ===== */
.focus-saying {
font-size: 22px;
line-height: 1.45;
@ -329,7 +338,37 @@ html.dark .focus-backdrop {
letter-spacing: -0.015em;
color: var(--mc-text-primary);
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 {
@ -467,49 +506,75 @@ html.dark .time-ring-warn::before {
font-weight: 500;
}
/* ===== Secondary grid ===== */
.focus-grid {
margin: 0 0 8px;
border-top: 1px solid var(--mc-border-light);
/* ===== Secondary metadata as bento KPI tiles =====
* Three equal tiles: each is one glance big value (mono tabular-nums),
* small caption beneath. Replaces the dt/dd list form, which buried the
* 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;
justify-content: space-between;
align-items: baseline;
padding: 13px 0;
border-bottom: 1px solid var(--mc-border-light);
margin: 0;
flex-direction: column;
align-items: center;
text-align: center;
min-width: 0;
}
.focus-row dt {
font-size: 12px;
color: var(--mc-text-tertiary);
margin: 0;
letter-spacing: 0.01em;
html.dark .focus-tile {
background: rgba(255, 255, 255, 0.04);
}
.focus-row dd {
font-size: 13.5px;
.focus-tile-value {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--mc-text-primary);
margin: 0;
text-align: right;
font-variant-numeric: tabular-nums;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.focus-warn {
color: hsl(265, 50%, 50%);
}
html.dark .focus-warn {
color: hsl(265, 55%, 70%);
}
.focus-id {
.focus-tile-id {
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 12px;
font-size: 14px;
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);
letter-spacing: 0.06em;
margin-top: 8px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ===== Subagents ===== */

View File

@ -61,12 +61,20 @@ export function useBackstageAgent() {
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 {
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 === 'hard_cap') return t('backstage.saying.hardCap')
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 === 'summarizing') return t('backstage.saying.wrappingUp')
if (run.currentPhase === 'planning') return t('backstage.saying.planning')

View File

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

View File

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

View File

@ -99,8 +99,14 @@
</div>
</div>
<!-- Single human sentence -->
<div class="agent-saying">{{ humanSentence(run) }}</div>
<!-- Status sentence + (when present) the tool chip standing
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) -->
<div class="agent-meta-row">
@ -257,14 +263,37 @@ const showFilterRow = computed(() => {
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 runs = snapshot.value?.runs ?? []
switch (activeFilter.value) {
case 'working': return runs.filter(isWorking)
case 'attention': return runs.filter(isAttention)
case 'quiet': return runs.filter(isQuiet)
default: return runs
}
const filtered = (() => {
switch (activeFilter.value) {
case 'working': return runs.filter(isWorking)
case 'attention': return runs.filter(isAttention)
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
@ -867,13 +896,20 @@ html.dark .agent-avatar {
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 {
font-size: 14.5px;
line-height: 1.55;
color: var(--mc-text-secondary);
min-height: 22px;
margin-bottom: 16px;
letter-spacing: -0.005em;
}
@ -886,6 +922,48 @@ html.dark .agent-card.is-stuck .agent-saying {
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 ===== */
.agent-meta-row {
display: flex;