From da6378382fd406560e6959bb3fdcd400691901a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 00:13:03 +0200 Subject: [PATCH] feat(ui): organize sessions by worktree Treat SessionInfo.location as the authoritative worktree assignment, load every project-scoped cursor page, and project complete session families for search, filtering, and activity/name/worktree sorting. Family moves now run through the server transaction and refresh from OpenCode instead of mutating local paths optimistically. Show localized root and linked-worktree badges, preserve complete ancestry during filters, refresh worktrees and sessions after deletion, and apply the complete authoritative session.moved payload. Expose the desktop file-manager action only for local paths with keyboard support. Add coverage for project query construction, path normalization, family projection, native move events, request authority, serialized family moves, and deletion refresh; register the new runnable tests in CI. Validated with UI typecheck, 35 affected CI-mode tests, and production builds. --- .github/workflows/pr-build.yml | 4 + packages/ui/src/components/session-list.tsx | 89 ++++++++++------ .../ui/src/components/worktree-selector.tsx | 51 ++++++++- packages/ui/src/lib/api-client.ts | 9 ++ .../ui/src/lib/i18n/messages/de/instance.ts | 2 + .../ui/src/lib/i18n/messages/de/session.ts | 9 ++ .../ui/src/lib/i18n/messages/en/instance.ts | 2 + .../ui/src/lib/i18n/messages/en/session.ts | 9 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 2 + .../ui/src/lib/i18n/messages/es/session.ts | 9 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 2 + .../ui/src/lib/i18n/messages/fr/session.ts | 9 ++ .../ui/src/lib/i18n/messages/he/instance.ts | 2 + .../ui/src/lib/i18n/messages/he/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 2 + .../ui/src/lib/i18n/messages/ja/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ne/instance.ts | 2 + .../ui/src/lib/i18n/messages/ne/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 2 + .../ui/src/lib/i18n/messages/ru/session.ts | 9 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 2 + .../src/lib/i18n/messages/zh-Hans/session.ts | 9 ++ packages/ui/src/lib/native/client-state.ts | 12 ++- packages/ui/src/lib/sse-manager.ts | 2 + packages/ui/src/stores/session-actions.ts | 9 -- packages/ui/src/stores/session-api.ts | 37 +++++-- packages/ui/src/stores/session-events.ts | 12 +++ .../src/stores/session-list-options.test.ts | 18 ++++ .../ui/src/stores/session-list-options.ts | 19 ++-- .../src/stores/session-native-events.test.ts | 19 ++++ .../ui/src/stores/session-pagination.test.ts | 29 +++-- .../stores/session-request-authority.test.ts | 17 +-- packages/ui/src/stores/session-state.ts | 5 +- packages/ui/src/stores/session-tree.test.ts | 33 ++++++ packages/ui/src/stores/session-tree.ts | 41 +++++++ packages/ui/src/stores/worktree-ready.test.ts | 74 ++++++++++++- packages/ui/src/stores/worktrees.ts | 100 +++++++++++------- packages/ui/src/types/global.d.ts | 1 + 38 files changed, 551 insertions(+), 129 deletions(-) create mode 100644 packages/ui/src/stores/session-list-options.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index a87e1674..84e18163 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -126,7 +126,9 @@ jobs: packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/session-generation-recovery.test.ts + packages/ui/src/stores/session-list-options.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -137,8 +139,10 @@ jobs: packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/session-actions.test.ts + packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts + packages/ui/src/stores/worktree-ready.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index db154943..81f6186e 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -32,8 +32,9 @@ import { getSessionSearchThreads, isSessionSearchLoading, } from "../stores/sessions" -import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" -import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree" +import { getGitRepoStatus, getWorktreeSlugForParentSession, getWorktrees } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, projectSessionFamilies, sortSessionIdsDeepestFirst, type SessionFamilySort } from "../stores/session-tree" +import { normalizeSessionDirectory } from "../stores/session-list-options" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -66,6 +67,8 @@ const SessionList: Component = (props) => { const [isRenaming, setIsRenaming] = createSignal(false) const [filterQuery, setFilterQuery] = createSignal("") + const [sortBy, setSortBy] = createSignal("activity") + const [worktreeDirectory, setWorktreeDirectory] = createSignal("") const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) @@ -186,32 +189,25 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } - const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { - const matchingChildren: SessionThread[] = [] - for (const child of thread.children) { - const filteredChild = filterThreadTree(child, query) - if (filteredChild !== null) matchingChildren.push(filteredChild) - } - if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null - return { ...thread, children: matchingChildren } - } - const filteredThreads = createMemo(() => { 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 searchThreads = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId) + ? getSessionSearchThreads(props.instanceId) + : props.threads + const worktrees = getWorktrees(props.instanceId) + const getWorktreeLabel = (directory: string) => { + const normalized = normalizeSessionDirectory(directory) + const worktree = worktrees.find((candidate) => normalizeSessionDirectory(candidate.directory) === normalized) + return worktree?.kind === "root" ? t("sessionList.worktree.workspace") : worktree?.slug ?? directory } - - const result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) - } - return result + return projectSessionFamilies(searchThreads, { + sort: sortBy(), + worktreeDirectory: worktreeDirectory(), + getWorktreeLabel, + ...(query && searchThreads === props.threads + ? { matchesSession: (session) => sessionMatchesQuery(session.id, query) } + : {}), + }) }) const visibleProjection = createMemo(() => { @@ -251,6 +247,14 @@ const SessionList: Component = (props) => { const selectedCount = createMemo(() => selectedSessionIds().size) + createEffect(() => { + const available = new Set(allMatchingSessionIds()) + setSelectedSessionIds((selected) => { + const next = new Set([...selected].filter((id) => available.has(id))) + return next.size === selected.size ? selected : next + }) + }) + const isAllSelected = createMemo(() => { const ids = allMatchingSessionIds() if (ids.length === 0) return false @@ -423,8 +427,7 @@ const SessionList: Component = (props) => { } const getSelectableThreadIds = (sessionId: string): string[] => { - const source = normalizedQuery() ? filteredThreads() : props.threads - const thread = findSessionThread(source, sessionId) + const thread = findSessionThread(filteredThreads(), sessionId) return thread ? collectSessionThreadIds([thread]) : [sessionId] } @@ -528,14 +531,14 @@ const SessionList: Component = (props) => { const worktreeSlug = createMemo(() => { if (isChild()) return "root" - return getWorktreeSlugForParentSession(props.instanceId, sessionId()) + const slug = getWorktreeSlugForParentSession(props.instanceId, sessionId()) + return slug === "root" ? t("sessionList.worktree.workspace") : slug }) const showWorktreeBadge = createMemo(() => { if (isChild()) return false if (getGitRepoStatus(props.instanceId) === false) return false - const slug = worktreeSlug() - return Boolean(slug) && slug !== "root" + return Boolean(worktreeSlug()) }) const isActive = () => props.activeSessionId === sessionId() @@ -691,7 +694,7 @@ const SessionList: Component = (props) => { - + @@ -824,6 +827,30 @@ const SessionList: Component = (props) => { +
+ + +
+ 0}>
+