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
28 changes: 28 additions & 0 deletions app/ui_layer/adapters/browser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1611,6 +1611,9 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None:
elif msg_type == "session_clear":
await self._handle_session_clear(data)

elif msg_type == "session_seen":
await self._handle_session_seen(data)

elif msg_type == "generative_ui_close":
await self._handle_generative_ui_close(data)

Expand Down Expand Up @@ -4534,6 +4537,26 @@ async def _handle_generative_ui_close(self, data: Dict[str, Any]) -> None:
}
)

async def _handle_session_seen(self, data: Dict[str, Any]) -> None:
"""Acknowledge read status only after its SQLite transaction commits."""
session_id = data.get("sessionId")
message_id = data.get("messageId")
if not isinstance(session_id, str) or not isinstance(message_id, str):
return
storage = self._chat._storage
if storage is None:
return
seen = await self._chat.run_storage(
storage.mark_session_seen, session_id, message_id
)
if seen is not None:
await self._broadcast(
{
"type": "session_seen",
"data": {"sessionId": session_id, "messageId": seen},
}
)

async def _handle_session_list(self, ws=None) -> None:
"""Send the current session list."""
message = {
Expand Down Expand Up @@ -9997,6 +10020,11 @@ def _get_initial_state(self) -> Dict[str, Any]:
# older history through chat_history. ChatMessage.to_dict() always
# carries sessionId.
"messages": [m.to_dict() for m in self._chat.get_recent_messages()],
"lastSeenMessageIds": (
self._chat._storage.get_last_seen_message_ids()
if self._chat._storage is not None
else {}
),
# Recent activity items (per-session inline feed); each carries sessionId.
"actions": [
BrowserActionPanelComponent._item_payload(a)
Expand Down
1 change: 1 addition & 0 deletions app/ui_layer/adapters/ws_lanes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"session_delete",
"session_rename",
"session_clear",
"session_seen",
"chat_history",
"chat_attachment_upload",
"option_click",
Expand Down
28 changes: 5 additions & 23 deletions app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,8 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
renameSession,
clearSession,
} = useWebSocket()
// Both lists follow the user's drag-and-drop order. Items created since the
// last reorder land where they always have: new apps last, new chats first.
// Apps retain their saved drag-and-drop order; chats follow latest activity.
const [agentAppOrder, setAgentAppOrder] = usePersistedState(UI_STATE.nav.agentAppOrder)
const [chatOrder, setChatOrder] = usePersistedState(UI_STATE.nav.chatOrder)
const unorderedAgentAppProjects = useAppSelector(selectAgentAppProjects)
const agentAppProjects = useMemo(
() => applySavedOrder(unorderedAgentAppProjects, agentAppOrder, p => p.id, 'last'),
Expand All @@ -204,11 +202,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
const [agentDelete, setAgentDelete] = useState<{ id: string; name: string } | null>(null)

const mainSession = useAppSelector(selectMainSession)
const unorderedChatSessions = useAppSelector(selectChatSessions)
const chatSessions = useMemo(
() => applySavedOrder(unorderedChatSessions, chatOrder, s => s.id, 'first'),
[unorderedChatSessions, chatOrder],
)
const chatSessions = useAppSelector(selectChatSessions)
const lastMessageIdBySession = useAppSelector(selectLastMessageIdBySession)
const runStateBySession = useAppSelector(state => state.agent.runStateBySession)

Expand Down Expand Up @@ -296,8 +290,8 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
: agentAppProjects.slice(0, GROUP_PREVIEW_COUNT)
const visibleChatSessions = chatSessions.slice(0, chatVisibleCount)

// Drag-to-reorder within each group. The rendered rows are always a prefix
// of the full list ("Show more" / scroll pagination), so the reordered
// Drag-to-reorder apps. The rendered rows are always a prefix
// of the full list ("Show more"), so the reordered
// prefix followed by the untouched tail is the new full order.
const withTail = (prefix: string[], all: string[]) => {
const moved = new Set(prefix)
Expand All @@ -308,11 +302,6 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
onReorder: ids => setAgentAppOrder(withTail(ids, agentAppProjects.map(p => p.id))),
scrollRef,
})
const chatSortable = useSortableList({
ids: visibleChatSessions.map(s => s.id),
onReorder: ids => setChatOrder(withTail(ids, chatSessions.map(s => s.id))),
scrollRef,
})

const isActive = (path: string) => {
if (path === '/') {
Expand Down Expand Up @@ -567,16 +556,10 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
const path = sessionPath(session.id)
const active = isActive(path)
const renaming = renamingId === session.id
// Main stays pinned first; every other chat can be dragged.
const sortable = opts.isMain ? null : chatSortable
const dragging = chatSortable.draggingId === session.id

return (
<div
key={session.id}
ref={sortable?.rowRef(session.id)}
style={sortable?.rowStyle(session.id)}
className={`${styles.sessionRow} ${active ? styles.sessionRowActive : ''} ${opts.isMain ? styles.sessionRowMain : ''} ${dragging ? styles.rowDragging : ''}`}
className={`${styles.sessionRow} ${active ? styles.sessionRowActive : ''} ${opts.isMain ? styles.sessionRowMain : ''}`}
title={opts.isMain ? t('nav:items.main') : session.title}
{...(opts.isMain ? tourAnchorProps('nav-main-session') : {})}
>
Expand All @@ -598,7 +581,6 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
className={styles.sessionRowButton}
onClick={() => navigate(path)}
title={opts.isMain ? t('nav:items.main') : session.title}
{...sortable?.handleProps(session.id)}
>
<span className={styles.icon}>
{opts.isMain ? <MessageSquare size={16} /> : <MessageCircle size={14} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,15 +438,16 @@ export function WebSocketProvider({ children }: { children: ReactNode }) {
}))
}, [dispatch])

// Mark a session's newest message as seen (unread-dot bookkeeping). Stored
// as persisted UI state, so it survives reloads and syncs across tabs.
// Update the dot immediately, then commit the marker on the backend so
// a browser crash cannot undo it before Chrome flushes localStorage.
const markSessionSeen = useCallback((sessionId: string) => {
const current = store.getState()
const lastId = selectLastMessageIdBySession(current)[sessionId]
if (!lastId) return
const seen = selectUiState(current, UI_STATE.chat.lastSeenMessageIds)
if (seen[sessionId] === lastId) return
dispatch(setUiState(UI_STATE.chat.lastSeenMessageIds, { ...seen, [sessionId]: lastId }))
client.sendString(JSON.stringify({ type: 'session_seen', sessionId, messageId: lastId }))
}, [dispatch, store])

const enhancePrompt = useCallback((content: string, sessionId: string) => {
Expand Down
1 change: 1 addition & 0 deletions app/ui_layer/browser/frontend/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import miniBrowserReducer from './slices/miniBrowserSlice'
import uiReducer from './slices/uiSlice'
import { socketMiddleware } from './socket/socketMiddleware'
import './socket/versionWatch'
import './socket/readReceipts'
import { createUiPersistenceMiddleware, loadPersistedUiState } from './uiState'

// Redux Toolkit's development-only safety checks walk the state on every
Expand Down
10 changes: 7 additions & 3 deletions app/ui_layer/browser/frontend/src/store/selectors/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@ export const selectMainSession = createSelector(
(sessions): SessionInfo | undefined => sessions.find(s => s.type === 'main'),
)

// Plain chat sessions, newest first (createdAt is an ISO string, so a
// lexicographic descending sort is chronological).
// Plain chats follow their latest activity, including after reconnecting.
// Older session records can fall back to their creation timestamp.
export const selectChatSessions = createSelector(
selectSessions,
(sessions): SessionInfo[] =>
sessions
.filter(s => s.type === 'chat')
.slice()
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0)),
.sort((a, b) => {
const aUpdated = a.lastActiveAt || a.createdAt
const bUpdated = b.lastActiveAt || b.createdAt
return aUpdated < bUpdated ? 1 : aUpdated > bUpdated ? -1 : 0
}),
)

export const selectSessionById = (state: RootState, sessionId: string): SessionInfo | undefined =>
Expand Down
32 changes: 32 additions & 0 deletions app/ui_layer/browser/frontend/src/store/socket/readReceipts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { register } from './messageRegistry'
import { getSocketClient } from './socketInstance'
import { setUiState } from '../slices/uiSlice'
import { selectUiState } from '../selectors/ui'
import { UI_STATE } from '../uiState/catalog'

// Read receipts are durable on the backend. The local preference remains a
// cache and a migration source for messages read before this feature existed.
register('init', (data, dispatch, getState) => {
const durable = (data as { lastSeenMessageIds?: Record<string, string> } | undefined)?.lastSeenMessageIds
if (!durable) return // Older backends still use browser-only read status.
const local = selectUiState(getState(), UI_STATE.chat.lastSeenMessageIds)
dispatch(setUiState(UI_STATE.chat.lastSeenMessageIds, { ...local, ...durable }))
// Import legacy/local receipts; the server validates ownership and only
// advances a marker, so a stale tab can never overwrite a newer receipt.
for (const [sessionId, messageId] of Object.entries(local)) {
if (messageId && messageId !== durable[sessionId]) {
getSocketClient().sendString(JSON.stringify({ type: 'session_seen', sessionId, messageId }))
}
}
})

register('session_seen', (data, dispatch, getState) => {
const receipt = data as { sessionId?: string; messageId?: string } | undefined
if (!receipt?.sessionId || !receipt.messageId) return
const seen = selectUiState(getState(), UI_STATE.chat.lastSeenMessageIds)
if (seen[receipt.sessionId] !== receipt.messageId) {
dispatch(setUiState(UI_STATE.chat.lastSeenMessageIds, {
...seen, [receipt.sessionId]: receipt.messageId,
}))
}
})
2 changes: 0 additions & 2 deletions app/ui_layer/browser/frontend/src/store/uiState/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ export const UI_STATE = {
agentAppExpanded: defineUiState('nav.agentAppExpanded', true, 'preference'),
/** Agent App ids in drag-and-drop order; apps created since go last. */
agentAppOrder: defineUiState<string[]>('nav.agentAppOrder', [], 'preference'),
/** Chat session ids in drag-and-drop order; chats created since go first. */
chatOrder: defineUiState<string[]>('nav.chatOrder', [], 'preference'),
},

tour: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export function createUiPersistenceMiddleware(storages: UiStorages = uiStorages)
if (uiStateChanged.match(action)) {
const { key, lifetime, value } = action.payload
persister.enqueue(key, lifetime, value)
// Read markers are discrete acknowledgements, not continuous UI
// changes. Do not leave them waiting in the coalescing timer.
if (key === 'chat.lastSeenMessageIds') persister.flush()
}
return result
}
Expand Down
1 change: 1 addition & 0 deletions app/ui_layer/browser/frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export type WSMessageType =
| 'session_updated'
| 'session_deleted'
| 'session_cleared'
| 'session_seen'
| 'generative_ui_close'
| 'generative_ui_closed'
| 'session_busy'
Expand Down
62 changes: 62 additions & 0 deletions app/usage/chat_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,65 @@ def _init_db(self) -> None:
ON chat_messages(session_id, timestamp)
""")

# Read receipts must outlive a browser process crash. Keep them
# beside the messages rather than relying on browser disk flushes.
cursor.execute("""
CREATE TABLE IF NOT EXISTS chat_session_reads (
session_id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
message_timestamp REAL NOT NULL,
message_row_id INTEGER NOT NULL
)
""")

conn.commit()

def get_last_seen_message_ids(self) -> Dict[str, str]:
"""Durable read markers for this single-user CraftBot instance."""
with sqlite3.connect(self._db_path) as conn:
return dict(
conn.execute("""
SELECT r.session_id, r.message_id FROM chat_session_reads r
JOIN chat_messages m ON m.message_id = r.message_id
AND m.session_id = r.session_id
""")
)

def mark_session_seen(self, session_id: str, message_id: str) -> Optional[str]:
"""Commit a read receipt, ignoring unknown IDs and older receipts.

