feat(web): green dot for just-finished sessions until opened (completed-unseen)

This commit is contained in:
2026-08-03 01:07:48 +08:00
parent 8ae9db2a03
commit 6483985d2f
6 changed files with 181 additions and 10 deletions
@@ -501,6 +501,31 @@ describe('SessionList collapse behavior', () => {
expect(header.getAttribute('aria-expanded')).toBe('true')
})
it('marks a just-finished session as completed until it is opened', async () => {
localStorage.clear()
const working = [
makeSession({ id: 'session-task', active: true, thinking: true, updatedAt: 100, metadata: { path: '/work/hapi', name: 'Task', flavor: 'codex' } }),
]
const { rerender } = render(renderSessionList(working))
// The task finishes: working → idle
rerender(renderSessionList([
makeSession({ id: 'session-task', active: true, thinking: false, updatedAt: 110, metadata: { path: '/work/hapi', name: 'Task', flavor: 'codex' } }),
]))
expect(screen.getByText(/Completed \(1\)/)).toBeInTheDocument()
expect(screen.queryByText(/Idle \(1\)/)).toBeNull()
// Opening the session acknowledges it → back to plain idle
// (row click is bound to mouseup via the long-press handler)
const taskRow = screen.getByRole('button', { name: /Task/ })
fireEvent.mouseDown(taskRow)
fireEvent.mouseUp(taskRow)
expect(screen.queryByText(/Completed/)).toBeNull()
expect(screen.getByText(/Idle \(1\)/)).toBeInTheDocument()
})
it('keeps the previous selected path open when selection moves', async () => {
const sessions = [
makeSession({
+111 -10
View File
@@ -20,6 +20,7 @@ import { useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { formatReopenError } from '@/lib/reopenError'
import { getSessionTitle } from '@/lib/sessionTitle'
import { readCompletedUnseen, writeCompletedUnseen } from '@/lib/completedUnseen'
import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
import type { Machine } from '@/types/api'
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
@@ -44,7 +45,6 @@ type SessionGroup = {
const RUNNING_BUCKETS = [
{ key: 'working', labelKey: 'session.item.running', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true },
{ key: 'pending', labelKey: 'session.item.pending', colorClass: 'text-[var(--app-badge-warning-text)]', pulse: true },
{ key: 'idle', labelKey: 'session.item.idle', colorClass: 'text-[var(--app-hint)]', pulse: false },
] as const
export type SessionTimeRange = {
@@ -771,11 +771,12 @@ function SessionItem(props: {
selected?: boolean
showDetailedStatus?: boolean
inRunningSection?: boolean
completedUnseen?: boolean
projectLabel?: string
machineLabel?: string
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false, inRunningSection = false, projectLabel, machineLabel } = props
const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false, inRunningSection = false, completedUnseen = false, projectLabel, machineLabel } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
@@ -869,6 +870,7 @@ function SessionItem(props: {
attentionTooltipId={attentionId}
scheduleTooltipId={scheduleId}
inRunningSection={inRunningSection}
completedUnseen={completedUnseen}
projectLabel={projectLabel}
machineLabel={machineLabel}
/>
@@ -1105,6 +1107,64 @@ export function SessionList(props: {
const runningSessionTotal = runningSessions.working.length
+ runningSessions.pending.length
+ runningSessions.idle.length
const [completedUnseen, setCompletedUnseen] = useState<Set<string>>(readCompletedUnseen)
const prevSessionStateRef = useRef<Map<string, string>>(new Map())
const updateCompletedUnseen = (mutate: (set: Set<string>) => void) => {
setCompletedUnseen(prev => {
const next = new Set(prev)
mutate(next)
writeCompletedUnseen(next)
return next
})
}
// Detect "just finished" sessions: a session that was working and is now
// idle gets a green dot until the user opens it.
useEffect(() => {
const prev = prevSessionStateRef.current
const next = new Map<string, string>()
const toMark = new Set<string>()
const toUnmark = new Set<string>()
for (const session of machineFilteredSessions) {
let state: string
if (!session.active) {
state = 'inactive'
} else if (session.thinking || (session.backgroundTaskCount ?? 0) > 0) {
state = 'working'
} else if ((session.pendingRequestsCount ?? 0) > 0) {
state = 'pending'
} else {
state = 'idle'
}
next.set(session.id, state)
const prevState = prev.get(session.id)
if (prevState === 'working' && state === 'idle') {
toMark.add(session.id)
} else if ((prevState === 'idle' || prevState === 'pending') && state === 'working') {
toUnmark.add(session.id)
}
}
for (const id of prev.keys()) {
if (!next.has(id)) {
toUnmark.add(id)
}
}
if (toMark.size > 0 || toUnmark.size > 0) {
updateCompletedUnseen(set => {
for (const id of toMark) {
set.add(id)
}
for (const id of toUnmark) {
set.delete(id)
}
})
}
prevSessionStateRef.current = next
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [machineFilteredSessions])
const handleSelectSession = (sessionId: string) => {
updateCompletedUnseen(set => set.delete(sessionId))
props.onSelect(sessionId)
}
const groups = useMemo(
() => groupSessionsByDirectory(machineFilteredSessions.filter((session) => !session.active)),
[machineFilteredSessions]
@@ -1495,7 +1555,7 @@ export function SessionList(props: {
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
onSelect={handleSelectSession}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
@@ -1508,6 +1568,47 @@ export function SessionList(props: {
</div>
)
})}
{(() => {
const completedIdle = runningSessions.idle.filter((s) => completedUnseen.has(s.id))
const plainIdle = runningSessions.idle.filter((s) => !completedUnseen.has(s.id))
const sessionItem = (s: SessionSummary, completed: boolean) => (
<SessionItem
key={s.id}
session={s}
onSelect={handleSelectSession}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
showDetailedStatus={showDetailedStatus}
inRunningSection
completedUnseen={completed}
projectLabel={getGroupDisplayName(s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other')}
machineLabel={resolveMachineLabel(s.metadata?.machineId ?? null)}
/>
)
return (
<>
{completedIdle.length > 0 ? (
<div key="completed">
<div className="flex items-center gap-1 px-1 pt-1 pb-0.5 text-[11px] font-medium text-[var(--app-badge-success-text)]">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current animate-pulse" aria-hidden="true" />
{t('session.item.completed')} ({completedIdle.length})
</div>
{completedIdle.map((s) => sessionItem(s, true))}
</div>
) : null}
{plainIdle.length > 0 ? (
<div key="idle">
<div className="flex items-center gap-1 px-1 pt-1 pb-0.5 text-[11px] font-medium text-[var(--app-hint)]">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
{t('session.item.idle')} ({plainIdle.length})
</div>
{plainIdle.map((s) => sessionItem(s, false))}
</div>
) : null}
</>
)
})()}
</div>
</div>
</div>
@@ -1573,13 +1674,13 @@ export function SessionList(props: {
<div className="collapsible-panel" data-open={!isCollapsed || undefined}>
<div className="collapsible-inner">
<div className="flex flex-col gap-0.5 ml-3 pl-1 py-1">
{visibleGroupSessions.map((s) => (
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
showPath={false}
api={api}
{visibleGroupSessions.map((s) => (
<SessionItem
key={s.id}
session={s}
onSelect={handleSelectSession}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
showDetailedStatus={showDetailedStatus}
/>
+13
View File
@@ -113,6 +113,8 @@ export function SessionRowSummary(props: {
className?: string
/** Rows inside the pinned "in progress" section skip the text label (dot only). */
inRunningSection?: boolean
/** Task just finished and the user has not opened it yet (green dot). */
completedUnseen?: boolean
/** Short project name shown under the title (pinned "in progress" rows). */
projectLabel?: string
/** Machine label shown next to the project name (pinned "in progress" rows). */
@@ -128,6 +130,7 @@ export function SessionRowSummary(props: {
scheduleTooltipId: scheduleTooltipIdProp,
className,
inRunningSection = false,
completedUnseen = false,
projectLabel,
machineLabel,
} = props
@@ -205,6 +208,16 @@ export function SessionRowSummary(props: {
<span className="text-[11px] font-medium leading-none">{t('session.item.pending')}</span>
) : null}
</span>
) : s.active && completedUnseen ? (
<span
className="inline-flex shrink-0 items-center gap-1 text-[var(--app-badge-success-text)]"
title={t('session.item.completed')}
>
<span className="h-1.5 w-1.5 rounded-full bg-current animate-pulse" aria-hidden="true" />
{!inRunningSection ? (
<span className="text-[11px] font-medium leading-none">{t('session.item.completed')}</span>
) : null}
</span>
) : s.active ? (
<span
className="inline-flex shrink-0 items-center gap-1 text-[var(--app-hint)]"
+30
View File
@@ -0,0 +1,30 @@
/**
* Persisted set of session ids whose task just finished (transitioned from
* working to idle) and has not been acknowledged by the user yet. Surfaced as
* a green dot in the pinned "in progress" section until the session is opened.
*/
const STORAGE_KEY = 'hapi.completed-unseen'
export function readCompletedUnseen(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) {
return new Set()
}
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) {
return new Set()
}
return new Set(parsed.filter((item): item is string => typeof item === 'string'))
} catch {
return new Set()
}
}
export function writeCompletedUnseen(set: ReadonlySet<string>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
} catch {
// Ignore storage errors (private mode, quota, …)
}
}
+1
View File
@@ -136,6 +136,7 @@ export default {
'session.item.thinking': 'thinking',
'session.item.running': 'Running',
'session.item.idle': 'Idle',
'session.item.completed': 'Completed',
'session.item.permission': 'Permission required',
'session.item.needsInput': 'Needs input',
'session.item.background': 'Background tasks running',
+1
View File
@@ -136,6 +136,7 @@ export default {
'session.item.thinking': '思考中',
'session.item.running': '运行中',
'session.item.idle': '空闲',
'session.item.completed': '已完成',
'session.item.permission': '需要权限',
'session.item.needsInput': '需要输入',
'session.item.background': '后台任务运行中',