Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions dev-docs/WORKTREE_SESSION_PLACEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 28 additions & 10 deletions packages/ui/src/components/session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) }
: {}),
})
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/de/session.ts
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/en/session.ts
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/es/session.ts
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/fr/session.ts
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/he/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "הצגת תת־סשנים",
"session.pruning.maintenance_required": "הסשן או האחסון שלו עסוקים. יש להמתין לסיום הפעולה הנוכחית ולנסות לנקות שוב.",
"session.pruning.unavailable": "תוסף הניקוי אינו זמין או שלא הגיב. יש לפתוח את CodeNomad מחדש ולנסות שוב.",
"session.pruning.conflict": "ההודעה השתנתה מאז שנבחרה. יש לטעון אותה מחדש ולבחור את התוכן שוב.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ja/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "サブセッションを表示",
"session.pruning.maintenance_required": "セッションまたはストレージが使用中です。現在の処理が完了してから、クリーンアップを再試行してください。",
"session.pruning.unavailable": "クリーンアッププラグインが利用できないか、応答しませんでした。CodeNomadを開き直して再試行してください。",
"session.pruning.conflict": "選択後にメッセージが変更されました。再読み込みして内容を選択し直してください。",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ne/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "उप-सत्रहरू देखाउनुहोस्",
"session.pruning.maintenance_required": "सत्र वा यसको भण्डारण व्यस्त छ। हालको कार्य सकिएपछि सफाइ फेरि प्रयास गर्नुहोस्।",
"session.pruning.unavailable": "सफाइ प्लगइन उपलब्ध छैन वा प्रतिक्रिया दिएन। CodeNomad फेरि खोलेर प्रयास गर्नुहोस्।",
"session.pruning.conflict": "चयन गरेपछि सन्देश परिवर्तन भयो। यसलाई पुनः लोड गरेर सामग्री फेरि चयन गर्नुहोस्।",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ru/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "Показывать подсессии",
"session.pruning.maintenance_required": "Сессия или её хранилище заняты. Дождитесь завершения текущей операции и повторите очистку.",
"session.pruning.unavailable": "Плагин очистки недоступен или не ответил. Откройте CodeNomad заново и повторите попытку.",
"session.pruning.conflict": "Сообщение изменилось после выбора. Перезагрузите его и выберите содержимое снова.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/tr/session.ts
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/zh-Hans/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const sessionMessages = {
"sessionList.filter.includeSubsessions": "显示子会话",
"session.pruning.maintenance_required": "会话或其存储正忙。请等待当前操作完成后重试清理。",
"session.pruning.unavailable": "清理插件不可用或未响应。请重新打开 CodeNomad 后重试。",
"session.pruning.conflict": "消息在选中后已更改。请重新加载并再次选择内容。",
Expand Down
12 changes: 0 additions & 12 deletions packages/ui/src/stores/session-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions packages/ui/src/stores/session-request-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 6 additions & 19 deletions packages/ui/src/stores/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1284,7 +1271,7 @@ export {
getSessionRoot,
getSessionFamily,
getSessionThreads,
getSessionSearchThreads,
getSessionSearchSessions,
getVisibleSessionIds,
expandedSessions,
isSessionExpanded,
Expand Down
Loading
Loading