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
38 changes: 37 additions & 1 deletion src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ use crate::parsers::hermes::HermesParser;
use crate::parsers::openclaw::OpenClawParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::{path_eq_for_matching, AgentParser, ParseError};

const DEFAULT_CONVERSATION_TURNS_PAGE_SIZE: usize = 30;

fn paginate_turns(
turns: Vec<MessageTurn>,
before_turn_index: Option<usize>,
page_size: Option<usize>,
) -> (Vec<MessageTurn>, bool, Option<usize>, usize) {
let total = turns.len();
let page_size = page_size.unwrap_or(DEFAULT_CONVERSATION_TURNS_PAGE_SIZE).max(1);
let end = before_turn_index.unwrap_or(total).min(total);
let start = end.saturating_sub(page_size);
let has_more_history = start > 0;
let next_before_turn_index = has_more_history.then_some(start);
(
turns[start..end].to_vec(),
has_more_history,
next_before_turn_index,
page_size,
)
}
use crate::web::event_bridge::{
emit_event, ConversationChange, EventEmitter, TabsChanged, CONVERSATION_CHANGED_EVENT,
TABS_CHANGED_EVENT,
Expand Down Expand Up @@ -481,6 +502,8 @@ fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationS
pub async fn get_folder_conversation_core(
conn: &sea_orm::DatabaseConnection,
conversation_id: i32,
before_turn_index: Option<usize>,
page_size: Option<usize>,
) -> Result<(DbConversationDetail, Option<String>), AppCommandError> {
let summary = conversation_service::get_by_id(conn, conversation_id)
.await
Expand Down Expand Up @@ -589,10 +612,16 @@ pub async fn get_folder_conversation_core(
.unwrap_or_default();
inject_delegation_meta(&mut turns, &children);

let (turns, has_more_history, next_before_turn_index, page_size) =
paginate_turns(turns, before_turn_index, page_size);

Ok((
DbConversationDetail {
summary,
turns,
has_more_history,
next_before_turn_index,
page_size,
session_stats,
in_flight_user_turn_id: None,
},
Expand Down Expand Up @@ -762,8 +791,11 @@ pub async fn get_folder_conversation_with_live_core(
manager: &crate::acp::manager::ConnectionManager,
emitter: &EventEmitter,
conversation_id: i32,
before_turn_index: Option<usize>,
page_size: Option<usize>,
) -> Result<DbConversationDetail, AppCommandError> {
let (mut detail, parsed_title) = get_folder_conversation_core(conn, conversation_id).await?;
let (mut detail, parsed_title) =
get_folder_conversation_core(conn, conversation_id, before_turn_index, page_size).await?;

// Per-turn auto-title backfill. The parse `get_folder_conversation_core`
// just did already produced the session-file title; adopt it (and broadcast
Expand Down Expand Up @@ -811,12 +843,16 @@ pub async fn get_folder_conversation(
db: tauri::State<'_, AppDatabase>,
manager: tauri::State<'_, crate::acp::manager::ConnectionManager>,
conversation_id: i32,
before_turn_index: Option<usize>,
page_size: Option<usize>,
) -> Result<DbConversationDetail, AppCommandError> {
get_folder_conversation_with_live_core(
&db.conn,
&manager,
&EventEmitter::Tauri(app),
conversation_id,
before_turn_index,
page_size,
)
.await
}
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/models/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ pub struct ConversationDetail {
pub struct DbConversationDetail {
pub summary: DbConversationSummary,
pub turns: Vec<MessageTurn>,
pub has_more_history: bool,
pub next_before_turn_index: Option<usize>,
pub page_size: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_stats: Option<SessionStats>,
/// Id of the persisted user turn the live-correlation pass identified as the
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/web/handlers/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ pub async fn get_conversation(
#[serde(rename_all = "camelCase")]
pub struct GetFolderConversationParams {
pub conversation_id: i32,
pub before_turn_index: Option<usize>,
pub page_size: Option<usize>,
}

pub async fn get_folder_conversation(
Expand All @@ -137,6 +139,8 @@ pub async fn get_folder_conversation(
&state.connection_manager,
&state.emitter,
params.conversation_id,
params.before_turn_index,
params.page_size,
)
.await?;
Ok(Json(result))
Expand Down
11 changes: 11 additions & 0 deletions src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ const ConversationTabView = memo(function ConversationTabView({
removeOptimisticTurn,
appendViewerUserTurn,
completeTurn,
fetchOlderDetail,
getSession,
refetchDetail,
syncTurnMetadata,
Expand Down Expand Up @@ -301,12 +302,17 @@ const ConversationTabView = memo(function ConversationTabView({
const {
detail,
loading: detailLoading,
loadingMore: detailLoadingMore,
error: detailError,
acpLoadError,
} = useConversationDetail(effectiveConversationId)

const runtimeSession = getSession(effectiveConversationId)
const effectiveSessionStats = runtimeSession?.sessionStats ?? null
const handleLoadOlderMessages = useCallback(() => {
if (dbConversationId == null) return
fetchOlderDetail(dbConversationId)
}, [dbConversationId, fetchOlderDetail])

useEffect(() => {
if (!isActive) return
Expand Down Expand Up @@ -1081,6 +1087,11 @@ const ConversationTabView = memo(function ConversationTabView({
sendSignal={sendSignal}
sessionStats={effectiveSessionStats}
detailLoading={detailLoading}
detailLoadingMore={detailLoadingMore}
hasMoreHistory={detail?.has_more_history ?? false}
onLoadOlder={
hasPersistedConversation ? handleLoadOlderMessages : undefined
}
detailError={detailError}
acpLoadError={acpLoadError}
hideEmptyState={!hasPersistedConversation || hasSentMessage}
Expand Down
31 changes: 28 additions & 3 deletions src/components/message/message-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ interface MessageListViewProps {
sendSignal?: number
sessionStats?: SessionStats | null
detailLoading?: boolean
detailLoadingMore?: boolean
hasMoreHistory?: boolean
onLoadOlder?: () => void
detailError?: string | null
/**
* Set when the agent rejected `session/load` non-recoverably (e.g. the
Expand Down Expand Up @@ -497,6 +500,9 @@ export function MessageListView({
sendSignal = 0,
sessionStats = null,
detailLoading = false,
detailLoadingMore = false,
hasMoreHistory = false,
onLoadOlder,
detailError = null,
acpLoadError = null,
hideEmptyState = false,
Expand Down Expand Up @@ -903,9 +909,28 @@ export function MessageListView({
<MessageThread
className="flex-1 min-h-0"
resize={shouldUseSmoothResize ? "smooth" : undefined}
>
<AutoScrollOnSend signal={sendSignal} />
<VirtualizedMessageThread
>
{(hasMoreHistory || detailLoadingMore) && (
<div className="flex justify-center px-4 pt-4">
<Button
size="sm"
variant="outline"
onClick={onLoadOlder}
disabled={detailLoadingMore}
>
{detailLoadingMore ? (
<Loader2 className="me-1.5 h-4 w-4 animate-spin" />
) : (
<ChevronDown className="me-1.5 h-4 w-4" />
)}
{detailLoadingMore
? t("loadingEarlierMessages")
: t("loadEarlierMessages")}
</Button>
</div>
)}
<AutoScrollOnSend signal={sendSignal} />
<VirtualizedMessageThread
items={threadItems}
getItemKey={getThreadItemKey}
renderItem={renderThreadItem}
Expand Down
9 changes: 9 additions & 0 deletions src/contexts/conversation-runtime-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ function detailWithTitle(title: string): DbConversationDetail {
pinned_at: null,
},
turns: [],
has_more_history: false,
next_before_turn_index: null,
page_size: 30,
session_stats: null,
}
}
Expand Down Expand Up @@ -569,6 +572,9 @@ describe("ConversationRuntimeProvider delegation kickoff projection", () => {
pinned_at: null,
},
turns,
has_more_history: false,
next_before_turn_index: null,
page_size: 30,
session_stats: null,
}
}
Expand Down Expand Up @@ -900,6 +906,9 @@ describe("ConversationRuntimeProvider viewer user-turn synthesis", () => {
pinned_at: null,
},
turns,
has_more_history: false,
next_before_turn_index: null,
page_size: 30,
session_stats: null,
}
}
Expand Down
62 changes: 59 additions & 3 deletions src/contexts/conversation-runtime-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface ConversationRuntimeSession {
// DB data (cold open only)
detail: DbConversationDetail | null
detailLoading: boolean
detailLoadingMore: boolean
detailError: string | null

// ACP `session/load` failed in a non-recoverable way (currently only when
Expand Down Expand Up @@ -112,11 +113,13 @@ type Action =
| {
type: "FETCH_DETAIL_START"
conversationId: number
appendHistory?: boolean
}
| {
type: "FETCH_DETAIL_SUCCESS"
conversationId: number
detail: DbConversationDetail
appendHistory?: boolean
/**
* Keep `liveMessage` / `optimisticTurns` / `localTurns` across this
* detail load even though `syncState` isn't "awaiting_persist". The
Expand Down Expand Up @@ -250,6 +253,7 @@ function createEmptySession(
externalId: null,
detail: null,
detailLoading: false,
detailLoadingMore: false,
detailError: null,
acpLoadError: null,
localTurns: [],
Expand Down Expand Up @@ -808,7 +812,8 @@ function reducer(
case "FETCH_DETAIL_START":
return updateSessionInState(state, action.conversationId, (current) => ({
...current,
detailLoading: true,
detailLoading: !action.appendHistory,
detailLoadingMore: Boolean(action.appendHistory),
detailError: null,
}))

Expand Down Expand Up @@ -843,13 +848,22 @@ function reducer(
const keepAllLiveBuffers =
action.preserveLive === true || detailIsInFlight

const nextDetail =
action.appendHistory && current.detail
? {
...action.detail,
turns: [...action.detail.turns, ...current.detail.turns],
}
: action.detail

const nextSession: ConversationRuntimeSession = {
...current,
detail: action.detail,
detail: nextDetail,
detailLoading: false,
detailLoadingMore: false,
detailError: null,
externalId: nextExternalId ?? current.externalId,
sessionStats: action.detail.session_stats ?? current.sessionStats,
sessionStats: nextDetail.session_stats ?? current.sessionStats,
...(isActivelyInteracting
? keepAllLiveBuffers
? {}
Expand All @@ -876,6 +890,7 @@ function reducer(
return updateSessionInState(state, action.conversationId, (current) => ({
...current,
detailLoading: false,
detailLoadingMore: false,
detailError: action.error,
}))

Expand Down Expand Up @@ -1288,6 +1303,7 @@ interface ConversationRuntimeContextValue {
getConversationIdByExternalId: (externalId: string) => number | null
getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[]
fetchDetail: (conversationId: number) => void
fetchOlderDetail: (conversationId: number) => void
/**
* Re-fetch persisted detail, bypassing the active-data guard.
* `options.preserveLive` (default false) keeps the current `liveMessage`,
Expand Down Expand Up @@ -1702,6 +1718,44 @@ export function ConversationRuntimeProvider({
[bumpFetchGeneration, isLatestGeneration]
)

const fetchOlderDetail = useCallback(
(conversationId: number) => {
const session = stateRef.current.byConversationId.get(conversationId)
const detail = session?.detail
if (!detail || session.detailLoading || session.detailLoadingMore) return
if (!detail.has_more_history || detail.next_before_turn_index == null) return

const generation = bumpFetchGeneration(conversationId)
dispatch({
type: "FETCH_DETAIL_START",
conversationId,
appendHistory: true,
})
getFolderConversation(conversationId, {
beforeTurnIndex: detail.next_before_turn_index,
pageSize: detail.page_size,
})
.then((nextDetail) => {
if (!isLatestGeneration(conversationId, generation)) return
dispatch({
type: "FETCH_DETAIL_SUCCESS",
conversationId,
detail: nextDetail,
appendHistory: true,
})
})
.catch((error: unknown) => {
if (!isLatestGeneration(conversationId, generation)) return
dispatch({
type: "FETCH_DETAIL_ERROR",
conversationId,
error: toErrorMessage(error),
})
})
},
[bumpFetchGeneration, isLatestGeneration]
)

const syncTurnMetadata = useCallback(
(
dbConversationId: number,
Expand Down Expand Up @@ -1973,6 +2027,7 @@ export function ConversationRuntimeProvider({
getConversationIdByExternalId,
getTimelineTurns,
fetchDetail,
fetchOlderDetail,
refetchDetail,
syncTurnMetadata,
completeTurn,
Expand All @@ -1994,6 +2049,7 @@ export function ConversationRuntimeProvider({
getConversationIdByExternalId,
getTimelineTurns,
fetchDetail,
fetchOlderDetail,
refetchDetail,
syncTurnMetadata,
completeTurn,
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/use-conversation-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function useConversationDetail(
): {
detail: DbConversationDetail | null
loading: boolean
loadingMore: boolean
error: string | null
acpLoadError: string | null
} {
Expand All @@ -49,6 +50,7 @@ export function useConversationDetail(
return {
detail: session?.detail ?? null,
loading: session ? session.detailLoading : !isVirtual,
loadingMore: session?.detailLoadingMore ?? false,
error: session?.detailError ?? null,
acpLoadError: session?.acpLoadError ?? null,
}
Expand Down
Loading