Skip to content

Commit 6a0923b

Browse files
committed
feat(activity-review): follow pill, live response, brain state + tests v1.8.9
- Redesign Follow Activity toggle into animated pill row with live-dot (aria-pressed button with violet glow when active, muted when idle) - Add Latest Response block in ActivityStoryDetailPanel showing latest reasoning_start details with thinking-segment highlights - Add 3-state brain animation: violet+Sparkles (idle), blue+Loader2 spin (working), emerald+Sparkles (complete 7s) — mirrors AgentThinkingSidebar - Add CountUpNumber stats row: total / done / processing inline - Add brainState state machine in useActivityReviewData: recent entry (<90s) + new poll entries → working growth stops → complete (7s) → idle old entries → idle - Extract liveResponseText from latest reasoning_start.details - Add brainStateRef to avoid functional-updater anti-pattern in effects - Remove spurious key props from CountUpNumber stat spans - Add 10 new unit tests: brainState machine states, liveResponseText extraction edge cases, isFollowing auto-scroll behaviour - Bump version 1.8.8 → 1.8.9
1 parent 8c6707d commit 6a0923b

File tree

6 files changed

+428
-41
lines changed

6 files changed

+428
-41
lines changed

apps/chat/src/app/activity-review-panel.tsx

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Brain, ChevronDown, ChevronRight, Search, Sparkles, Terminal, X } from 'lucide-react';
1+
import { Brain, ChevronDown, ChevronRight, Loader2, Search, Sparkles, Terminal, X } from 'lucide-react';
22
import { createPortal } from 'react-dom';
33
import { useState } from 'react';
4+
import { CountUpNumber } from './count-up-number';
45
import {
56
getActivityIcon,
67
getActivityLabel,
@@ -352,6 +353,10 @@ export interface ActivityStoryDetailPanelProps {
352353
copyTooltipAnchor: { centerX: number; bottom: number } | null;
353354
brainButtonRef: React.RefObject<HTMLDivElement | null>;
354355
onCopyClick: () => void;
356+
liveResponseText?: string;
357+
brainState?: 'idle' | 'working' | 'complete';
358+
totalStories?: number;
359+
completedStories?: number;
355360
}
356361

357362
export function ActivityStoryDetailPanel({
@@ -362,7 +367,16 @@ export function ActivityStoryDetailPanel({
362367
copyTooltipAnchor,
363368
brainButtonRef,
364369
onCopyClick,
370+
liveResponseText = '',
371+
brainState = 'idle',
372+
totalStories = 0,
373+
completedStories = 0,
365374
}: ActivityStoryDetailPanelProps) {
375+
const isWorking = brainState === 'working';
376+
const isComplete = brainState === 'complete';
377+
const brainColor = isWorking ? 'text-blue-400' : isComplete ? 'text-emerald-400' : 'text-violet-400';
378+
const accentColor = isWorking ? 'text-blue-300' : isComplete ? 'text-emerald-300' : 'text-violet-300';
379+
const statColor = isWorking ? 'text-blue-300' : isComplete ? 'text-emerald-400' : 'text-foreground';
366380
return (
367381
<main
368382
className="flex-1 min-w-0 overflow-y-auto flex flex-col bg-transparent"
@@ -401,11 +415,20 @@ export function ActivityStoryDetailPanel({
401415
</span>
402416
) : (
403417
<>
404-
<Brain className="size-8 text-violet-400 transition-colors" />
405-
<Sparkles
406-
className="size-5 text-violet-300 absolute -top-0.5 -right-0.5 animate-pulse transition-colors"
407-
aria-hidden
408-
/>
418+
<Brain className={`size-8 transition-colors ${brainColor}`} />
419+
{isWorking ? (
420+
<Loader2
421+
className={`size-5 ${accentColor} absolute -top-0.5 -right-0.5 animate-spin transition-colors`}
422+
aria-hidden
423+
/>
424+
) : (
425+
<Sparkles
426+
className={`size-5 ${accentColor} absolute -top-0.5 -right-0.5 ${
427+
isComplete ? '' : 'animate-pulse'
428+
} transition-colors`}
429+
aria-hidden
430+
/>
431+
)}
409432
</>
410433
)}
411434
</button>
@@ -426,11 +449,32 @@ export function ActivityStoryDetailPanel({
426449
</span>,
427450
document.body
428451
)}
429-
<h1 className="font-semibold text-sm text-foreground truncate min-w-0 flex-1 pl-3">
430-
{selectedStory
431-
? `${getActivityLabel(selectedStory.type)} · ${formatRelativeTime(selectedStory.timestamp)}`
432-
: 'All activities'}
433-
</h1>
452+
<div className="flex flex-col min-w-0 flex-1 pl-3 gap-0.5">
453+
<h1 className="font-semibold text-sm text-foreground truncate min-w-0">
454+
{selectedStory
455+
? `${getActivityLabel(selectedStory.type)} · ${formatRelativeTime(selectedStory.timestamp)}`
456+
: 'All activities'}
457+
</h1>
458+
{totalStories > 0 && (
459+
<p className="text-xs font-medium tabular-nums leading-none flex items-center gap-0.5 flex-wrap">
460+
<span className={`${statColor} transition-colors`}>
461+
<CountUpNumber value={totalStories} format="raw" />
462+
</span>
463+
<span className="text-muted-foreground/60 text-[10px]"> total</span>
464+
<span className="text-muted-foreground/40 mx-0.5">·</span>
465+
<span className="text-emerald-400 transition-colors">
466+
<CountUpNumber value={completedStories} format="raw" />
467+
</span>
468+
<span className="text-muted-foreground/60 text-[10px]"> done</span>
469+
{isWorking && (
470+
<>
471+
<span className="text-muted-foreground/40 mx-0.5">·</span>
472+
<span className="text-cyan-400 text-[10px] animate-pulse">processing</span>
473+
</>
474+
)}
475+
</p>
476+
)}
477+
</div>
434478
</div>
435479
<div className={SEARCH_ROW_WRAPPER}>
436480
<Search className={SEARCH_ICON_POSITION} aria-hidden />
@@ -455,12 +499,30 @@ export function ActivityStoryDetailPanel({
455499
</div>
456500
</div>
457501
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4">
502+
{liveResponseText && (
503+
<div className="mb-4 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 py-2.5 flex flex-col gap-1.5 shadow-sm shadow-violet-500/10">
504+
<div className="flex items-center gap-2 shrink-0">
505+
<span className="relative flex size-2 shrink-0">
506+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
507+
<span className="relative inline-flex rounded-full size-2 bg-violet-500" />
508+
</span>
509+
<p className="text-[10px] font-semibold text-violet-300 uppercase tracking-wide">
510+
Latest Response
511+
</p>
512+
</div>
513+
<div className="max-h-[40vh] overflow-y-auto">
514+
<p className={`text-[11px] ${ACTIVITY_MONO} whitespace-pre-wrap`}>
515+
{reasoningBodyWithHighlights(liveResponseText, detailSearchQuery)}
516+
</p>
517+
</div>
518+
</div>
519+
)}
458520
{selectedStory ? (
459521
<div className="w-full min-w-0">
460522
<StoryDetail story={selectedStory} highlightQuery={detailSearchQuery} />
461523
</div>
462524
) : (
463-
<p className="text-sm text-muted-foreground">Select a story from the list.</p>
525+
!liveResponseText && <p className="text-sm text-muted-foreground">Select a story from the list.</p>
464526
)}
465527
</div>
466528
</main>

apps/chat/src/app/pages/activity-review-page.spec.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ describe('ActivityReviewPage', () => {
173173
expect(screen.getAllByText('Step one').length).toBeGreaterThanOrEqual(1);
174174
});
175175

176-
it('renders a checkbox for each story row in the list', async () => {
176+
it('renders the follow activity toggle button', async () => {
177177
const { apiRequest } = await import('../api-url');
178178
(apiRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
179179
ok: true,
@@ -184,10 +184,18 @@ describe('ActivityReviewPage', () => {
184184
await waitFor(() => {
185185
expect(screen.queryByText('Loading activities…')).toBeNull();
186186
});
187-
const checkboxes = screen.getAllByRole('checkbox');
188-
expect(checkboxes.length).toBeGreaterThanOrEqual(1);
187+
const followBtn = screen.getByRole('button', { name: /Follow activity/i });
188+
expect(followBtn).toBeTruthy();
189+
expect(followBtn.getAttribute('aria-pressed')).toBe('false');
190+
// Toggle on
191+
await act(async () => {
192+
fireEvent.click(followBtn);
193+
});
194+
expect(followBtn.getAttribute('aria-pressed')).toBe('true');
195+
expect(screen.getByText('Live')).toBeTruthy();
189196
});
190197

198+
191199
it('shows most recent story at the top of the list', async () => {
192200
const twoActivityData = [
193201
{

apps/chat/src/app/pages/activity-review-page.tsx

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export function ActivityReviewPage() {
5050
closeSettings,
5151
isFollowing,
5252
setIsFollowing,
53+
liveResponseText,
54+
brainState,
5355
} = useActivityReviewData({
5456
activityId: routeActivityId,
5557
storyId: routeStoryId,
@@ -91,6 +93,7 @@ export function ActivityReviewPage() {
9193
className="flex flex-col flex-shrink-0 bg-gradient-to-br from-background via-background to-purple-950/5 border-r border-violet-500/20 transition-all duration-300 overflow-hidden"
9294
style={{ width: RIGHT_SIDEBAR_WIDTH_PX, minWidth: 0 }}
9395
>
96+
{/* Header: nav + settings/theme + search */}
9497
<div
9598
className={`${SIDEBAR_HEADER} flex flex-col shrink-0 min-w-0`}
9699
style={{ minHeight: PANEL_HEADER_MIN_HEIGHT_PX }}
@@ -138,33 +141,42 @@ export function ActivityReviewPage() {
138141
</button>
139142
) : null}
140143
</div>
141-
<label className="flex items-center gap-1.5 cursor-pointer select-none px-1 pb-1 group" htmlFor="follow-activity-toggle">
142-
<span
143-
id="follow-activity-toggle"
144-
role="checkbox"
145-
aria-checked={isFollowing}
146-
tabIndex={0}
147-
onClick={() => setIsFollowing((v) => !v)}
148-
onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && setIsFollowing((v) => !v)}
149-
className={`relative inline-flex h-4 w-7 shrink-0 rounded-full border transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-violet-500/50 ${
150-
isFollowing
151-
? 'bg-violet-500 border-violet-400'
152-
: 'bg-muted/60 border-border/50 group-hover:border-violet-500/40'
153-
}`}
154-
>
155-
<span
156-
className={`absolute top-0.5 left-0.5 size-2.5 rounded-full bg-white shadow transition-transform ${
157-
isFollowing ? 'translate-x-3' : 'translate-x-0'
158-
}`}
159-
/>
160-
</span>
161-
<span className={`text-[11px] font-medium transition-colors ${
162-
isFollowing ? 'text-violet-300' : 'text-muted-foreground group-hover:text-foreground'
163-
}`}>
164-
Follow activity
144+
</div>
145+
146+
{/* Follow Activity pill — own row with animated live dot */}
147+
<div className="px-3 py-2 flex items-center gap-2 border-b border-border/40 shrink-0">
148+
<button
149+
id="follow-activity-toggle"
150+
type="button"
151+
aria-pressed={isFollowing}
152+
onClick={() => setIsFollowing((v) => !v)}
153+
className={`
154+
flex items-center gap-2 px-2.5 py-1 rounded-full border text-[11px] font-medium
155+
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-violet-500/40
156+
${isFollowing
157+
? 'bg-violet-500/15 border-violet-500/40 text-violet-300 shadow-sm shadow-violet-500/20'
158+
: 'bg-muted/40 border-border/50 text-muted-foreground hover:bg-violet-500/10 hover:text-violet-400 hover:border-violet-500/30'
159+
}
160+
`}
161+
>
162+
{/* Animated live dot */}
163+
<span className="relative flex size-2 shrink-0" aria-hidden>
164+
{isFollowing ? (
165+
<>
166+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
167+
<span className="relative inline-flex rounded-full size-2 bg-violet-500" />
168+
</>
169+
) : (
170+
<span className="relative inline-flex rounded-full size-2 bg-muted-foreground/40" />
171+
)}
165172
</span>
166-
</label>
173+
Follow activity
174+
</button>
175+
{isFollowing && (
176+
<span className="text-[10px] text-violet-400/80 italic select-none">Live</span>
177+
)}
167178
</div>
179+
168180
<ActivityStoryList
169181
stories={filteredStories}
170182
selectedIndex={selectedIndexSafe}
@@ -188,6 +200,10 @@ export function ActivityReviewPage() {
188200
copyTooltipAnchor={copyTooltipAnchor}
189201
brainButtonRef={brainButtonRef}
190202
onCopyClick={() => void runCopyActivityWithAnimation()}
203+
liveResponseText={liveResponseText}
204+
brainState={brainState}
205+
totalStories={activityStories.length}
206+
completedStories={brainState === 'working' ? Math.max(0, activityStories.length - 1) : activityStories.length}
191207
/>
192208
</div>
193209
<ChatSettingsModal

0 commit comments

Comments
 (0)