Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-row-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'

import { sessionDotState, sessionShowsRunningArc } from './session-row-state'

describe('session row running appearance', () => {
it('keeps the running arc when an authoritative turn becomes quiet', () => {
expect(sessionShowsRunningArc({ isWorking: true, needsInput: false })).toBe(true)
expect(
sessionDotState({
hasBackground: false,
isStalled: true,
isUnread: false,
isWorking: true,
needsInput: false
})
).toBe('stalled')
})

it('uses the needs-input treatment instead of the running arc', () => {
expect(sessionShowsRunningArc({ isWorking: true, needsInput: true })).toBe(false)
expect(
sessionDotState({
hasBackground: true,
isStalled: true,
isUnread: true,
isWorking: true,
needsInput: true
})
).toBe('needs-input')
})

it('keeps background and unread states below active-turn states', () => {
expect(
sessionDotState({
hasBackground: true,
isStalled: false,
isUnread: true,
isWorking: false,
needsInput: false
})
).toBe('background')
})
})
42 changes: 42 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-row-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'

interface SessionRowState {
hasBackground: boolean
isStalled: boolean
isUnread: boolean
isWorking: boolean
needsInput: boolean
}

/** Resolve the sidebar dot's mutually-exclusive display state by priority. */
export function sessionDotState({
hasBackground,
isStalled,
isUnread,
isWorking,
needsInput
}: SessionRowState): SessionDotState {
if (needsInput) {
return 'needs-input'
}

if (isWorking) {
return isStalled ? 'stalled' : 'working'
}

if (hasBackground) {
return 'background'
}

return isUnread ? 'unread' : 'idle'
}

/** A quiet turn is still authoritatively running. Keep the unmistakable row
* arc until the gateway reports completion; only a blocking prompt suppresses
* it in favour of the needs-input treatment. */
export function sessionShowsRunningArc({
isWorking,
needsInput
}: Pick<SessionRowState, 'isWorking' | 'needsInput'>): boolean {
return isWorking && !needsInput
}
31 changes: 15 additions & 16 deletions apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById } from '@/store/session-color'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'

import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
import { useProfilePrewarm } from './use-profile-prewarm'

interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
Expand Down Expand Up @@ -90,6 +91,9 @@ export function SidebarSessionRow({
// True when the session's most recent turn finished in the background (while
// the user was viewing a different session) and hasn't been opened since.
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
// True when the turn is still running but the stream has been quiet long
// enough to soften the animation. This must never look like an idle row.
const isStalled = useStore($stalledSessionIds).includes(session.id)
// True when a terminal(background=true) process is alive in this session.
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
// The session's resolved color (idle dot tint), read from the ONE shared map
Expand All @@ -99,15 +103,7 @@ export function SidebarSessionRow({
// Resolve the dot's display state once — the four signals are mutually
// exclusive by priority, so threading them as booleans through wrappers just
// to collapse them at the leaf is backwards.
const dotState: SessionDotState = needsInput
? 'needs-input'
: isWorking
? 'working'
: hasBackground
? 'background'
: isUnread
? 'unread'
: 'idle'
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })

return (
<SessionContextMenu
Expand Down Expand Up @@ -183,7 +179,7 @@ export function SidebarSessionRow({
style={style}
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
{sessionShowsRunningArc({ isWorking, needsInput }) && <span aria-hidden="true" className="arc-border" />}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
Expand Down Expand Up @@ -271,11 +267,6 @@ export function SidebarSessionRow({
)
}

/** The session's display state for the sidebar lead dot. The call site
* resolves this from the four underlying signals (needs-input, working,
* background, unread) so the dot component itself is a pure lookup. */
type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'

function SessionRowLeadDot({
branchStem,
dotState = 'idle',
Expand Down Expand Up @@ -333,6 +324,14 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Quiet accent pulse — the turn is still authoritative-running, but no
// stream activity has arrived for the watchdog window.
stalled: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
role: 'status',
title: r => r.sessionRunning
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along". Brighter
// than muted-foreground so it's visible against the sidebar surface.
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
$attentionSessionIds,
$stalledSessionIds,
$workingSessionIds,
clearAllSessionStates,
SESSION_WATCHDOG_TIMEOUT_MS
} from '@/store/session-states'

import { rehydrateLiveSessionStatuses } from './use-background-sync'

describe('rehydrateLiveSessionStatuses', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
})

it('restores running sessions after reconnect without opening them', () => {
const now = 1_800_000_000_000

rehydrateLiveSessionStatuses(
{
sessions: [
{
id: 'runtime-overnight',
last_active: (now - SESSION_WATCHDOG_TIMEOUT_MS - 1_000) / 1000,
session_key: 'overnight-exam-learning',
status: 'working'
},
{
id: 'runtime-cleanup',
last_active: now / 1000,
session_key: 'temporary-file-cleanup',
status: 'working'
}
]
},
now
)

expect($workingSessionIds.get()).toEqual(['overnight-exam-learning', 'temporary-file-cleanup'])
expect($stalledSessionIds.get()).toEqual(['overnight-exam-learning'])
expect($attentionSessionIds.get()).toEqual([])
})

it('restores a waiting turn as working and needing attention', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-needs-user', session_key: 'needs-user', status: 'waiting' }]
})

expect($workingSessionIds.get()).toEqual(['needs-user'])
expect($attentionSessionIds.get()).toEqual(['needs-user'])
expect($stalledSessionIds.get()).toEqual([])
})

it('ignores idle, starting, and malformed live-session rows', () => {
rehydrateLiveSessionStatuses({
sessions: [
{ id: 'runtime-idle', session_key: 'idle-session', status: 'idle' },
{ id: 'runtime-starting', session_key: 'starting-session', status: 'starting' },
{ id: 'runtime-malformed', status: 'working' }
]
})

expect($workingSessionIds.get()).toEqual([])
expect($attentionSessionIds.get()).toEqual([])
expect($stalledSessionIds.get()).toEqual([])
})
})
122 changes: 122 additions & 0 deletions apps/desktop/src/app/contrib/hooks/use-background-sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { useEffect } from 'react'

import { createClientSessionState } from '@/lib/chat-runtime'
import { refreshActiveProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
import {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main no longer exposes these mutable session-status APIs: #66454 moved working/attention to computed projections in apps/desktop/src/store/session-states.ts:193-208. Please adapt recovery through the current cache/state publisher rather than restoring independent atoms.

$sessionStates,
publishSessionState,
SESSION_WATCHDOG_TIMEOUT_MS,
setSessionStalled
} from '@/store/session-states'

import type { GatewayRequester } from '../types'

Expand All @@ -11,8 +18,79 @@ import type { GatewayRequester } from '../types'
const CRON_POLL_INTERVAL_MS = 30_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
// stored session id while its turn keeps running; until the next snapshot the
// sidebar row points at the new id while the renderer still knows the old one.
// A 15s cadence made that healthy transition look finished long enough to be
// alarming (and clicking the row appeared to "fix" it by touching the live
// session). This snapshot is small and already polled at 1.5s by the TUI.
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500

interface LiveSessionStatusItem {
id?: string
last_active?: number
session_key?: string
status?: 'idle' | 'starting' | 'waiting' | 'working'
}

interface LiveSessionStatusResponse {
sessions?: LiveSessionStatusItem[]
}

/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
* normally own these states, but events emitted while Desktop was disconnected
* cannot be replayed. `session.active_list` is the authoritative in-memory
* snapshot and does not resume, focus, or otherwise mutate a chat. */
export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse, nowMs = Date.now()): void {
for (const session of response.sessions ?? []) {
const runtimeSessionId = session.id?.trim()
const storedSessionId = session.session_key?.trim()
const needsInput = session.status === 'waiting'
const working = session.status === 'working' || needsInput

if (!runtimeSessionId || !storedSessionId) {
continue
}

const existing = $sessionStates.get()[runtimeSessionId]

// Avoid re-arming the watchdog on every poll. Publish only when the
// authoritative live snapshot differs from the renderer mirror; normal
// gateway events continue to own subsequent transitions.
if (
!existing ||
existing.storedSessionId !== storedSessionId ||
existing.busy !== working ||
existing.needsInput !== needsInput
) {
publishSessionState(runtimeSessionId, {
...(existing ?? createClientSessionState(storedSessionId)),
busy: working,
needsInput,
storedSessionId
})
}

if (!working) {
setSessionStalled(storedSessionId, false)

continue
}

const lastActiveMs = Number(session.last_active) * 1000

const isQuiet =
session.status === 'working' &&
Number.isFinite(lastActiveMs) &&
lastActiveMs > 0 &&
nowMs - lastActiveMs >= SESSION_WATCHDOG_TIMEOUT_MS

setSessionStalled(storedSessionId, isQuiet)
}
}

interface BackgroundSyncParams {
activeGatewayProfile: string
activeIsMessaging: boolean
activeSessionId: null | string
freshDraftReady: boolean
Expand Down Expand Up @@ -51,6 +129,7 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void {
* All the "the desktop websocket won't tell us, so poll" logic in one place.
*/
export function useBackgroundSync({
activeGatewayProfile,
activeIsMessaging,
activeSessionId,
freshDraftReady,
Expand Down Expand Up @@ -89,6 +168,49 @@ export function useBackgroundSync({
}
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])

// A reconnect loses renderer-only working/attention atoms while the backend
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
// registry immediately, then cheaply poll while visible so a profile switch
// or missed reconnect edge cannot leave running rows dark until clicked.
useEffect(() => {
if (gatewayState !== 'open') {
return
}

let cancelled = false
let inFlight = false

const refreshLiveStatuses = async () => {
if (inFlight) {
return
}

inFlight = true

try {
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})

if (!cancelled) {
rehydrateLiveSessionStatuses(response)
}
} catch {
// Older gateways may not expose session.active_list. Live stream events
// still work as before; leave the current sidebar state untouched.
} finally {
inFlight = false
}
}

const dispose = visiblePoll(LIVE_SESSION_STATUS_POLL_INTERVAL_MS, () => void refreshLiveStatuses())

void refreshLiveStatuses()

return () => {
cancelled = true
dispose()
}
}, [activeGatewayProfile, gatewayState, requestGateway])

// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background); re-check on tab re-focus too.
useEffect(() => {
Expand Down
Loading
Loading