Return the committed marker so delayed requests from another window
cannot move the browser's read position backwards.
"""
with sqlite3.connect(self._db_path) as conn:
message = conn.execute(
"SELECT timestamp, id FROM chat_messages "
"WHERE session_id = ? AND message_id = ?",
(session_id, message_id),
).fetchone()
if message is None:
return None
conn.execute(
"""
INSERT INTO chat_session_reads
(session_id, message_id, message_timestamp, message_row_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
message_id = excluded.message_id,
message_timestamp = excluded.message_timestamp,
message_row_id = excluded.message_row_id
WHERE excluded.message_timestamp > chat_session_reads.message_timestamp
OR (excluded.message_timestamp = chat_session_reads.message_timestamp
AND excluded.message_row_id > chat_session_reads.message_row_id)
""",
(session_id, message_id, *message),
)
seen = conn.execute(
"SELECT message_id FROM chat_session_reads WHERE session_id = ?",
(session_id,),
).fetchone()[0]
conn.commit()
return seen

def insert_message(self, message: StoredChatMessage) -> int:
"""
Expand Down Expand Up @@ -385,10 +443,14 @@ def clear_messages(self, session_id: Optional[str] = None) -> int:
cursor.execute(
"DELETE FROM chat_messages WHERE session_id = ?", (session_id,)
)
cursor.execute(
"DELETE FROM chat_session_reads WHERE session_id = ?", (session_id,)
)
else:
cursor.execute("SELECT COUNT(*) FROM chat_messages")
count = cursor.fetchone()[0]
cursor.execute("DELETE FROM chat_messages")
cursor.execute("DELETE FROM chat_session_reads")
conn.commit()
return count

Expand Down
Loading