mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: refine session activity panel
Tested: cd desktop && bun run test -- src/components/activity/SessionActivityPanel.test.tsx --run Tested: cd desktop && bun run test -- src/components/activity/sessionActivityModel.test.ts src/components/activity/SessionActivityPanel.test.tsx src/components/layout/TabBar.test.tsx src/pages/ActiveSession.test.tsx --run Tested: bun run check:desktop Scope-risk: moderate Confidence: high
This commit is contained in:
parent
b6aeaf0109
commit
697b2efdc9
@ -101,10 +101,15 @@ function model(overrides: Partial<SessionActivityModel> = {}): SessionActivityMo
|
||||
describe('SessionActivityPanel', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('renders sections, rows, and empty states', () => {
|
||||
it('renders populated tasks section without empty visible section labels', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
model={model()}
|
||||
model={model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
subagents: { id: 'subagents', title: 'SubAgents', emptyLabel: 'No SubAgents', rows: [] },
|
||||
},
|
||||
})}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
@ -118,15 +123,14 @@ describe('SessionActivityPanel', () => {
|
||||
expect(screen.getByText('Add panel coverage')).toHaveAttribute('title', 'Add panel coverage')
|
||||
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
|
||||
expect(screen.queryByText('In progress')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Team')).toBeInTheDocument()
|
||||
expect(screen.getByText('No team members')).toBeInTheDocument()
|
||||
expect(screen.getByText('Background Tasks')).toBeInTheDocument()
|
||||
expect(screen.getByText('SubAgents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Kuhn')).toBeInTheDocument()
|
||||
expect(screen.getByText('Running')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('No background tasks')).toBeInTheDocument()
|
||||
expect(screen.getByText('No sources')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Team')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Background Tasks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('SubAgents')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sources')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No team members')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No background tasks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No SubAgents')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No sources')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders task rows as checklist markers instead of status chips', () => {
|
||||
@ -386,11 +390,12 @@ describe('SessionActivityPanel', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('my-3')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-3')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-xl')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('my-4')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-4')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('w-[340px]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-[24px]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('self-start')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(480px,calc(100vh-88px))]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(480px,calc(100vh-96px))]')
|
||||
expect(screen.getByTestId('session-activity-panel')).not.toHaveClass('h-[calc(100%-24px)]')
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
|
||||
|
||||
@ -455,10 +460,10 @@ describe('SessionActivityPanel', () => {
|
||||
const backgroundSection = document.querySelector('section[aria-label="Background Tasks"]')
|
||||
const subagentsSection = document.querySelector('section[aria-label="SubAgents"]')
|
||||
|
||||
expect(tasksSection?.querySelector('.max-h-40.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(teamSection?.querySelector('.max-h-32.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(backgroundSection?.querySelector('.max-h-36.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(subagentsSection?.querySelector('.max-h-36.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(tasksSection?.querySelector('.max-h-44.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(teamSection?.querySelector('.max-h-36.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(backgroundSection?.querySelector('.max-h-40.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(subagentsSection?.querySelector('.max-h-40.overflow-y-auto')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a SubAgent row when the row is openable', () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, LoaderCircle, X } from 'lucide-react'
|
||||
import type { ActivityRow, ActivitySectionId, SessionActivityModel } from './sessionActivityModel'
|
||||
import { Bot, Check, ChevronRight, Circle, FileText, LoaderCircle, Terminal, Users, X } from 'lucide-react'
|
||||
import { getVisibleActivitySections, type ActivityRow, type ActivitySectionId, type SessionActivityModel } from './sessionActivityModel'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { BackgroundAgentTask } from '../../types/chat'
|
||||
import type { TeamMember } from '../../types/team'
|
||||
@ -14,8 +14,6 @@ export type OpenSubagentPayload = {
|
||||
|
||||
type SessionActivityPanelPlacement = 'overlay' | 'rail'
|
||||
|
||||
const SECTION_ORDER = ['tasks', 'team', 'backgroundTasks', 'subagents', 'sources'] as const
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>
|
||||
|
||||
function fallbackStatusLabel(status: ActivityRow['status']): string {
|
||||
@ -64,40 +62,23 @@ function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string
|
||||
}
|
||||
}
|
||||
|
||||
function getSectionEmptyLabel(sectionId: ActivitySectionId, t: TranslationFn): string {
|
||||
switch (sectionId) {
|
||||
case 'tasks':
|
||||
return t('session.activity.empty.tasks')
|
||||
case 'team':
|
||||
return t('session.activity.empty.team')
|
||||
case 'backgroundTasks':
|
||||
return t('session.activity.empty.backgroundTasks')
|
||||
case 'subagents':
|
||||
return t('session.activity.empty.subagents')
|
||||
case 'sources':
|
||||
return t('session.activity.empty.sources')
|
||||
case 'output':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number): string {
|
||||
const base = 'space-y-0.5'
|
||||
const base = 'space-y-1'
|
||||
if (rowCount === 0) return base
|
||||
|
||||
switch (sectionId) {
|
||||
case 'tasks':
|
||||
return `${base} max-h-40 overflow-y-auto overscroll-contain pr-1`
|
||||
return `${base} max-h-44 overflow-y-auto overscroll-contain pr-1`
|
||||
case 'team':
|
||||
return `${base} max-h-32 overflow-y-auto overscroll-contain pr-1`
|
||||
return `${base} max-h-36 overflow-y-auto overscroll-contain pr-1`
|
||||
case 'backgroundTasks':
|
||||
return `${base} max-h-36 overflow-y-auto overscroll-contain pr-1`
|
||||
return `${base} max-h-40 overflow-y-auto overscroll-contain pr-1`
|
||||
case 'subagents':
|
||||
return `${base} max-h-36 overflow-y-auto overscroll-contain pr-1`
|
||||
return `${base} max-h-40 overflow-y-auto overscroll-contain pr-1`
|
||||
case 'sources':
|
||||
return `${base} max-h-24 overflow-y-auto overscroll-contain pr-1`
|
||||
return `${base} max-h-28 overflow-y-auto overscroll-contain pr-1`
|
||||
case 'output':
|
||||
return `${base} max-h-24 overflow-y-auto overscroll-contain pr-1`
|
||||
return `${base} max-h-28 overflow-y-auto overscroll-contain pr-1`
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,9 +137,9 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
|
||||
return (
|
||||
<span
|
||||
aria-label={t('session.activity.task.completed')}
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-[var(--color-success)] bg-[var(--color-success)] text-white"
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--color-success)] bg-[var(--color-success)] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.24)]"
|
||||
>
|
||||
<Check size={12} strokeWidth={3} aria-hidden="true" />
|
||||
<Check size={13} strokeWidth={3} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -167,9 +148,9 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
|
||||
return (
|
||||
<span
|
||||
aria-label={t('session.activity.task.inProgress')}
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-[var(--color-accent)] text-[var(--color-accent)]"
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--color-accent)] bg-[var(--color-surface)] text-[var(--color-accent)]"
|
||||
>
|
||||
<LoaderCircle size={12} strokeWidth={2.4} aria-hidden="true" className="animate-spin" />
|
||||
<LoaderCircle size={13} strokeWidth={2.4} aria-hidden="true" className="animate-spin" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -177,11 +158,40 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
|
||||
return (
|
||||
<span
|
||||
aria-label={t('session.activity.task.pending')}
|
||||
className="inline-flex h-4 w-4 shrink-0 rounded border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
className="inline-flex h-5 w-5 shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getRowIcon(row: ActivityRow) {
|
||||
switch (row.section) {
|
||||
case 'team':
|
||||
return Users
|
||||
case 'backgroundTasks':
|
||||
return Terminal
|
||||
case 'subagents':
|
||||
return Bot
|
||||
case 'sources':
|
||||
case 'output':
|
||||
return FileText
|
||||
case 'tasks':
|
||||
return Circle
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTone(status: ActivityRow['status']) {
|
||||
if (status === 'running' || status === 'in_progress') {
|
||||
return 'bg-[var(--color-accent)]'
|
||||
}
|
||||
if (status === 'completed' || status === 'idle') {
|
||||
return 'bg-[var(--color-success)]'
|
||||
}
|
||||
if (status === 'failed' || status === 'error' || status === 'stopped') {
|
||||
return 'bg-[var(--color-error)]'
|
||||
}
|
||||
return 'bg-[var(--color-text-tertiary)]'
|
||||
}
|
||||
|
||||
function ActivityRowView({
|
||||
row,
|
||||
sessionId,
|
||||
@ -215,17 +225,26 @@ function ActivityRowView({
|
||||
: undefined
|
||||
const content = (
|
||||
<>
|
||||
{isTask ? <TaskStatusMarker status={row.status} t={t} /> : null}
|
||||
{isTask ? (
|
||||
<TaskStatusMarker status={row.status} t={t} />
|
||||
) : (
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)]">
|
||||
{(() => {
|
||||
const Icon = getRowIcon(row)
|
||||
return <Icon size={17} strokeWidth={2} aria-hidden="true" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
<span
|
||||
className={`block truncate text-xs font-medium ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through' : 'text-[var(--color-text-primary)]'}`}
|
||||
className={`block truncate text-[13px] font-semibold leading-5 ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through decoration-[var(--color-text-tertiary)]/60' : 'text-[var(--color-text-primary)]'}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span
|
||||
className="block truncate text-[11px] text-[var(--color-text-tertiary)]"
|
||||
className="block truncate text-[11px] leading-4 text-[var(--color-text-tertiary)]"
|
||||
title={detail}
|
||||
>
|
||||
{detail}
|
||||
@ -233,12 +252,18 @@ function ActivityRowView({
|
||||
) : null}
|
||||
</span>
|
||||
{isTask ? null : (
|
||||
<span className="shrink-0 rounded bg-[var(--color-surface-container)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)]">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${getStatusTone(row.status)}`} aria-hidden="true" />
|
||||
{getActivityStatusLabel(row.status, t)}
|
||||
</span>
|
||||
)}
|
||||
{!isTask && row.openable ? (
|
||||
<ChevronRight size={14} strokeWidth={2.2} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
const interactiveRowClassName =
|
||||
'flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
|
||||
if (row.section === 'team' && row.member && onOpenMember) {
|
||||
return (
|
||||
@ -246,7 +271,7 @@ function ActivityRowView({
|
||||
type="button"
|
||||
aria-label={t('session.activity.openTeamMember', { name: row.label })}
|
||||
onClick={() => onOpenMember(row.member!)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
className={interactiveRowClassName}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
@ -259,7 +284,7 @@ function ActivityRowView({
|
||||
type="button"
|
||||
aria-label={t('session.activity.openRun', { name: row.label })}
|
||||
onClick={() => onOpenSubagent({ sessionId, toolUseId: row.toolUseId!, title: row.label })}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
className={interactiveRowClassName}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
@ -273,7 +298,7 @@ function ActivityRowView({
|
||||
aria-label={t('session.activity.openBackgroundTask', { name: row.label })}
|
||||
aria-expanded={selected}
|
||||
onClick={() => onOpenBackgroundTask(row)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
|
||||
className={`${interactiveRowClassName} ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
@ -281,7 +306,7 @@ function ActivityRowView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-1.5">
|
||||
<div className="flex items-center gap-2.5 rounded-xl px-3 py-2.5">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
@ -317,14 +342,14 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
|
||||
if (details.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mx-2 mb-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
|
||||
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
<div className="mx-3 mb-1.5 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.54)]">
|
||||
<div className="mb-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('session.activity.details.title')}
|
||||
</div>
|
||||
<dl className="space-y-1.5">
|
||||
<dl className="space-y-2">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="min-w-0">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="max-h-28 overflow-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
@ -358,6 +383,7 @@ export function SessionActivityPanel({
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedBackgroundTaskId, setSelectedBackgroundTaskId] = useState<string | null>(null)
|
||||
const finishedBackgroundTaskKeys = useMemo(() => getFinishedBackgroundTaskKeys(model), [model])
|
||||
const visibleSections = useMemo(() => getVisibleActivitySections(model), [model])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@ -401,8 +427,8 @@ export function SessionActivityPanel({
|
||||
|
||||
if (!open) return null
|
||||
const className = placement === 'rail'
|
||||
? 'my-3 ml-2 mr-3 flex max-h-[min(480px,calc(100vh-88px))] w-[300px] shrink-0 self-start flex-col overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_18px_48px_-30px_rgba(15,23,42,0.42),0_8px_20px_-18px_rgba(15,23,42,0.28),inset_0_1px_0_rgba(255,255,255,0.72)]'
|
||||
: 'absolute right-3 top-3 z-40 flex max-h-[calc(100%-96px)] w-[min(300px,calc(100%-24px))] flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-dropdown)]'
|
||||
? 'my-4 ml-3 mr-4 flex max-h-[min(480px,calc(100vh-96px))] w-[340px] shrink-0 self-start flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
|
||||
: 'absolute right-4 top-4 z-40 flex max-h-[calc(100%-112px)] w-[min(340px,calc(100%-32px))] flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -413,33 +439,35 @@ export function SessionActivityPanel({
|
||||
data-placement={placement}
|
||||
className={className}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||||
<h2 className="text-xs font-semibold text-[var(--color-text-primary)]">{t('session.activity.title')}</h2>
|
||||
<div className="flex items-center justify-between px-5 pb-2 pt-4">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--color-text-secondary)]">{t('session.activity.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('session.activity.close')}
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-tertiary)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<X size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||
<X size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{SECTION_ORDER.map((sectionId) => {
|
||||
const section = model.sections[sectionId]
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-5 pt-1">
|
||||
{visibleSections.map((section, index) => {
|
||||
const sectionTitle = getSectionTitle(section.id, t)
|
||||
const sectionEmptyLabel = getSectionEmptyLabel(section.id, t)
|
||||
|
||||
return (
|
||||
<section key={section.id} aria-label={sectionTitle}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">
|
||||
<section
|
||||
key={section.id}
|
||||
aria-label={sectionTitle}
|
||||
className={index > 0 ? 'border-t border-[var(--color-border)] pt-4' : undefined}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="text-[12px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{sectionTitle}
|
||||
</h3>
|
||||
{section.rows.length > 0 ? (
|
||||
<span className="rounded bg-[var(--color-surface-container)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--color-text-tertiary)]">
|
||||
<span className="rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[10px] leading-none text-[var(--color-text-tertiary)]">
|
||||
{section.rows.length}
|
||||
</span>
|
||||
) : null}
|
||||
@ -454,31 +482,27 @@ export function SessionActivityPanel({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{section.rows.length === 0 ? (
|
||||
<div className="px-2 py-1 text-xs text-[var(--color-text-tertiary)]">{sectionEmptyLabel}</div>
|
||||
) : (
|
||||
<div className={getSectionRowsClassName(section.id, section.rows.length)}>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.id}>
|
||||
<ActivityRowView
|
||||
row={row}
|
||||
sessionId={model.sessionId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenMember={onOpenMember}
|
||||
onOpenBackgroundTask={(backgroundRow) => {
|
||||
setSelectedBackgroundTaskId((current) => (
|
||||
current === backgroundRow.id ? null : backgroundRow.id
|
||||
))
|
||||
}}
|
||||
selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id}
|
||||
/>
|
||||
{section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? (
|
||||
<BackgroundTaskDetail row={row} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={getSectionRowsClassName(section.id, section.rows.length)}>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.id}>
|
||||
<ActivityRowView
|
||||
row={row}
|
||||
sessionId={model.sessionId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenMember={onOpenMember}
|
||||
onOpenBackgroundTask={(backgroundRow) => {
|
||||
setSelectedBackgroundTaskId((current) => (
|
||||
current === backgroundRow.id ? null : backgroundRow.id
|
||||
))
|
||||
}}
|
||||
selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id}
|
||||
/>
|
||||
{section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? (
|
||||
<BackgroundTaskDetail row={row} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSessionActivityModel } from './sessionActivityModel'
|
||||
import {
|
||||
buildSessionActivityModel,
|
||||
getVisibleActivitySections,
|
||||
hasVisibleSessionActivity,
|
||||
} from './sessionActivityModel'
|
||||
import { createBackgroundTaskDismissKey } from '../../lib/backgroundTasks'
|
||||
import type { BackgroundAgentTask, AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import type { CLITask } from '../../types/cliTask'
|
||||
@ -77,6 +81,167 @@ const agentMessages: UIMessage[] = [
|
||||
]
|
||||
|
||||
describe('buildSessionActivityModel', () => {
|
||||
it('reports no visible activity for an empty model', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [],
|
||||
agentNotifications: [],
|
||||
})
|
||||
|
||||
expect(hasVisibleSessionActivity(model)).toBe(false)
|
||||
expect(getVisibleActivitySections(model)).toEqual([])
|
||||
expect(model.badgeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps completed TodoWrite historical tasks visible without badge attention', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
messages: [{
|
||||
id: 'todo-1',
|
||||
type: 'tool_use',
|
||||
toolName: 'TodoWrite',
|
||||
toolUseId: 'todo-1',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: '审查现有实现', status: 'completed' },
|
||||
],
|
||||
},
|
||||
timestamp: 1000,
|
||||
}],
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [],
|
||||
agentNotifications: [],
|
||||
})
|
||||
|
||||
expect(hasVisibleSessionActivity(model)).toBe(true)
|
||||
expect(getVisibleActivitySections(model).map((section) => section.id)).toEqual(['tasks'])
|
||||
expect(model.badgeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps completed Agent tool_use/tool_result rows visible without badge attention', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
messages: [agentMessages[0]!, agentMessages[1]!],
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [],
|
||||
agentNotifications: [],
|
||||
})
|
||||
|
||||
expect(hasVisibleSessionActivity(model)).toBe(true)
|
||||
expect(getVisibleActivitySections(model).map((section) => section.id)).toEqual(['subagents'])
|
||||
expect(model.badgeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('counts running and failed rows as visible while preserving badge attention semantics', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
tasks: [
|
||||
task({ id: '1', subject: 'Implement', status: 'in_progress' }),
|
||||
],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [
|
||||
background({ taskId: 'agent-1', toolUseId: 'tool-1', status: 'failed', taskType: 'local_agent' }),
|
||||
background({ taskId: 'bg-2', toolUseId: 'tool-2', status: 'running', taskType: 'local_bash' }),
|
||||
],
|
||||
agentNotifications: [],
|
||||
})
|
||||
|
||||
expect(hasVisibleSessionActivity(model)).toBe(true)
|
||||
expect(getVisibleActivitySections(model).map((section) => section.id)).toEqual([
|
||||
'tasks',
|
||||
'backgroundTasks',
|
||||
'subagents',
|
||||
])
|
||||
expect(model.badgeCount).toBe(3)
|
||||
})
|
||||
|
||||
it('counts running team members as badge attention', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [],
|
||||
agentNotifications: [],
|
||||
teamMembers: [
|
||||
{ agentId: 'security', role: 'Security reviewer', status: 'running' },
|
||||
{ agentId: 'performance', role: 'Performance reviewer', status: 'completed' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(hasVisibleSessionActivity(model)).toBe(true)
|
||||
expect(getVisibleActivitySections(model).map((section) => section.id)).toEqual(['team'])
|
||||
expect(model.badgeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('counts error team members as badge attention', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [],
|
||||
agentNotifications: [],
|
||||
teamMembers: [
|
||||
{ agentId: 'security', role: 'Security reviewer', status: 'error' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(hasVisibleSessionActivity(model)).toBe(true)
|
||||
expect(getVisibleActivitySections(model).map((section) => section.id)).toEqual(['team'])
|
||||
expect(model.badgeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not count output-only rows as visible activity', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [],
|
||||
agentNotifications: [
|
||||
notification({
|
||||
taskId: 'bg-bash-1',
|
||||
toolUseId: 'bash-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Task completed',
|
||||
outputFile: '/tmp/bg-test.log',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(model.sections.output.rows).toHaveLength(1)
|
||||
expect(hasVisibleSessionActivity(model)).toBe(false)
|
||||
expect(getVisibleActivitySections(model)).toEqual([])
|
||||
expect(model.badgeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not keep Activity visible for dismissed finished background tasks', () => {
|
||||
const dismissedTask = background({
|
||||
taskId: 'bg-1',
|
||||
toolUseId: 'tool-1',
|
||||
status: 'completed',
|
||||
taskType: 'local_bash',
|
||||
startedAt: 1000,
|
||||
description: 'Dismissed run',
|
||||
})
|
||||
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
tasks: [],
|
||||
completedAndDismissed: false,
|
||||
backgroundTasks: [dismissedTask],
|
||||
dismissedBackgroundTaskKeys: new Set([createBackgroundTaskDismissKey(dismissedTask)]),
|
||||
agentNotifications: [],
|
||||
})
|
||||
|
||||
expect(model.sections.backgroundTasks.rows).toHaveLength(0)
|
||||
expect(hasVisibleSessionActivity(model)).toBe(false)
|
||||
expect(getVisibleActivitySections(model)).toEqual([])
|
||||
expect(model.badgeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('counts incomplete tasks and running agent rows for the badge', () => {
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: 'session-1',
|
||||
|
||||
@ -56,7 +56,15 @@ export type BuildSessionActivityModelInput = {
|
||||
teamMembers?: TeamMember[]
|
||||
}
|
||||
|
||||
const BADGE_STATUSES = new Set<ActivityStatus>(['pending', 'in_progress', 'running', 'failed'])
|
||||
export const VISIBLE_ACTIVITY_SECTION_ORDER = [
|
||||
'tasks',
|
||||
'team',
|
||||
'backgroundTasks',
|
||||
'subagents',
|
||||
'sources',
|
||||
] as const satisfies readonly ActivitySectionId[]
|
||||
|
||||
const BADGE_STATUSES = new Set<ActivityStatus>(['pending', 'in_progress', 'running', 'failed', 'error'])
|
||||
|
||||
const SECTION_META: Record<ActivitySectionId, Pick<ActivitySection, 'title' | 'emptyLabel'>> = {
|
||||
output: { title: 'Output', emptyLabel: 'No output' },
|
||||
@ -87,6 +95,16 @@ function createSection(id: ActivitySectionId): ActivitySection {
|
||||
}
|
||||
}
|
||||
|
||||
export function getVisibleActivitySections(model: SessionActivityModel): ActivitySection[] {
|
||||
return VISIBLE_ACTIVITY_SECTION_ORDER
|
||||
.map((sectionId) => model.sections[sectionId])
|
||||
.filter((section) => section.rows.length > 0)
|
||||
}
|
||||
|
||||
export function hasVisibleSessionActivity(model: SessionActivityModel): boolean {
|
||||
return getVisibleActivitySections(model).length > 0
|
||||
}
|
||||
|
||||
function isBadgeStatus(status: ActivityStatus): boolean {
|
||||
return BADGE_STATUSES.has(status)
|
||||
}
|
||||
@ -620,6 +638,11 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
|
||||
for (const member of input.teamMembers ?? []) {
|
||||
sections.team.rows.push(buildTeamRow(member))
|
||||
}
|
||||
for (const row of sections.team.rows) {
|
||||
if (isBadgeStatus(row.status)) {
|
||||
badgeCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const subagentRowsByKey = new Map<string, ActivityRow>()
|
||||
const subagentKeyByTaskId = new Map<string, string>()
|
||||
|
||||
@ -2,9 +2,11 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import type { PerSessionState } from '../../stores/chatStore'
|
||||
import type { ChatState } from '../../types/chat'
|
||||
import type { ChatState, UIMessage } from '../../types/chat'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
|
||||
type ToolUseMessage = Extract<UIMessage, { type: 'tool_use' }>
|
||||
|
||||
const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
|
||||
startDragging: startDraggingMock,
|
||||
@ -47,6 +49,20 @@ function makeChatSession(chatState: ChatState): PerSessionState {
|
||||
}
|
||||
}
|
||||
|
||||
const completedTodoWriteMessage = (overrides: Partial<ToolUseMessage> = {}): UIMessage => ({
|
||||
id: 'todo-1',
|
||||
type: 'tool_use',
|
||||
toolName: 'TodoWrite',
|
||||
toolUseId: 'todo-1',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: 'Review existing implementation', status: 'completed' },
|
||||
],
|
||||
},
|
||||
timestamp: 1000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: getCurrentWindowMock,
|
||||
}))
|
||||
@ -182,6 +198,7 @@ describe('TabBar', () => {
|
||||
const { useBrowserPanelStore } = await import('../../stores/browserPanelStore')
|
||||
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
|
||||
const { useCLITaskStore } = await import('../../stores/cliTaskStore')
|
||||
const { useTeamStore } = await import('../../stores/teamStore')
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useChatStore.setState({
|
||||
@ -200,17 +217,122 @@ describe('TabBar', () => {
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true)
|
||||
useCLITaskStore.setState(useCLITaskStore.getInitialState(), true)
|
||||
useTeamStore.setState({
|
||||
teams: [],
|
||||
activeTeam: null,
|
||||
memberColors: new Map(),
|
||||
error: null,
|
||||
})
|
||||
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
Reflect.deleteProperty(window, '__TAURI__')
|
||||
})
|
||||
|
||||
it('shows the activity button for active session tabs', async () => {
|
||||
it('hides the activity button for no-activity chat session tabs', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const sessionId = 'session-1'
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: makeChatSession('idle'),
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the activity button for output-only activity rows', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const sessionId = 'session-1'
|
||||
const chatSession = makeChatSession('idle')
|
||||
chatSession.agentTaskNotifications = {
|
||||
'bash-tool-1': {
|
||||
taskId: 'bg-bash-1',
|
||||
toolUseId: 'bash-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Task completed',
|
||||
outputFile: '/tmp/bg-test.log',
|
||||
},
|
||||
}
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: chatSession,
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the activity button for completed TodoWrite history without a badge', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const sessionId = 'session-1'
|
||||
const chatSession = makeChatSession('idle')
|
||||
chatSession.messages = [completedTodoWriteMessage()]
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: chatSession,
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the activity button with a badge for running or failed activity', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
|
||||
const sessionId = 'session-1'
|
||||
const chatSession = makeChatSession('idle')
|
||||
chatSession.backgroundAgentTasks = {
|
||||
'agent-1': {
|
||||
@ -222,18 +344,27 @@ describe('TabBar', () => {
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
'agent-2': {
|
||||
taskId: 'agent-2',
|
||||
toolUseId: 'tool-2',
|
||||
status: 'failed',
|
||||
taskType: 'local_agent',
|
||||
description: 'Report',
|
||||
startedAt: 3,
|
||||
updatedAt: 4,
|
||||
},
|
||||
}
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: 'session-1',
|
||||
tabs: [{ sessionId, title: 'Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: 'session-1', title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
sessions: [{ id: sessionId, title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'session-1': chatSession,
|
||||
[sessionId]: chatSession,
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
@ -244,8 +375,8 @@ describe('TabBar', () => {
|
||||
|
||||
const button = screen.getByRole('button', { name: /activity/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('1')
|
||||
expect(useActivityPanelStore.getState().isOpen('session-1')).toBe(false)
|
||||
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('2')
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
@ -254,7 +385,133 @@ describe('TabBar', () => {
|
||||
expect(button).toHaveAttribute('data-active', 'true')
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(useActivityPanelStore.getState().isOpen('session-1')).toBe(true)
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the activity button for team members associated with the active session', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const { useTeamStore } = await import('../../stores/teamStore')
|
||||
const sessionId = 'session-team'
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Team Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Team Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: makeChatSession('idle'),
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
useTeamStore.setState({
|
||||
activeTeam: {
|
||||
name: 'review-team',
|
||||
leadAgentId: 'lead',
|
||||
leadSessionId: sessionId,
|
||||
members: [
|
||||
{ agentId: 'lead', role: 'Lead', status: 'running' },
|
||||
{ agentId: 'security', role: 'Security reviewer', status: 'running' },
|
||||
],
|
||||
},
|
||||
} as Partial<ReturnType<typeof useTeamStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('hides team-only activity when the active team belongs to another session', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const { useTeamStore } = await import('../../stores/teamStore')
|
||||
const sessionId = 'session-team'
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Team Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Team Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: makeChatSession('idle'),
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
useTeamStore.setState({
|
||||
activeTeam: {
|
||||
name: 'other-review-team',
|
||||
leadAgentId: 'lead',
|
||||
leadSessionId: 'other-session',
|
||||
members: [
|
||||
{ agentId: 'security', role: 'Security reviewer', status: 'running' },
|
||||
],
|
||||
},
|
||||
} as Partial<ReturnType<typeof useTeamStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the activity button when team activity arrives after initial render', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||
const { useTeamStore } = await import('../../stores/teamStore')
|
||||
const sessionId = 'session-team'
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Team Chat', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{ id: sessionId, title: 'Team Chat', workDir: '/tmp/project', workDirExists: true }],
|
||||
} as Partial<ReturnType<typeof useSessionStore.getState>>)
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: makeChatSession('idle'),
|
||||
},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
useTeamStore.setState({
|
||||
activeTeam: {
|
||||
name: 'review-team',
|
||||
leadAgentId: 'lead',
|
||||
leadSessionId: sessionId,
|
||||
members: [
|
||||
{ agentId: 'security', role: 'Security reviewer', status: 'error' },
|
||||
],
|
||||
},
|
||||
} as Partial<ReturnType<typeof useTeamStore.getState>>)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('does not show the activity button for settings tabs', async () => {
|
||||
@ -420,7 +677,7 @@ describe('TabBar', () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { isPlaceholderSessionTitle } from '../../lib/sessionTitle'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
|
||||
import { useCLITaskStore } from '../../stores/cliTaskStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
|
||||
@ -25,7 +26,7 @@ import { WindowControls, showWindowControls } from './WindowControls'
|
||||
import { OpenProjectMenu } from './OpenProjectMenu'
|
||||
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
|
||||
import { ActionDialog } from '../shared/ActionDialog'
|
||||
import { buildSessionActivityModel } from '../activity/sessionActivityModel'
|
||||
import { buildSessionActivityModel, hasVisibleSessionActivity } from '../activity/sessionActivityModel'
|
||||
import { SessionActivityButton } from '../activity/SessionActivityButton'
|
||||
import { useActivityPanelStore } from '../../stores/activityPanelStore'
|
||||
|
||||
@ -110,12 +111,23 @@ export function TabBar() {
|
||||
() => new Set(dismissedBackgroundTaskKeyList),
|
||||
[dismissedBackgroundTaskKeyList],
|
||||
)
|
||||
const activityBadgeCount = useChatStore((state) => {
|
||||
if (!activeTabId || !isActiveSessionTab) return 0
|
||||
const activityTeamMembers = useTeamStore(useShallow((state) => {
|
||||
const activeTeam = state.activeTeam
|
||||
if (!activeTabId || !activeTeam || activeTeam.leadSessionId !== activeTabId) {
|
||||
return []
|
||||
}
|
||||
return activeTeam.members.filter((member) =>
|
||||
!activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId
|
||||
)
|
||||
}))
|
||||
const activityState = useChatStore(useShallow((state) => {
|
||||
if (!activeTabId || !isActiveSessionTab) {
|
||||
return { badgeCount: 0, hasVisibleActivity: false }
|
||||
}
|
||||
const sessionState = state.sessions[activeTabId]
|
||||
const includeCliTasks = cliTasksSessionId === activeTabId
|
||||
|
||||
return buildSessionActivityModel({
|
||||
const model = buildSessionActivityModel({
|
||||
sessionId: activeTabId,
|
||||
messages: sessionState?.messages ?? [],
|
||||
tasks: includeCliTasks ? cliTasks : [],
|
||||
@ -123,8 +135,15 @@ export function TabBar() {
|
||||
backgroundTasks: Object.values(sessionState?.backgroundAgentTasks ?? {}),
|
||||
dismissedBackgroundTaskKeys,
|
||||
agentNotifications: Object.values(sessionState?.agentTaskNotifications ?? {}),
|
||||
}).badgeCount
|
||||
})
|
||||
teamMembers: activityTeamMembers,
|
||||
})
|
||||
return {
|
||||
badgeCount: model.badgeCount,
|
||||
hasVisibleActivity: hasVisibleSessionActivity(model),
|
||||
}
|
||||
}))
|
||||
const showActivityButton = activeTabId && activityState.hasVisibleActivity
|
||||
const activityBadgeCount = activityState.badgeCount
|
||||
|
||||
const moveTab = useTabStore((s) => s.moveTab)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@ -415,7 +434,7 @@ export function TabBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
|
||||
{isActiveSessionTab && activeTabId && (
|
||||
{showActivityButton && activeTabId && (
|
||||
<SessionActivityButton sessionId={activeTabId} badgeCount={activityBadgeCount} />
|
||||
)}
|
||||
{isDesktopRuntime && isActiveSessionTab && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { cleanup, createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { act } from 'react'
|
||||
|
||||
@ -579,6 +579,203 @@ describe('ActiveSession task polling', () => {
|
||||
expect(screen.queryByTestId('background-tasks-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the activity panel when the store is open without visible activity', async () => {
|
||||
const sessionId = 'activity-open-empty-session'
|
||||
|
||||
useActivityPanelStore.getState().open(sessionId)
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Empty Activity Session',
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Empty Activity Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
backgroundAgentTasks: {},
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders completed historical TodoWrite activity in the rail', () => {
|
||||
const sessionId = 'activity-todowrite-history-session'
|
||||
|
||||
useActivityPanelStore.getState().open(sessionId)
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'TodoWrite History Session',
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'TodoWrite History Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [{
|
||||
id: 'todo-1',
|
||||
type: 'tool_use',
|
||||
toolName: 'TodoWrite',
|
||||
toolUseId: 'todo-1',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: 'Review historical implementation', status: 'completed' },
|
||||
],
|
||||
},
|
||||
timestamp: 1,
|
||||
}],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
backgroundAgentTasks: {},
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const panel = screen.getByTestId('session-activity-panel')
|
||||
expect(panel).toHaveAttribute('data-placement', 'rail')
|
||||
expect(within(panel).getByText('Review historical implementation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores unrelated active team rows when deciding Activity visibility', async () => {
|
||||
const sessionId = 'activity-unrelated-team-session'
|
||||
|
||||
useActivityPanelStore.getState().open(sessionId)
|
||||
useTeamStore.setState({
|
||||
teams: [],
|
||||
activeTeam: {
|
||||
name: 'other-team',
|
||||
leadAgentId: 'team-lead@other-team',
|
||||
leadSessionId: 'other-session',
|
||||
members: [
|
||||
{
|
||||
agentId: 'security-reviewer@other-team',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
currentTask: 'Auditing another session',
|
||||
},
|
||||
],
|
||||
},
|
||||
memberColors: new Map(),
|
||||
error: null,
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Unrelated Team Session',
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Unrelated Team Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
backgroundAgentTasks: {},
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
expect(screen.queryByText('security-reviewer')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a SubAgent detail tab from the activity panel', () => {
|
||||
const sessionId = 'activity-subagent-open-session'
|
||||
|
||||
@ -755,7 +952,7 @@ describe('ActiveSession task polling', () => {
|
||||
]))
|
||||
})
|
||||
|
||||
it('clears finished background tasks in the activity panel without hiding later runs', () => {
|
||||
it('clears the last visible background task by closing Activity while preserving later runs', () => {
|
||||
const sessionId = 'activity-background-clear-session'
|
||||
const otherSessionId = 'activity-background-other-session'
|
||||
|
||||
@ -856,6 +1053,8 @@ describe('ActiveSession task polling', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /clear finished/i }))
|
||||
|
||||
expect(screen.queryByText('Finished smoke run')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument()
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
|
||||
|
||||
act(() => {
|
||||
useTabStore.getState().setActiveTab(otherSessionId)
|
||||
@ -888,6 +1087,12 @@ describe('ActiveSession task polling', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Finished smoke rerun')).not.toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
useActivityPanelStore.getState().open(sessionId)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Finished smoke rerun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ import { ChatInput } from '../components/chat/ChatInput'
|
||||
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
||||
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
|
||||
import { SessionActivityPanel } from '../components/activity/SessionActivityPanel'
|
||||
import { buildSessionActivityModel } from '../components/activity/sessionActivityModel'
|
||||
import { buildSessionActivityModel, hasVisibleSessionActivity } from '../components/activity/sessionActivityModel'
|
||||
import { TerminalSettings } from './TerminalSettings'
|
||||
import type { SessionListItem } from '../types/session'
|
||||
import type { ActiveGoalState, TokenUsage } from '../types/chat'
|
||||
@ -402,11 +402,11 @@ export function ActiveSession() {
|
||||
hasRunningBackgroundTasks
|
||||
const totalTokens = getTokenUsageTotal(tokenUsage)
|
||||
const activityTeamMembers = useMemo(() => {
|
||||
if (!activeTeam) return []
|
||||
if (!activeTeam || activeTeam.leadSessionId !== activeTabId) return []
|
||||
return activeTeam.members.filter((member) =>
|
||||
!activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId
|
||||
)
|
||||
}, [activeTeam])
|
||||
}, [activeTabId, activeTeam])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return
|
||||
@ -441,6 +441,13 @@ export function ActiveSession() {
|
||||
messages,
|
||||
trackedTaskSessionId,
|
||||
])
|
||||
const hasVisibleActivity = activityModel ? hasVisibleSessionActivity(activityModel) : false
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !isActivityPanelOpen || hasVisibleActivity) return
|
||||
closeActivityPanel(activeTabId)
|
||||
}, [activeTabId, closeActivityPanel, hasVisibleActivity, isActivityPanelOpen])
|
||||
|
||||
const handleOpenSubagentRun = useCallback((payload: { sessionId: string; toolUseId: string; title: string }) => {
|
||||
useTabStore.getState().openSubagentTab(payload.sessionId, payload.toolUseId, payload.title)
|
||||
}, [])
|
||||
@ -638,7 +645,7 @@ export function ActiveSession() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{activityModel && isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? (
|
||||
{activityModel && hasVisibleActivity && isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? (
|
||||
<SessionActivityPanel
|
||||
model={activityModel}
|
||||
open={isActivityPanelOpen}
|
||||
@ -683,7 +690,7 @@ export function ActiveSession() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activityModel && !isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? (
|
||||
{activityModel && hasVisibleActivity && !isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? (
|
||||
<SessionActivityPanel
|
||||
model={activityModel}
|
||||
open={isActivityPanelOpen}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user