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:
Pascal André 2026-09-18 00:12:44 +02:00 • committed by GitHub
parent 83a4124b37
commit 6309a73ffb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 276 additions and 51 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "הצגת תת־סשנים",
"session.pruning.maintenance_required": "הסשן או האחסון שלו עסוקים. יש להמתין לסיום הפעולה הנוכחית ולנסות לנקות שוב.",
"session.pruning.unavailable": "תוסף הניקוי אינו זמין או שלא הגיב. יש לפתוח את CodeNomad מחדש ולנסות שוב.",
"session.pruning.conflict": "ההודעה השתנתה מאז שנבחרה. יש לטעון אותה מחדש ולבחור את התוכן שוב.",

View file

@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "サブセッションを表示",
"session.pruning.maintenance_required": "セッションまたはストレージが使用中です。現在の処理が完了してから、クリーンアップを再試行してください。",
"session.pruning.unavailable": "クリーンアッププラグインが利用できないか、応答しませんでした。CodeNomadを開き直して再試行してください。",
"session.pruning.conflict": "選択後にメッセージが変更されました。再読み込みして内容を選択し直してください。",

View file

@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "उप-सत्रहरू देखाउनुहोस्",
"session.pruning.maintenance_required": "सत्र वा यसको भण्डारण व्यस्त छ। हालको कार्य सकिएपछि सफाइ फेरि प्रयास गर्नुहोस्।",
"session.pruning.unavailable": "सफाइ प्लगइन उपलब्ध छैन वा प्रतिक्रिया दिएन। CodeNomad फेरि खोलेर प्रयास गर्नुहोस्।",
"session.pruning.conflict": "चयन गरेपछि सन्देश परिवर्तन भयो। यसलाई पुनः लोड गरेर सामग्री फेरि चयन गर्नुहोस्।",

View file

@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "Показывать подсессии",
"session.pruning.maintenance_required": "Сессия или её хранилище заняты. Дождитесь завершения текущей операции и повторите очистку.",
"session.pruning.unavailable": "Плагин очистки недоступен или не ответил. Откройте CodeNomad заново и повторите попытку.",
"session.pruning.conflict": "Сообщение изменилось после выбора. Перезагрузите его и выберите содержимое снова.",

View file

@ -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.",

View file

@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "显示子会话",
"session.pruning.maintenance_required": "会话或其存储正忙。请等待当前操作完成后重试清理。",
"session.pruning.unavailable": "清理插件不可用或未响应。请重新打开 CodeNomad 后重试。",
"session.pruning.conflict": "消息在选中后已更改。请重新加载并再次选择内容。",

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -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")
})
})

View file

@ -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

View file

@ -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,

View 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 }

View 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() }
})