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
30 changes: 28 additions & 2 deletions src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ pub async fn import_local_conversations(
pub async fn get_folder_conversation_core(
conn: &sea_orm::DatabaseConnection,
conversation_id: i32,
offset: Option<usize>,
limit: Option<usize>,
latest: Option<bool>,
) -> Result<DbConversationDetail, AppCommandError> {
let summary = conversation_service::get_by_id(conn, conversation_id)
.await
Expand Down Expand Up @@ -370,11 +373,31 @@ pub async fn get_folder_conversation_core(
}

let mut summary = summary;
summary.message_count = turns.len() as u32;
let total_turns = turns.len();
summary.message_count = total_turns as u32;
let wants_page = offset.is_some() || limit.is_some() || latest.unwrap_or(false);
let page_limit = limit.unwrap_or(total_turns).min(total_turns);
let page_offset = if latest.unwrap_or(false) {
total_turns.saturating_sub(page_limit)
} else {
offset.unwrap_or(0).min(total_turns)
};
let turns = if wants_page {
let page_end = page_offset.saturating_add(page_limit).min(total_turns);
turns
.into_iter()
.skip(page_offset)
.take(page_end.saturating_sub(page_offset))
.collect()
} else {
turns
};

Ok(DbConversationDetail {
summary,
turns,
turns_offset: wants_page.then_some(page_offset),
total_turns: wants_page.then_some(total_turns),
session_stats,
})
}
Expand All @@ -384,8 +407,11 @@ pub async fn get_folder_conversation_core(
pub async fn get_folder_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
offset: Option<usize>,
limit: Option<usize>,
latest: Option<bool>,
) -> Result<DbConversationDetail, AppCommandError> {
get_folder_conversation_core(&db.conn, conversation_id).await
get_folder_conversation_core(&db.conn, conversation_id, offset, limit, latest).await
}

/// Core logic for creating a conversation with git branch detection.
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/models/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub struct DbConversationDetail {
pub summary: DbConversationSummary,
pub turns: Vec<MessageTurn>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turns_offset: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_turns: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_stats: Option<SessionStats>,
}

Expand Down
13 changes: 11 additions & 2 deletions src-tauri/src/web/handlers/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,24 @@ pub async fn get_conversation(
#[serde(rename_all = "camelCase")]
pub struct GetFolderConversationParams {
pub conversation_id: i32,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub latest: Option<bool>,
}

pub async fn get_folder_conversation(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<GetFolderConversationParams>,
) -> Result<Json<DbConversationDetail>, AppCommandError> {
let db = &state.db;
let result =
conv_commands::get_folder_conversation_core(&db.conn, params.conversation_id).await?;
let result = conv_commands::get_folder_conversation_core(
&db.conn,
params.conversation_id,
params.offset,
params.limit,
params.latest,
)
.await?;
Ok(Json(result))
}

Expand Down
29 changes: 24 additions & 5 deletions src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useSessionStats } from "@/contexts/session-stats-context"
import { useTaskContext } from "@/contexts/task-context"
import { cn, copyTextToClipboard, randomUUID } from "@/lib/utils"
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
import { useIsMobile } from "@/hooks/use-mobile"
import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue"
import { MessageListView } from "@/components/message/message-list-view"
import { ConversationShell } from "@/components/chat/conversation-shell"
Expand Down Expand Up @@ -163,6 +164,7 @@ const ConversationTabView = memo(function ConversationTabView({
appendOptimisticTurn,
completeTurn,
getSession,
loadOlderTurns,
refetchDetail,
syncTurnMetadata,
removeConversation,
Expand Down Expand Up @@ -238,6 +240,11 @@ const ConversationTabView = memo(function ConversationTabView({
const createConversationPendingRef = useRef(false)
const sessionIdRef = useRef<string | null>(null)
const syncCancelRef = useRef<(() => void) | null>(null)
const isMobile = useIsMobile()
const detailFetchOptions = useMemo(
() => (isMobile ? { paginated: true } : undefined),
[isMobile]
)

useEffect(() => {
dbConvIdRef.current = dbConversationId
Expand Down Expand Up @@ -267,7 +274,7 @@ const ConversationTabView = memo(function ConversationTabView({
loading: detailLoading,
error: detailError,
acpLoadError,
} = useConversationDetail(effectiveConversationId)
} = useConversationDetail(effectiveConversationId, detailFetchOptions)

const runtimeSession = getSession(effectiveConversationId)
const effectiveSessionStats = runtimeSession?.sessionStats ?? null
Expand Down Expand Up @@ -500,8 +507,8 @@ const ConversationTabView = memo(function ConversationTabView({
signal: reloadSignal,
sawLoading: false,
}
refetchDetail(dbConversationId)
}, [dbConversationId, reloadSignal, refetchDetail])
refetchDetail(dbConversationId, detailFetchOptions)
}, [dbConversationId, detailFetchOptions, reloadSignal, refetchDetail])

useEffect(() => {
const pending = pendingReloadState.current
Expand Down Expand Up @@ -820,8 +827,19 @@ const ConversationTabView = memo(function ConversationTabView({
if (acpLoadError) {
acpActions.clearAcpLoadError(tabId)
}
refetchDetail(dbConversationId)
}, [acpActions, acpLoadError, dbConversationId, refetchDetail, tabId])
refetchDetail(dbConversationId, detailFetchOptions)
}, [
acpActions,
acpLoadError,
dbConversationId,
detailFetchOptions,
refetchDetail,
tabId,
])
const handleLoadOlderTurns = useCallback(() => {
if (dbConversationId == null) return
void loadOlderTurns(dbConversationId)
}, [dbConversationId, loadOlderTurns])
// Open (or re-activate) the singleton draft tab BEFORE closing the failing
// tab. closeTab auto-creates a replacement draft when it removes the last
// tab, and `openNewConversationTab` reads `rawTabsRef.current` which
Expand Down Expand Up @@ -851,6 +869,7 @@ const ConversationTabView = memo(function ConversationTabView({
onNewSession={
canShowDetailErrorActions ? handleOpenNewSession : undefined
}
onLoadOlder={handleLoadOlderTurns}
/>
)

Expand Down
9 changes: 9 additions & 0 deletions src/components/message/message-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ interface MessageListViewProps {
hideEmptyState?: boolean
onReload?: () => void
onNewSession?: () => void
onLoadOlder?: () => void
}

interface ResolvedMessageGroup {
Expand Down Expand Up @@ -415,13 +416,15 @@ export function MessageListView({
hideEmptyState = false,
onReload,
onNewSession,
onLoadOlder,
}: MessageListViewProps) {
const t = useTranslations("Folder.chat.messageList")
const sharedT = useTranslations("Folder.chat.shared")
const { getSession, getTimelineTurns } = useConversationRuntime()
const session = getSession(conversationId)
const liveMessage = session?.liveMessage ?? null
const timelineTurns = getTimelineTurns(conversationId)
const persistedOffset = session?.detail?.turns_offset ?? 0

const { setSessionStats } = useSessionStats()

Expand Down Expand Up @@ -701,6 +704,12 @@ export function MessageListView({
getItemKey={(item) => item.key}
renderItem={renderThreadItem}
emptyState={emptyState}
onNearTop={
session?.hasOlderTurns && !session.olderTurnsLoading
? onLoadOlder
: undefined
}
preserveScrollOnPrependKey={persistedOffset}
/>
<MessageThreadScrollButton />
</MessageThread>
Expand Down
42 changes: 41 additions & 1 deletion src/components/message/virtualized-message-thread.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useCallback, useMemo, useRef } from "react"
import { useCallback, useEffect, useMemo, useRef } from "react"
import type { CSSProperties, ReactNode, RefObject } from "react"
import { Virtualizer, type VirtualizerHandle } from "virtua"
import { useStickToBottomContext } from "use-stick-to-bottom"
Expand Down Expand Up @@ -45,6 +45,8 @@ interface VirtualizedMessageThreadProps<T> {
contentClassName?: string
/** Extra props forwarded to MessageThreadContent. */
contentProps?: Omit<MessageThreadContentProps, "children" | "className">
onNearTop?: () => void
preserveScrollOnPrependKey?: number | string | null
}

export function VirtualizedMessageThread<T>({
Expand All @@ -59,9 +61,17 @@ export function VirtualizedMessageThread<T>({
className,
contentClassName,
contentProps,
onNearTop,
preserveScrollOnPrependKey,
}: VirtualizedMessageThreadProps<T>) {
const { scrollRef } = useStickToBottomContext()
const virtualizerHandleRef = useRef<VirtualizerHandle>(null)
const beforePrependRef = useRef<{
key: number | string | null | undefined
scrollHeight: number
scrollTop: number
} | null>(null)
const nearTopLoadKeyRef = useRef<number | string | null | undefined>(null)

const scrollToIndex = useCallback<MessageScrollContextValue["scrollToIndex"]>(
(index, opts) => {
Expand Down Expand Up @@ -93,6 +103,36 @@ export function VirtualizedMessageThread<T>({
return styles.middle
}

useEffect(() => {
const el = scrollRef.current
if (!el || !onNearTop) return
const handleScroll = () => {
if (el.scrollTop > 240) return
if (nearTopLoadKeyRef.current === preserveScrollOnPrependKey) return
nearTopLoadKeyRef.current = preserveScrollOnPrependKey
beforePrependRef.current = {
key: preserveScrollOnPrependKey,
scrollHeight: el.scrollHeight,
scrollTop: el.scrollTop,
}
onNearTop()
}
el.addEventListener("scroll", handleScroll, { passive: true })
return () => el.removeEventListener("scroll", handleScroll)
}, [onNearTop, preserveScrollOnPrependKey, scrollRef])

useEffect(() => {
const snapshot = beforePrependRef.current
const el = scrollRef.current
if (!snapshot || !el) return
if (snapshot.key === preserveScrollOnPrependKey) return
beforePrependRef.current = null
nearTopLoadKeyRef.current = null
const heightDelta = el.scrollHeight - snapshot.scrollHeight
if (heightDelta <= 0) return
el.scrollTop = snapshot.scrollTop + heightDelta
}, [preserveScrollOnPrependKey, scrollRef])

return (
<MessageScrollProvider value={scrollContextValue}>
<MessageThreadContent
Expand Down
Loading