diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index bcedec27..a9b9a547 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -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({ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index f38e4aaf..b28e754f 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -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>(readCompletedUnseen) + const prevSessionStateRef = useRef>(new Map()) + const updateCompletedUnseen = (mutate: (set: Set) => 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() + const toMark = new Set() + const toUnmark = new Set() + 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: { ) })} + {(() => { + 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) => ( + + ) + return ( + <> + {completedIdle.length > 0 ? ( +
+
+
+ {completedIdle.map((s) => sessionItem(s, true))} +
+ ) : null} + {plainIdle.length > 0 ? ( +
+
+
+ {plainIdle.map((s) => sessionItem(s, false))} +
+ ) : null} + + ) + })()} @@ -1573,13 +1674,13 @@ export function SessionList(props: {
- {visibleGroupSessions.map((s) => ( - ( + diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index 73fcfd6d..678aa4b8 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -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: { {t('session.item.pending')} ) : null} + ) : s.active && completedUnseen ? ( + + ) : s.active ? ( { + 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): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])) + } catch { + // Ignore storage errors (private mode, quota, …) + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index db81ffe9..9e717c97 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 20e7e51f..2bfa8bc6 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '后台任务运行中',