Refine goal session presentation

Goal sessions need to feel like an ongoing agent-loop state, not a large success alert dropped into the chat. The UI now uses compact status and event treatments, and session titles stay anchored to the original goal objective instead of later /goal status commands.

Constraint: /goal history uses local_command transcript entries and later status checks can append fresh AI titles.
Rejected: Keep the existing green cards | they duplicate the objective, consume too much vertical space, and read as one-off notifications instead of durable loop state.
Confidence: high
Scope-risk: narrow
Directive: Goal status UI should remain compact and stateful; do not reintroduce large centered cards for routine lifecycle events.
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: Browser preview at 127.0.0.1:5179 with real session 3e9117d5-b792-43c9-bf57-7aec2b124f7e on desktop and mobile viewport
Not-tested: Packaged macOS app rebuild.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 01:28:09 +08:00
parent 052cc4da5d
commit 929ddd0ebe
4 changed files with 131 additions and 42 deletions

View File

@ -153,32 +153,52 @@ function GoalEventCard({ message }: { message: GoalEvent }) {
const t = useTranslation()
const titleKey = `chat.goalEvent.${message.action === 'status' ? 'statusTitle' : message.action}` as TranslationKey
const title = t(titleKey) === titleKey ? t('chat.goalEvent.message') : t(titleKey)
const objective = message.objective ? t('chat.goalEvent.objective', { value: message.objective }) : null
const details = [
message.objective ? t('chat.goalEvent.objective', { value: message.objective }) : null,
message.status ? t('chat.goalEvent.statusValue', { value: message.status }) : null,
message.budget ? t('chat.goalEvent.budget', { value: message.budget }) : null,
message.continuations ? t('chat.goalEvent.continuations', { value: message.continuations }) : null,
].filter((detail): detail is string => detail !== null)
return (
<div className="mb-3 flex justify-center">
<div className="flex max-w-[min(680px,100%)] items-start gap-3 rounded-[8px] border border-[var(--color-success)]/25 bg-[var(--color-success-container)]/28 px-3.5 py-3 text-left shadow-sm">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[var(--color-success)]/12 text-[var(--color-success)]">
<Target size={15} strokeWidth={2.25} aria-hidden="true" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
{details.length > 0 ? (
<div className="mt-1 space-y-0.5 text-xs leading-5 text-[var(--color-text-secondary)]">
{details.map((detail) => (
<div key={detail} className="break-words">{detail}</div>
))}
<div className="mb-3 flex justify-start">
<div className="group relative w-full max-w-[760px] rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2.5 text-left shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<div className="absolute inset-y-2 left-0 w-[3px] rounded-r-full bg-[var(--color-success)]" aria-hidden="true" />
<div className="flex min-w-0 items-start gap-2.5 pl-2">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] border border-[var(--color-success)]/20 bg-[var(--color-success)]/8 text-[var(--color-success)]">
<Target size={15} strokeWidth={2.25} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[13px] font-semibold text-[var(--color-text-primary)]">{title}</div>
{message.status && (
<span className="shrink-0 rounded-[6px] border border-[var(--color-success)]/20 bg-[var(--color-success)]/7 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-normal text-[var(--color-success)]">
{message.status}
</span>
)}
</div>
) : message.message ? (
<div className="mt-1 whitespace-pre-wrap text-xs leading-5 text-[var(--color-text-secondary)]">
{message.message}
</div>
) : null}
{objective ? (
<div
className="mt-1 overflow-hidden text-[12px] leading-5 text-[var(--color-text-secondary)]"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}
>
{objective}
</div>
) : message.message ? (
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-5 text-[var(--color-text-secondary)]">
{message.message}
</div>
) : null}
{details.length > 0 && (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
{details.map((detail) => (
<span key={detail} className="rounded-[6px] bg-[var(--color-surface-container-high)] px-2 py-1 text-[11px] leading-none text-[var(--color-text-tertiary)]">
{detail}
</span>
))}
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -29,6 +29,7 @@ import type { SessionListItem } from '../types/session'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isTauriRuntime } from '../lib/desktopRuntime'
import type { ActiveGoalState } from '../types/chat'
import { Target } from 'lucide-react'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
@ -223,33 +224,51 @@ function GoalStatusPanel({
data-testid="active-goal-panel"
className={
compact
? 'border-b border-[var(--color-success)]/20 bg-[var(--color-success)]/6 px-4 py-2'
: 'mx-auto w-full max-w-[860px] border-b border-[var(--color-success)]/20 px-8 py-2'
? 'border-b border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2'
: 'mx-auto w-full max-w-[920px] border-b border-[var(--color-border)] px-8 py-2'
}
>
<div className="flex min-w-0 items-center gap-3 rounded-md border border-[var(--color-success)]/25 bg-[var(--color-success)]/8 px-3 py-2">
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-success)]">track_changes</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-normal text-[var(--color-success)]">
{t('chat.activeGoal.title')}
</span>
<span className="shrink-0 rounded-sm bg-[var(--color-success)]/12 px-1.5 py-0.5 text-[10px] font-semibold text-[var(--color-success)]">
{stateLabel}
</span>
{goal.objective && (
<span className="truncate text-xs font-semibold text-[var(--color-text-primary)]">
{goal.objective}
<div className="relative overflow-hidden rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<div className="absolute inset-y-2 left-0 w-[3px] rounded-r-full bg-[var(--color-success)]" aria-hidden="true" />
<div className="flex min-w-0 items-center gap-3 pl-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border border-[var(--color-success)]/20 bg-[var(--color-success)]/8 text-[var(--color-success)]">
<Target size={17} strokeWidth={2.2} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-normal text-[var(--color-text-secondary)]">
{t('chat.activeGoal.title')}
</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-[6px] border border-[var(--color-success)]/20 bg-[var(--color-success)]/7 px-1.5 py-0.5 text-[10px] font-semibold text-[var(--color-success)]">
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{stateLabel}
</span>
{goal.objective && (
<span className="truncate text-[13px] font-semibold text-[var(--color-text-primary)]">
{goal.objective}
</span>
)}
</div>
{(goal.budget || goal.continuations || goal.elapsed) && (
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
{goal.budget && (
<span className="rounded-[6px] bg-[var(--color-surface-container-high)] px-2 py-1 text-[11px] leading-none text-[var(--color-text-tertiary)]">
{t('chat.activeGoal.budget', { value: goal.budget })}
</span>
)}
{goal.continuations && (
<span className="rounded-[6px] bg-[var(--color-surface-container-high)] px-2 py-1 text-[11px] leading-none text-[var(--color-text-tertiary)]">
{t('chat.activeGoal.continuations', { value: goal.continuations })}
</span>
)}
{goal.elapsed && (
<span className="rounded-[6px] bg-[var(--color-surface-container-high)] px-2 py-1 text-[11px] leading-none text-[var(--color-text-tertiary)]">
{t('chat.activeGoal.elapsed', { value: goal.elapsed })}
</span>
)}
</div>
)}
</div>
{(goal.budget || goal.continuations || goal.elapsed) && (
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-[var(--color-text-secondary)]">
{goal.budget && <span>{t('chat.activeGoal.budget', { value: goal.budget })}</span>}
{goal.continuations && <span>{t('chat.activeGoal.continuations', { value: goal.continuations })}</span>}
{goal.elapsed && <span>{t('chat.activeGoal.elapsed', { value: goal.elapsed })}</span>}
</div>
)}
</div>
</div>
</div>

View File

@ -1429,6 +1429,31 @@ describe('SessionService', () => {
expect(detail!.title).toBe('/frontend-design @website 重新设计首页')
})
it('should keep a goal creation title instead of later goal status titles', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
{
parentUuid: null,
isSidechain: false,
type: 'system',
subtype: 'local_command',
content: '<command-name>/goal</command-name>\n<command-message>goal</command-message>\n<command-args>ship the actual objective</command-args>',
level: 'info',
timestamp: '2026-01-01T00:00:01.000Z',
uuid: 'goal-command',
},
{
type: 'ai-title',
aiTitle: '/goal status',
timestamp: '2026-01-01T00:02:00.000Z',
},
])
const detail = await service.getSession(sessionId)
expect(detail!.title).toBe('/goal ship the actual objective')
})
it('should display stored AI titles without internal XML tags', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [

View File

@ -601,6 +601,25 @@ export class SessionService {
}
}
private goalCreationCommandTitle(entry: RawEntry): string | null {
if (
entry.type !== 'system' ||
entry.subtype !== 'local_command' ||
typeof entry.content !== 'string'
) {
return null
}
const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '')
if (commandName !== 'goal') return null
const args = this.readXmlTag(entry.content, 'command-args')?.trim()
if (!args || /^(status|pause|resume|complete|clear)\b/i.test(args)) return null
const title = cleanSessionTitleSource(`/goal ${args}`)
return title ? title.length > 80 ? title.slice(0, 80) + '...' : title : null
}
private extractAgentToolUseId(entry: RawEntry): string | undefined {
const content = entry.message?.content
if (!Array.isArray(content)) return undefined
@ -825,7 +844,13 @@ export class SessionService {
}
}
// 2. Look for AI-generated title (written by titleService)
// 2. Goal sessions should keep the original objective as the stable title.
for (const e of entries) {
const goalTitle = this.goalCreationCommandTitle(e)
if (goalTitle) return goalTitle
}
// 3. Look for AI-generated title (written by titleService)
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'ai-title' && e.aiTitle) {
@ -834,7 +859,7 @@ export class SessionService {
}
}
// 3. Look for first non-meta user message as title
// 4. Look for first non-meta user message as title
for (const e of entries) {
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
const content = e.message.content