Skip to content

Commit 9465501

Browse files
MrOrzclaude
andcommitted
feat(sidebar): use lastEventTime from agent callback for stable sorting
- Add after_agent_callback on ai_writer that writes lastEventTime to session state; only fires once per agent turn, not on every LLM call - Sort sidebar by lastEventTime (falls back to lastUpdateTime for old sessions); opening a session no longer causes reordering - Restore lastOpenedAt in session.state via markSessionOpened for cross-device unread tracking; stored in seconds to match time.time() - Invalidate ['sessions'] query after SSE stream ends so lastEventTime is reflected in the sidebar immediately after agent responds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f354c51 commit 9465501

6 files changed

Lines changed: 74 additions & 20 deletions

File tree

adk/cofacts_ai/agent.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Dict, Optional
1212
import re
1313
import json
14+
import time
1415

1516
from dotenv import load_dotenv
1617
from google.adk.apps import App
@@ -34,6 +35,17 @@
3435
# Initialize Langfuse instrumentation for observability
3536
setup_instrumentation()
3637

38+
# lastEventTime: records when the agent turn last completed, used by the sidebar
39+
# for sorting and unread-dot logic. We cannot rely on ADK's built-in lastUpdateTime
40+
# because any session state PATCH (including the client writing lastOpenedAt)
41+
# bumps it, which would cause sidebar reordering on every session open.
42+
SESSION_LAST_EVENT_TIME_KEY = 'lastEventTime'
43+
44+
45+
async def update_last_event_time(callback_context: CallbackContext) -> None:
46+
"""Records the current time in session state after each ai_writer agent turn."""
47+
callback_context.state[SESSION_LAST_EVENT_TIME_KEY] = time.time()
48+
3749

