Skip to content

Commit 7276901

Browse files
committed
feat(web): thinking spinner, live timer, consistent stop button
- Replace blinking cursor with 3-dot amber pulsing spinner + "thinking..." - Add live elapsed timer in right rail (MM:SS, ticks every second) - Show final duration when session completes - Replace Button stop with chip-sized danger button (matches status chip) - Refresh session data on completion to get completed_at for timer
1 parent 74a10d9 commit 7276901

4 files changed

Lines changed: 87 additions & 28 deletions

File tree

apps/web/src/features/session/ContainerSession.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,17 @@ export default function ContainerSession({
116116
})
117117
}, [appendMessage])
118118

119-
// Fetch scorecard when session completes
120-
const loadScorecard = useCallback(async (sessionId: string) => {
119+
// Refresh session data (to get completed_at for the timer) and scorecard
120+
const refreshSessionData = useCallback(async (sessionId: string) => {
121121
try {
122-
const data = await fetchScorecard(sessionId)
123-
if (data) setScorecard(data)
122+
const [fresh, sc] = await Promise.all([
123+
getContainerSession(sessionId).catch(() => null),
124+
fetchScorecard(sessionId).catch(() => null),
125+
])
126+
if (fresh) setSession(fresh)
127+
if (sc) setScorecard(sc)
124128
} catch {
125-
// Scorecard is optional — silently ignore
129+
// best-effort
126130
}
127131
}, [])
128132

@@ -159,7 +163,7 @@ export default function ContainerSession({
159163
const isTerminalStatus = ['completed', 'failed', 'timeout', 'stopped'].includes(existing.status)
160164
if (isTerminalStatus) {
161165
await loadStoredEvents(existing.id)
162-
await loadScorecard(existing.id)
166+
await refreshSessionData(existing.id)
163167
} else if (existing.status === 'running') {
164168
connectStream(existing.id)
165169
}
@@ -219,7 +223,7 @@ export default function ContainerSession({
219223
if (fresh.status === 'completed') {
220224
appendStatus('Session completed.')
221225
await loadStoredEvents(sessionId)
222-
await loadScorecard(sessionId)
226+
await refreshSessionData(sessionId)
223227
} else if (fresh.status === 'failed') {
224228
setError('Container failed')
225229
appendError('Container failed.')
@@ -306,7 +310,7 @@ export default function ContainerSession({
306310
}
307311
// Load scorecard after completion
308312
if (session?.id) {
309-
loadScorecard(session.id)
313+
refreshSessionData(session.id)
310314
}
311315
})
312316

@@ -347,7 +351,7 @@ export default function ContainerSession({
347351
eventSourceRef.current = null
348352
}
349353
}
350-
}, [installationId, repoFullName, prNumber, skillName, existingSessionId, appendMessage, appendStatus, appendError, loadScorecard])
354+
}, [installationId, repoFullName, prNumber, skillName, existingSessionId, appendMessage, appendStatus, appendError, refreshSessionData])
351355

352356
const handleStop = useCallback(async () => {
353357
if (!session) return
@@ -439,18 +443,18 @@ export default function ContainerSession({
439443
<Chip variant="accent">{session?.skill_name ?? skillName}</Chip>
440444

441445
{/* Status + controls right-aligned */}
442-
<div className="ml-auto flex items-center gap-3">
446+
<div className="ml-auto flex items-center gap-2">
443447
<span data-testid="session-status"><Chip variant={statusVariant}>{status}</Chip></span>
444448

445449
{isRunning && (
446-
<Button
447-
variant="danger"
450+
<button
448451
onClick={handleStop}
449452
disabled={stopping}
450453
data-testid="stop-button"
454+
className="font-mono text-[11px] font-medium px-2 py-0.5 rounded-default border border-danger/35 text-danger bg-danger/10 hover:bg-danger/20 transition-colors cursor-pointer disabled:opacity-40"
451455
>
452-
{stopping ? 'Stopping...' : 'Stop'}
453-
</Button>
456+
{stopping ? 'stopping...' : 'stop'}
457+
</button>
454458
)}
455459
</div>
456460
</div>

apps/web/src/features/session/ConversationOutput.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,14 @@ export default function ConversationOutput({ messages, isRunning }: Conversation
5252
<MessageBlock key={message.id} message={message} />
5353
))}
5454
{isRunning && messages.length > 0 && (
55-
<span
56-
data-testid="conversation-cursor"
57-
className="inline-block w-2 h-4 bg-accent animate-pulse mt-2"
58-
/>
55+
<div data-testid="conversation-cursor" className="flex items-center gap-2 mt-3 py-2">
56+
<div className="flex gap-1">
57+
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-[pulse-dot_1.4s_ease-in-out_infinite]" />
58+
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-[pulse-dot_1.4s_ease-in-out_0.2s_infinite]" />
59+
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-[pulse-dot_1.4s_ease-in-out_0.4s_infinite]" />
60+
</div>
61+
<span className="font-mono text-[11px] text-dim">thinking...</span>
62+
</div>
5963
)}
6064
</div>
6165
</div>

apps/web/src/features/session/SessionRail.tsx

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
22
* SessionRail — right sidebar for the session view.
3-
* Shows progress tracker, scorecard, session meta, and privacy note.
3+
* Shows live timer, progress tracker, scorecard, session meta, and privacy note.
44
*/
55

6-
import { Chip, Overline } from '../../shared/components'
6+
import { useEffect, useState } from 'react'
7+
import { Chip, Dot, Overline } from '../../shared/components'
78
import type { ScorecardResponse } from './containerApi'
89
import type { ContainerSessionResponse, ContainerStatus } from './containerTypes'
910
import ProgressTracker from './ProgressTracker'
@@ -18,6 +19,44 @@ interface SessionRailProps {
1819
isComplete: boolean
1920
}
2021

22+
function formatElapsed(ms: number): string {
23+
const totalSec = Math.floor(ms / 1000)
24+
const min = Math.floor(totalSec / 60)
25+
const sec = totalSec % 60
26+
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
27+
}
28+
29+
function LiveTimer({ startedAt }: { startedAt: string }) {
30+
const [elapsed, setElapsed] = useState(0)
31+
32+
useEffect(() => {
33+
const start = new Date(startedAt).getTime()
34+
setElapsed(Date.now() - start)
35+
const interval = setInterval(() => {
36+
setElapsed(Date.now() - start)
37+
}, 1000)
38+
return () => clearInterval(interval)
39+
}, [startedAt])
40+
41+
return (
42+
<div className="flex items-center gap-2">
43+
<Dot color="ok" pulse />
44+
<span className="font-mono text-lg font-bold text-ink tabular-nums">
45+
{formatElapsed(elapsed)}
46+
</span>
47+
</div>
48+
)
49+
}
50+
51+
function FinalDuration({ startedAt, completedAt }: { startedAt: string; completedAt: string }) {
52+
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime()
53+
return (
54+
<span className="font-mono text-lg font-bold text-dim tabular-nums">
55+
{formatElapsed(ms)}
56+
</span>
57+
)
58+
}
59+
2160
export default function SessionRail({
2261
session,
2362
status,
@@ -27,10 +66,25 @@ export default function SessionRail({
2766
isComplete,
2867
}: SessionRailProps) {
2968
const isTerminal = status === 'completed' || status === 'failed' || status === 'stopped'
69+
const isRunning = status === 'running' || status === 'starting'
3070

3171
return (
3272
<div className="w-[260px] shrink-0 border-l border-rule bg-bg2 overflow-y-auto">
3373
<div className="p-4 space-y-6">
74+
{/* Timer */}
75+
{session?.started_at && (
76+
<section>
77+
<Overline className="mb-2">{'\u25b8'} ELAPSED</Overline>
78+
{isRunning && <LiveTimer startedAt={session.started_at} />}
79+
{isTerminal && session.completed_at && (
80+
<FinalDuration startedAt={session.started_at} completedAt={session.completed_at} />
81+
)}
82+
{isTerminal && !session.completed_at && (
83+
<span className="font-mono text-lg font-bold text-dim">--:--</span>
84+
)}
85+
</section>
86+
)}
87+
3488
{/* Progress tracker */}
3589
{questionCount > 0 && (
3690
<section>
@@ -76,14 +130,6 @@ export default function SessionRail({
76130
{session.repo_full_name.split('/').pop()}
77131
</span>
78132
</div>
79-
{session.started_at && (
80-
<div className="flex justify-between">
81-
<span className="text-dim font-mono">Started</span>
82-
<span className="text-ink2 font-mono">
83-
{new Date(session.started_at).toLocaleTimeString()}
84-
</span>
85-
</div>
86-
)}
87133
</div>
88134
</section>
89135
)}

apps/web/src/index.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ html, body, #root {
66
min-height: 100vh;
77
}
88

9+
@keyframes pulse-dot {
10+
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
11+
40% { opacity: 1; transform: scale(1); }
12+
}
13+
914
/* JetBrains Mono — primary monospace font */
1015
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
1116

0 commit comments

Comments
 (0)