mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-01 03:20:37 +00:00
feat(sessions): flat search results with optional subsessions (#698)
## Summary Follow-up to merged #649. Search/filter mode now displays independent, flat session results. A localized **Show subsessions** switch (off by default) includes matching children without requiring their parents. Each result uses its own native worktree, activity timestamp, name, badge and selection identity. Closing search restores normal hierarchy. Native search results no longer hydrate ancestors just to render a child. Request-authority/deletion fences remain covered, and unqueried flat filters exhaust normal pagination. This supersedes the temporary root-only filter adjustment published on the former PR branch after #649 merged. ## Validation - UI typecheck and production build passed. - 14 session-tree and 65 request-authority cases passed. - Real Solid/browser regression passed: toggle, activity/name/worktree sorts, combined text/directory filters, child badges, bulk selection, navigation, missing parents, and hierarchy restoration. - Rebuilt UI loaded in installed Windows Tauri: an existing child in D:\CodeNomad appeared alone under Workspace while its parent remained in pr649-final. Disabling subsessions or selecting the parent checkout hid it; closing search restored hierarchy and kept the current conversation active. No native locations changed. - One 30-second desktop search wait expired; the repeat completed within the extended 90-second bound. Search still traverses the local worktree catalogue; no latency guarantee is claimed. - Follow-up commit tree exactly matches the tested tree on the original worktree branch; it is based directly on the #649 squash merge in dev. ## Maintenance note Existing touched oversized files: session-list.tsx (~1022 lines), session-api.ts (~1732), session-state.ts (~1307), session-request-authority.test.ts (~2016). No size-only refactor included.
This commit is contained in:
parent
83a4124b37
commit
6309a73ffb
21 changed files with 276 additions and 51 deletions
|
|
@ -10,6 +10,7 @@
|
|||
- Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints.
|
||||
- Session rows keep actions inline until their measured title, badges, and controls no longer fit. Keep responsive action styles in `styles/components/session-row-actions.css`; hidden inline controls remain measurable but inert, and an open overflow menu stays mounted until dismissal.
|
||||
- Session hierarchy geometry lives in `styles/components/session-tree.css`; connector axes follow the parent expander at every depth, including selection mode, RTL and touch layouts.
|
||||
- Session search/filter mode uses flat per-session results with an optional subsession switch; filters, sorting, worktree badges and selection use each result's own identity. Normal browsing retains the session hierarchy.
|
||||
- Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change.
|
||||
- Explicit round exceptions: Yolo and MCP switches (shared `styles/components/switches.css` geometry), overlay drawer navigation buttons, and floating message scroll buttons. Other chrome remains square.
|
||||
- Tags and numeric/context/token labels also use rounded geometry via `--chip-radius` (`--pill-radius` is an alias). Register badge variants in `styles/components/badges.css`; use `.badge-shape` for utility-styled labels rather than adding a local radius.
|
||||
|
|
|
|||
|
|
@ -174,6 +174,28 @@ authorized workspace spelling before changing UI state.
|
|||
|
||||
### Final native/UI integration (2026-09-17)
|
||||
|
||||
Follow-up: search/filter mode presents independent flat rows. The "Show subsessions"
|
||||
switch (off by default) includes children without requiring their parents in the
|
||||
results. Text and worktree filters must match the same session; activity, names and
|
||||
worktree labels also sort each session independently. Selection targets only the
|
||||
displayed matches. Server search results no longer require ancestor hydration.
|
||||
Closing search restores normal hierarchy and ignores its worktree filter. This
|
||||
supersedes the temporary root-only worktree-filter fix in `3921ba08`.
|
||||
|
||||
The real-component browser regression covers the switch, cross-worktree children,
|
||||
text plus directory filters, independent badges, bulk selection, direct child
|
||||
navigation, results without loaded ancestors and returning to normal hierarchy.
|
||||
|
||||
The rebuilt UI was also loaded in the installed Windows Tauri renderer. An
|
||||
existing child, "Analyze automation update", was verified through native reads
|
||||
in `D:\CodeNomad` while its parent remained in `pr649-final`. With its title and
|
||||
the Workspace filter selected, the child appeared alone only when subsessions
|
||||
were enabled. Selecting the parent's checkout hid it; closing search restored
|
||||
the hierarchy and preserved the active parent conversation. A first desktop wait
|
||||
of 30 seconds expired; the completed repeat used a 90-second bound. Directory
|
||||
search still traverses the local worktree catalogue, so this is not a latency
|
||||
guarantee. No native session locations were changed by this check.
|
||||
|
||||
Merged `dev@e47e01c6` (PR #697) into this branch. Discovery and canonical
|
||||
`server.status()` adaptation are now inherited from that independently merged
|
||||
change. The native worktree/family fixture passed again against isolated official
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ import {
|
|||
clearSessionSearch,
|
||||
fetchSessions,
|
||||
getSessionSearchQuery,
|
||||
getSessionSearchThreads,
|
||||
getSessionSearchSessions,
|
||||
isSessionSearchLoading,
|
||||
} from "../stores/sessions"
|
||||
import { getGitRepoStatus, getWorktreeSlugForParentSession, getWorktrees } from "../stores/worktrees"
|
||||
import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, projectSessionFamilies, sortSessionIdsDeepestFirst, type SessionFamilySort } from "../stores/session-tree"
|
||||
import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, projectSessionFamilies, projectSessionSearchResults, sortSessionIdsDeepestFirst, type SessionFamilySort } from "../stores/session-tree"
|
||||
import { normalizeSessionDirectory } from "../stores/session-list-options"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
|
@ -72,6 +72,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
const [filterQuery, setFilterQuery] = createSignal("")
|
||||
const [sortBy, setSortBy] = createSignal<SessionFamilySort>("activity")
|
||||
const [worktreeDirectory, setWorktreeDirectory] = createSignal("")
|
||||
const [includeSubsessions, setIncludeSubsessions] = createSignal(false)
|
||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||
let failedSortExhaustion: string | undefined
|
||||
|
||||
|
|
@ -133,7 +134,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
createEffect(() => {
|
||||
const sort = sortBy()
|
||||
const key = `${props.instanceId}:${sort}`
|
||||
if (sort === "activity") {
|
||||
if (sort === "activity" && !props.enableFilterBar) {
|
||||
failedSortExhaustion = undefined
|
||||
return
|
||||
}
|
||||
|
|
@ -222,20 +223,26 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
|
||||
const filteredThreads = createMemo<SessionThread[]>(() => {
|
||||
const query = normalizedQuery()
|
||||
const searchThreads = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId)
|
||||
? getSessionSearchThreads(props.instanceId)
|
||||
: props.threads
|
||||
const hasSearchResults = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId)
|
||||
const worktrees = getWorktrees(props.instanceId)
|
||||
const getWorktreeLabel = (directory: string) => {
|
||||
const normalized = normalizeSessionDirectory(directory)
|
||||
const worktree = worktrees.find((candidate) => normalizeSessionDirectory(candidate.serviceDirectory ?? candidate.directory) === normalized)
|
||||
return worktree?.kind === "root" ? t("sessionList.worktree.workspace") : worktree?.label ?? worktree?.slug ?? directory
|
||||
}
|
||||
return projectSessionFamilies(searchThreads, {
|
||||
if (!props.enableFilterBar) return projectSessionFamilies(props.threads, { sort: sortBy(), getWorktreeLabel })
|
||||
const instanceSessions = sessionStateSessions().get(props.instanceId)
|
||||
const candidates = hasSearchResults ? getSessionSearchSessions(props.instanceId)
|
||||
: collectSessionThreadIds(props.threads).flatMap(id => {
|
||||
const session = instanceSessions?.get(id)
|
||||
return session ? [session] : []
|
||||
})
|
||||
return projectSessionSearchResults(candidates, {
|
||||
sort: sortBy(),
|
||||
worktreeDirectory: worktreeDirectory(),
|
||||
includeSubsessions: includeSubsessions(),
|
||||
getWorktreeLabel,
|
||||
...(query && searchThreads === props.threads
|
||||
...(query && !hasSearchResults
|
||||
? { matchesSession: (session) => sessionMatchesQuery(session.id, query) }
|
||||
: {}),
|
||||
})
|
||||
|
|
@ -557,6 +564,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
}> = (rowProps) => {
|
||||
const sessionId = () => rowProps.session.id
|
||||
const isChild = () => rowProps.depth > 0
|
||||
const isSubsession = () => Boolean(rowProps.session.parentId)
|
||||
|
||||
const worktreeSlug = createMemo(() => {
|
||||
if (isChild()) return ""
|
||||
|
|
@ -686,7 +694,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
return (
|
||||
<div class={`session-list-item group ${rowProps.isLastRow ? "session-list-item-last" : ""}`}>
|
||||
<div
|
||||
class={`session-item-base ${isChild() ? "session-item-nested" : ""} ${isChild() && rowProps.isLastChild ? "session-item-child-last" : ""} ${isChild() ? "session-item-border-assistant session-item-kind-assistant" : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
class={`session-item-base ${isChild() ? "session-item-nested" : ""} ${isChild() && rowProps.isLastChild ? "session-item-child-last" : ""} ${isSubsession() ? "session-item-border-assistant session-item-kind-assistant" : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
style={nestedStyle()}
|
||||
data-session-id={sessionId()}
|
||||
ref={setRowElement}
|
||||
|
|
@ -727,7 +735,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
title={title()}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
>
|
||||
<Show when={isChild()} fallback={<User class="session-item-kind-icon w-4 h-4 flex-shrink-0" aria-hidden="true" />}>
|
||||
<Show when={isSubsession()} fallback={<User class="session-item-kind-icon w-4 h-4 flex-shrink-0" aria-hidden="true" />}>
|
||||
<Bot class="session-item-kind-icon w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
</Show>
|
||||
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
|
||||
|
|
@ -869,6 +877,16 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<label class="mt-2 flex items-center gap-2 text-xs text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={includeSubsessions()}
|
||||
onChange={(event) => setIncludeSubsessions(event.currentTarget.checked)}
|
||||
/>
|
||||
{t("sessionList.filter.includeSubsessions")}
|
||||
</label>
|
||||
|
||||
<Show when={selectedCount() > 0}>
|
||||
<div class="mt-2 flex items-center justify-end gap-2">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "Untersitzungen anzeigen",
|
||||
"session.pruning.maintenance_required": "Die Sitzung oder ihr Speicher ist beschäftigt. Warte, bis der aktuelle Vorgang abgeschlossen ist, und versuche die Bereinigung erneut.",
|
||||
"session.pruning.unavailable": "Das Bereinigungs-Plugin ist nicht verfügbar oder hat nicht geantwortet. Öffne CodeNomad erneut und versuche es noch einmal.",
|
||||
"session.pruning.conflict": "Die Nachricht wurde seit der Auswahl geändert. Lade sie neu und wähle den Inhalt erneut aus.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "Show subsessions",
|
||||
"session.pruning.maintenance_required": "The session or its storage is busy. Wait for the current operation to finish, then retry cleanup.",
|
||||
"session.pruning.unavailable": "The cleanup plugin is unavailable or did not respond. Reopen CodeNomad and retry.",
|
||||
"session.pruning.conflict": "The message changed since it was selected. Reload it and select the content again.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "Mostrar subsesiones",
|
||||
"session.pruning.maintenance_required": "La sesión o su almacenamiento está ocupado. Espera a que termine la operación actual y vuelve a intentar la limpieza.",
|
||||
"session.pruning.unavailable": "El complemento de limpieza no está disponible o no respondió. Vuelve a abrir CodeNomad e inténtalo de nuevo.",
|
||||
"session.pruning.conflict": "El mensaje cambió desde su selección. Recárgalo y vuelve a seleccionar el contenido.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "Afficher les sous-sessions",
|
||||
"session.pruning.maintenance_required": "La session ou son stockage est occupé. Attendez la fin de l’opération en cours, puis réessayez le nettoyage.",
|
||||
"session.pruning.unavailable": "Le plugin de nettoyage est indisponible ou n’a pas répondu. Rouvrez CodeNomad puis réessayez.",
|
||||
"session.pruning.conflict": "Le message a changé depuis sa sélection. Rechargez-le puis sélectionnez à nouveau le contenu.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "הצגת תת־סשנים",
|
||||
"session.pruning.maintenance_required": "הסשן או האחסון שלו עסוקים. יש להמתין לסיום הפעולה הנוכחית ולנסות לנקות שוב.",
|
||||
"session.pruning.unavailable": "תוסף הניקוי אינו זמין או שלא הגיב. יש לפתוח את CodeNomad מחדש ולנסות שוב.",
|
||||
"session.pruning.conflict": "ההודעה השתנתה מאז שנבחרה. יש לטעון אותה מחדש ולבחור את התוכן שוב.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "サブセッションを表示",
|
||||
"session.pruning.maintenance_required": "セッションまたはストレージが使用中です。現在の処理が完了してから、クリーンアップを再試行してください。",
|
||||
"session.pruning.unavailable": "クリーンアッププラグインが利用できないか、応答しませんでした。CodeNomadを開き直して再試行してください。",
|
||||
"session.pruning.conflict": "選択後にメッセージが変更されました。再読み込みして内容を選択し直してください。",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "उप-सत्रहरू देखाउनुहोस्",
|
||||
"session.pruning.maintenance_required": "सत्र वा यसको भण्डारण व्यस्त छ। हालको कार्य सकिएपछि सफाइ फेरि प्रयास गर्नुहोस्।",
|
||||
"session.pruning.unavailable": "सफाइ प्लगइन उपलब्ध छैन वा प्रतिक्रिया दिएन। CodeNomad फेरि खोलेर प्रयास गर्नुहोस्।",
|
||||
"session.pruning.conflict": "चयन गरेपछि सन्देश परिवर्तन भयो। यसलाई पुनः लोड गरेर सामग्री फेरि चयन गर्नुहोस्।",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "Показывать подсессии",
|
||||
"session.pruning.maintenance_required": "Сессия или её хранилище заняты. Дождитесь завершения текущей операции и повторите очистку.",
|
||||
"session.pruning.unavailable": "Плагин очистки недоступен или не ответил. Откройте CodeNomad заново и повторите попытку.",
|
||||
"session.pruning.conflict": "Сообщение изменилось после выбора. Перезагрузите его и выберите содержимое снова.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "Alt oturumları göster",
|
||||
"session.pruning.maintenance_required": "Oturum veya depolama alanı meşgul. Geçerli işlem bitince temizlemeyi yeniden deneyin.",
|
||||
"session.pruning.unavailable": "Temizleme eklentisi kullanılamıyor veya yanıt vermedi. CodeNomad'i yeniden açıp tekrar deneyin.",
|
||||
"session.pruning.conflict": "Mesaj seçildikten sonra değişti. Yeniden yükleyip içeriği tekrar seçin.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const sessionMessages = {
|
||||
"sessionList.filter.includeSubsessions": "显示子会话",
|
||||
"session.pruning.maintenance_required": "会话或其存储正忙。请等待当前操作完成后重试清理。",
|
||||
"session.pruning.unavailable": "清理插件不可用或未响应。请重新打开 CodeNomad 后重试。",
|
||||
"session.pruning.conflict": "消息在选中后已更改。请重新加载并再次选择内容。",
|
||||
|
|
|
|||
|
|
@ -826,22 +826,10 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
|
|||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
await ensureV2ParentChainsLoaded(instanceId, searchResults, undefined, isCurrent)
|
||||
|
||||
if (!isCurrent()) return
|
||||
|
||||
const hydratedSessions = sessions().get(instanceId)
|
||||
const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId)
|
||||
const currentSearchResults = searchResults.filter((session) => !deletedSessionIds.has(session.id))
|
||||
const hasUnrenderableChildResult = currentSearchResults.some((session) => {
|
||||
const parentId = session.parentID
|
||||
return Boolean(parentId && !hydratedSessions?.has(parentId))
|
||||
})
|
||||
|
||||
if (hasUnrenderableChildResult) {
|
||||
clearSessionSearch(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
setSessionSearchResults(instanceId, trimmedQuery, currentSearchResults.map((session) => session.id), requestId)
|
||||
|
|
|
|||
|
|
@ -302,18 +302,16 @@ describe("session request authority", () => {
|
|||
const instanceId = "late-search-delete"
|
||||
const { client, cleanup } = setup(instanceId)
|
||||
const search = deferred<any>()
|
||||
const parents = deferred<any>()
|
||||
let calls = 0
|
||||
;(client.session as any).list = () => { calls += 1; return search.promise }
|
||||
;(client.session as any).get = () => parents.promise
|
||||
;(client.session as any).get = () => { throw new Error("Flat search must not hydrate parents") }
|
||||
|
||||
try {
|
||||
const request = searchSessions(instanceId, "child")
|
||||
search.resolve({ data: [apiSession("child", "parent")] })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
removeSessionRuntimeState(instanceId, "child")
|
||||
removeSessionRuntimeState(instanceId, "parent")
|
||||
parents.resolve(apiSession("parent"))
|
||||
search.resolve({ data: [apiSession("child", "parent")] })
|
||||
await request
|
||||
|
||||
assert.equal(sessions().get(instanceId)?.has("child") ?? false, false)
|
||||
|
|
|
|||
|
|
@ -907,26 +907,13 @@ function getSessionThreads(instanceId: string): SessionThread[] {
|
|||
return buildSessionThreads(instanceId, getSessionListIds(instanceId))
|
||||
}
|
||||
|
||||
function getSessionSearchThreads(instanceId: string): SessionThread[] {
|
||||
const resultIds = getSessionSearchResultIds(instanceId)
|
||||
if (resultIds.length === 0) return []
|
||||
|
||||
function getSessionSearchSessions(instanceId: string): Session[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return []
|
||||
|
||||
const rootIds: string[] = []
|
||||
for (const sessionId of resultIds) {
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) continue
|
||||
if (session.parentId === null) {
|
||||
if (!rootIds.includes(session.id)) rootIds.push(session.id)
|
||||
} else {
|
||||
const root = getSessionRootFromMap(instanceSessions, session.id)
|
||||
if (root && !rootIds.includes(root.id)) rootIds.push(root.id)
|
||||
}
|
||||
}
|
||||
|
||||
return buildSessionThreads(instanceId, rootIds)
|
||||
return getSessionSearchResultIds(instanceId).flatMap(id => {
|
||||
const session = instanceSessions.get(id)
|
||||
return session ? [session] : []
|
||||
})
|
||||
}
|
||||
|
||||
function isSessionExpanded(instanceId: string, sessionId: string): boolean {
|
||||
|
|
@ -1284,7 +1271,7 @@ export {
|
|||
getSessionRoot,
|
||||
getSessionFamily,
|
||||
getSessionThreads,
|
||||
getSessionSearchThreads,
|
||||
getSessionSearchSessions,
|
||||
getVisibleSessionIds,
|
||||
expandedSessions,
|
||||
isSessionExpanded,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getSessionAncestorIdsFromMap,
|
||||
getSessionRootFromMap,
|
||||
projectSessionFamilies,
|
||||
projectSessionSearchResults,
|
||||
sortSessionIdsDeepestFirst,
|
||||
} from "./session-tree"
|
||||
import { normalizeSessionDirectory } from "./session-list-options"
|
||||
|
|
@ -201,4 +202,51 @@ describe("session tree", () => {
|
|||
})
|
||||
assert.deepEqual(collectSessionThreadIds(matched), ["root", "matching-child", "sibling"])
|
||||
})
|
||||
|
||||
it("searches individual sessions without ancestors, inherited activity or hidden selection descendants", () => {
|
||||
const sessions = sessionMap([
|
||||
["root", null, 100],
|
||||
["child", "root", 200],
|
||||
["grandchild", "child", 300],
|
||||
["local-root", null, 400],
|
||||
])
|
||||
for (const item of sessions.values()) {
|
||||
item.location = { directory: "D:\\repo" }
|
||||
item.status = "idle"
|
||||
}
|
||||
const parent = sessions.get("root")!
|
||||
parent.location = { directory: "D:\\worktrees\\feature" }
|
||||
parent.status = "working"
|
||||
const grandchild = sessions.get("grandchild")!
|
||||
const project = (worktreeDirectory: string, includeSubsessions = true) => projectSessionSearchResults(
|
||||
sessions.values(),
|
||||
{ sort: "activity", worktreeDirectory, includeSubsessions, getWorktreeLabel: directory => directory },
|
||||
)
|
||||
assert.deepEqual(project("D:\\repo").map(thread => thread.session.id), ["local-root", "grandchild", "child"])
|
||||
assert.deepEqual(collectSessionThreadIds(project("D:\\repo", false)), ["local-root"])
|
||||
assert.equal(project("D:/worktrees/feature")[0].session.id, "root")
|
||||
assert.deepEqual(collectSessionThreadIds(project("")), ["local-root", "grandchild", "child", "root"], "each row uses its own activity")
|
||||
assert.equal(project("", false).length, 2)
|
||||
for (const status of ["working", "compacting"] as const) {
|
||||
grandchild.status = status
|
||||
assert.deepEqual(collectSessionThreadIds(project("D:\\repo")), ["local-root", "grandchild", "child"])
|
||||
assert.deepEqual(collectSessionThreadIds(project("D:/worktrees/feature")), ["root"])
|
||||
}
|
||||
grandchild.location = { directory: "D:\\another-checkout" }
|
||||
assert.equal(project("D:\\repo").length, 2)
|
||||
grandchild.location = { directory: "D:\\repo" }
|
||||
grandchild.status = "idle"
|
||||
assert.equal(project("D:\\repo").length, 3)
|
||||
const match = projectSessionSearchResults([grandchild], {
|
||||
sort: "name", worktreeDirectory: "D:\\repo", includeSubsessions: true,
|
||||
matchesSession: item => item.id === "grandchild", getWorktreeLabel: directory => directory,
|
||||
})
|
||||
assert.deepEqual(collectSessionThreadIds(match), ["grandchild"], "a result renders even without its parents loaded")
|
||||
assert.equal(match[0].depth, 0)
|
||||
assert.equal(match[0].hasChildren, false)
|
||||
assert.equal(projectSessionSearchResults([grandchild], {
|
||||
sort: "worktree", worktreeDirectory: parent.location.directory, includeSubsessions: true,
|
||||
matchesSession: item => item.id === "grandchild", getWorktreeLabel: directory => directory,
|
||||
}).length, 0, "text and directory must match the same session")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -39,10 +39,7 @@ export function projectSessionFamilies(
|
|||
const projected = threads.filter((thread) => {
|
||||
if (options.matchesSession && !someSession(thread, options.matchesSession)) return false
|
||||
if (!worktreeDirectory) return true
|
||||
return someSession(
|
||||
thread,
|
||||
(session) => normalizeSessionDirectory(session.location?.directory) === worktreeDirectory,
|
||||
)
|
||||
return someSession(thread, (session) => normalizeSessionDirectory(session.location?.directory) === worktreeDirectory)
|
||||
})
|
||||
|
||||
return [...projected].sort((left, right) => {
|
||||
|
|
@ -58,6 +55,20 @@ export function projectSessionFamilies(
|
|||
})
|
||||
}
|
||||
|
||||
// Search rows are independent leaves: filtering, sorting and selection all use
|
||||
// the session itself, without adding ancestors or inheriting descendant activity.
|
||||
export function projectSessionSearchResults(
|
||||
sessions: Iterable<Session>,
|
||||
options: SessionFamilyProjection & { includeSubsessions: boolean },
|
||||
): SessionThread[] {
|
||||
const rows = new Map<string, SessionThread>()
|
||||
for (const session of sessions) {
|
||||
if (session.parentId && !options.includeSubsessions) continue
|
||||
rows.set(session.id, { session, children: [], depth: 0, hasChildren: false, latestUpdated: session.time.updated })
|
||||
}
|
||||
return projectSessionFamilies([...rows.values()], options)
|
||||
}
|
||||
|
||||
export function getSessionRootFromMap(instanceSessions: Map<string, Session>, sessionId: string): Session | null {
|
||||
let current = instanceSessions.get(sessionId)
|
||||
if (!current) return null
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import {
|
|||
getSessionListError,
|
||||
getSessionMessagesLoadError,
|
||||
getSessionSearchQuery,
|
||||
getSessionSearchThreads,
|
||||
getSessionSearchSessions,
|
||||
getSessionThreads,
|
||||
getThreadTotals,
|
||||
getSessions,
|
||||
|
|
@ -197,7 +197,7 @@ export {
|
|||
getSessionListError,
|
||||
getSessionMessagesLoadError,
|
||||
getSessionSearchQuery,
|
||||
getSessionSearchThreads,
|
||||
getSessionSearchSessions,
|
||||
getSessionThreads,
|
||||
getThreadTotals,
|
||||
getSessions,
|
||||
|
|
|
|||
61
packages/ui/tests/browser/fixtures/session-search.tsx
Normal file
61
packages/ui/tests/browser/fixtures/session-search.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { createMemo, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import SessionList from "../../../src/components/session-list"
|
||||
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { serverApi } from "../../../src/lib/api-client"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { addInstance } from "../../../src/stores/instances"
|
||||
import { sessions, setSessions, setSessionPage } from "../../../src/stores/session-state"
|
||||
import { buildSessionThreadsFromMap } from "../../../src/stores/session-tree"
|
||||
import { ensureWorktreesLoaded } from "../../../src/stores/worktrees"
|
||||
import type { Session } from "../../../src/types/session"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const id = "session-search-fixture"
|
||||
function make(sessionId: string, title: string, directory: string, updated: number, parentId: string | null = null): Session {
|
||||
return { id: sessionId, title, instanceId: id, parentId, location: { directory }, status: "idle", agent: "build",
|
||||
projectID: "fixture", cost: 0, tokens: {}, time: { created: 1, updated }, model: { providerId: "fixture", modelId: "fixture" } } as Session
|
||||
}
|
||||
const items = [
|
||||
make("parent", "Parent conversation", "/feature", 1),
|
||||
make("child", "Child needle", "/repo", 4, "parent"),
|
||||
make("grandchild", "Deep needle", "/feature", 3, "child"),
|
||||
make("local", "Local conversation", "/repo", 2),
|
||||
]
|
||||
const orphan = make("orphan", "Remote content match", "/feature", 5, "unloaded-parent")
|
||||
const searches: unknown[] = [], selected: string[] = []
|
||||
let parentReads = 0
|
||||
const client: any = { session: {
|
||||
list: async (input: any) => {
|
||||
searches.push(input)
|
||||
const result = input.search === "remote" ? [orphan] : items.filter(item => item.title.toLowerCase().includes(input.search ?? ""))
|
||||
return { data: result.filter(item => !input.directory || item.location.directory === input.directory).map(item => ({ ...item, parentID: item.parentId })), cursor: {} }
|
||||
},
|
||||
get: async () => { parentReads++; throw new Error("Search must not hydrate ancestors") },
|
||||
} }
|
||||
;(sdkManager as any).clients.set(`${id}:/workspaces/${id}/instance`, client)
|
||||
const uiConfig = { settings: { locale: "en" } }
|
||||
serverApi.fetchConfigOwner = async () => uiConfig as any
|
||||
serverApi.patchConfigOwner = async (_owner, patch) => Object.assign(uiConfig, patch) as any
|
||||
serverApi.fetchStateOwner = async () => ({} as any)
|
||||
serverApi.fetchWorktrees = async () => ({ isGitRepo: true, worktrees: [
|
||||
{ slug: "root", label: "main", directory: "/repo", kind: "root" },
|
||||
{ slug: "feature-id", label: "feature", directory: "/feature", kind: "worktree" },
|
||||
] })
|
||||
addInstance({ id, folder: "/repo", port: 0, pid: 0, proxyPath: `/workspaces/${id}/instance`, status: "ready", client })
|
||||
setSessions(previous => new Map(previous).set(id, new Map(items.map(item => [item.id, item]))))
|
||||
setSessionPage(id, ["parent", "local"], false, true)
|
||||
await ensureWorktreesLoaded(id)
|
||||
const [searchMode, setSearchMode] = createSignal(true)
|
||||
const [active, setActive] = createSignal<string | null>(null)
|
||||
function Fixture() {
|
||||
const threads = createMemo(() => buildSessionThreadsFromMap(sessions().get(id)!, ["parent", "local"]))
|
||||
return <ConfigProvider><I18nProvider><div style={{ width: "440px", height: "650px", display: "flex" }}>
|
||||
<SessionList instanceId={id} threads={threads()} activeSessionId={active()} onSelect={value => { selected.push(value); setActive(value) }}
|
||||
onNew={() => {}} enableFilterBar={searchMode()} showHeader={false} showFooter={false} />
|
||||
</div></I18nProvider></ConfigProvider>
|
||||
}
|
||||
render(() => <Fixture />, document.getElementById("root")!)
|
||||
await updatePreferences({ locale: "en" })
|
||||
;(window as any).fixture = { searches, selected, parentReads: () => parentReads, setSearchMode }
|
||||
81
packages/ui/tests/browser/session-search.test.ts
Normal file
81
packages/ui/tests/browser/session-search.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium, type Browser } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), { name: "session-search-fixture", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/session-search.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
test("search is flat, filters each session, selects only matches and restores the normal hierarchy", async () => {
|
||||
const page = await browser.newPage()
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
|
||||
const rows = () => page.locator(".session-item-base").evaluateAll(elements => elements.map(el => el.getAttribute("data-session-id")))
|
||||
try {
|
||||
await page.goto(url)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
const toggle = page.getByRole("switch", { name: "Show subsessions" })
|
||||
await page.locator('.session-item-base[data-session-id="parent"]').waitFor()
|
||||
assert.deepEqual(await rows(), ["local", "parent"])
|
||||
await toggle.check()
|
||||
assert.deepEqual(await rows(), ["child", "grandchild", "local", "parent"])
|
||||
assert.equal(await page.locator(".session-item-nested").count(), 0)
|
||||
assert.equal(await page.locator(".session-item-expander[aria-expanded]").count(), 0)
|
||||
assert.equal(await page.locator('[data-session-id="grandchild"] .worktree-indicator').innerText(), "feature")
|
||||
const sort = page.getByRole("combobox").first()
|
||||
await sort.selectOption("name")
|
||||
assert.deepEqual(await rows(), ["child", "grandchild", "local", "parent"])
|
||||
await sort.selectOption("worktree")
|
||||
assert.deepEqual(await rows(), ["grandchild", "parent", "child", "local"])
|
||||
await sort.selectOption("activity")
|
||||
assert.deepEqual(await rows(), ["child", "grandchild", "local", "parent"])
|
||||
const filter = page.getByRole("combobox", { name: "Filter sessions by worktree" })
|
||||
await filter.selectOption("/repo")
|
||||
assert.deepEqual(await rows(), ["child", "local"])
|
||||
const search = page.getByRole("textbox", { name: "Search sessions" })
|
||||
await search.fill("needle")
|
||||
await page.waitForFunction(() => (window as any).fixture.searches.some((s: any) => s.search === "needle"))
|
||||
await page.waitForFunction(() => document.querySelectorAll(".session-item-base").length === 1)
|
||||
assert.deepEqual(await rows(), ["child"])
|
||||
await page.getByRole("button", { name: "Select all sessions" }).click()
|
||||
assert.equal(await page.locator('.session-item-base input[type="checkbox"]:checked').count(), 1)
|
||||
await toggle.uncheck()
|
||||
assert.deepEqual(await rows(), [])
|
||||
assert.equal(await page.getByRole("button", { name: /Delete selected/ }).count(), 0)
|
||||
await toggle.check()
|
||||
assert.equal(await page.locator('.session-item-base input[type="checkbox"]:checked').count(), 0)
|
||||
await page.locator('[data-session-id="child"] .session-item-select').click()
|
||||
assert.deepEqual(await page.evaluate(() => (window as any).fixture.selected), ["child"])
|
||||
await filter.selectOption("/feature")
|
||||
await search.fill("remote")
|
||||
await page.locator('[data-session-id="orphan"]').waitFor()
|
||||
assert.deepEqual(await rows(), ["orphan"], "server result without its parent is independently renderable")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.parentReads()), 0)
|
||||
await page.evaluate(() => (window as any).fixture.setSearchMode(false))
|
||||
await page.locator('[data-session-id="parent"]').waitFor()
|
||||
assert.equal(await page.getByRole("switch").count(), 0)
|
||||
assert.ok((await rows()).includes("parent"))
|
||||
assert.ok(await page.locator(".session-item-expander[aria-expanded]").count() > 0)
|
||||
assert.deepEqual(errors, [])
|
||||
} catch (error) {
|
||||
console.error({ errors, body: await page.locator("body").innerText() })
|
||||
throw error
|
||||
} finally { await page.close() }
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue