mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
052cc4da5d
commit
929ddd0ebe
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user