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.
This commit is contained in:
Pascal André 2026-08-13 00:13:03 +02:00
parent 9b18a37994
commit da6378382f
No known key found for this signature in database
38 changed files with 551 additions and 129 deletions

View file

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

View file

@ -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<SessionListProps> = (props) => {
const [isRenaming, setIsRenaming] = createSignal(false)
const [filterQuery, setFilterQuery] = createSignal("")
const [sortBy, setSortBy] = createSignal<SessionFamilySort>("activity")
const [worktreeDirectory, setWorktreeDirectory] = createSignal("")
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
@ -186,32 +189,25 @@ const SessionList: Component<SessionListProps> = (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<SessionThread[]>(() => {
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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (props) => {
</span>
</Show>
<Show when={showWorktreeBadge()}>
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
<span class="status-indicator session-status-list worktree-indicator" title={t("sessionList.worktree.tooltip", { worktree: worktreeSlug() })}>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
<span class="worktree-indicator-label">{worktreeSlug()}</span>
</span>
@ -824,6 +827,30 @@ const SessionList: Component<SessionListProps> = (props) => {
</button>
</div>
<div class="mt-2 grid grid-cols-2 gap-2">
<select
class="selector-input min-w-0"
value={sortBy()}
onChange={(event) => setSortBy(event.currentTarget.value as SessionFamilySort)}
aria-label={t("sessionList.sort.ariaLabel")}
>
<option value="activity">{t("sessionList.sort.activity")}</option>
<option value="name">{t("sessionList.sort.name")}</option>
<option value="worktree">{t("sessionList.sort.worktree")}</option>
</select>
<select
class="selector-input min-w-0"
value={worktreeDirectory()}
onChange={(event) => setWorktreeDirectory(event.currentTarget.value)}
aria-label={t("sessionList.worktreeFilter.ariaLabel")}
>
<option value="">{t("sessionList.worktreeFilter.all")}</option>
{getWorktrees(props.instanceId).map((worktree) => (
<option value={worktree.directory}>{worktree.kind === "root" ? t("sessionList.worktree.workspace") : worktree.slug}</option>
))}
</select>
</div>
<Show when={selectedCount() > 0}>
<div class="mt-2 flex items-center justify-end gap-2">
<button

View file

@ -1,7 +1,7 @@
import { Select } from "@kobalte/core/select"
import { Dialog } from "@kobalte/core/dialog"
import { For, Show, createMemo, createSignal } from "solid-js"
import { ChevronDown, Copy, Trash2 } from "lucide-solid"
import { ChevronDown, Copy, FolderOpen, Trash2 } from "lucide-solid"
import type { WorktreeDescriptor } from "../../../server/src/api-types"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
@ -18,12 +18,14 @@ import {
} from "../stores/worktrees"
import { sessions } from "../stores/sessions"
import { useI18n } from "../lib/i18n"
import { isDesktopHost, isLocalWindow, isMobilePlatform } from "../lib/runtime-env"
import { openNativeWorktreeInFileManager } from "../lib/native/client-state"
const log = getLogger("session")
type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
| { kind: "worktree"; key: string; slug: string; directory: string; registeredDirectory?: string; raw: WorktreeDescriptor }
type DeleteErrorKind = "localChanges" | "inUse" | "notFound" | "permissionDenied" | "unknown"
@ -45,6 +47,11 @@ function normalizePath(input: string): string {
return (input ?? "").replace(/\\/g, "/").replace(/\/+$/, "")
}
function isLocalPath(input: string): boolean {
const prefix = input.slice(0, 2).replace(/\\/g, "/")
return prefix !== "//"
}
function relativePath(fromDir: string, toDir: string): string {
const from = normalizePath(fromDir)
const to = normalizePath(toDir)
@ -156,6 +163,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
key: wt.slug,
slug: wt.slug,
directory: wt.directory,
registeredDirectory: wt.registeredDirectory,
raw: wt,
}))
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
@ -187,6 +195,9 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
const list = getWorktrees(props.instanceId)
return list.find((wt) => wt.slug === "root")?.directory ?? ""
})
const registeredRepoRoot = createMemo(() => {
return getWorktrees(props.instanceId).find((wt) => wt.slug === "root")?.registeredDirectory
})
const displayPathFor = (directory: string) => {
const base = repoRoot()
@ -204,6 +215,17 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
}
}
const handleOpenInFileManager = async (registeredDirectory: string, targetDirectory: string) => {
const rootDirectory = registeredRepoRoot()
if (!rootDirectory) return
try {
await openNativeWorktreeInFileManager(rootDirectory, registeredDirectory, targetDirectory)
} catch (error) {
log.error("Failed to open worktree in file manager", error)
showToastNotification({ message: t("instanceShell.worktree.openInFileManager.error"), variant: "error" })
}
}
const sanitizeDeleteError = (input: string) => {
let sanitized = (input ?? "").trim()
if (!sanitized) {
@ -360,6 +382,30 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
>
{displayPathFor(opt.directory)}
</span>
<Show when={isDesktopHost() && !isMobilePlatform() && isLocalWindow() && registeredRepoRoot() && opt.registeredDirectory && isLocalPath(opt.directory)}>
<button
type="button"
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
aria-label={t("instanceShell.worktree.openInFileManager.action")}
title={t("instanceShell.worktree.openInFileManager.action")}
onPointerDown={(event) => {
preventSelectPress(event)
void handleOpenInFileManager(opt.registeredDirectory!, opt.directory)
setIsOpen(false)
}}
onPointerUp={preventSelectPress}
onMouseDown={preventSelectPress}
onMouseUp={preventSelectPress}
onClick={(event) => {
preventSelectPress(event)
if (event.detail !== 0) return
void handleOpenInFileManager(opt.registeredDirectory!, opt.directory)
setIsOpen(false)
}}
>
<FolderOpen class="w-3 h-3" />
</button>
</Show>
<button
type="button"
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
@ -546,7 +592,6 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
setIsDeleting(true)
setDeleteError(null)
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
await reloadWorktrees(props.instanceId)
if (currentSlug() === target.slug) {
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")

View file

@ -40,6 +40,8 @@ import type {
WorkspaceEventType,
WorktreeListResponse,
WorktreeCreateRequest,
WorktreeSessionMoveRequest,
WorktreeSessionMoveResponse,
WorktreeGitDiffResponse,
WorktreeGitStatusResponse,
} from "../../../server/src/api-types"
@ -204,6 +206,13 @@ export const serverApi = {
})
},
moveSessionFamily(id: string, sessionId: string, payload: WorktreeSessionMoveRequest): Promise<WorktreeSessionMoveResponse> {
return request<WorktreeSessionMoveResponse>(`/api/workspaces/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/worktree`, {
method: "POST",
body: JSON.stringify(payload),
})
},
createWorkspace(payload: WorkspaceCreateRequest, options?: { signal?: AbortSignal }): Promise<WorkspaceCreateResponse> {
return request<WorkspaceCreateResponse>("/api/workspaces", {
method: "POST",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Zeilenumbruch aktivieren",
"instanceShell.diff.disableWordWrap": "Zeilenumbruch deaktivieren",
"instanceShell.worktree.create": "+ Worktree erstellen",
"instanceShell.worktree.openInFileManager.action": "Im Dateimanager öffnen",
"instanceShell.worktree.openInFileManager.error": "Worktree konnte nicht im Dateimanager geöffnet werden",
"instanceShell.worktree.delete.error.title": "Löschen fehlgeschlagen",
"instanceShell.worktree.delete.error.fallback": "Worktree konnte nicht gelöscht werden",
"instanceShell.worktree.delete.error.causeLabel": "Wahrscheinliche Ursache:",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "Sitzungen suchen...",
"sessionList.filter.ariaLabel": "Sitzungen suchen",
"sessionList.sort.ariaLabel": "Sitzungen sortieren",
"sessionList.sort.activity": "Aktivität",
"sessionList.sort.name": "Name",
"sessionList.sort.worktree": "Worktree",
"sessionList.worktreeFilter.ariaLabel": "Sitzungen nach Worktree filtern",
"sessionList.worktreeFilter.all": "Alle Worktrees",
"sessionList.worktree.tooltip": "Worktree: {worktree}",
"sessionList.worktree.workspace": "Arbeitsbereich",
"sessionList.worktreeMove.error": "Sitzungsfamilie konnte nicht verschoben werden",
"sessionList.loading.more": "Weitere Sitzungen werden geladen...",
"sessionList.loading.initial": "Sitzungen werden geladen...",
"sessionList.loadError.title": "Sitzungen konnten nicht geladen werden",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Enable word wrap",
"instanceShell.diff.disableWordWrap": "Disable word wrap",
"instanceShell.worktree.create": "+ Create worktree",
"instanceShell.worktree.openInFileManager.action": "Open in file manager",
"instanceShell.worktree.openInFileManager.error": "Failed to open worktree in file manager",
"instanceShell.worktree.delete.error.title": "Delete failed",
"instanceShell.worktree.delete.error.fallback": "Failed to delete worktree",
"instanceShell.worktree.delete.error.causeLabel": "Likely cause:",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "Search sessions…",
"sessionList.filter.ariaLabel": "Search sessions",
"sessionList.sort.ariaLabel": "Sort sessions",
"sessionList.sort.activity": "Activity",
"sessionList.sort.name": "Name",
"sessionList.sort.worktree": "Worktree",
"sessionList.worktreeFilter.ariaLabel": "Filter sessions by worktree",
"sessionList.worktreeFilter.all": "All worktrees",
"sessionList.worktree.tooltip": "Worktree: {worktree}",
"sessionList.worktree.workspace": "Workspace",
"sessionList.worktreeMove.error": "Unable to move session family",
"sessionList.loading.more": "Loading more sessions…",
"sessionList.loading.initial": "Loading sessions…",
"sessionList.loadError.title": "Unable to load sessions",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Activar ajuste de línea",
"instanceShell.diff.disableWordWrap": "Desactivar ajuste de línea",
"instanceShell.worktree.create": "+ Crear worktree",
"instanceShell.worktree.openInFileManager.action": "Abrir en el administrador de archivos",
"instanceShell.worktree.openInFileManager.error": "No se pudo abrir el worktree en el administrador de archivos",
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "Buscar sesiones…",
"sessionList.filter.ariaLabel": "Buscar sesiones",
"sessionList.sort.ariaLabel": "Ordenar sesiones",
"sessionList.sort.activity": "Actividad",
"sessionList.sort.name": "Nombre",
"sessionList.sort.worktree": "Worktree",
"sessionList.worktreeFilter.ariaLabel": "Filtrar sesiones por worktree",
"sessionList.worktreeFilter.all": "Todos los worktrees",
"sessionList.worktree.tooltip": "Worktree: {worktree}",
"sessionList.worktree.workspace": "Espacio de trabajo",
"sessionList.worktreeMove.error": "No se pudo mover la familia de sesiones",
"sessionList.loading.more": "Cargando más sesiones…",
"sessionList.loading.initial": "Cargando sesiones…",
"sessionList.loadError.title": "No se pudieron cargar las sesiones",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Activer le retour à la ligne",
"instanceShell.diff.disableWordWrap": "Désactiver le retour à la ligne",
"instanceShell.worktree.create": "+ Créer un worktree",
"instanceShell.worktree.openInFileManager.action": "Ouvrir dans le gestionnaire de fichiers",
"instanceShell.worktree.openInFileManager.error": "Impossible douvrir le worktree dans le gestionnaire de fichiers",
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "Rechercher des sessions…",
"sessionList.filter.ariaLabel": "Rechercher des sessions",
"sessionList.sort.ariaLabel": "Trier les sessions",
"sessionList.sort.activity": "Activité",
"sessionList.sort.name": "Nom",
"sessionList.sort.worktree": "Worktree",
"sessionList.worktreeFilter.ariaLabel": "Filtrer les sessions par worktree",
"sessionList.worktreeFilter.all": "Tous les worktrees",
"sessionList.worktree.tooltip": "Worktree : {worktree}",
"sessionList.worktree.workspace": "Espace de travail",
"sessionList.worktreeMove.error": "Impossible de déplacer la famille de sessions",
"sessionList.loading.more": "Chargement de plus de sessions…",
"sessionList.loading.initial": "Chargement des sessions…",
"sessionList.loadError.title": "Impossible de charger les sessions",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
"instanceShell.worktree.create": "+ צור worktree",
"instanceShell.worktree.openInFileManager.action": "פתח במנהל הקבצים",
"instanceShell.worktree.openInFileManager.error": "לא ניתן לפתוח את ה-worktree במנהל הקבצים",
"instanceShell.worktree.delete.error.title": "המחיקה נכשלה",
"instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה",
"instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "חפש סשנים…",
"sessionList.filter.ariaLabel": "חפש סשנים",
"sessionList.sort.ariaLabel": "מיון סשנים",
"sessionList.sort.activity": "פעילות",
"sessionList.sort.name": "שם",
"sessionList.sort.worktree": "Worktree",
"sessionList.worktreeFilter.ariaLabel": "סינון סשנים לפי worktree",
"sessionList.worktreeFilter.all": "כל ה-worktrees",
"sessionList.worktree.tooltip": "Worktree: {worktree}",
"sessionList.worktree.workspace": "סביבת עבודה",
"sessionList.worktreeMove.error": "לא ניתן להעביר את משפחת הסשנים",
"sessionList.loading.more": "טוען עוד סשנים…",
"sessionList.loading.initial": "טוען סשנים…",
"sessionList.loadError.title": "לא ניתן לטעון את הסשנים",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "折り返しを有効化",
"instanceShell.diff.disableWordWrap": "折り返しを無効化",
"instanceShell.worktree.create": "+ worktree を作成",
"instanceShell.worktree.openInFileManager.action": "ファイルマネージャーで開く",
"instanceShell.worktree.openInFileManager.error": "ファイルマネージャーで worktree を開けませんでした",
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "セッションを検索…",
"sessionList.filter.ariaLabel": "セッションを検索",
"sessionList.sort.ariaLabel": "セッションを並べ替え",
"sessionList.sort.activity": "アクティビティ",
"sessionList.sort.name": "名前",
"sessionList.sort.worktree": "ワークツリー",
"sessionList.worktreeFilter.ariaLabel": "ワークツリーでセッションを絞り込む",
"sessionList.worktreeFilter.all": "すべてのワークツリー",
"sessionList.worktree.tooltip": "ワークツリー: {worktree}",
"sessionList.worktree.workspace": "ワークスペース",
"sessionList.worktreeMove.error": "セッションファミリーを移動できませんでした",
"sessionList.loading.more": "セッションをさらに読み込んでいます…",
"sessionList.loading.initial": "セッションを読み込んでいます…",
"sessionList.loadError.title": "セッションを読み込めません",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Word wrap सक्षम गर्नुहोस्",
"instanceShell.diff.disableWordWrap": "Word wrap अक्षम गर्नुहोस्",
"instanceShell.worktree.create": "+ Worktree सिर्जना गर्नुहोस्",
"instanceShell.worktree.openInFileManager.action": "फाइल प्रबन्धकमा खोल्नुहोस्",
"instanceShell.worktree.openInFileManager.error": "फाइल प्रबन्धकमा worktree खोल्न सकिएन",
"instanceShell.worktree.delete.error.title": "मेटाउन असफल भयो",
"instanceShell.worktree.delete.error.fallback": "Worktree मेटाउन असफल भयो",
"instanceShell.worktree.delete.error.causeLabel": "सम्भावित कारण:",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "सत्रहरू खोज्नुहोस्...",
"sessionList.filter.ariaLabel": "सत्रहरू खोज्नुहोस्",
"sessionList.sort.ariaLabel": "सत्रहरू क्रमबद्ध गर्नुहोस्",
"sessionList.sort.activity": "गतिविधि",
"sessionList.sort.name": "नाम",
"sessionList.sort.worktree": "वर्कट्री",
"sessionList.worktreeFilter.ariaLabel": "वर्कट्री अनुसार सत्रहरू फिल्टर गर्नुहोस्",
"sessionList.worktreeFilter.all": "सबै वर्कट्रीहरू",
"sessionList.worktree.tooltip": "वर्कट्री: {worktree}",
"sessionList.worktree.workspace": "कार्यक्षेत्र",
"sessionList.worktreeMove.error": "सत्र परिवार सार्न सकिएन",
"sessionList.loading.more": "थप सत्रहरू लोड गर्दै...",
"sessionList.loading.initial": "सत्रहरू लोड गर्दै...",
"sessionList.loadError.title": "सत्रहरू लोड गर्न सकिएन",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Включить перенос строк",
"instanceShell.diff.disableWordWrap": "Отключить перенос строк",
"instanceShell.worktree.create": "+ Создать worktree",
"instanceShell.worktree.openInFileManager.action": "Открыть в файловом менеджере",
"instanceShell.worktree.openInFileManager.error": "Не удалось открыть worktree в файловом менеджере",
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "Поиск сессий…",
"sessionList.filter.ariaLabel": "Поиск сессий",
"sessionList.sort.ariaLabel": "Сортировать сессии",
"sessionList.sort.activity": "Активность",
"sessionList.sort.name": "Имя",
"sessionList.sort.worktree": "Worktree",
"sessionList.worktreeFilter.ariaLabel": "Фильтровать сессии по worktree",
"sessionList.worktreeFilter.all": "Все worktree",
"sessionList.worktree.tooltip": "Worktree: {worktree}",
"sessionList.worktree.workspace": "Рабочая область",
"sessionList.worktreeMove.error": "Не удалось переместить семейство сессий",
"sessionList.loading.more": "Загрузка дополнительных сессий…",
"sessionList.loading.initial": "Загрузка сессий…",
"sessionList.loadError.title": "Не удалось загрузить сессии",

View file

@ -178,6 +178,8 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "启用自动换行",
"instanceShell.diff.disableWordWrap": "禁用自动换行",
"instanceShell.worktree.create": "+ 创建 worktree",
"instanceShell.worktree.openInFileManager.action": "在文件管理器中打开",
"instanceShell.worktree.openInFileManager.error": "无法在文件管理器中打开 worktree",
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。",

View file

@ -48,6 +48,15 @@ export const sessionMessages = {
"sessionList.filter.placeholder": "搜索会话…",
"sessionList.filter.ariaLabel": "搜索会话",
"sessionList.sort.ariaLabel": "会话排序",
"sessionList.sort.activity": "活动时间",
"sessionList.sort.name": "名称",
"sessionList.sort.worktree": "工作树",
"sessionList.worktreeFilter.ariaLabel": "按工作树筛选会话",
"sessionList.worktreeFilter.all": "所有工作树",
"sessionList.worktree.tooltip": "工作树:{worktree}",
"sessionList.worktree.workspace": "工作区",
"sessionList.worktreeMove.error": "无法移动会话系列",
"sessionList.loading.more": "正在加载更多会话…",
"sessionList.loading.initial": "正在加载会话…",
"sessionList.loadError.title": "无法加载会话",

View file

@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"
import { isElectronHost, isLocalWindow, isTauriHost } from "../runtime-env"
import { isElectronHost, isLocalWindow, isMobilePlatform, isTauriHost } from "../runtime-env"
const LEGACY_WEB_KEYS = ["codenomad-client-snapshot-v1", "codenomad-client-restore-enabled-v1"]
export type NativeClientStateLoadResult = {
isPrimary: boolean
@ -48,6 +48,16 @@ export const setNativeRestoreEnabled = (enabled: boolean): Promise<boolean> =>
mutateNativeClientState((api) => api.setClientStateRestoreEnabled?.(accessToken, enabled), "client_state_set_restore_enabled", { enabled })
export const clearNativeClientState = (): Promise<boolean> =>
mutateNativeClientState((api) => api.clearClientState?.(accessToken), "client_state_clear")
export async function openNativeWorktreeInFileManager(rootDirectory: string, registeredDirectory: string, targetDirectory: string): Promise<void> {
if (isMobilePlatform() || !isLocalWindow() || !nativeAccessClaimed) throw new Error("Native renderer access is unavailable")
const result = dispatchNative(
(api) => api?.openWorktreeInFileManager?.(accessToken, rootDirectory, registeredDirectory, targetDirectory),
"open_worktree_in_file_manager",
{ rootDirectory, registeredDirectory, targetDirectory },
)
if (!result) throw new Error("Native file manager is unavailable")
await result
}
function acknowledge(command: string, args: Record<string, unknown> = {}): Promise<void> {
if (!isTauriHost() || !nativeAccessClaimed) return Promise.resolve()
return invoke(command, { accessToken, ...args })

View file

@ -7,6 +7,7 @@ import {
MessagePartDeltaEvent,
} from "../types/message"
import type {
LocationRef,
PermissionAsked,
PermissionReplied,
QuestionAsked,
@ -42,6 +43,7 @@ export interface NativeSessionEvent {
type: string
data?: {
sessionID?: string
location?: LocationRef
[key: string]: unknown
}
location?: { directory?: string }

View file

@ -432,14 +432,6 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
})
}
async function moveSession(instanceId: string, sessionId: string, directory: string): Promise<void> {
if (!directory.trim()) throw new Error("Session directory is required")
await getRootClient(instanceId).session.move({ sessionID: sessionId, directory })
withSession(instanceId, sessionId, (session) => {
session.location = { directory }
})
}
async function compactSession(instanceId: string, sessionId: string): Promise<void> {
await getRootClient(instanceId).session.compact({ sessionID: sessionId })
}
@ -449,7 +441,6 @@ export {
executeCustomCommand,
compactSession,
renameSession,
moveSession,
runShellCommand,
sendMessage,
updateSessionAgent,

View file

@ -70,11 +70,11 @@ import {
buildProjectSessionListOptions,
filterProjectScopedSessions,
getAuthoritativelyMissingSessionIds,
isProjectSessionListComplete,
} from "./session-list-options"
import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery"
const log = getLogger("api")
const MAX_PROJECT_SESSION_PAGES = 1000
const sessionListRequestIds = new Map<string, number>()
let nextSessionListRequestId = 0
@ -133,15 +133,38 @@ function hasMissingParentChain(session: SDKSession, loaded: Map<string, SDKSessi
async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise<ProjectSessionListResponse> {
const client = getRootClient(instanceId)
const listOptions = buildProjectSessionListOptions(options)
const response: SessionsResponse = await client.session.list(listOptions)
const data = response.data
const location = await client.location.get({ location: { directory: options.directory } })
if (!location?.project?.id) throw new Error("OpenCode could not resolve the workspace project")
const data: SDKSession[] = []
const listedIds = new Set<string>()
const cursors = new Set<string>()
let cursor: string | undefined
let page = 0
do {
if (++page > MAX_PROJECT_SESSION_PAGES) throw new Error("Session inventory exceeded the page limit")
const listOptions = buildProjectSessionListOptions({ project: location.project.id, search: options.search, cursor })
const response: SessionsResponse = await client.session.list(listOptions)
if (!response || !Array.isArray(response.data) || !response.cursor || typeof response.cursor !== "object") {
throw new Error("OpenCode returned an invalid session inventory")
}
for (const session of response.data) {
if (!session?.id || session.projectID !== location.project.id || listedIds.has(session.id)) {
throw new Error("OpenCode returned an inconsistent session inventory")
}
listedIds.add(session.id)
data.push(session)
}
const next = response.cursor.next || undefined
if (next && cursors.has(next)) throw new Error("OpenCode repeated a session inventory cursor")
if (next) cursors.add(next)
cursor = next
} while (cursor)
const allowedDirectories = [options.directory, ...getWorktrees(instanceId).map((worktree) => worktree.directory)]
return {
data: filterProjectScopedSessions(data, allowedDirectories),
listedIds: new Set(data.map((session) => session.id)),
complete: isProjectSessionListComplete(data.length),
listedIds,
complete: true,
}
}
@ -244,7 +267,7 @@ async function fetchSessions(instanceId: string, options?: {
const sessionListOptions = instance.folder ? { directory: instance.folder } : {}
const existingSessions = new Map(sessions().get(instanceId) ?? new Map<string, Session>())
log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" })
log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, project: true })
const [response, activeSessions] = await Promise.all([
fetchV2Sessions(instanceId, sessionListOptions),
getRootClient(instanceId).session.active(),

View file

@ -166,6 +166,18 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
const sessionId = event.data?.sessionID
if (!sessionId) return
const movedLocation = event.data?.location
if (event.type === "session.moved" && movedLocation) {
const projectID = typeof event.data?.projectID === "string" ? event.data.projectID : undefined
const subpath = typeof event.data?.subpath === "string" ? event.data.subpath : undefined
withSession(instanceId, sessionId, (session) => {
session.location = movedLocation
if (projectID !== undefined) session.projectID = projectID
if (subpath !== undefined) session.subpath = subpath
})
return
}
if (event.type === "session.compaction.started" || event.type === "session.compaction.admitted") {
ensureSessionStatus(instanceId, sessionId, "compacting", event.location?.directory)
} else if (

View file

@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { filterProjectScopedSessions, normalizeSessionDirectory } from "./session-list-options.ts"
describe("session directory mapping", () => {
it("uses location.directory and normalizes Windows paths", () => {
assert.equal(normalizeSessionDirectory("C:\\Repo\\Feature\\"), "c:/repo/feature")
const sessions = [
{ id: "location-wins", location: { directory: "C:/repo/feature" } },
{ id: "root", location: { directory: "C:/repo" } },
]
assert.deepEqual(
filterProjectScopedSessions(sessions, ["c:\\REPO\\FEATURE"]).map((session) => session.id),
["location-wins"],
)
})
})

View file

@ -1,21 +1,21 @@
export const PROJECT_SESSION_LIST_LIMIT = 10000
type ProjectSessionListInput = {
directory?: string
project: string
search?: string
cursor?: string
}
export type ProjectSessionListOptions = ProjectSessionListInput & {
limit: typeof PROJECT_SESSION_LIST_LIMIT
scope: "project"
order: "asc"
}
type SessionDirectorySource = {
directory?: string | null
location?: { directory?: string | null }
}
function normalizeSessionDirectory(directory: string | null | undefined): string {
export function normalizeSessionDirectory(directory: string | null | undefined): string {
const trimmed = directory?.trim()
if (!trimmed) return ""
@ -28,17 +28,14 @@ function normalizeSessionDirectory(directory: string | null | undefined): string
export function buildProjectSessionListOptions(options: ProjectSessionListInput): ProjectSessionListOptions {
return {
...(options.directory ? { directory: options.directory } : {}),
project: options.project,
...(options.search ? { search: options.search } : {}),
...(options.cursor ? { cursor: options.cursor } : {}),
limit: PROJECT_SESSION_LIST_LIMIT,
scope: "project",
order: "asc",
}
}
export function isProjectSessionListComplete(resultCount: number): boolean {
return resultCount < PROJECT_SESSION_LIST_LIMIT
}
export function filterProjectScopedSessions<T extends SessionDirectorySource>(
sessions: T[],
allowedDirectories: Array<string | null | undefined>,
@ -47,7 +44,7 @@ export function filterProjectScopedSessions<T extends SessionDirectorySource>(
if (allowed.size === 0) return sessions
return sessions.filter((session) => {
const directory = normalizeSessionDirectory(session.location?.directory ?? session.directory)
const directory = normalizeSessionDirectory(session.location?.directory)
return !directory || allowed.has(directory)
})
}

View file

@ -20,6 +20,25 @@ function session(instanceId: string, id: string): Session {
}
describe("native session event reducer", () => {
it("replaces the entire location on session.moved", () => {
const instanceId = "native-moved"
const current = session(instanceId, "session")
current.location = { directory: "/old", extra: "stale" } as any
setSessions((prev) => new Map(prev).set(instanceId, new Map([[current.id, current]])))
try {
handleNativeSessionEvent(instanceId, {
type: "session.moved",
data: { sessionID: current.id, location: { directory: "/new" }, projectID: "new-project", subpath: "apps/web" },
})
assert.deepEqual(sessions().get(instanceId)?.get(current.id)?.location, { directory: "/new" })
assert.equal(sessions().get(instanceId)?.get(current.id)?.projectID, "new-project")
assert.equal(sessions().get(instanceId)?.get(current.id)?.subpath, "apps/web")
} finally {
setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next })
}
})
it("coalesces text and tool events, then refreshes authoritatively on idle", async () => {
const instanceId = "native-events"
const sessionId = "session"

View file

@ -7,28 +7,27 @@ import {
buildProjectSessionListOptions,
filterProjectScopedSessions,
getAuthoritativelyMissingSessionIds,
isProjectSessionListComplete,
} from "./session-list-options.ts"
describe("project session list loading", () => {
it("builds a one-shot project-scoped request without pagination params", () => {
const options = buildProjectSessionListOptions({ directory: "/tmp/project", search: "worktree" })
it("builds a project-scoped cursor request", () => {
const options = buildProjectSessionListOptions({ project: "project-id", search: "worktree", cursor: "next" })
assert.deepEqual(options, {
directory: "/tmp/project",
project: "project-id",
search: "worktree",
cursor: "next",
limit: PROJECT_SESSION_LIST_LIMIT,
scope: "project",
order: "asc",
})
assert.equal("start" in options, false)
assert.equal("cursor" in options, false)
})
it("filters project-scoped results to the root and known worktree directories", () => {
const sessions = [
{ id: "root", directory: "/repo" },
{ id: "worktree", directory: "/repo/.codenomad/worktrees/feature" },
{ id: "sibling", directory: "/other" },
{ id: "root", location: { directory: "/repo" } },
{ id: "worktree", location: { directory: "/repo/.codenomad/worktrees/feature" } },
{ id: "sibling", location: { directory: "/other" } },
{ id: "unknown" },
]
@ -40,9 +39,9 @@ describe("project session list loading", () => {
it("normalizes Windows paths when filtering project-scoped results", () => {
const sessions = [
{ id: "root", directory: String.raw`C:\Repo` },
{ id: "worktree", directory: "c:/repo/.codenomad/worktrees/feature/" },
{ id: "other", directory: String.raw`C:\Other` },
{ id: "root", location: { directory: String.raw`C:\Repo` } },
{ id: "worktree", location: { directory: "c:/repo/.codenomad/worktrees/feature/" } },
{ id: "other", location: { directory: String.raw`C:\Other` } },
]
assert.deepEqual(
@ -53,7 +52,7 @@ describe("project session list loading", () => {
)
})
it("marks the loaded session list complete because the API does not paginate", () => {
it("marks the projection complete after all API pages are collected", () => {
const state = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], false, true)
assert.deepEqual(state.ids, ["root-1", "root-2"])
@ -61,7 +60,7 @@ describe("project session list loading", () => {
assert.equal(state.nextCursor, undefined)
})
it("resets stale cursor state when the one-shot list refreshes", () => {
it("resets stale UI cursor state after a complete project refresh", () => {
const previous = applySessionPage(getDefaultSessionPaginationState(), ["old-root"], true, true, "old-cursor")
const next = applySessionPage(previous, ["new-root"], false, true)
@ -75,8 +74,6 @@ describe("project session list loading", () => {
const listed = ["retained", "outside-current-worktree"]
assert.deepEqual(getAuthoritativelyMissingSessionIds(existing, listed, true), ["deleted-remotely"])
assert.equal(isProjectSessionListComplete(PROJECT_SESSION_LIST_LIMIT - 1), true)
assert.equal(isProjectSessionListComplete(PROJECT_SESSION_LIST_LIMIT), false)
assert.deepEqual(
getAuthoritativelyMissingSessionIds(existing, listed, false),
[],

View file

@ -59,7 +59,10 @@ async function loadTestWorktree(instanceId: string): Promise<void> {
}
function setup(instanceId: string) {
const client = { session: { active: async () => ({}) } } as any
const client = {
location: { get: async () => ({ directory: "/work", project: { id: "project", directory: "/work", canonical: "/work" } }) },
session: { active: async () => ({}) },
} as any
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client })
return {
@ -85,11 +88,11 @@ describe("session request authority", () => {
try {
const request = searchSessions(instanceId, "child")
search.resolve({ data: [apiSession("child", "parent")] })
search.resolve({ data: [apiSession("child", "parent")], cursor: {} })
await new Promise<void>((resolve) => setImmediate(resolve))
removeSessionRuntimeState(instanceId, "child")
removeSessionRuntimeState(instanceId, "parent")
parents.resolve({ data: [apiSession("parent")] })
parents.resolve({ data: [apiSession("parent")], cursor: {} })
await request
assert.equal(sessions().get(instanceId)?.has("child") ?? false, false)
@ -212,9 +215,9 @@ describe("session request authority", () => {
})
const newRequest = fetchSessions(instanceId)
invalidateOld()
newResponse.resolve({ data: [apiSession("new-session")] })
newResponse.resolve({ data: [apiSession("new-session")], cursor: {} })
await newRequest
oldResponse.resolve({ data: [apiSession("old-session")] })
oldResponse.resolve({ data: [apiSession("old-session")], cursor: {} })
await oldRequest
assert.equal(sessions().get(instanceId)?.has("new-session"), true)
@ -242,7 +245,7 @@ describe("session request authority", () => {
apiSession("working"),
{ ...apiSession("compacting"), directory: "/worktree", workspaceID: "workspace-1" },
apiSession("stale-working"),
] })
], cursor: {} })
;(client.session as any).active = async () => ({ working: { type: "running" } })
;(client.session as any).status = async (options: unknown) => {
statusOptions.push(options)
@ -277,7 +280,7 @@ describe("session request authority", () => {
setSessions((prev) => new Map(prev).set(instanceId, new Map([[existing.id, existing]])))
;(client.session as any).list = async () => ({ data: [
{ ...apiSession(existing.id), location: { directory: "/worktree" } },
] })
], cursor: {} })
;(client.session as any).active = async () => ({ [existing.id]: { type: "running" } })
try {

View file

@ -887,21 +887,18 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] {
if (!instanceSessions) return []
const rootIds: string[] = []
const childIds = new Set<string>()
for (const sessionId of resultIds) {
const session = instanceSessions.get(sessionId)
if (!session) continue
if (session.parentId === null) {
if (!rootIds.includes(session.id)) rootIds.push(session.id)
} else {
childIds.add(session.id)
const root = getSessionRootFromMap(instanceSessions, session.id)
if (root && !rootIds.includes(root.id)) rootIds.push(root.id)
}
}
return buildSessionThreads(instanceId, rootIds, childIds)
return buildSessionThreads(instanceId, rootIds)
}
function isSessionExpanded(instanceId: string, sessionId: string): boolean {

View file

@ -11,6 +11,7 @@ import {
getDescendantSessionsFromMap,
getSessionAncestorIdsFromMap,
getSessionRootFromMap,
projectSessionFamilies,
sortSessionIdsDeepestFirst,
} from "./session-tree"
@ -177,4 +178,36 @@ describe("session tree", () => {
["grandchild", "child", "root"],
)
})
it("sorts and filters complete families", () => {
const sessions = sessionMap([
["z-root", null, 100],
["matching-child", "z-root", 500],
["sibling", "z-root", 200],
["a-root", null, 300],
])
sessions.get("z-root")!.title = "Zulu"
sessions.get("z-root")!.location = { directory: "C:\\repo\\feature" }
sessions.get("matching-child")!.location = { directory: "C:\\repo\\feature" }
sessions.get("sibling")!.location = { directory: "C:\\repo\\feature" }
sessions.get("a-root")!.title = "Alpha"
sessions.get("a-root")!.location = { directory: "C:\\repo" }
const threads = buildSessionThreadsFromMap(sessions, ["z-root", "a-root"])
const labels = (directory: string) => directory.endsWith("feature") ? "feature" : "root"
const matched = projectSessionFamilies(threads, {
sort: "name",
matchesSession: (item) => item.id === "matching-child",
getWorktreeLabel: labels,
})
assert.deepEqual(collectSessionThreadIds(matched), ["z-root", "matching-child", "sibling"])
const filtered = projectSessionFamilies(threads, {
sort: "worktree",
worktreeDirectory: "c:/REPO/FEATURE/",
getWorktreeLabel: labels,
})
assert.deepEqual(collectSessionThreadIds(filtered), ["z-root", "matching-child", "sibling"])
assert.deepEqual(projectSessionFamilies(threads, { sort: "name", getWorktreeLabel: labels }).map((item) => item.session.id), ["a-root", "z-root"])
})
})

View file

@ -1,4 +1,5 @@
import type { Session } from "../types/session"
import { normalizeSessionDirectory } from "./session-list-options"
export type SessionThread = {
session: Session
@ -17,6 +18,46 @@ export type VisibleSessionRow = {
expanded: boolean
}
export type SessionFamilySort = "activity" | "name" | "worktree"
type SessionFamilyProjection = {
matchesSession?: (session: Session) => boolean
worktreeDirectory?: string
sort: SessionFamilySort
getWorktreeLabel: (directory: string) => string
}
function someSession(thread: SessionThread, predicate: (session: Session) => boolean): boolean {
return predicate(thread.session) || thread.children.some((child) => someSession(child, predicate))
}
export function projectSessionFamilies(
threads: SessionThread[],
options: SessionFamilyProjection,
): SessionThread[] {
const worktreeDirectory = normalizeSessionDirectory(options.worktreeDirectory)
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 [...projected].sort((left, right) => {
if (options.sort === "activity") {
return right.latestUpdated - left.latestUpdated || right.session.id.localeCompare(left.session.id)
}
if (options.sort === "name") {
return (left.session.title ?? "").localeCompare(right.session.title ?? "") || left.session.id.localeCompare(right.session.id)
}
const leftLabel = options.getWorktreeLabel(left.session.location?.directory ?? "")
const rightLabel = options.getWorktreeLabel(right.session.location?.directory ?? "")
return leftLabel.localeCompare(rightLabel) || (left.session.title ?? "").localeCompare(right.session.title ?? "")
})
}
export function getSessionRootFromMap(instanceSessions: Map<string, Session>, sessionId: string): Session | null {
let current = instanceSessions.get(sessionId)
if (!current) return null

View file

@ -2,7 +2,9 @@ import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { serverApi } from "../lib/api-client.ts"
import { ensureWorktreesLoaded, getWorktrees, handleWorktreeReady, reloadWorktrees } from "./worktrees.ts"
import { deleteWorktree, ensureWorktreesLoaded, getWorktrees, handleWorktreeReady, reloadWorktrees, setWorktreeSlugForParentSession } from "./worktrees.ts"
import type { Session } from "../types/session.ts"
import { sessions, setSessions } from "./session-state.ts"
describe("handleWorktreeReady", () => {
it("refreshes worktrees", async () => {
@ -133,3 +135,73 @@ describe("handleWorktreeReady", () => {
}
})
})
describe("session family worktree moves", () => {
it("resolves descendants to the root, serializes moves, and refreshes authoritatively", async () => {
const instanceId = "family-move"
const originalFetchWorktrees = serverApi.fetchWorktrees
serverApi.fetchWorktrees = async () => ({
isGitRepo: true,
worktrees: [
{ slug: "root", directory: "/repo", kind: "root" },
{ slug: "feature", directory: "/repo-feature", kind: "worktree" },
],
})
const root = { id: "root", parentId: null, location: { directory: "/repo" } } as Session
const child = { id: "child", parentId: "root", location: { directory: "/repo" } } as Session
setSessions((prev) => new Map(prev).set(instanceId, new Map([[root.id, root], [child.id, child]])))
await reloadWorktrees(instanceId)
const calls: string[] = []
let releaseFirst!: () => void
const firstPending = new Promise<void>((resolve) => { releaseFirst = resolve })
const moveFamily = async (_instanceId: string, rootSessionId: string, worktreeSlug: string) => {
calls.push(`move:${rootSessionId}:${worktreeSlug}`)
if (calls.length === 1) await firstPending
}
const refreshSessions = async () => { calls.push("refresh") }
try {
const first = setWorktreeSlugForParentSession(instanceId, child.id, "feature", { moveFamily, refreshSessions })
await Promise.resolve()
const second = setWorktreeSlugForParentSession(instanceId, root.id, "root", { moveFamily, refreshSessions })
await Promise.resolve()
assert.deepEqual(calls, ["move:root:feature"])
assert.equal(sessions().get(instanceId)?.get(root.id)?.location.directory, "/repo")
releaseFirst()
await Promise.all([first, second])
assert.deepEqual(calls, ["move:root:feature", "refresh", "move:root:root", "refresh"])
} finally {
serverApi.fetchWorktrees = originalFetchWorktrees
setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next })
}
})
it("refreshes sessions and worktrees after a deletion error", async () => {
const instanceId = "delete-error"
const originalDeleteWorktree = serverApi.deleteWorktree
const originalFetchWorktrees = serverApi.fetchWorktrees
const calls: string[] = []
serverApi.deleteWorktree = async () => {
calls.push("delete")
throw new Error("transaction rolled back")
}
serverApi.fetchWorktrees = async () => {
calls.push("worktrees")
return { isGitRepo: true, worktrees: [{ slug: "root", directory: "/repo", kind: "root" }] }
}
try {
await assert.rejects(
deleteWorktree(instanceId, "feature", undefined, async () => { calls.push("sessions") }),
/transaction rolled back/,
)
assert.equal(calls[0], "delete")
assert.deepEqual(calls.slice(1).sort(), ["sessions", "worktrees"])
} finally {
serverApi.deleteWorktree = originalDeleteWorktree
serverApi.fetchWorktrees = originalFetchWorktrees
}
})
})

View file

@ -1,10 +1,11 @@
import { createSignal } from "solid-js"
import type { WorktreeDescriptor } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getSessionRoot, sessions, withSession } from "./session-state"
import { getSessionRoot, sessions } from "./session-state"
import { getLogger } from "../lib/logger"
import type { WorktreeReadyEvent } from "../lib/sse-manager"
import { getRootClient } from "./opencode-client"
import { showToastNotification } from "../lib/notifications"
import { tGlobal } from "../lib/i18n"
const log = getLogger("api")
@ -13,6 +14,7 @@ const [gitRepoStatusByInstance, setGitRepoStatusByInstance] = createSignal<Map<s
const worktreeRequests = new Map<string, Promise<void>>()
const worktreeReadyRefreshes = new Map<string, Promise<void>>()
const familyMoveRequests = new Map<string, Promise<void>>()
type WorktreeReadyRefresh = (instanceId: string) => Promise<void>
@ -34,7 +36,7 @@ async function queueWorktreeRequest(instanceId: string, initial: boolean): Promi
})
} catch (error) {
log.warn(initial ? "Failed to load worktrees" : "Failed to reload worktrees", { instanceId, error })
if (!initial) return
if (!initial) throw error
setWorktreesByInstance((prev) => {
const next = new Map(prev)
@ -119,7 +121,13 @@ async function createWorktree(instanceId: string, slug: string): Promise<{ slug:
return serverApi.createWorktree(instanceId, { slug: trimmed })
}
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
async function deleteWorktree(
instanceId: string,
slug: string,
options?: { force?: boolean },
refreshSessions: (instanceId: string) => Promise<void> = (id) =>
import("./session-api").then(({ fetchSessions }) => fetchSessions(id, { reset: true, strictStatus: true })),
): Promise<void> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
@ -127,23 +135,21 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc
if (!trimmed || trimmed === "root") {
throw new Error("Invalid worktree")
}
await moveSessionsFromDeletedWorktree(instanceId, trimmed).catch((error) => {
log.warn("Failed to move sessions from deleted worktree", { instanceId, slug: trimmed, error })
let deleteError: unknown
try {
await serverApi.deleteWorktree(instanceId, trimmed, options)
} catch (error) {
deleteError = error
}
await Promise.all([
reloadWorktrees(instanceId),
refreshSessions(instanceId),
]).catch((error) => {
if (!deleteError) throw error
log.warn("Failed to refresh after worktree deletion error", { instanceId, slug: trimmed, error })
})
await serverApi.deleteWorktree(instanceId, trimmed, options)
}
async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string): Promise<void> {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const parentSessionIds = Array.from(instanceSessions.values())
.filter((session) => !session.parentId)
.filter((session) => getWorktreeSlugForParentSession(instanceId, session.id) === slug)
.map((session) => session.id)
for (const parentSessionId of parentSessionIds) {
await setWorktreeSlugForParentSession(instanceId, parentSessionId, "root")
if (deleteError) {
throw deleteError
}
}
@ -151,20 +157,8 @@ function getWorktrees(instanceId: string): WorktreeDescriptor[] {
return worktreesByInstance().get(instanceId) ?? []
}
function isWorktreeSlugAvailable(instanceId: string, slug: string): boolean {
const normalized = (slug ?? "").trim() || "root"
if (normalized === "root") return true
const list = getWorktrees(instanceId)
// If worktrees aren't loaded yet, don't force root incorrectly.
if (list.length === 0) return true
return list.some((wt) => wt.slug === normalized)
}
function normalizeWorktreeSlug(instanceId: string, slug: string): string {
const normalized = (slug ?? "").trim() || "root"
if (normalized === "root") return "root"
return isWorktreeSlugAvailable(instanceId, normalized) ? normalized : "root"
return (slug ?? "").trim() || "root"
}
function getDefaultWorktreeSlug(instanceId: string): string {
@ -198,19 +192,47 @@ async function setWorktreeSlugForParentSession(
instanceId: string,
parentSessionId: string,
slug: string,
_options: { currentSlug?: string } = {},
options: {
currentSlug?: string
moveFamily?: (instanceId: string, rootSessionId: string, worktreeSlug: string) => Promise<unknown>
refreshSessions?: (instanceId: string) => Promise<void>
} = {},
): Promise<void> {
await ensureWorktreesLoaded(instanceId)
const rootSessionId = getParentSessionId(instanceId, parentSessionId)
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug)
const worktree = getWorktrees(instanceId).find((candidate) => candidate.slug === normalizedSlug)
if (!worktree) throw new Error(`Worktree not found: ${normalizedSlug}`)
await getRootClient(instanceId).session.move({
sessionID: parentSessionId,
directory: worktree.directory,
const key = `${instanceId}:${rootSessionId}`
const previous = familyMoveRequests.get(key)
const moveFamily = options.moveFamily ?? ((id: string, sessionId: string, worktreeSlug: string) =>
serverApi.moveSessionFamily(id, sessionId, { worktreeSlug }))
const refreshSessions = options.refreshSessions ?? ((id: string) =>
import("./session-api").then(({ fetchSessions }) => fetchSessions(id, { reset: true, strictStatus: true })))
const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => {
let moveError: unknown
try {
await moveFamily(instanceId, rootSessionId, normalizedSlug)
} catch (error) {
moveError = error
}
await refreshSessions(instanceId).catch((error) => {
if (!moveError) throw error
log.warn("Failed to refresh sessions after family move error", { instanceId, rootSessionId, error })
})
if (moveError) {
showToastNotification({
message: moveError instanceof Error && moveError.message ? moveError.message : tGlobal("sessionList.worktreeMove.error"),
variant: "error",
})
throw moveError
}
})
withSession(instanceId, parentSessionId, (session) => {
session.location = { directory: worktree.directory }
familyMoveRequests.set(key, task)
await task.finally(() => {
if (familyMoveRequests.get(key) === task) familyMoveRequests.delete(key)
})
}

View file

@ -43,6 +43,7 @@ declare global {
saveClientState?: (accessToken: string, snapshot: unknown) => Promise<boolean>
setClientStateRestoreEnabled?: (accessToken: string, enabled: boolean) => Promise<boolean>
clearClientState?: (accessToken: string) => Promise<boolean>
openWorktreeInFileManager?: (accessToken: string, rootDirectory: string, registeredDirectory: string, targetDirectory: string) => Promise<void>
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
openRemoteWindow?: (payload: {