mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-26 00:31:54 +00:00
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:
parent
9b18a37994
commit
da6378382f
38 changed files with 551 additions and 129 deletions
4
.github/workflows/pr-build.yml
vendored
4
.github/workflows/pr-build.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 d’ouvrir 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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "סיבה סבירה:",
|
||||
|
|
|
|||
|
|
@ -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": "לא ניתן לטעון את הסשנים",
|
||||
|
|
|
|||
|
|
@ -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": "まだ計画はありません。",
|
||||
|
|
|
|||
|
|
@ -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": "セッションを読み込めません",
|
||||
|
|
|
|||
|
|
@ -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": "सम्भावित कारण:",
|
||||
|
|
|
|||
|
|
@ -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": "सत्रहरू लोड गर्न सकिएन",
|
||||
|
|
|
|||
|
|
@ -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": "Пока ничего не запланировано.",
|
||||
|
|
|
|||
|
|
@ -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": "Не удалось загрузить сессии",
|
||||
|
|
|
|||
|
|
@ -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": "暂无计划。",
|
||||
|
|
|
|||
|
|
@ -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": "无法加载会话",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
18
packages/ui/src/stores/session-list-options.test.ts
Normal file
18
packages/ui/src/stores/session-list-options.test.ts
Normal 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"],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue