Skip to content
Closed
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
21 changes: 18 additions & 3 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds
$workingSessionIds,
$workingSessionMeta,
mergeWorkingSessions
} from '@/store/session'

import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
Expand Down Expand Up @@ -177,6 +179,7 @@ export function ChatSidebar({
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const workingSessionIds = useStore($workingSessionIds)
const workingSessionMeta = useStore($workingSessionMeta)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])

Expand All @@ -187,9 +190,21 @@ export function ChatSidebar({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)

const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
// Working sessions that aren't in the loaded list yet (e.g. an untitled
// session mid-turn with zero persisted messages, filtered out by the
// backend's min_messages=1 query) get a synthetic row so they stay visible
// after "New session" instead of silently dropping out of the sidebar.
const mergedSessions = useMemo(
() => mergeWorkingSessions(sessions, workingSessionIds, workingSessionMeta),
[sessions, workingSessionIds, workingSessionMeta]
)

const sortedSessions = useMemo(
() => [...mergedSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[mergedSessions]
)

const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const sessionsById = useMemo(() => new Map(mergedSessions.map(s => [s.id, s])), [mergedSessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])

const visiblePinnedIds = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, $messages, setSessionWorking } from '@/store/session'
import { $busy, $currentModel, $messages, noteWorkingSessionMeta, setSessionWorking } from '@/store/session'

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

Expand Down Expand Up @@ -139,6 +139,10 @@ export function useSessionStateCache({
setSessionWorking(previous.storedSessionId, false)
}

if (next.busy) {
noteWorkingSessionMeta(next.storedSessionId, { cwd: next.cwd || null, model: $currentModel.get() || null })
}

setSessionWorking(next.storedSessionId, next.busy)
syncSessionStateToView(sessionId, next)

Expand Down
120 changes: 120 additions & 0 deletions apps/desktop/src/store/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it } from 'vitest'

import type { SessionInfo } from '@/types/hermes'

import {
$workingSessionIds,
$workingSessionMeta,
mergeWorkingSessions,
noteWorkingSessionMeta,
setSessionWorking,
type WorkingSessionMeta
} from './session'

function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
cwd: null,
ended_at: null,
id,
input_tokens: 0,
is_active: true,
last_active: 1_000,
message_count: 3,
model: 'anthropic/claude-opus-4.8',
output_tokens: 0,
preview: 'hello',
source: 'tui',
started_at: 1_000,
title: 'Real session',
tool_call_count: 0,
...overrides
}
}

describe('mergeWorkingSessions', () => {
it('returns the original list unchanged when nothing is working', () => {
const sessions = [session('a'), session('b')]

expect(mergeWorkingSessions(sessions, [], {})).toBe(sessions)
})

it('returns the original list when every working session is already loaded', () => {
const sessions = [session('a'), session('b')]

expect(mergeWorkingSessions(sessions, ['a'], {})).toBe(sessions)
})

it('synthesizes a row for a working session missing from the loaded list', () => {
const sessions = [session('a')]

const meta: Record<string, WorkingSessionMeta> = {
ghost: { cwd: '/work/proj', model: 'openai/gpt-5.4', startedAt: 2_000 }
}

const merged = mergeWorkingSessions(sessions, ['ghost'], meta)

expect(merged).toHaveLength(2)
// synthetic row is prepended so a recent working session is visible
expect(merged[0].id).toBe('ghost')
expect(merged[0].cwd).toBe('/work/proj')
expect(merged[0].model).toBe('openai/gpt-5.4')
expect(merged[0].message_count).toBe(0)
expect(merged[0].title).toBeNull()
expect(merged[0].started_at).toBe(2_000)
expect(merged[1].id).toBe('a')
})

it('falls back to nulls when no meta is recorded for the working session', () => {
const merged = mergeWorkingSessions([], ['ghost'], {})

expect(merged).toHaveLength(1)
expect(merged[0].id).toBe('ghost')
expect(merged[0].cwd).toBeNull()
expect(merged[0].model).toBeNull()
expect(typeof merged[0].started_at).toBe('number')
})

it('only synthesizes the unloaded working ids', () => {
const sessions = [session('a')]
const merged = mergeWorkingSessions(sessions, ['a', 'ghost'], {})

expect(merged.map(s => s.id)).toEqual(['ghost', 'a'])
})
})

