mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-09 16:00:52 +00:00
feat(sessions): progressive loading, server-side search, and workspace scoping (#511)
# Session Pagination & Search Enhancement
## Changes
### Progressive Session Loading
- `fetchSessions()` now accepts `{ limit, search }` options
- Added pagination state: `sessionFetchLimit`, `sessionHasMore`
(50-session pages)
- Scroll-to-bottom sentinel triggers `loadMoreSessions()` via
IntersectionObserver
- Hydration loads initial 50 sessions; subsequent scrolls fetch 100,
150, etc.
### Server-Side Search
- Added `searchSessions()` using `session.list({ search, limit: 50,
directory })`
- Merges results into store (preserves active session, no replacement)
- Added `"sessionList.loading.more"` i18n key (7 locales)
### Hybrid Search Performance
- Client-side filtering runs first — instant results for loaded sessions
- Server search only fires when no client matches exist
- Debounce reduced from 300ms → 150ms for server fallback
### Workspace Scoping
- All `session.list()` calls pass `directory: instance.folder`
- Prevents cross-workspace session pollution
## Files
- `packages/ui/src/stores/session-state.ts`
- `packages/ui/src/stores/session-api.ts`
- `packages/ui/src/stores/instances.ts`
- `packages/ui/src/stores/sessions.ts`
- `packages/ui/src/components/session-list.tsx`
- `packages/ui/src/lib/i18n/messages/*/session.ts`
---------
Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
parent
873235ee1e
commit
b3594d29e5
13 changed files with 494 additions and 24 deletions
|
|
@ -47,6 +47,7 @@ import {
|
|||
clearActiveParentSession,
|
||||
createSession,
|
||||
fetchSessions,
|
||||
getSessionFetchLimit,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
} from "./stores/sessions"
|
||||
|
|
@ -405,7 +406,7 @@ const App: Component = () => {
|
|||
clearActiveParentSession(instanceId)
|
||||
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
await fetchSessions(instanceId, { start: 0, limit: getSessionFetchLimit(instanceId) })
|
||||
} catch (error) {
|
||||
log.error("Failed to refresh sessions after closing", error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ import {
|
|||
sessions as sessionStateSessions,
|
||||
setActiveSessionFromList,
|
||||
toggleSessionParentExpanded,
|
||||
loadMoreSessions,
|
||||
searchSessions,
|
||||
getSessionHasMore,
|
||||
clearSessionSearch,
|
||||
getSessionSearchQuery,
|
||||
getSessionSearchThreads,
|
||||
isSessionSearchLoading,
|
||||
} from "../stores/sessions"
|
||||
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
|
@ -65,6 +72,71 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const [sentinelEl, setSentinelEl] = createSignal<HTMLDivElement | null>(null)
|
||||
|
||||
const hasMore = createMemo(() => {
|
||||
if (normalizedQuery()) return false
|
||||
return getSessionHasMore(props.instanceId)
|
||||
})
|
||||
|
||||
const isFetchingSessions = createMemo(() => {
|
||||
return loading().fetchingSessions.get(props.instanceId) ?? false
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = sentinelEl()
|
||||
if (!el || !hasMore() || isFetchingSessions()) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && hasMore() && !isFetchingSessions()) {
|
||||
void loadMoreSessions(props.instanceId).catch((error) => {
|
||||
log.error("Failed to load more sessions:", error)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ root: el.parentElement ?? null, rootMargin: "0px 0px 200px 0px" }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
createEffect(() => {
|
||||
const query = normalizedQuery()
|
||||
if (!props.enableFilterBar) {
|
||||
clearSessionSearch(props.instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
clearSessionSearch(props.instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
// Always run server search in background for workspace-complete results.
|
||||
// Client-side filtering (filteredThreads) shows instant results from loaded sessions.
|
||||
const queryAtDispatch = query
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
void searchSessions(props.instanceId, queryAtDispatch)
|
||||
.catch((error) => {
|
||||
log.error("Failed to search sessions:", error)
|
||||
})
|
||||
}, 150)
|
||||
|
||||
onCleanup(() => {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const title = (session?.title ?? "").trim()
|
||||
|
|
@ -82,6 +154,12 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
const query = normalizedQuery()
|
||||
if (!query) return props.threads
|
||||
|
||||
const searchQuery = getSessionSearchQuery(props.instanceId)
|
||||
const searchLoading = isSessionSearchLoading(props.instanceId)
|
||||
if (searchQuery === query && !searchLoading) {
|
||||
return getSessionSearchThreads(props.instanceId)
|
||||
}
|
||||
|
||||
const next: SessionThread[] = []
|
||||
for (const thread of props.threads) {
|
||||
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
|
||||
|
|
@ -795,10 +873,22 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</For>
|
||||
|
||||
<Show when={hasMore() || isFetchingSessions()}>
|
||||
<div
|
||||
ref={(el) => setSentinelEl(el)}
|
||||
class="session-list-sentinel flex items-center justify-center py-3 text-text-weak text-xs"
|
||||
data-session-sentinel
|
||||
>
|
||||
<Show when={isFetchingSessions()}>
|
||||
<span class="animate-pulse">{t("sessionList.loading.more")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.showFooter !== false}>
|
||||
<div class="session-list-footer p-3 border-t border-base">
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "Search sessions…",
|
||||
"sessionList.filter.ariaLabel": "Search sessions",
|
||||
"sessionList.loading.more": "Loading more sessions…",
|
||||
"sessionList.selection.selectAllLabel": "Select all",
|
||||
"sessionList.selection.selectAllAriaLabel": "Select all sessions",
|
||||
"sessionList.selection.clearLabel": "Clear",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "Buscar sesiones…",
|
||||
"sessionList.filter.ariaLabel": "Buscar sesiones",
|
||||
"sessionList.loading.more": "Cargando más sesiones…",
|
||||
"sessionList.selection.selectAllLabel": "Seleccionar todo",
|
||||
"sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones",
|
||||
"sessionList.selection.clearLabel": "Limpiar",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "Rechercher des sessions…",
|
||||
"sessionList.filter.ariaLabel": "Rechercher des sessions",
|
||||
"sessionList.loading.more": "Chargement de plus de sessions…",
|
||||
"sessionList.selection.selectAllLabel": "Tout sélectionner",
|
||||
"sessionList.selection.selectAllAriaLabel": "Sélectionner toutes les sessions",
|
||||
"sessionList.selection.clearLabel": "Effacer",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "חפש סשנים…",
|
||||
"sessionList.filter.ariaLabel": "חפש סשנים",
|
||||
"sessionList.loading.more": "טוען עוד סשנים…",
|
||||
"sessionList.selection.selectAllLabel": "בחר הכל",
|
||||
"sessionList.selection.selectAllAriaLabel": "בחר את כל הסשנים",
|
||||
"sessionList.selection.clearLabel": "נקה",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "セッションを検索…",
|
||||
"sessionList.filter.ariaLabel": "セッションを検索",
|
||||
"sessionList.loading.more": "セッションをさらに読み込んでいます…",
|
||||
"sessionList.selection.selectAllLabel": "すべて選択",
|
||||
"sessionList.selection.selectAllAriaLabel": "すべてのセッションを選択",
|
||||
"sessionList.selection.clearLabel": "クリア",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "Поиск сессий…",
|
||||
"sessionList.filter.ariaLabel": "Поиск сессий",
|
||||
"sessionList.loading.more": "Загрузка дополнительных сессий…",
|
||||
"sessionList.selection.selectAllLabel": "Выбрать все",
|
||||
"sessionList.selection.selectAllAriaLabel": "Выбрать все сессии",
|
||||
"sessionList.selection.clearLabel": "Очистить",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const sessionMessages = {
|
|||
|
||||
"sessionList.filter.placeholder": "搜索会话…",
|
||||
"sessionList.filter.ariaLabel": "搜索会话",
|
||||
"sessionList.loading.more": "正在加载更多会话…",
|
||||
"sessionList.selection.selectAllLabel": "全选",
|
||||
"sessionList.selection.selectAllAriaLabel": "选择所有会话",
|
||||
"sessionList.selection.clearLabel": "清除",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
fetchAgents,
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
resetSessionPagination,
|
||||
} from "./sessions"
|
||||
import {
|
||||
ensureWorktreesLoaded,
|
||||
|
|
@ -280,6 +281,7 @@ async function hydrateInstanceData(instanceId: string, options?: { force?: boole
|
|||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
}
|
||||
resetSessionPagination(instanceId)
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ import {
|
|||
cleanupBlankSessions,
|
||||
syncInstanceSessionIndicator,
|
||||
updateThreadTotalsForParent,
|
||||
SESSION_PAGE_SIZE,
|
||||
getSessionNextStart,
|
||||
setSessionPage,
|
||||
prependSessionListId,
|
||||
removeSessionListId,
|
||||
beginSessionSearch,
|
||||
clearSessionSearch,
|
||||
isLatestSessionSearch,
|
||||
setSessionSearchResults,
|
||||
} from "./session-state"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
|
|
@ -120,7 +129,7 @@ interface SessionForkResponse {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchSessions(instanceId: string): Promise<void> {
|
||||
async function fetchSessions(instanceId: string, options?: { start?: number; limit?: number }): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
|
|
@ -137,19 +146,28 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||
try {
|
||||
const projectResponse = await rootClient.project.current()
|
||||
const projectId = projectResponse.data?.id
|
||||
const sessionListOptions = instance.folder ? { directory: instance.folder } : undefined
|
||||
const start = options?.start ?? 0
|
||||
const limit = options?.limit ?? SESSION_PAGE_SIZE
|
||||
|
||||
log.info("session.list", { instanceId, projectId, directory: sessionListOptions?.directory })
|
||||
const response = sessionListOptions
|
||||
? await rootClient.session.list(sessionListOptions)
|
||||
: await rootClient.session.list()
|
||||
const sessionListOptions: { directory?: string; roots?: boolean; start?: number; limit?: number } = {
|
||||
roots: true,
|
||||
start,
|
||||
limit,
|
||||
}
|
||||
if (instance.folder) sessionListOptions.directory = instance.folder
|
||||
|
||||
log.info("session.list", { instanceId, projectId, start, limit, directory: sessionListOptions.directory })
|
||||
const response = await rootClient.session.list(sessionListOptions)
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
setSessionPage(instanceId, [], start, false)
|
||||
return
|
||||
}
|
||||
|
||||
const hasMore = response.data.length >= limit
|
||||
|
||||
let statusById: Record<string, any> = {}
|
||||
try {
|
||||
const statusResponse = await rootClient.session.status()
|
||||
|
|
@ -208,11 +226,17 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionMap)
|
||||
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
|
||||
for (const session of sessionMap.values()) {
|
||||
instanceSessions.set(session.id, session)
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId, sessionMap)
|
||||
setSessionPage(instanceId, Array.from(validSessionIds), start, hasMore)
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
|
|
@ -220,7 +244,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||
if (loadedSet) {
|
||||
const filtered = new Set<string>()
|
||||
for (const id of loadedSet) {
|
||||
if (validSessionIds.has(id)) {
|
||||
if (sessions().get(instanceId)?.has(id)) {
|
||||
filtered.add(id)
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +254,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||
})
|
||||
|
||||
|
||||
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||
pruneDraftPrompts(instanceId, new Set(sessions().get(instanceId)?.keys() ?? []))
|
||||
|
||||
const parentIds = Array.from(sessionMap.values())
|
||||
.filter((session) => session.parentId === null)
|
||||
|
|
@ -255,6 +279,106 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadMoreSessions(instanceId: string): Promise<void> {
|
||||
await fetchSessions(instanceId, { start: getSessionNextStart(instanceId), limit: SESSION_PAGE_SIZE })
|
||||
}
|
||||
|
||||
async function searchSessions(instanceId: string, query: string): Promise<void> {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) return
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const rootClient = getRootClient(instanceId)
|
||||
const requestId = beginSessionSearch(instanceId, trimmedQuery)
|
||||
|
||||
try {
|
||||
log.info("session.search", { instanceId, query: trimmedQuery, directory: instance.folder })
|
||||
const response = await rootClient.session.list({
|
||||
search: trimmedQuery,
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
directory: instance.folder,
|
||||
})
|
||||
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return
|
||||
|
||||
const searchResults = response.data ?? []
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
setSessionSearchResults(instanceId, trimmedQuery, [], requestId)
|
||||
return
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
|
||||
|
||||
for (const apiSession of searchResults) {
|
||||
const existingSession = instanceSessions.get(apiSession.id)
|
||||
instanceSessions.set(apiSession.id, toClientSession(instanceId, apiSession, existingSession))
|
||||
}
|
||||
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
// Fetch any missing parents so child results are rendered correctly
|
||||
const currentSessions = sessions().get(instanceId)
|
||||
const missingParentIds = new Set<string>()
|
||||
for (const apiSession of searchResults) {
|
||||
const parentId = apiSession.parentID
|
||||
if (parentId && !currentSessions?.has(parentId)) {
|
||||
missingParentIds.add(parentId)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingParentIds.size > 0) {
|
||||
const parentFetches = Array.from(missingParentIds).map(async (parentId) => {
|
||||
try {
|
||||
const parentResponse = await rootClient.session.get({ sessionID: parentId })
|
||||
if (parentResponse.data) {
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
|
||||
const existingSession = instanceSessions.get(parentId)
|
||||
instanceSessions.set(parentId, toClientSession(instanceId, parentResponse.data, existingSession))
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to fetch missing parent session:", { parentId, error })
|
||||
}
|
||||
})
|
||||
await Promise.all(parentFetches)
|
||||
}
|
||||
|
||||
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return
|
||||
|
||||
const hydratedSessions = sessions().get(instanceId)
|
||||
const hasUnrenderableChildResult = searchResults.some((session) => {
|
||||
const parentId = session.parentID
|
||||
return Boolean(parentId && !hydratedSessions?.has(parentId))
|
||||
})
|
||||
|
||||
if (hasUnrenderableChildResult) {
|
||||
clearSessionSearch(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
setSessionSearchResults(instanceId, trimmedQuery, searchResults.map((session) => session.id), requestId)
|
||||
} catch (error) {
|
||||
log.error("Failed to search sessions:", error)
|
||||
if (isLatestSessionSearch(instanceId, trimmedQuery, requestId)) {
|
||||
clearSessionSearch(instanceId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function toClientSession(instanceId: string, apiSession: any, existingSession?: Session): Session {
|
||||
return {
|
||||
id: apiSession.id,
|
||||
|
|
@ -311,10 +435,24 @@ async function fetchSessionChildren(instanceId: string, parentSessionId: string)
|
|||
|
||||
const currentSessions = sessions().get(instanceId)
|
||||
const children = apiChildren.map((apiSession) => toClientSession(instanceId, apiSession, currentSessions?.get(apiSession.id)))
|
||||
const returnedChildIds = new Set(children.map((child) => child.id))
|
||||
let staleChildIds: string[] = []
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(next.get(instanceId))
|
||||
staleChildIds = []
|
||||
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (session.parentId === parentSessionId && !returnedChildIds.has(session.id)) {
|
||||
staleChildIds.push(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const staleChildId of staleChildIds) {
|
||||
instanceSessions.delete(staleChildId)
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
instanceSessions.set(child.id, child)
|
||||
}
|
||||
|
|
@ -322,6 +460,43 @@ async function fetchSessionChildren(instanceId: string, parentSessionId: string)
|
|||
return next
|
||||
})
|
||||
|
||||
if (staleChildIds.length > 0) {
|
||||
const staleChildIdSet = new Set(staleChildIds)
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const loadedSet = prev.get(instanceId)
|
||||
if (!loadedSet) return prev
|
||||
const updated = new Set(loadedSet)
|
||||
let changed = false
|
||||
for (const staleChildId of staleChildIdSet) {
|
||||
changed = updated.delete(staleChildId) || changed
|
||||
}
|
||||
if (!changed) return prev
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const instanceInfo = prev.get(instanceId)
|
||||
if (!instanceInfo) return prev
|
||||
const updated = new Map(instanceInfo)
|
||||
let changed = false
|
||||
for (const staleChildId of staleChildIdSet) {
|
||||
changed = updated.delete(staleChildId) || changed
|
||||
}
|
||||
if (!changed) return prev
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
|
||||
for (const staleChildId of staleChildIds) {
|
||||
messageStoreBus.getOrCreate(instanceId).clearSession(staleChildId)
|
||||
clearCacheForSession(instanceId, staleChildId)
|
||||
}
|
||||
}
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
updateThreadTotalsForParent(instanceId, parentSessionId)
|
||||
|
||||
|
|
@ -415,6 +590,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
prependSessionListId(instanceId, session.id)
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||
|
|
@ -593,6 +769,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
removeSessionListId(instanceId, sessionId)
|
||||
|
||||
clearSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
|
|
@ -924,6 +1101,8 @@ export {
|
|||
fetchProviders,
|
||||
|
||||
fetchSessions,
|
||||
loadMoreSessions,
|
||||
searchSessions,
|
||||
fetchSessionChildren,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,134 @@ type InstanceIndicatorCounts = {
|
|||
|
||||
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
|
||||
|
||||
const SESSION_PAGE_SIZE = 100
|
||||
|
||||
type SessionPaginationState = {
|
||||
ids: string[]
|
||||
nextStart: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
type SessionSearchState = {
|
||||
query: string
|
||||
ids: string[]
|
||||
loading: boolean
|
||||
requestId: number
|
||||
}
|
||||
|
||||
const [sessionPagination, setSessionPagination] = createSignal<Map<string, SessionPaginationState>>(new Map())
|
||||
const [sessionSearch, setSessionSearch] = createSignal<Map<string, SessionSearchState>>(new Map())
|
||||
|
||||
function getSessionPaginationState(instanceId: string): SessionPaginationState {
|
||||
return sessionPagination().get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true }
|
||||
}
|
||||
|
||||
function getSessionListIds(instanceId: string): string[] {
|
||||
return getSessionPaginationState(instanceId).ids
|
||||
}
|
||||
|
||||
function getSessionFetchLimit(instanceId: string): number {
|
||||
return Math.max(getSessionPaginationState(instanceId).ids.length, SESSION_PAGE_SIZE)
|
||||
}
|
||||
|
||||
function getSessionNextStart(instanceId: string): number {
|
||||
return getSessionPaginationState(instanceId).nextStart
|
||||
}
|
||||
|
||||
function setSessionPage(instanceId: string, ids: string[], start: number, hasMore: boolean): void {
|
||||
setSessionPagination((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true }
|
||||
const nextIds = start <= 0 ? ids : Array.from(new Set([...current.ids, ...ids]))
|
||||
next.set(instanceId, {
|
||||
ids: nextIds,
|
||||
nextStart: start + ids.length,
|
||||
hasMore,
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getSessionHasMore(instanceId: string): boolean {
|
||||
return getSessionPaginationState(instanceId).hasMore
|
||||
}
|
||||
|
||||
function resetSessionPagination(instanceId: string): void {
|
||||
setSessionPagination((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, { ids: [], nextStart: 0, hasMore: true })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function prependSessionListId(instanceId: string, sessionId: string): void {
|
||||
setSessionPagination((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true }
|
||||
const ids = [sessionId, ...current.ids.filter((id) => id !== sessionId)]
|
||||
next.set(instanceId, { ...current, ids, nextStart: current.nextStart + (current.ids.includes(sessionId) ? 0 : 1) })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeSessionListId(instanceId: string, sessionId: string): void {
|
||||
setSessionPagination((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true }
|
||||
const ids = current.ids.filter((id) => id !== sessionId)
|
||||
next.set(instanceId, { ...current, ids, nextStart: Math.max(0, current.nextStart - (ids.length === current.ids.length ? 0 : 1)) })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function beginSessionSearch(instanceId: string, query: string): number {
|
||||
const current = sessionSearch().get(instanceId)
|
||||
const requestId = (current?.requestId ?? 0) + 1
|
||||
setSessionSearch((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, { query, ids: current?.ids ?? [], loading: true, requestId })
|
||||
return next
|
||||
})
|
||||
return requestId
|
||||
}
|
||||
|
||||
function isLatestSessionSearch(instanceId: string, query: string, requestId: number): boolean {
|
||||
const current = sessionSearch().get(instanceId)
|
||||
return Boolean(current && current.query === query && current.requestId === requestId)
|
||||
}
|
||||
|
||||
function setSessionSearchResults(instanceId: string, query: string, ids: string[], requestId: number): boolean {
|
||||
if (!isLatestSessionSearch(instanceId, query, requestId)) return false
|
||||
setSessionSearch((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, { query, ids, loading: false, requestId })
|
||||
return next
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function clearSessionSearch(instanceId: string): void {
|
||||
setSessionSearch((prev) => {
|
||||
const current = prev.get(instanceId)
|
||||
const requestId = (current?.requestId ?? 0) + 1
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, { query: "", ids: [], loading: false, requestId })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getSessionSearchResultIds(instanceId: string): string[] {
|
||||
return sessionSearch().get(instanceId)?.ids ?? []
|
||||
}
|
||||
|
||||
function getSessionSearchQuery(instanceId: string): string {
|
||||
return sessionSearch().get(instanceId)?.query ?? ""
|
||||
}
|
||||
|
||||
function isSessionSearchLoading(instanceId: string): boolean {
|
||||
return sessionSearch().get(instanceId)?.loading ?? false
|
||||
}
|
||||
|
||||
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">): InstanceSessionIndicatorStatus | "idle" {
|
||||
if (session.pendingPermission || session.pendingQuestion) {
|
||||
return "permission"
|
||||
|
|
@ -477,9 +605,9 @@ function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache {
|
|||
return cache
|
||||
}
|
||||
|
||||
function getSessionThreads(instanceId: string): SessionThread[] {
|
||||
function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: Set<string>): SessionThread[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions || instanceSessions.size === 0) {
|
||||
if (!instanceSessions || instanceSessions.size === 0 || rootIds.length === 0) {
|
||||
sessionThreadCache.delete(instanceId)
|
||||
return []
|
||||
}
|
||||
|
|
@ -487,17 +615,12 @@ function getSessionThreads(instanceId: string): SessionThread[] {
|
|||
const cache = getOrCreateSessionThreadCache(instanceId)
|
||||
const seenParents = new Set<string>()
|
||||
|
||||
const parents: Session[] = []
|
||||
const childrenByParent = new Map<string, Session[]>()
|
||||
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (session.parentId === null) {
|
||||
parents.push(session)
|
||||
continue
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) continue
|
||||
if (childIds && !childIds.has(session.id)) continue
|
||||
const children = childrenByParent.get(parentId)
|
||||
if (children) {
|
||||
children.push(session)
|
||||
|
|
@ -508,7 +631,10 @@ function getSessionThreads(instanceId: string): SessionThread[] {
|
|||
|
||||
const threads: SessionThread[] = []
|
||||
|
||||
for (const parent of parents) {
|
||||
for (const parentId of rootIds) {
|
||||
const parent = instanceSessions.get(parentId)
|
||||
if (!parent || parent.parentId !== null) continue
|
||||
|
||||
seenParents.add(parent.id)
|
||||
|
||||
const children = childrenByParent.get(parent.id) ?? []
|
||||
|
|
@ -550,6 +676,34 @@ function getSessionThreads(instanceId: string): SessionThread[] {
|
|||
return threads
|
||||
}
|
||||
|
||||
function getSessionThreads(instanceId: string): SessionThread[] {
|
||||
return buildSessionThreads(instanceId, getSessionListIds(instanceId))
|
||||
}
|
||||
|
||||
function getSessionSearchThreads(instanceId: string): SessionThread[] {
|
||||
const resultIds = getSessionSearchResultIds(instanceId)
|
||||
if (resultIds.length === 0) return []
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return []
|
||||
|
||||
const rootIds: string[] = []
|
||||
const childIds = new Set<string>()
|
||||
|
||||
for (const sessionId of resultIds) {
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) continue
|
||||
if (session.parentId === null) {
|
||||
rootIds.push(session.id)
|
||||
} else {
|
||||
childIds.add(session.id)
|
||||
if (!rootIds.includes(session.parentId)) rootIds.push(session.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
return buildSessionThreads(instanceId, rootIds, childIds)
|
||||
}
|
||||
|
||||
function isSessionParentExpanded(instanceId: string, parentSessionId: string): boolean {
|
||||
return Boolean(expandedSessionParents().get(instanceId)?.has(parentSessionId))
|
||||
}
|
||||
|
|
@ -826,6 +980,7 @@ export {
|
|||
getChildSessions,
|
||||
getSessionFamily,
|
||||
getSessionThreads,
|
||||
getSessionSearchThreads,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
setSessionParentExpanded,
|
||||
|
|
@ -837,4 +992,22 @@ export {
|
|||
getSessionInfo,
|
||||
isBlankSession,
|
||||
cleanupBlankSessions,
|
||||
SESSION_PAGE_SIZE,
|
||||
sessionPagination,
|
||||
sessionSearch,
|
||||
getSessionListIds,
|
||||
getSessionFetchLimit,
|
||||
getSessionNextStart,
|
||||
setSessionPage,
|
||||
getSessionHasMore,
|
||||
resetSessionPagination,
|
||||
prependSessionListId,
|
||||
removeSessionListId,
|
||||
beginSessionSearch,
|
||||
isLatestSessionSearch,
|
||||
setSessionSearchResults,
|
||||
clearSessionSearch,
|
||||
getSessionSearchResultIds,
|
||||
getSessionSearchQuery,
|
||||
isSessionSearchLoading,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import {
|
|||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionSearchQuery,
|
||||
getSessionSearchThreads,
|
||||
getSessionThreads,
|
||||
getThreadTotals,
|
||||
getSessions,
|
||||
|
|
@ -37,6 +39,11 @@ import {
|
|||
setSessionParentExpanded,
|
||||
setSessionStatus,
|
||||
toggleSessionParentExpanded,
|
||||
clearSessionSearch,
|
||||
getSessionFetchLimit,
|
||||
getSessionHasMore,
|
||||
isSessionSearchLoading,
|
||||
resetSessionPagination,
|
||||
} from "./session-state"
|
||||
|
||||
import { getDefaultModel } from "./session-models"
|
||||
|
|
@ -46,6 +53,8 @@ import {
|
|||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
loadMoreSessions,
|
||||
searchSessions,
|
||||
fetchSessionChildren,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
|
|
@ -111,6 +120,8 @@ export {
|
|||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
loadMoreSessions,
|
||||
searchSessions,
|
||||
fetchSessionChildren,
|
||||
forkSession,
|
||||
getActiveParentSession,
|
||||
|
|
@ -121,6 +132,8 @@ export {
|
|||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionSearchQuery,
|
||||
getSessionSearchThreads,
|
||||
getSessionThreads,
|
||||
getThreadTotals,
|
||||
getSessions,
|
||||
|
|
@ -145,5 +158,10 @@ export {
|
|||
toggleSessionParentExpanded,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
clearSessionSearch,
|
||||
getSessionFetchLimit,
|
||||
getSessionHasMore,
|
||||
isSessionSearchLoading,
|
||||
resetSessionPagination,
|
||||
}
|
||||
export type { SessionInfo }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue