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:
程序员阿江(Relakkes) 2026-07-04 21:48:57 +08:00
parent b6aeaf0109
commit 697b2efdc9
8 changed files with 835 additions and 130 deletions

View File

@ -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', () => {

View File

@ -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>
)
})}

View File

@ -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',

View File

@ -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>()

View File

@ -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()
})

View File

@ -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 && (

View File

@ -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()
})

View File

@ -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}