Skip to content
Open
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
24 changes: 24 additions & 0 deletions hub/src/store/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export function addMessage(
local_id: localId ?? null
})

db.prepare(`
UPDATE sessions
SET updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END,
seq = seq + 1
WHERE id = @session_id
`).run({
session_id: sessionId,
updated_at: now
})

const row = db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined
if (!row) {
throw new Error('Failed to create message')
Expand Down Expand Up @@ -154,6 +164,20 @@ export function mergeSessionMessages(
'UPDATE messages SET session_id = ? WHERE session_id = ?'
).run(toSessionId, fromSessionId)

db.prepare(`
UPDATE sessions
SET updated_at = (
SELECT MAX(value)
FROM (
SELECT updated_at AS value FROM sessions WHERE id = ?
UNION ALL
SELECT COALESCE(MAX(created_at), 0) AS value FROM messages WHERE session_id = ?
)
),
seq = seq + 1
WHERE id = ?
`).run(toSessionId, toSessionId, toSessionId)

db.exec('COMMIT')
return { moved: result.changes, oldMaxSeq, newMaxSeq }
} catch (error) {
Expand Down
59 changes: 59 additions & 0 deletions hub/src/sync/sessionModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,65 @@ describe('session model', () => {
expect(toSessionSummary(session).effort).toBe('high')
})

it('updates session timestamp when a message is stored', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))

const session = cache.getOrCreateSession(
'session-message-time',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const before = store.sessions.getSession(session.id)
await new Promise((resolve) => setTimeout(resolve, 2))

const message = store.messages.addMessage(session.id, { role: 'assistant', content: 'hello' })
const after = store.sessions.getSession(session.id)

expect(after?.updatedAt).toBe(message.createdAt)
expect(after?.updatedAt ?? 0).toBeGreaterThan(before?.updatedAt ?? 0)
})

it('broadcasts a session update when a message is received', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
const events: SyncEvent[] = []
const unsubscribe = engine.subscribe((event) => events.push(event))

try {
const session = engine.getOrCreateSession(
'session-message-event-time',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
await new Promise((resolve) => setTimeout(resolve, 2))
const message = store.messages.addMessage(session.id, { role: 'assistant', content: 'hello' })

engine.handleRealtimeEvent({
type: 'message-received',
sessionId: session.id,
message
})

const sessionUpdated = events.find((event) =>
event.type === 'session-updated' && event.sessionId === session.id
)
expect(sessionUpdated).toBeDefined()
expect(engine.getSession(session.id)?.updatedAt).toBe(message.createdAt)
} finally {
unsubscribe()
engine.stop()
}
})

it('persists explicit model reasoning effort on Codex sessions', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
Expand Down
4 changes: 1 addition & 3 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,7 @@ export class SyncEngine {
}

if (event.type === 'message-received' && event.sessionId) {
if (!this.getSession(event.sessionId)) {
this.sessionCache.refreshSession(event.sessionId)
}
this.sessionCache.refreshSession(event.sessionId)
}

this.eventPublisher.emit(event)
Expand Down
27 changes: 20 additions & 7 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,30 +239,43 @@ function AppInner() {
})
}, [addToast])

const eventSubscription = useMemo(() => {
if (selectedSessionId) {
return { sessionId: selectedSessionId }
}
return { all: true }
}, [selectedSessionId])
const globalEventSubscription = useMemo(() => ({ all: true }), [])
const sessionEventSubscription = useMemo(
() => (selectedSessionId ? { sessionId: selectedSessionId } : undefined),
[selectedSessionId]
)

const { subscriptionId } = useSSE({
enabled: Boolean(api && token),
token: token ?? '',
baseUrl,
subscription: eventSubscription,
subscription: globalEventSubscription,
onConnect: handleSseConnect,
onDisconnect: handleSseDisconnect,
onEvent: handleSseEvent,
onToast: handleToast
})

const { subscriptionId: sessionSubscriptionId } = useSSE({
enabled: Boolean(api && token && selectedSessionId),
token: token ?? '',
baseUrl,
subscription: sessionEventSubscription,
onEvent: handleSseEvent
})

useVisibilityReporter({
api,
subscriptionId,
enabled: Boolean(api && token)
})

useVisibilityReporter({
api,
subscriptionId: sessionSubscriptionId,
enabled: Boolean(api && token && selectedSessionId)
})

// Loading auth source
if (isAuthSourceLoading) {
return (
Expand Down
140 changes: 139 additions & 1 deletion web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,39 @@ function groupByMachine(
})
}

const SESSION_LIST_LAST_SEEN_KEY = 'hapi.session-list.last-seen.v1'

function loadLastSeenUpdatedAt(): Record<string, number> {
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(SESSION_LIST_LAST_SEEN_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
const next: Record<string, number> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'number' && Number.isFinite(value)) {
next[key] = value
}
}
return next
} catch {
return {}
}
}

function persistLastSeenUpdatedAt(value: Record<string, number>) {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(SESSION_LIST_LAST_SEEN_KEY, JSON.stringify(value))
} catch {
// Ignore storage failures.
}
}

function CopyPathButton({ path, className }: { path: string; className?: string }) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
Expand Down Expand Up @@ -181,6 +214,16 @@ function CopyPathButton({ path, className }: { path: string; className?: string
)
}

function UnreadDot({ className }: { className?: string }) {
return (
<span
aria-label="Unread updates"
title="Unread updates"
className={`inline-block h-2 w-2 rounded-full bg-[var(--app-link)] shadow-[0_0_0_2px_var(--app-bg)] ${className ?? ''}`}
/>
)
}

function PlusIcon(props: { className?: string }) {
return (
<svg
Expand Down Expand Up @@ -352,9 +395,10 @@ function SessionItem(props: {
showPath?: boolean
api: ApiClient | null
selected?: boolean
unread?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false } = props
const { session: s, onSelect, showPath = true, api, selected = false, unread = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
Expand Down Expand Up @@ -399,6 +443,9 @@ function SessionItem(props: {
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
{sessionName}
</div>
{unread ? (
<UnreadDot className="shrink-0" />
) : null}
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 text-[var(--app-hint)] animate-spin-slow" />
) : null}
Expand Down Expand Up @@ -489,9 +536,71 @@ export function SessionList(props: {
() => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions, selectedSessionId)),
[props.sessions, selectedSessionId]
)
const [lastSeenUpdatedAtBySession, setLastSeenUpdatedAtBySession] = useState<Record<string, number>>(
() => loadLastSeenUpdatedAt()
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
)

useEffect(() => {
setLastSeenUpdatedAtBySession((prev) => {
const next: Record<string, number> = {}
let changed = false

for (const session of props.sessions) {
const previousValue = prev[session.id]
if (typeof previousValue === 'number' && Number.isFinite(previousValue)) {
next[session.id] = previousValue
} else {
next[session.id] = session.updatedAt
changed = true
}
}

if (!changed && Object.keys(prev).length !== Object.keys(next).length) {
changed = true
}

return changed ? next : prev
})
}, [props.sessions])

useEffect(() => {
if (!selectedSessionId) return
const selectedSession = props.sessions.find((session) => session.id === selectedSessionId)
if (!selectedSession) return

setLastSeenUpdatedAtBySession((prev) => {
const previousValue = prev[selectedSession.id] ?? 0
if (previousValue >= selectedSession.updatedAt) {
return prev
}
return {
...prev,
[selectedSession.id]: selectedSession.updatedAt
}
})
}, [props.sessions, selectedSessionId])

useEffect(() => {
persistLastSeenUpdatedAt(lastSeenUpdatedAtBySession)
}, [lastSeenUpdatedAtBySession])

const unreadSessionIds = useMemo(() => {
const next = new Set<string>()
for (const session of props.sessions) {
if (session.id === selectedSessionId) {
continue
}
const lastSeenUpdatedAt = lastSeenUpdatedAtBySession[session.id] ?? session.updatedAt
if (session.updatedAt > lastSeenUpdatedAt) {
next.add(session.id)
}
}
return next
}, [lastSeenUpdatedAtBySession, props.sessions, selectedSessionId])

const isGroupCollapsed = (group: SessionGroup): boolean => {
const override = collapseOverrides.get(group.key)
if (override !== undefined) return override
Expand Down Expand Up @@ -524,6 +633,26 @@ export function SessionList(props: {
[groups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)

const groupHasUnread = useMemo(() => {
const map = new Map<string, boolean>()
for (const group of groups) {
map.set(group.key, group.sessions.some((session) => unreadSessionIds.has(session.id)))
}
return map
}, [groups, unreadSessionIds])

const machineHasUnread = useMemo(() => {
const map = new Map<string, boolean>()
for (const machineGroup of machineGroups) {
const key = machineGroup.machineId ?? UNKNOWN_MACHINE_ID
map.set(
key,
machineGroup.projectGroups.some((group) => groupHasUnread.get(group.key))
)
}
return map
}, [groupHasUnread, machineGroups])

const isMachineCollapsed = (mg: MachineGroup): boolean => {
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const override = collapseOverrides.get(key)
Expand Down Expand Up @@ -611,6 +740,7 @@ export function SessionList(props: {
<div className="flex flex-col gap-3 px-2 pt-1 pb-2">
{machineGroups.map((mg) => {
const machineCollapsed = isMachineCollapsed(mg)
const hasUnreadInMachine = machineHasUnread.get(mg.machineId ?? UNKNOWN_MACHINE_ID) === true
return (
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
{/* Level 1: Machine */}
Expand All @@ -622,6 +752,9 @@ export function SessionList(props: {
<ChevronIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" collapsed={machineCollapsed} />
<MachineIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" />
<span className="text-sm font-semibold truncate flex-1">{mg.label}</span>
{hasUnreadInMachine ? (
<UnreadDot className="shrink-0" />
) : null}
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">({mg.totalSessions})</span>
</button>

Expand All @@ -631,6 +764,7 @@ export function SessionList(props: {
<div className="flex flex-col ml-3.5 pl-1 mt-0.5">
{mg.projectGroups.map((group) => {
const isCollapsed = isGroupCollapsed(group)
const hasUnreadInGroup = groupHasUnread.get(group.key) === true
return (
<div key={group.key}>
<div
Expand All @@ -642,6 +776,9 @@ export function SessionList(props: {
<span className="font-medium text-sm truncate flex-1">
{group.displayName}
</span>
{hasUnreadInGroup ? (
<UnreadDot className="shrink-0" />
) : null}
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">
({group.sessions.length})
Expand All @@ -660,6 +797,7 @@ export function SessionList(props: {
showPath={false}
api={api}
selected={s.id === selectedSessionId}
unread={unreadSessionIds.has(s.id)}
/>
))}
</div>
Expand Down
3 changes: 3 additions & 0 deletions web/src/hooks/useSSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,9 @@ export function useSSE(options: {

if (event.type === 'message-received') {
ingestIncomingMessages(event.sessionId, [event.message])
const patch = { updatedAt: event.message.createdAt }
patchSessionDetail(event.sessionId, patch)
patchSessionSummary(event.sessionId, patch)
}

if (event.type === 'session-added' || event.type === 'session-updated' || event.type === 'session-removed') {
Expand Down
Loading