worktrees - Implementation

This commit is contained in:
Shantur Rathore
2026-02-07 11:46:56 +00:00
parent 6f73adaef6
commit ef14b9acb6
17 changed files with 1399 additions and 72 deletions

View File

@@ -12,6 +12,7 @@ import {
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core"
import { Dialog } from "@kobalte/core/dialog"
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import AppBar from "@suid/material/AppBar"
import Box from "@suid/material/Box"
@@ -35,6 +36,7 @@ import {
getSessionFamily,
getSessionInfo,
getSessionThreads,
loadMessages,
sessions,
setActiveParentSession,
setActiveSession,
@@ -63,6 +65,17 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client"
import { showToastNotification } from "../../lib/notifications"
import {
createWorktree,
deleteWorktree,
getParentSessionId,
getWorktreeSlugForParentSession,
getWorktrees,
reloadWorktrees,
reloadWorktreeMap,
setWorktreeSlugForParentSession,
} from "../../stores/worktrees"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n"
@@ -74,6 +87,9 @@ import {
const log = getLogger("session")
const CREATE_WORKTREE_VALUE = "__codenomad_create_worktree__"
const DELETE_WORKTREE_VALUE = "__codenomad_delete_worktree__"
interface InstanceShellProps {
instance: Instance
escapeInDebounce: boolean
@@ -149,6 +165,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [createWorktreeOpen, setCreateWorktreeOpen] = createSignal(false)
const [createWorktreeSlug, setCreateWorktreeSlug] = createSignal("")
const [isCreatingWorktree, setIsCreatingWorktree] = createSignal(false)
const [deleteWorktreeOpen, setDeleteWorktreeOpen] = createSignal(false)
const [isDeletingWorktree, setIsDeletingWorktree] = createSignal(false)
const [forceDeleteWorktree, setForceDeleteWorktree] = createSignal(false)
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
@@ -920,16 +944,244 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
/>
<div class="session-sidebar-separator" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<div class="space-y-1">
<div class="text-xs font-medium text-muted uppercase tracking-wide">Worktree</div>
<select
class="selector-input w-full"
value={getWorktreeSlugForParentSession(
props.instance.id,
getParentSessionId(props.instance.id, activeSession().id),
)}
disabled={Boolean(activeSession().parentId)}
onChange={(e) => {
const nextSlug = e.currentTarget.value
const sessionId = activeSession().id
const parentId = getParentSessionId(props.instance.id, sessionId)
if (nextSlug === CREATE_WORKTREE_VALUE) {
setCreateWorktreeSlug("")
setCreateWorktreeOpen(true)
return
}
if (nextSlug === DELETE_WORKTREE_VALUE) {
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
if (currentSlug && currentSlug !== "root") {
setForceDeleteWorktree(false)
setDeleteWorktreeOpen(true)
}
return
}
void (async () => {
await setWorktreeSlugForParentSession(props.instance.id, parentId, nextSlug)
})().catch((error) => {
log.warn("Failed to apply worktree change", error)
})
}}
>
<For each={getWorktrees(props.instance.id)}>
{(wt) => (
<option value={wt.slug}>{wt.slug === "root" ? "root" : wt.slug}</option>
)}
</For>
<Show when={getWorktrees(props.instance.id).length === 0}>
<option value="root">root</option>
</Show>
<option value={CREATE_WORKTREE_VALUE}>+ Create worktree</option>
<option
value={DELETE_WORKTREE_VALUE}
disabled={
Boolean(activeSession().parentId) ||
getWorktreeSlugForParentSession(
props.instance.id,
getParentSessionId(props.instance.id, activeSession().id),
) === "root"
}
>
Delete worktree
</option>
</select>
</div>
<Dialog open={createWorktreeOpen()} onOpenChange={(open) => !open && setCreateWorktreeOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Creates a git worktree under <span class="font-mono">.codenomad/worktrees/&lt;name&gt;</span> from HEAD.
</Dialog.Description>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-muted uppercase tracking-wide">Worktree name</label>
<input
class="form-input w-full"
value={createWorktreeSlug()}
onInput={(e) => setCreateWorktreeSlug(e.currentTarget.value)}
placeholder="feature-x"
disabled={isCreatingWorktree()}
spellcheck={false}
autocapitalize="off"
autocomplete="off"
/>
<div class="text-[11px] text-secondary">
Allowed: letters, numbers, <span class="font-mono">_ . - /</span>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setCreateWorktreeOpen(false)}
disabled={isCreatingWorktree()}
>
Cancel
</button>
<button
type="button"
class="selector-button selector-button-primary"
disabled={
isCreatingWorktree() ||
!createWorktreeSlug().trim() ||
createWorktreeSlug().trim() === "root" ||
!/^[a-zA-Z0-9_.\/-]+$/.test(createWorktreeSlug().trim())
}
onClick={() => {
const slug = createWorktreeSlug().trim()
void (async () => {
setIsCreatingWorktree(true)
await createWorktree(props.instance.id, slug)
await reloadWorktrees(props.instance.id)
const sessionId = activeSession().id
const parentId = getParentSessionId(props.instance.id, sessionId)
await setWorktreeSlugForParentSession(props.instance.id, parentId, slug)
setCreateWorktreeOpen(false)
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to create worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to create worktree",
variant: "error",
})
})
.finally(() => {
setIsCreatingWorktree(false)
})
}}
>
{isCreatingWorktree() ? "Creating…" : "Create"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<Dialog open={deleteWorktreeOpen()} onOpenChange={(open) => !open && setDeleteWorktreeOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Removes the git worktree checkout directory for this branch.
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
<p class="text-sm font-mono text-primary break-all">
{getWorktreeSlugForParentSession(
props.instance.id,
getParentSessionId(props.instance.id, activeSession().id),
)}
</p>
</div>
<label class="flex items-center gap-2 text-sm text-secondary">
<input
type="checkbox"
checked={forceDeleteWorktree()}
onChange={(e) => setForceDeleteWorktree(e.currentTarget.checked)}
disabled={isDeletingWorktree()}
/>
Force delete (discard local changes)
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setDeleteWorktreeOpen(false)}
disabled={isDeletingWorktree()}
>
Cancel
</button>
<button
type="button"
class="selector-button selector-button-primary"
disabled={isDeletingWorktree()}
onClick={() => {
const sessionId = activeSession().id
const parentId = getParentSessionId(props.instance.id, sessionId)
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
if (!currentSlug || currentSlug === "root") {
setDeleteWorktreeOpen(false)
return
}
void (async () => {
setIsDeletingWorktree(true)
await deleteWorktree(props.instance.id, currentSlug, { force: forceDeleteWorktree() })
await reloadWorktrees(props.instance.id)
await reloadWorktreeMap(props.instance.id)
// If the active session mapped to the deleted worktree, switch to root.
await setWorktreeSlugForParentSession(props.instance.id, parentId, "root")
setDeleteWorktreeOpen(false)
showToastNotification({ message: `Deleted worktree ${currentSlug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to delete worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to delete worktree",
variant: "error",
})
})
.finally(() => {
setIsDeletingWorktree(false)
})
}}
>
{isDeletingWorktree() ? "Deleting…" : "Delete"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instance.id}

View File

@@ -20,6 +20,9 @@ import type {
WorkspaceLogEntry,
WorkspaceEventPayload,
WorkspaceEventType,
WorktreeListResponse,
WorktreeMap,
WorktreeCreateRequest,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
@@ -127,6 +130,39 @@ export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces")
},
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
},
createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> {
return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, {
method: "POST",
body: JSON.stringify(payload),
})
},
deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise<void> {
const params = new URLSearchParams()
if (options?.force) {
params.set("force", "true")
}
const suffix = params.toString() ? `?${params.toString()}` : ""
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, {
method: "DELETE",
})
},
readWorktreeMap(id: string): Promise<WorktreeMap> {
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
},
writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
method: "PUT",
body: JSON.stringify(map),
})
},
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
return request<WorkspaceDescriptor>("/api/workspaces", {
method: "POST",

View File

@@ -4,8 +4,13 @@ import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager {
private clients = new Map<string, OpencodeClient>()
createClient(instanceId: string, proxyPath: string): OpencodeClient {
const existing = this.clients.get(instanceId)
private key(instanceId: string, worktreeSlug: string): string {
return `${instanceId}:${worktreeSlug || "root"}`
}
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, worktreeSlug)
const existing = this.clients.get(key)
if (existing) {
return existing
}
@@ -13,17 +18,25 @@ class SDKManager {
const baseUrl = buildInstanceBaseUrl(proxyPath)
const client = createOpencodeClient({ baseUrl })
this.clients.set(instanceId, client)
this.clients.set(key, client)
return client
}
getClient(instanceId: string): OpencodeClient | null {
return this.clients.get(instanceId) ?? null
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
}
destroyClient(instanceId: string): void {
this.clients.delete(instanceId)
destroyClient(instanceId: string, worktreeSlug = "root"): void {
this.clients.delete(this.key(instanceId, worktreeSlug))
}
destroyClientsForInstance(instanceId: string): void {
for (const key of Array.from(this.clients.keys())) {
if (key === instanceId || key.startsWith(`${instanceId}:`)) {
this.clients.delete(key)
}
}
}
destroyAll(): void {

View File

@@ -18,6 +18,7 @@ import {
fetchProviders,
clearInstanceDraftPrompts,
} from "./sessions"
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
@@ -136,10 +137,10 @@ function attachClient(descriptor: WorkspaceDescriptor) {
}
if (instance.client) {
sdkManager.destroyClient(descriptor.id)
sdkManager.destroyClientsForInstance(descriptor.id)
}
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
const client = sdkManager.createClient(descriptor.id, nextProxyPath, "root")
updateInstance(descriptor.id, {
client,
port: nextPort ?? 0,
@@ -157,7 +158,7 @@ function releaseInstanceResources(instanceId: string) {
if (!instance) return
if (instance.client) {
sdkManager.destroyClient(instanceId)
sdkManager.destroyClientsForInstance(instanceId)
}
sseManager.seedStatus(instanceId, "disconnected")
}
@@ -227,6 +228,8 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
async function hydrateInstanceData(instanceId: string) {
try {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
await fetchSessions(instanceId)
await fetchAgents(instanceId)
await fetchProviders(instanceId)

View File

@@ -1,5 +1,6 @@
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
import { providers, sessions, withSession } from "./session-state"
@@ -83,6 +84,9 @@ async function sendMessage(
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
@@ -204,7 +208,7 @@ async function sendMessage(
try {
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
await requestData(
instance.client.session.promptAsync({
client.session.promptAsync({
sessionID: sessionId,
...(requestBody as any),
}),
@@ -227,6 +231,9 @@ async function executeCustomCommand(
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
@@ -256,7 +263,7 @@ async function executeCustomCommand(
}
await requestData(
instance.client.session.command({
client.session.command({
sessionID: sessionId,
...(body as any),
}),
@@ -270,6 +277,9 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
@@ -278,7 +288,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
const agent = session.agent || "build"
await requestData(
instance.client.session.shell({
client.session.shell({
sessionID: sessionId,
agent,
command,
@@ -293,12 +303,15 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
log.info("abortSession", { instanceId, sessionId })
try {
log.info("session.abort", { instanceId, sessionId })
await requestData(
instance.client.session.abort({
client.session.abort({
sessionID: sessionId,
}),
"session.abort",
@@ -370,6 +383,9 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
@@ -381,7 +397,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
}
await requestData(
instance.client.session.update({
client.session.update({
sessionID: sessionId,
title: trimmedTitle,
}),
@@ -403,8 +419,11 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
await requestData(
instance.client.part.delete({
client.part.delete({
sessionID: sessionId,
messageID: messageId,
partID: partId,

View File

@@ -32,6 +32,13 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import {
getOrCreateWorktreeClient,
getRootClient,
getWorktreeSlugForSession,
removeParentSessionMapping,
setWorktreeSlugForParentSession,
} from "./worktrees"
const log = getLogger("api")
@@ -62,6 +69,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
throw new Error("Instance not ready")
}
const rootClient = getRootClient(instanceId)
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, true)
@@ -70,7 +79,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
try {
log.info("session.list", { instanceId })
const response = await instance.client.session.list()
const response = await rootClient.session.list()
const sessionMap = new Map<string, Session>()
@@ -80,7 +89,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
let statusById: Record<string, any> = {}
try {
const statusResponse = await instance.client.session.status()
const statusResponse = await rootClient.session.status()
if (statusResponse.data && typeof statusResponse.data === "object") {
statusById = statusResponse.data as Record<string, any>
}
@@ -171,6 +180,12 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
throw new Error("Instance not ready")
}
// New parent sessions inherit the currently active session's worktree.
// If no session is active (fresh instance), fall back to root.
const activeId = activeSessionId().get(instanceId)
const worktreeSlug = activeId && activeId !== "info" ? getWorktreeSlugForSession(instanceId, activeId) : "root"
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceAgents = agents().get(instanceId) || []
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
@@ -189,7 +204,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
try {
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
const response = await instance.client.session.create()
const response = await client.session.create()
if (!response.data) {
throw new Error("Failed to create session: No data returned")
@@ -260,6 +275,11 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
await cleanupBlankSessions(instanceId, session.id)
}
// Persist mapping for this *parent* session (best-effort).
await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => {
log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error })
})
return session
} catch (error) {
log.error("Failed to create session:", error)
@@ -283,6 +303,9 @@ async function forkSession(
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const request: { sessionID: string; messageID?: string } = {
sessionID: sourceSessionId,
messageID: options?.messageId,
@@ -290,7 +313,7 @@ async function forkSession(
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const info = await requestData<SessionForkResponse>(
instance.client.session.fork(request),
client.session.fork(request),
"session.fork",
)
const forkedSession = {
@@ -362,6 +385,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const deletingSession = sessions().get(instanceId)?.get(sessionId)
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId) || new Set()
@@ -372,7 +400,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
try {
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete")
await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
setSessions((prev) => {
const next = new Map(prev)
@@ -416,6 +444,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
return next
})
}
// Clean up mapping for deleted parent sessions.
if (deletingSession?.parentId === null) {
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
}
} catch (error) {
log.error("Failed to delete session:", error)
throw error
@@ -437,9 +470,11 @@ async function fetchAgents(instanceId: string): Promise<void> {
throw new Error("Instance not ready")
}
const rootClient = getRootClient(instanceId)
try {
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents()
const response = await rootClient.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
name: agent.name,
description: agent.description || "",
@@ -468,9 +503,11 @@ async function fetchProviders(instanceId: string): Promise<void> {
throw new Error("Instance not ready")
}
const rootClient = getRootClient(instanceId)
try {
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
const response = await instance.client.config.providers()
const response = await rootClient.config.providers()
if (!response.data) return
const providerList = response.data.providers.map((provider) => ({
@@ -524,6 +561,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
@@ -541,7 +581,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
try {
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const apiMessages = await requestData<any[]>(
instance.client.session.messages({ sessionID: sessionId }),
client.session.messages({ sessionID: sessionId }),
"session.messages",
)

View File

@@ -37,6 +37,7 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { tGlobal } from "../lib/i18n"
import { loadMessages } from "./session-api"
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
import {
applyPartUpdateV2,
replaceMessageIdV2,
@@ -81,19 +82,34 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
})
}
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> {
async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
const instance = instances().get(instanceId)
if (!instance?.client) return null
const slugFromDirectory = getWorktreeSlugForDirectory(instanceId, directory)
const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, slug)
const rootClient = getRootClient(instanceId)
try {
const info = await requestData<any>(
instance.client.session.get({ sessionID: sessionId }),
client.session.get({ sessionID: sessionId }),
"session.get",
)
let fetchedStatus: SessionStatus = "idle"
try {
const statuses = await requestData<Record<string, any>>(instance.client.session.status(), "session.status")
let statuses: Record<string, any> = {}
try {
statuses = await requestData<Record<string, any>>(rootClient.session.status(), "session.status")
} catch {
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
}
// Session status is global-ish; prefer the root context when available.
// (OpenCode may scope status by directory in older builds.)
// If root fails, fall back to the worktree-scoped client.
//
// Note: requestData throws on error, so we catch below.
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
@@ -132,7 +148,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
}
}
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) {
const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId)
if (existing) {
@@ -149,7 +165,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
}
const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId)
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
if (!fetched) return
applySessionStatus(instanceId, sessionId, status)
})()
@@ -197,7 +213,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return
if (part.type === "compaction") {
ensureSessionStatus(instanceId, sessionId, "compacting")
ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory)
}
const store = messageStoreBus.getOrCreate(instanceId)
@@ -381,7 +397,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
ensureSessionStatus(instanceId, sessionId, "idle")
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
log.info(`[SSE] Session idle: ${sessionId}`)
}
@@ -390,7 +406,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status)
ensureSessionStatus(instanceId, sessionId, status)
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory)
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
}
@@ -406,7 +422,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
session.status = "working"
})
} else {
ensureSessionStatus(instanceId, sessionID, "working")
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
}
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))

View File

@@ -8,6 +8,7 @@ import { instances } from "./instances"
import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import { tGlobal } from "../lib/i18n"
const log = getLogger("session")
@@ -602,12 +603,14 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
return isFreshSession
}
let messages: any[] = []
try {
messages = await requestData<any[]>(
instance.client.session.messages({ sessionID: session.id }),
"session.messages",
)
} catch (error) {
try {
const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
messages = await requestData<any[]>(
client.session.messages({ sessionID: session.id }),
"session.messages",
)
} catch (error) {
log.error(`Failed to fetch messages for session ${session.id}`, error)
return isFreshSession
}

View File

@@ -0,0 +1,264 @@
import { createSignal } from "solid-js"
import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { sdkManager, type OpencodeClient } from "../lib/sdk-manager"
import { sessions } from "./session-state"
import { getLogger } from "../lib/logger"
const log = getLogger("api")
const [worktreesByInstance, setWorktreesByInstance] = createSignal<Map<string, WorktreeDescriptor[]>>(new Map())
const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal<Map<string, WorktreeMap>>(new Map())
const worktreeLoads = new Map<string, Promise<void>>()
const mapLoads = new Map<string, Promise<void>>()
function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
if (!input || typeof input !== "object") {
return { version: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }
}
return {
version: 1,
defaultWorktreeSlug: input.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: input.parentSessionWorktreeSlug ?? {},
}
}
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
if (!instanceId) return
if (worktreesByInstance().has(instanceId)) return
const existing = worktreeLoads.get(instanceId)
if (existing) return existing
const task = serverApi
.fetchWorktrees(instanceId)
.then((response) => {
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, response.worktrees ?? [])
return next
})
})
.catch((error) => {
log.warn("Failed to load worktrees", { instanceId, error })
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, [])
return next
})
})
.finally(() => {
worktreeLoads.delete(instanceId)
})
worktreeLoads.set(instanceId, task)
return task
}
async function reloadWorktrees(instanceId: string): Promise<void> {
if (!instanceId) return
await serverApi
.fetchWorktrees(instanceId)
.then((response) => {
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, response.worktrees ?? [])
return next
})
})
.catch((error) => {
log.warn("Failed to reload worktrees", { instanceId, error })
})
}
async function createWorktree(instanceId: string, slug: string): Promise<{ slug: string; directory: string; branch?: string }> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
const trimmed = (slug ?? "").trim()
if (!trimmed) {
throw new Error("Worktree name is required")
}
return await serverApi.createWorktree(instanceId, { slug: trimmed })
}
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
const trimmed = (slug ?? "").trim()
if (!trimmed || trimmed === "root") {
throw new Error("Invalid worktree")
}
await serverApi.deleteWorktree(instanceId, trimmed, options)
}
async function ensureWorktreeMapLoaded(instanceId: string): Promise<void> {
if (!instanceId) return
if (worktreeMapByInstance().has(instanceId)) return
const existing = mapLoads.get(instanceId)
if (existing) return existing
const task = serverApi
.readWorktreeMap(instanceId)
.then((map) => {
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(map))
return next
})
})
.catch((error) => {
log.warn("Failed to load worktree map", { instanceId, error })
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(null))
return next
})
})
.finally(() => {
mapLoads.delete(instanceId)
})
mapLoads.set(instanceId, task)
return task
}
async function reloadWorktreeMap(instanceId: string): Promise<void> {
if (!instanceId) return
await serverApi
.readWorktreeMap(instanceId)
.then((map) => {
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(map))
return next
})
})
.catch((error) => {
log.warn("Failed to reload worktree map", { instanceId, error })
})
}
function getWorktrees(instanceId: string): WorktreeDescriptor[] {
return worktreesByInstance().get(instanceId) ?? []
}
function getWorktreeMap(instanceId: string): WorktreeMap {
return worktreeMapByInstance().get(instanceId) ?? normalizeMap(null)
}
function getDefaultWorktreeSlug(instanceId: string): string {
return getWorktreeMap(instanceId).defaultWorktreeSlug || "root"
}
async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
const next: WorktreeMap = { ...current, defaultWorktreeSlug: slug }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist default worktree", { instanceId, slug, error })
})
}
function getParentSessionId(instanceId: string, sessionId: string): string {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return sessionId
return session.parentId ?? session.id
}
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {
const map = getWorktreeMap(instanceId)
return map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root"
}
function getWorktreeSlugForSession(instanceId: string, sessionId: string): string {
const parentId = getParentSessionId(instanceId, sessionId)
return getWorktreeSlugForParentSession(instanceId, parentId)
}
async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
nextMapping[parentSessionId] = slug
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug, error })
})
}
async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
if (!current.parentSessionWorktreeSlug[parentSessionId]) return
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
delete nextMapping[parentSessionId]
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist session worktree mapping removal", { instanceId, parentSessionId, error })
})
}
function getWorktreeSlugForDirectory(instanceId: string, directory: string | undefined): string | null {
if (!directory) return null
const list = getWorktrees(instanceId)
const match = list.find((wt) => wt.directory === directory)
return match?.slug ?? null
}
function buildWorktreeProxyPath(instanceId: string, slug: string): string {
const normalizedSlug = slug || "root"
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
}
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
const proxyPath = buildWorktreeProxyPath(instanceId, slug)
return sdkManager.createClient(instanceId, proxyPath, slug)
}
function getRootClient(instanceId: string): OpencodeClient {
return getOrCreateWorktreeClient(instanceId, "root")
}
export {
worktreesByInstance,
worktreeMapByInstance,
ensureWorktreesLoaded,
reloadWorktrees,
reloadWorktreeMap,
ensureWorktreeMapLoaded,
getWorktrees,
getWorktreeMap,
getDefaultWorktreeSlug,
setDefaultWorktreeSlug,
getParentSessionId,
getWorktreeSlugForParentSession,
getWorktreeSlugForSession,
setWorktreeSlugForParentSession,
removeParentSessionMapping,
getWorktreeSlugForDirectory,
buildWorktreeProxyPath,
getOrCreateWorktreeClient,
getRootClient,
createWorktree,
deleteWorktree,
}