describe('working-session meta lifecycle', () => {
beforeEach(() => {
$workingSessionIds.set([])
$workingSessionMeta.set({})
})

it('records meta and preserves startedAt across updates', () => {
noteWorkingSessionMeta('s1', { cwd: '/a', model: 'm1' })
const first = $workingSessionMeta.get().s1
expect(first.cwd).toBe('/a')
expect(first.model).toBe('m1')

noteWorkingSessionMeta('s1', { cwd: '/b', model: 'm2' })
const second = $workingSessionMeta.get().s1
expect(second.cwd).toBe('/b')
expect(second.model).toBe('m2')
// age must stay stable so the row doesn't flicker its timestamp
expect(second.startedAt).toBe(first.startedAt)
})

it('drops meta when the session stops working', () => {
noteWorkingSessionMeta('s1', { cwd: '/a', model: 'm1' })
setSessionWorking('s1', true)
expect($workingSessionMeta.get().s1).toBeDefined()

setSessionWorking('s1', false)
expect($workingSessionMeta.get().s1).toBeUndefined()
expect($workingSessionIds.get()).not.toContain('s1')
})

it('ignores empty session ids', () => {
noteWorkingSessionMeta(null, { cwd: '/a' })
noteWorkingSessionMeta(undefined, { cwd: '/a' })
expect($workingSessionMeta.get()).toEqual({})
})
})
97 changes: 97 additions & 0 deletions apps/desktop/src/store/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import type { SessionInfo, UsageStats } from '@/types/hermes'

type Updater<T> = T | ((current: T) => T)

export interface WorkingSessionMeta {
cwd: string | null
model: string | null
startedAt: number
}

interface AppAtom<T> {
get: () => T
set: (value: T) => void
Expand All @@ -22,6 +28,11 @@ export const $sessions = atom<SessionInfo[]>([])
export const $sessionsTotal = atom<number>(0)
export const $sessionsLoading = atom(true)
export const $workingSessionIds = atom<string[]>([])
// Minimal runtime metadata for sessions that are currently working but may
// not yet appear in the loaded `$sessions` list — e.g. an untitled session
// whose agent is mid-turn with zero persisted messages, which the backend
// session list (min_messages=1) filters out. Keyed by stored session id.
export const $workingSessionMeta = atom<Record<string, WorkingSessionMeta>>({})
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
export const $messages = atom<ChatMessage[]>([])
Expand Down Expand Up @@ -56,6 +67,8 @@ export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($session
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
export const setWorkingSessionMeta = (next: Updater<Record<string, WorkingSessionMeta>>) =>
updateAtom($workingSessionMeta, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
Expand Down Expand Up @@ -93,4 +106,88 @@ export function setSessionWorking(sessionId: string | null | undefined, working:

return alreadyWorking ? current.filter(id => id !== sessionId) : current
})

// Drop synthetic-row metadata once a session stops working. Its real row
// (if any) comes back through the normal session list on the next refresh.
if (!working) {
setWorkingSessionMeta(current => {
if (!(sessionId in current)) {
return current
}

const { [sessionId]: _removed, ...rest } = current

return rest
})
}
}

// Record just enough about a working session to render a synthetic sidebar
// row for it before it lands in the loaded session list. Idempotent: the
// startedAt timestamp is preserved across updates so the row's age is stable.
export function noteWorkingSessionMeta(
sessionId: string | null | undefined,
meta: { cwd?: string | null; model?: string | null }
) {
if (!sessionId) {
return
}

setWorkingSessionMeta(current => {
const existing = current[sessionId]
const cwd = meta.cwd ?? existing?.cwd ?? null
const model = meta.model ?? existing?.model ?? null

if (existing && existing.cwd === cwd && existing.model === model) {
return current
}

return {
...current,
[sessionId]: { cwd, model, startedAt: existing?.startedAt ?? Date.now() / 1000 }
}
})
}

// Merge synthetic rows for working sessions that aren't in the loaded list.
// Pure so it can be unit-tested and reused by any sidebar surface.
export function mergeWorkingSessions(
sessions: SessionInfo[],
workingIds: string[],
meta: Record<string, WorkingSessionMeta>
): SessionInfo[] {
if (!workingIds.length) {
return sessions
}

const known = new Set(sessions.map(s => s.id))
const synthetic: SessionInfo[] = []

for (const id of workingIds) {
if (known.has(id)) {
continue
}

const info = meta[id]
const startedAt = info?.startedAt ?? Date.now() / 1000

synthetic.push({
cwd: info?.cwd ?? null,
ended_at: null,
id,
input_tokens: 0,
is_active: true,
last_active: startedAt,
message_count: 0,
model: info?.model ?? null,
output_tokens: 0,
preview: null,
source: 'tui',
started_at: startedAt,
title: null,
tool_call_count: 0
})
}

return synthetic.length ? [...synthetic, ...sessions] : sessions
}