diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
index 15d0e31e..39c9d292 100644
--- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
@@ -577,13 +577,13 @@ export function AgentDetailPanel({
setDraft({ ...draft, soul: e.currentTarget.value })
}
placeholder="输入 SOUL.md 内容"
- className="min-h-[45vh] resize-y font-mono text-xs"
+ className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
/>
) : (
- // No inner max-height: the SOUL text flows at full length and the
- // panel body scrolls as a whole, instead of a small nested scroll
- // box on an otherwise empty screen.
-
+ // Viewport-relative cap: the SOUL preview expands with the screen
+ // (taller than the old fixed 240px box) but never swallows the
+ // whole panel — long content still scrolls inside.
+
{soulPreview}
)}
diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts
index 121b84e8..caea58c4 100644
--- a/frontend-web/src/core/threads/hooks.ts
+++ b/frontend-web/src/core/threads/hooks.ts
@@ -841,6 +841,47 @@ export function useThreadsPage(options: {
});
}
+/**
+ * Total number of conversations matching the same filters as
+ * `useThreadsPage`, via the lightweight `/threads/count` endpoint — powers
+ * the "共 N 条 / X 页" display without fetching any rows. Depends only on the
+ * filters (not the page), so paging doesn't refire it.
+ */
+export function useThreadsCount(options: {
+ search?: string;
+ metadata?: Record;
+}) {
+ const normalizedSearch = options.search?.trim() ?? "";
+ return useQuery({
+ queryKey: [
+ "threads",
+ "search",
+ "count",
+ normalizedSearch,
+ options.metadata ?? null,
+ ],
+ queryFn: async () => {
+ const response = await apiFetch(
+ `${getLangGraphBaseURL()}/threads/count`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ metadata: options.metadata ?? {},
+ query: normalizedSearch || undefined,
+ }),
+ },
+ );
+ if (!response.ok) {
+ throw new Error("Failed to count conversations");
+ }
+ const data = (await response.json()) as { total: number };
+ return data.total;
+ },
+ refetchOnWindowFocus: false,
+ });
+}
+
export function useDeleteThread() {
const queryClient = useQueryClient();
const apiClient = getAPIClient();
diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx
index e0bb9aad..49c6d497 100644
--- a/frontend-web/src/pages/ChatsPage.tsx
+++ b/frontend-web/src/pages/ChatsPage.tsx
@@ -22,6 +22,7 @@ import {
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import {
+ useThreadsCount,
useThreadsPage,
useToggleThreadFavorite,
useToggleThreadPin,
@@ -90,6 +91,10 @@ export default function ChatsPage() {
search: debouncedSearch,
metadata: pinnedFilter,
});
+ const { data: totalCount } = useThreadsCount({
+ search: debouncedSearch,
+ metadata: favoriteFilter,
+ });
const pinnedThreads = pinnedData?.threads ?? [];
const pagedThreads = useMemo(
@@ -98,6 +103,10 @@ export default function ChatsPage() {
);
const currentPage = page;
const hasMore = pageData?.hasMore ?? false;
+ // Page windows are sliced over the raw (pinned-inclusive) row order, so the
+ // page count derives from the raw total as well.
+ const totalPages =
+ totalCount != null ? Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) : null;
const renderThread = (thread: AgentThread) => {
const pinned = isThreadPinned(thread);
@@ -201,6 +210,11 @@ export default function ChatsPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
+ {totalCount != null && (
+