3850
async def append_grounding_sources(
3951
callback_context: CallbackContext,
@@ -380,6 +392,7 @@ async def append_grounding_sources(
380392
name="writer",
381393
model="gemini-2.5-pro",
382394
description="AI agent that orchestrates fact-checking process and composes final fact-check replies for Cofacts.",
395+
after_agent_callback=update_last_event_time,
383396
instruction=f"""
384397
You are an AI Writer and orchestrator for the Cofacts fact-checking system. Today is {datetime.now().strftime("%Y-%m-%d")}.
385398

src/components/Sidebar.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useQueryClient } from '@tanstack/react-query'
44
import type { SessionListItem } from '@/lib/sessions.functions'
55
import { useSessions } from '@/hooks/useSessions'
66
import { updateSession } from '@/lib/sessions.functions'
7-
import { getLastOpenedTime } from '@/lib/sessionOpenedTime'
87

98
interface SidebarProps {
109
isOpen: boolean
@@ -23,13 +22,14 @@ function SessionItem({ session, isActive, onClose }: SessionItemProps) {
2322
const [editTitle, setEditTitle] = useState('')
2423

2524
const title = session.name
25+
const lastActivityTime = session.lastEventTime ?? session.lastUpdateTime
2626
const hasNew =
2727
!isActive &&
28-
session.lastUpdateTime > 0 &&
29-
session.lastUpdateTime > getLastOpenedTime(session.id)
28+
lastActivityTime > 0 &&
29+
lastActivityTime > (session.lastOpenedAt ?? 0)
3030

31-
const lastActiveLabel = session.lastUpdateTime
32-
? new Date(session.lastUpdateTime * 1000).toLocaleDateString('zh-TW', {
31+
const lastActiveLabel = lastActivityTime
32+
? new Date(lastActivityTime * 1000).toLocaleDateString('zh-TW', {
3333
month: 'short',
3434
day: 'numeric',
3535
})
@@ -141,7 +141,11 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
141141
const { data: rawSessions, isLoading } = useSessions()
142142
const sessions = rawSessions
143143
?.slice()
144-
.sort((a, b) => b.lastUpdateTime - a.lastUpdateTime)
144+
.sort(
145+
(a, b) =>
146+
(b.lastEventTime ?? b.lastUpdateTime) -
147+
(a.lastEventTime ?? a.lastUpdateTime),
148+
)
145149

146150
return (
147151
<>

src/lib/chatCache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ export async function startChatStream({
145145
})
146146
if (abortControllers.get(sessionId) === controller) {
147147
abortControllers.delete(sessionId)
148+
// Refresh session list so lastEventTime (written by agent callback) is picked up immediately.
149+
queryClient.invalidateQueries({ queryKey: ['sessions'] })
148150
}
149151
}
150152
}

src/lib/sessionOpenedTime.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

src/lib/sessions.functions.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@ import { handleAdkError, handleAdkResponseError } from './adk-errors'
44

55
const SESSION_TITLE_KEY = 'title'
66

7+
// lastEventTime: set by Python after_agent_callback when an agent turn completes.
8+
// We avoid using ADK's built-in lastUpdateTime because any PATCH to session state
9+
// (including writing lastOpenedAt) bumps it, causing sidebar sort to jump on session open.
10+
const SESSION_LAST_EVENT_TIME_KEY = 'lastEventTime'
11+
12+
// lastOpenedAt: set by the client when the user opens a session, for cross-device unread tracking.
13+
const SESSION_LAST_OPENED_KEY = 'lastOpenedAt'
14+
715
export interface SessionListItem {
816
id: string
917
name: string
1018
lastUpdateTime: number
19+
lastEventTime?: number
20+
lastOpenedAt?: number
1121
}
1222

1323
export const listSessions = createServerFn({ method: 'GET' }).handler(
@@ -31,7 +41,15 @@ export const listSessions = createServerFn({ method: 'GET' }).handler(
3141
(e) => e.content?.role === 'user' && e.content.parts?.[0]?.text,
3242
)
3343
?.content?.parts?.[0]?.text?.slice(0, 40) ?? session.id)
34-
return { id: session.id, name, lastUpdateTime: session.lastUpdateTime }
44+
const lastEventTime = session.state?.[SESSION_LAST_EVENT_TIME_KEY]
45+
const lastOpenedAt = session.state?.[SESSION_LAST_OPENED_KEY]
46+
return {
47+
id: session.id,
48+
name,
49+
lastUpdateTime: session.lastUpdateTime,
50+
lastEventTime: typeof lastEventTime === 'number' ? lastEventTime : undefined,
51+
lastOpenedAt: typeof lastOpenedAt === 'number' ? lastOpenedAt : undefined,
52+
}
3553
})
3654
},
3755
)
@@ -113,3 +131,26 @@ export const updateSession = createServerFn({ method: 'POST' })
113131
if (error) handleAdkError(error)
114132
return data
115133
})
134+
135+
export const markSessionOpened = createServerFn({ method: 'POST' })
136+
.inputValidator((sessionId: string) => sessionId)
137+
.handler(async ({ data: sessionId }) => {
138+
const { error } = await adkClient.PATCH(
139+
'/apps/{app_name}/users/{user_id}/sessions/{session_id}',
140+
{
141+
params: {
142+
path: {
143+
app_name: ADK_APP_NAME,
144+
user_id: ADK_USER_ID,
145+
session_id: sessionId,
146+
},
147+
},
148+
// Store as seconds (Date.now()/1000) to match Python's time.time() for comparison.
149+
body: {
150+
stateDelta: { [SESSION_LAST_OPENED_KEY]: Date.now() / 1000 },
151+
},
152+
},
153+
)
154+
if (error) handleAdkError(error)
155+
return { ok: true }
156+
})

src/routes/_app/session.$sessionId.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
import { createFileRoute, useParams } from '@tanstack/react-router'
22
import { useEffect } from 'react'
3+
import { useQueryClient } from '@tanstack/react-query'
34
import { ChatArea } from '@/components/ChatArea'
45
import { useChat } from '@/hooks/useChat'
5-
import { setLastOpenedTime } from '@/lib/sessionOpenedTime'
6+
import { markSessionOpened } from '@/lib/sessions.functions'
67

78
export const Route = createFileRoute('/_app/session/$sessionId')({
89
component: SessionPage,
910
})
1011

1112
function SessionPage() {
1213
const { sessionId } = useParams({ from: '/_app/session/$sessionId' })
14+
const queryClient = useQueryClient()
1315
const { messages, isStreaming, error, sendMessage, stopGeneration } = useChat(
1416
{ sessionId },
1517
)
1618

1719
useEffect(() => {
18-
setLastOpenedTime(sessionId)
19-
}, [sessionId])
20+
markSessionOpened({ data: sessionId }).then(() => {
21+
queryClient.invalidateQueries({ queryKey: ['sessions'] })
22+
})
23+
}, [sessionId, queryClient])
2024

2125
return (
2226
<>

0 commit comments

Comments
 (0)