diff --git a/apps/desktop/src/main/agent/agent-store.ts b/apps/desktop/src/main/agent/agent-store.ts index d23df88..1c5ef90 100644 --- a/apps/desktop/src/main/agent/agent-store.ts +++ b/apps/desktop/src/main/agent/agent-store.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto"; -import type { AgentSessionInfo, SubagentWorktreeInfo } from "../../shared/contracts"; +import type { + AgentSessionInfo, + SessionWorktreeInfo, + SubagentWorktreeInfo, +} from "../../shared/contracts"; import { getDatabase } from "../db/database"; type AgentSessionRow = { @@ -7,6 +11,7 @@ type AgentSessionRow = { workspace_id: string; title: string; cwd: string; + branch: string | null; status: AgentSessionInfo["status"]; runtime: "pi-sdk" | "pi-rpc"; model: string | null; @@ -16,6 +21,11 @@ type AgentSessionRow = { subagent_task: string | null; subagent_type: string | null; subagent_readonly: number; + worktree_path: string | null; + worktree_branch: string | null; + worktree_base_branch: string | null; + worktree_base_sha: string | null; + worktree_status: string | null; subagent_worktree_path: string | null; subagent_worktree_branch: string | null; subagent_worktree_base_sha: string | null; @@ -28,8 +38,9 @@ type AgentSessionRow = { updated_at: string; }; -const SESSION_COLUMNS = `id, workspace_id, title, cwd, status, runtime, model, pi_session_id, +const SESSION_COLUMNS = `id, workspace_id, title, cwd, branch, status, runtime, model, pi_session_id, pi_session_file, parent_session_id, subagent_task, subagent_type, subagent_readonly, + worktree_path, worktree_branch, worktree_base_branch, worktree_base_sha, worktree_status, subagent_worktree_path, subagent_worktree_branch, subagent_worktree_base_sha, subagent_integration_status, subagent_changed_files_json, subagent_conflict_files_json, pinned_at, archived_at, created_at, updated_at`; @@ -58,6 +69,9 @@ function toSession(row: AgentSessionRow): AgentSessionInfo { updatedAt: row.updated_at, }; + if (row.branch !== null) { + session.branch = row.branch; + } if (row.model !== null) { session.model = row.model; } @@ -85,6 +99,21 @@ function toSession(row: AgentSessionRow): AgentSessionInfo { if (row.subagent_readonly !== 0) { session.subagentReadOnly = true; } + if ( + row.worktree_path !== null && + row.worktree_branch !== null && + row.worktree_base_branch !== null && + row.worktree_base_sha !== null + ) { + const status = row.worktree_status as SessionWorktreeInfo["status"] | null; + session.worktree = { + path: row.worktree_path, + branch: row.worktree_branch, + baseBranch: row.worktree_base_branch, + baseSha: row.worktree_base_sha, + status: status ?? "active", + }; + } if ( row.subagent_worktree_path !== null && row.subagent_worktree_branch !== null && @@ -112,6 +141,7 @@ export function createAgentSessionRecord(input: { workspaceId: string; title: string; cwd: string; + branch?: string; runtime?: "pi-sdk" | "pi-rpc"; model?: string; piSessionId?: string; @@ -120,6 +150,7 @@ export function createAgentSessionRecord(input: { subagentTask?: string; subagentType?: string; subagentReadOnly?: boolean; + worktree?: SessionWorktreeInfo; subagentWorktree?: SubagentWorktreeInfo; }): AgentSessionInfo { const now = new Date().toISOString(); @@ -135,6 +166,9 @@ export function createAgentSessionRecord(input: { updatedAt: now, }; + if (input.branch !== undefined) { + session.branch = input.branch; + } if (input.model !== undefined) { session.model = input.model; } @@ -156,25 +190,30 @@ export function createAgentSessionRecord(input: { if (input.subagentReadOnly !== undefined) { session.subagentReadOnly = input.subagentReadOnly; } + if (input.worktree !== undefined) { + session.worktree = input.worktree; + } if (input.subagentWorktree !== undefined) { session.subagentWorktree = input.subagentWorktree; } getDatabase() .prepare( `insert into agent_sessions ( - id, workspace_id, title, cwd, status, runtime, model, pi_session_id, pi_session_file, + id, workspace_id, title, cwd, branch, status, runtime, model, pi_session_id, pi_session_file, parent_session_id, subagent_task, subagent_type, subagent_readonly, + worktree_path, worktree_branch, worktree_base_branch, worktree_base_sha, worktree_status, subagent_worktree_path, subagent_worktree_branch, subagent_worktree_base_sha, subagent_integration_status, subagent_changed_files_json, subagent_conflict_files_json, created_at, updated_at ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( session.id, session.workspaceId, session.title, session.cwd, + session.branch ?? null, session.status, runtime, session.model ?? null, @@ -184,6 +223,11 @@ export function createAgentSessionRecord(input: { session.subagentTask ?? null, session.subagentType ?? null, session.subagentReadOnly ? 1 : 0, + session.worktree?.path ?? null, + session.worktree?.branch ?? null, + session.worktree?.baseBranch ?? null, + session.worktree?.baseSha ?? null, + session.worktree?.status ?? null, session.subagentWorktree?.path ?? null, session.subagentWorktree?.branch ?? null, session.subagentWorktree?.baseSha ?? null, @@ -202,6 +246,39 @@ export function createAgentSessionRecord(input: { } export function updateAgentSessionWorktree( + sessionId: string, + worktree: SessionWorktreeInfo | undefined, +): AgentSessionInfo | undefined { + const existing = getAgentSession(sessionId); + if (!existing) { + return undefined; + } + + getDatabase() + .prepare( + `update agent_sessions + set worktree_path = ?, + worktree_branch = ?, + worktree_base_branch = ?, + worktree_base_sha = ?, + worktree_status = ?, + updated_at = ? + where id = ?`, + ) + .run( + worktree?.path ?? null, + worktree?.branch ?? null, + worktree?.baseBranch ?? null, + worktree?.baseSha ?? null, + worktree?.status ?? null, + new Date().toISOString(), + sessionId, + ); + + return getAgentSession(sessionId); +} + +export function updateAgentSessionSubagentWorktree( sessionId: string, worktree: SubagentWorktreeInfo | undefined, ): AgentSessionInfo | undefined { diff --git a/apps/desktop/src/main/agent/pi-rpc-service.ts b/apps/desktop/src/main/agent/pi-rpc-service.ts index 81685cf..b7fe7af 100644 --- a/apps/desktop/src/main/agent/pi-rpc-service.ts +++ b/apps/desktop/src/main/agent/pi-rpc-service.ts @@ -27,7 +27,7 @@ function stringifyLine(line: unknown): string { export function createPiRpcSession( window: BrowserWindowType, - input: { workspaceId: string; cwd: string; title: string }, + input: { workspaceId: string; cwd: string; title: string; branch?: string }, ): AgentSessionInfo { const info = createAgentSessionRecord({ ...input, runtime: "pi-rpc" }); const sessionDir = join(app.getPath("userData"), "pi-sessions"); diff --git a/apps/desktop/src/main/agent/pi-sdk-runtime.ts b/apps/desktop/src/main/agent/pi-sdk-runtime.ts index 1bccced..6a4c022 100644 --- a/apps/desktop/src/main/agent/pi-sdk-runtime.ts +++ b/apps/desktop/src/main/agent/pi-sdk-runtime.ts @@ -52,8 +52,8 @@ import { listSubagentSessions, updateAgentSessionMetadata, updateAgentSessionStatus, + updateAgentSessionSubagentWorktree, updateAgentSessionTitle, - updateAgentSessionWorktree, } from "./agent-store"; import { createCheckpoint } from "./checkpoint-service"; import { @@ -511,12 +511,14 @@ export class PiSdkRuntime implements AgentRuntime { workspaceId: input.workspaceId, cwd: input.cwd, title: input.title, + ...(input.branch !== undefined ? { branch: input.branch } : {}), runtime: "pi-sdk", ...(input.id !== undefined ? { id: input.id } : {}), ...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}), ...(input.subagentTask !== undefined ? { subagentTask: input.subagentTask } : {}), ...(input.subagentType !== undefined ? { subagentType: input.subagentType } : {}), ...(input.subagentReadOnly !== undefined ? { subagentReadOnly: input.subagentReadOnly } : {}), + ...(input.worktree !== undefined ? { worktree: input.worktree } : {}), ...(input.subagentWorktree !== undefined ? { subagentWorktree: input.subagentWorktree } : {}), }; if (modelId !== undefined) { @@ -1134,7 +1136,7 @@ export class PiSdkRuntime implements AgentRuntime { return; } const updated = await finishSubagentWorktree(session.subagentWorktree, input.task); - updateAgentSessionWorktree(session.id, updated); + updateAgentSessionSubagentWorktree(session.id, updated); if (!runFailed) { emit({ type: "subagent.updated", diff --git a/apps/desktop/src/main/agent/runtime.ts b/apps/desktop/src/main/agent/runtime.ts index 47064ca..b290de0 100644 --- a/apps/desktop/src/main/agent/runtime.ts +++ b/apps/desktop/src/main/agent/runtime.ts @@ -17,11 +17,13 @@ export type CreateAgentRuntimeInput = { workspaceId: string; cwd: string; title: string; + branch?: string; model?: string; parentSessionId?: string; subagentTask?: string; subagentType?: string; subagentReadOnly?: boolean; + worktree?: AgentSessionInfo["worktree"]; subagentWorktree?: AgentSessionInfo["subagentWorktree"]; }; diff --git a/apps/desktop/src/main/db/database.ts b/apps/desktop/src/main/db/database.ts index 6df278d..16e1053 100644 --- a/apps/desktop/src/main/db/database.ts +++ b/apps/desktop/src/main/db/database.ts @@ -170,6 +170,7 @@ function migrate(db: DatabaseSync): void { addColumn(db, "agent_sessions", "runtime", "text not null default 'pi-sdk'"); addColumn(db, "agent_sessions", "model", "text"); + addColumn(db, "agent_sessions", "branch", "text"); addColumn(db, "agent_sessions", "pi_session_id", "text"); addColumn(db, "agent_sessions", "pi_session_file", "text"); addColumn( @@ -181,6 +182,11 @@ function migrate(db: DatabaseSync): void { addColumn(db, "agent_sessions", "subagent_task", "text"); addColumn(db, "agent_sessions", "subagent_type", "text"); addColumn(db, "agent_sessions", "subagent_readonly", "integer not null default 0"); + addColumn(db, "agent_sessions", "worktree_path", "text"); + addColumn(db, "agent_sessions", "worktree_branch", "text"); + addColumn(db, "agent_sessions", "worktree_base_branch", "text"); + addColumn(db, "agent_sessions", "worktree_base_sha", "text"); + addColumn(db, "agent_sessions", "worktree_status", "text"); addColumn(db, "agent_sessions", "subagent_worktree_path", "text"); addColumn(db, "agent_sessions", "subagent_worktree_branch", "text"); addColumn(db, "agent_sessions", "subagent_worktree_base_sha", "text"); @@ -196,18 +202,6 @@ function migrate(db: DatabaseSync): void { // Sidebar project pinning: pinned projects sort to the top (pinned_at breaks ties). addColumn(db, "workspaces", "pinned", "integer not null default 0"); addColumn(db, "workspaces", "pinned_at", "text"); - if (hasColumn(db, "agent_sessions", "worktree_path")) { - db.exec(` - update agent_sessions - set - cwd = coalesce( - (select root_path from workspaces where workspaces.id = agent_sessions.workspace_id), - cwd - ), - worktree_path = null - where worktree_path is not null - `); - } // PI session-tree leaf id captured right before each prompt — the exact // branch point used to rewind the conversation when the message is edited. // "root" marks an empty tree (first message); NULL marks legacy runs. diff --git a/apps/desktop/src/main/git/git-service.test.ts b/apps/desktop/src/main/git/git-service.test.ts index 91a30fd..9943a24 100644 --- a/apps/desktop/src/main/git/git-service.test.ts +++ b/apps/desktop/src/main/git/git-service.test.ts @@ -9,8 +9,10 @@ import { abortSubagentWorktreeApply, applySubagentWorktree, checkoutBranch, + cleanupSessionWorktree, cleanupSubagentWorktree, commitOrPush, + createChatWorktree, createSubagentWorktree, discardFile, finishSubagentWorktree, @@ -276,6 +278,55 @@ describe("git-service", () => { } }); + it("creates a chat worktree from a selected local base branch", async () => { + const baseBranch = (await git(["branch", "--show-current"])).trim(); + const worktree = await createChatWorktree(repo, { + sessionId: "abcdef12-3456-7890-abcd-ef1234567890", + baseBranch, + }); + + expect(worktree.path.replace(/\\/g, "/")).toContain( + `/.modus/worktrees/chat-${baseBranch}-abcdef12`, + ); + expect(worktree.branch).toBe(`modus/chat/${baseBranch}-abcdef12`); + expect(worktree.baseBranch).toBe(baseBranch); + expect(worktree.status).toBe("active"); + expect(existsSync(worktree.path)).toBe(true); + + const cleaned = await cleanupSessionWorktree(repo, worktree); + expect(cleaned.status).toBe("cleaned"); + expect(existsSync(worktree.path)).toBe(false); + }); + + it("creates a chat worktree directly from a remote-tracking base branch", async () => { + const baseSha = (await git(["rev-parse", "HEAD"])).trim(); + await git(["update-ref", "refs/remotes/origin/feature/remote", baseSha]); + + const worktree = await createChatWorktree(repo, { + sessionId: "abcdef12-3456-7890-abcd-ef1234567890", + baseBranch: "origin/feature/remote", + baseBranchRemote: true, + }); + + expect(worktree.baseBranch).toBe("origin/feature/remote"); + expect(worktree.baseSha).toBe(baseSha); + expect((await execFileAsync("git", ["branch", "--show-current"], { cwd: worktree.path })).stdout.trim()).toBe( + worktree.branch, + ); + expect((await git(["branch", "--list", "feature/remote"])).trim()).toBe(""); + + await cleanupSessionWorktree(repo, worktree); + }); + + it("rejects a chat worktree from a missing local base branch", async () => { + await expect( + createChatWorktree(repo, { + sessionId: "abcdef12-3456-7890-abcd-ef1234567890", + baseBranch: "origin/main", + }), + ).rejects.toThrow("not a local branch"); + }); + it("creates, finishes, applies, and cleans up a subagent worktree", async () => { const worktree = await createSubagentWorktree(repo, { sessionId: "abcdef12-3456-7890-abcd-ef1234567890", diff --git a/apps/desktop/src/main/git/git-service.ts b/apps/desktop/src/main/git/git-service.ts index 765f531..c6334f9 100644 --- a/apps/desktop/src/main/git/git-service.ts +++ b/apps/desktop/src/main/git/git-service.ts @@ -13,6 +13,7 @@ import type { GitCommit, GitCommitResult, GitStatusSummary, + SessionWorktreeInfo, SubagentWorktreeInfo, WorkingChangeStats, } from "../../shared/contracts"; @@ -727,7 +728,7 @@ export async function checkoutBranch( return { kind: "ok", output: await git(cwd, ["switch", "--track", target]) }; } -function subagentSlug(value: string): string { +function worktreeSlug(value: string): string { return ( value .toLowerCase() @@ -746,6 +747,10 @@ function assertManagedWorktreePath(repoRoot: string, worktreePath: string): void } } +async function branchRefExists(cwd: string, ref: string): Promise { + return Boolean(await gitSafe(cwd, ["show-ref", "--verify", ref])); +} + async function changedFilesBetween(cwd: string, base: string, target: string): Promise { return (await gitSafe(cwd, ["diff", "--name-only", "-z", base, target])) .split("\0") @@ -767,6 +772,59 @@ async function excludeManagedWorktrees(commonGitDir: string): Promise { } } +export async function createChatWorktree( + cwd: string, + input: { sessionId: string; baseBranch: string; baseBranchRemote?: boolean }, +): Promise { + const repo = resolveRepo(cwd); + if (!repo) { + throw new Error("Worktree isolation requires a Git repository."); + } + const baseRef = input.baseBranchRemote + ? `refs/remotes/${input.baseBranch}` + : `refs/heads/${input.baseBranch}`; + if (!(await branchRefExists(repo.root, baseRef))) { + throw new Error( + input.baseBranchRemote + ? `Remote base branch "${input.baseBranch}" does not exist.` + : `Base branch "${input.baseBranch}" is not a local branch.`, + ); + } + const baseSha = await gitSafe(repo.root, [ + "rev-parse", + "--verify", + `${baseRef}^{commit}`, + ]); + if (!baseSha) { + throw new Error("Worktree isolation requires an initial commit."); + } + + const shortId = input.sessionId.replace(/[^a-f0-9]/gi, "").slice(0, 8); + const name = `${worktreeSlug(input.baseBranch)}-${shortId || input.sessionId.slice(0, 8)}`; + const worktreeRoot = join(repo.root, ".modus", "worktrees"); + const worktreePath = join(worktreeRoot, `chat-${name}`); + const branch = `modus/chat/${name}`; + await mkdir(worktreeRoot, { recursive: true }); + await excludeManagedWorktrees(repo.commonGitDir); + await git(repo.root, ["worktree", "add", "-b", branch, worktreePath, baseRef]); + return { path: worktreePath, branch, baseBranch: input.baseBranch, baseSha, status: "active" }; +} + +export async function cleanupSessionWorktree( + parentCwd: string, + worktree: SessionWorktreeInfo, +): Promise { + const repo = resolveRepo(parentCwd); + if (!repo) { + throw new Error("Worktree cleanup requires a Git repository."); + } + assertManagedWorktreePath(repo.root, worktree.path); + await git(repo.root, ["worktree", "remove", "--force", worktree.path]); + await gitSafe(repo.root, ["branch", "-D", worktree.branch]); + await rm(worktree.path, { recursive: true, force: true }).catch(() => undefined); + return { ...worktree, status: "cleaned" }; +} + export async function createSubagentWorktree( cwd: string, input: { sessionId: string; name: string }, @@ -781,7 +839,7 @@ export async function createSubagentWorktree( } const shortId = input.sessionId.replace(/[^a-f0-9]/gi, "").slice(0, 8); - const name = `${subagentSlug(input.name)}-${shortId || input.sessionId.slice(0, 8)}`; + const name = `${worktreeSlug(input.name)}-${shortId || input.sessionId.slice(0, 8)}`; const worktreeRoot = join(repo.root, ".modus", "worktrees"); const worktreePath = join(worktreeRoot, name); const branch = `modus/subagent/${name}`; diff --git a/apps/desktop/src/main/ipc/channels.ts b/apps/desktop/src/main/ipc/channels.ts index 5958dce..88c5ece 100644 --- a/apps/desktop/src/main/ipc/channels.ts +++ b/apps/desktop/src/main/ipc/channels.ts @@ -27,6 +27,7 @@ export const IPC_CHANNELS = { agentApplySubagentWorktree: "agent:apply-subagent-worktree", agentAbortSubagentWorktreeApply: "agent:abort-subagent-worktree-apply", agentCleanupSubagentWorktree: "agent:cleanup-subagent-worktree", + agentCleanupSessionWorktree: "agent:cleanup-session-worktree", agentSetModel: "agent:set-model", agentCycleModel: "agent:cycle-model", agentEvent: "agent:event", diff --git a/apps/desktop/src/main/ipc/register-app-ipc.ts b/apps/desktop/src/main/ipc/register-app-ipc.ts index 90d32ea..d3333d8 100644 --- a/apps/desktop/src/main/ipc/register-app-ipc.ts +++ b/apps/desktop/src/main/ipc/register-app-ipc.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { isAbsolute, resolve, sep } from "node:path"; import { app, @@ -14,6 +15,7 @@ import { listAgentSessions, listArchivedAgentSessions, setAgentSessionPinned, + updateAgentSessionSubagentWorktree, updateAgentSessionWorktree, } from "../agent/agent-store"; import { @@ -26,12 +28,12 @@ import { configureProvider, deleteCustomProvider, disconnectProvider, - getProviderAuthState, getCustomProviderConfig, getModelSettings, + getProviderAuthState, getProviderDetail, - listProviderConnectionMethods, listModels, + listProviderConnectionMethods, refreshRemoteModelCatalog, respondProviderAuth, setDefaultModel, @@ -78,8 +80,10 @@ import { abortSubagentWorktreeApply, applySubagentWorktree, checkoutBranch, + cleanupSessionWorktree, cleanupSubagentWorktree, commitOrPush, + createChatWorktree, discardFile, getChangeStatsSince, getStatusSummary, @@ -148,6 +152,7 @@ import { import { upsertWorkspace } from "../workspace/workspace-store"; import { IPC_CHANNELS } from "./channels"; import { + agentCleanupSessionWorktreeSchema, agentCreateSchema, agentCycleModelSchema, agentListSchema, @@ -188,11 +193,11 @@ import { parseIpcInput, permissionDecideSchema, personalizationSaveSchema, + processKillSchema, + processListSchema, providerAuthOperationSchema, providerAuthResponseSchema, providerAuthStartSchema, - processKillSchema, - processListSchema, questionRespondSchema, reviewStartSchema, sessionIdSchema, @@ -351,12 +356,49 @@ export function registerAppIpc({ ipcMain.handle(IPC_CHANNELS.agentCreate, async (event, input) => { assertTrustedSender(event); const parsed = parseIpcInput(agentCreateSchema, input, IPC_CHANNELS.agentCreate); - return await getAgentRuntime().create(getSenderWindow(event), { + const createInput = { workspaceId: parsed.workspaceId, cwd: parsed.cwd, title: parsed.title, ...(parsed.model !== undefined ? { model: parsed.model } : {}), + }; + const baseBranch = parsed.baseBranch; + if (parsed.draftScope === "local") { + if (!baseBranch) { + throw new Error("Base branch is required for local sessions."); + } + const result = await checkoutBranch(parsed.cwd, baseBranch); + return await getAgentRuntime().create(getSenderWindow(event), { + ...createInput, + branch: baseBranch, + ...(result.kind === "worktree" && result.worktreePath ? { cwd: result.worktreePath } : {}), + }); + } + if (parsed.draftScope !== "worktree") { + return await getAgentRuntime().create(getSenderWindow(event), createInput); + } + + const sessionId = randomUUID(); + if (!baseBranch) { + throw new Error("Base branch is required for worktree sessions."); + } + const worktree = await createChatWorktree(parsed.cwd, { + sessionId, + baseBranch, + ...(parsed.baseBranchRemote ? { baseBranchRemote: true } : {}), }); + try { + return await getAgentRuntime().create(getSenderWindow(event), { + ...createInput, + id: sessionId, + cwd: worktree.path, + branch: worktree.branch, + worktree, + }); + } catch (error) { + await cleanupSessionWorktree(parsed.cwd, worktree).catch(() => undefined); + throw error; + } }); ipcMain.handle(IPC_CHANNELS.agentList, (event, input) => { @@ -462,7 +504,7 @@ export function registerAppIpc({ throw new Error("Parent session not found."); } const worktree = await applySubagentWorktree(parent.cwd, child.subagentWorktree); - const updated = updateAgentSessionWorktree(child.id, worktree) ?? child; + const updated = updateAgentSessionSubagentWorktree(child.id, worktree) ?? child; emitGitEvent({ cwd: parent.cwd, kind: "index" }); return updated; }); @@ -486,7 +528,7 @@ export function registerAppIpc({ throw new Error("Parent session not found."); } const worktree = await abortSubagentWorktreeApply(parent.cwd, child.subagentWorktree); - const updated = updateAgentSessionWorktree(child.id, worktree) ?? child; + const updated = updateAgentSessionSubagentWorktree(child.id, worktree) ?? child; emitGitEvent({ cwd: parent.cwd, kind: "index" }); return updated; }); @@ -506,11 +548,28 @@ export function registerAppIpc({ throw new Error("Parent session not found."); } const worktree = await cleanupSubagentWorktree(parent.cwd, child.subagentWorktree); - const updated = updateAgentSessionWorktree(child.id, worktree) ?? child; + const updated = updateAgentSessionSubagentWorktree(child.id, worktree) ?? child; emitGitEvent({ cwd: parent.cwd, kind: "refs" }); return updated; }); + ipcMain.handle(IPC_CHANNELS.agentCleanupSessionWorktree, async (event, input) => { + assertTrustedSender(event); + const parsed = parseIpcInput( + agentCleanupSessionWorktreeSchema, + input, + IPC_CHANNELS.agentCleanupSessionWorktree, + ); + const session = getAgentSession(parsed.sessionId); + if (!session?.worktree) { + throw new Error("Session worktree not found."); + } + const worktree = await cleanupSessionWorktree(parsed.cwd, session.worktree); + const updated = updateAgentSessionWorktree(session.id, worktree) ?? session; + emitGitEvent({ cwd: parsed.cwd, kind: "refs" }); + return updated; + }); + ipcMain.handle(IPC_CHANNELS.agentSetModel, async (event, input) => { assertTrustedSender(event); const parsed = parseIpcInput(agentSetModelSchema, input, IPC_CHANNELS.agentSetModel); diff --git a/apps/desktop/src/main/ipc/schemas.ts b/apps/desktop/src/main/ipc/schemas.ts index e777997..e5ea21c 100644 --- a/apps/desktop/src/main/ipc/schemas.ts +++ b/apps/desktop/src/main/ipc/schemas.ts @@ -20,11 +20,28 @@ const modelCostSchema = z }) .optional(); -export const agentCreateSchema = z.object({ - workspaceId: nonEmptyString, +export const agentCreateSchema = z + .object({ + workspaceId: nonEmptyString, + cwd: nonEmptyString, + title: nonEmptyString, + model: optionalNonEmptyString, + draftScope: z.enum(["local", "worktree"]).optional(), + baseBranch: optionalNonEmptyString, + baseBranchRemote: z.boolean().optional(), + }) + .refine((input) => input.draftScope === undefined || input.baseBranch !== undefined, { + message: "baseBranch is required when selecting a chat branch.", + path: ["baseBranch"], + }) + .refine((input) => input.draftScope !== "local" || !input.baseBranchRemote, { + message: "Local sessions require a local branch.", + path: ["baseBranchRemote"], + }); + +export const agentCleanupSessionWorktreeSchema = z.object({ + sessionId: nonEmptyString, cwd: nonEmptyString, - title: nonEmptyString, - model: optionalNonEmptyString, }); export const workspacePinSchema = z.object({ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 66cb771..4aaba05 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -42,6 +42,7 @@ const api: ModusApi = { ipcRenderer.invoke("agent:abort-subagent-worktree-apply", sessionId), cleanupSubagentWorktree: (sessionId) => ipcRenderer.invoke("agent:cleanup-subagent-worktree", sessionId), + cleanupSessionWorktree: (input) => ipcRenderer.invoke("agent:cleanup-session-worktree", input), setModel: (input) => ipcRenderer.invoke("agent:set-model", input), cycleModel: (input) => ipcRenderer.invoke("agent:cycle-model", input), onEvent: (callback) => { diff --git a/apps/desktop/src/preload/types.ts b/apps/desktop/src/preload/types.ts index 377439a..a53a7e2 100644 --- a/apps/desktop/src/preload/types.ts +++ b/apps/desktop/src/preload/types.ts @@ -36,8 +36,6 @@ import type { McpServerInfo, McpServerUpsertInput, ModelInfo, - ProviderAuthOperationState, - ProviderConnectionMethod, ModelProviderDetail, ModelSettingsState, PermissionAction, @@ -45,6 +43,8 @@ import type { PersonalizationState, PromptDelivery, PromptImageAttachment, + ProviderAuthOperationState, + ProviderConnectionMethod, QuestionAnswer, QuestionResponse, RawMcpEntry, @@ -119,6 +119,9 @@ export type ModusApi = { cwd: string; title: string; model?: string; + draftScope?: "local" | "worktree"; + baseBranch?: string; + baseBranchRemote?: boolean; }): Promise; list(input?: { includeSessionId?: string }): Promise; listArchived(workspaceId: string): Promise; @@ -156,6 +159,7 @@ export type ModusApi = { applySubagentWorktree(sessionId: string): Promise; abortSubagentWorktreeApply(sessionId: string): Promise; cleanupSubagentWorktree(sessionId: string): Promise; + cleanupSessionWorktree(input: { sessionId: string; cwd: string }): Promise; setModel(input: { sessionId: string; model: string; diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index 0bff779..30e678b 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -36,6 +36,7 @@ import type { ContextItem, ContextUsageInfo, FileDiff, + GitBranchSummary, ModelInfo, ModelSettingsState, PlanRef, @@ -66,7 +67,6 @@ import type { ChatPaneHandle, } from "../features/agent/ChatPane"; import { Composer, createEmptyComposerDraft } from "../features/composer/Composer"; -import { BranchSwitcher } from "../features/git/BranchSwitcher"; import { INSPECTOR_MIN_WIDTH } from "../features/inspector/inspector-layout"; import { cn } from "../lib/cn"; import { useGitBranch } from "../lib/useGitBranch"; @@ -120,6 +120,9 @@ export function App() { // Composer mode for the hero (new-chat) screen — controlled so the "Plan New // Idea" pill can start a session straight in plan mode. const [heroMode, setHeroMode] = useState("build"); + const [heroScope, setHeroScope] = useState<"local" | "worktree">("local"); + const [heroBaseBranch, setHeroBaseBranch] = useState(); + const [heroBaseBranchRemote, setHeroBaseBranchRemote] = useState(false); const [models, setModels] = useState([]); const [model, setModel] = useState(""); const [modelSettings, setModelSettings] = useState(null); @@ -411,11 +414,17 @@ export function App() { return; } setActiveWorkspace(workspace); + setHeroScope("local"); + setHeroBaseBranch(undefined); + setHeroBaseBranchRemote(false); setWorkspaces(await window.modus.workspace.list()); await refreshSessions(); } - async function createSession(workspace: WorkspaceInfo | null): Promise { + async function createSession( + workspace: WorkspaceInfo | null, + target: { scope: "local" | "worktree"; baseBranch?: string; baseBranchRemote?: boolean }, + ): Promise { if (!workspace) { return null; } @@ -424,10 +433,17 @@ export function App() { setSessionCreateError("No model is configured. Connect a provider in Settings first."); return null; } + if (!target.baseBranch) { + setSessionCreateError("Select a branch before creating a chat."); + return null; + } try { const session = await window.modus.agent.create({ workspaceId: workspace.id, cwd: workspace.rootPath, + draftScope: target.scope, + baseBranch: target.baseBranch, + ...(target.baseBranchRemote ? { baseBranchRemote: true } : {}), ...(model ? { model } : {}), title: "New chat", }); @@ -507,6 +523,8 @@ export function App() { function openNewChat(workspace?: WorkspaceInfo | null): void { if (workspace !== undefined) { setActiveWorkspace(workspace); + setHeroScope("local"); + setHeroBaseBranch(undefined); } setSessionCreateError(undefined); setSettingsOpen(false); @@ -548,8 +566,15 @@ export function App() { await refreshSessions(); } - async function deleteSession(session: AgentSessionInfo): Promise { + async function deleteSession(session: AgentSessionInfo, cleanupWorktree = false): Promise { try { + if (cleanupWorktree && session.worktree) { + const workspace = workspaceById.get(session.workspaceId); + await window.modus.agent.cleanupSessionWorktree({ + sessionId: session.id, + cwd: workspace?.rootPath ?? session.cwd, + }); + } await window.modus.agent.delete(session.id); } catch (error) { setSessionCreateError(error instanceof Error ? error.message : String(error)); @@ -613,7 +638,12 @@ export function App() { if (!message.trim()) { return; } - const session = await createSession(activeWorkspace); + const target = { + scope: heroScope, + ...(heroBaseBranch ? { baseBranch: heroBaseBranch } : {}), + ...(heroBaseBranchRemote ? { baseBranchRemote: true } : {}), + }; + const session = await createSession(activeWorkspace, target); if (!session) { return; } @@ -813,7 +843,9 @@ export function App() { agentSessions={rootSessions} canCreateSession={canCreateSession} onArchiveSession={(session) => void archiveSession(session)} - onDeleteSession={(session) => void deleteSession(session)} + onDeleteSession={(session, cleanupWorktree) => + void deleteSession(session, cleanupWorktree) + } onListArchivedSessions={(workspaceId) => window.modus.agent.listArchived(workspaceId) } @@ -946,9 +978,21 @@ export function App() { footer={ { + setHeroScope(scope); + if (scope === "local" && heroBaseBranchRemote) { + setHeroBaseBranchRemote(false); + setHeroBaseBranch(undefined); + } + }} + onBaseBranchChange={(branch, remote = false) => { + setHeroBaseBranch(branch); + setHeroBaseBranchRemote(remote); + }} onOpenFolder={() => void openWorkspace()} onSelectWorkspace={openNewChat} workspaces={workspaces} @@ -1162,21 +1206,69 @@ const HERO_ENVIRONMENT_TRIGGER_CLASS = function HeroEnvironmentTray({ activeWorkspace, - branch, cwd, workspaces, + scope, + baseBranch, + baseBranchRemote, onSelectWorkspace, onOpenFolder, - onError, + onScopeChange, + onBaseBranchChange, }: { activeWorkspace: WorkspaceInfo | null; - branch: string | undefined; cwd: string | undefined; workspaces: WorkspaceInfo[]; + scope: "local" | "worktree"; + baseBranch: string | undefined; + baseBranchRemote: boolean; onSelectWorkspace(workspace: WorkspaceInfo): void; onOpenFolder(): void; - onError(message: string): void; + onScopeChange(scope: "local" | "worktree"): void; + onBaseBranchChange(branch: string | undefined, remote?: boolean): void; }) { + const [branches, setBranches] = useState({ local: [], remote: [] }); + + useEffect(() => { + let cancelled = false; + if (!cwd) { + setBranches({ local: [], remote: [] }); + return undefined; + } + void window.modus.git + .branches(cwd) + .then((next: GitBranchSummary) => { + if (!cancelled) { + setBranches(next); + } + }) + .catch(() => { + if (!cancelled) { + setBranches({ local: [], remote: [] }); + } + }); + return () => { + cancelled = true; + }; + }, [cwd]); + + const localBranches = branches.local; + const visibleRemotes = scope === "worktree" ? branches.remote : []; + const remoteGroups = visibleRemotes.reduce>((groups, branch) => { + const slash = branch.name.indexOf("/"); + if (slash <= 0 || slash === branch.name.length - 1) return groups; + const remote = branch.name.slice(0, slash); + (groups[remote] ??= []).push(branch); + return groups; + }, {}); + const selectedBaseBranch = baseBranch ?? branches.current ?? localBranches[0]?.name; + + useEffect(() => { + if (!baseBranch && selectedBaseBranch) { + onBaseBranchChange(selectedBaseBranch, false); + } + }, [baseBranch, onBaseBranchChange, selectedBaseBranch]); + return (
- - - - - {branch ?? "No branch"} - - + + + {scope === "local" ? "Local" : "Worktree"} + + + + + + {[ + { label: "Local", value: "local" as const }, + { label: "Worktree", value: "worktree" as const }, + ].map((option) => ( + onScopeChange(option.value)} + > + + {scope === option.value ? : null} + + {option.label} + + ))} + + + + + + + + + + {selectedBaseBranch ?? "Select branch"} + + + + + +
Local
+ {localBranches.map((item) => ( + onBaseBranchChange(item.name, false)} + > + + {!baseBranchRemote && item.name === selectedBaseBranch ? : null} + + {item.name} + {item.current ? current : null} + {item.worktreePath ? worktree : null} + + ))} + {Object.entries(remoteGroups).sort(([a], [b]) => a.localeCompare(b)).map(([remote, items]) => ( +
+
{remote}
+ {items.map((item) => { + const displayName = item.name.slice(remote.length + 1); + return ( + onBaseBranchChange(item.name, true)} + > + + {baseBranchRemote && item.name === selectedBaseBranch ? : null} + + {displayName} + + ); + })} +
+ ))} +
+
+
+
); } diff --git a/apps/desktop/src/renderer/src/components/Sidebar.tsx b/apps/desktop/src/renderer/src/components/Sidebar.tsx index 0ec4ffb..326ca07 100644 --- a/apps/desktop/src/renderer/src/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { Dialog } from "@base-ui/react/dialog"; import { Menu } from "@base-ui/react/menu"; import { IconArchive, @@ -9,8 +10,10 @@ import { IconFolder, IconFolderOpen, IconFolderPlus, + IconGitBranch, IconGridDots, IconLayoutSidebar, + IconMessage, IconPencil, IconPin, IconPinnedOff, @@ -23,6 +26,7 @@ import { animate, m, useMotionValue } from "motion/react"; import { type MouseEvent, type PointerEvent, + type ReactElement, type ReactNode, useEffect, useRef, @@ -32,6 +36,7 @@ import type { AgentSessionInfo, WorkspaceInfo } from "../../../shared/contracts" import type { SessionActivity } from "../features/agent/agentEventHub"; import { SessionStatusDot } from "../features/agent/SessionStatusDot"; import { cn } from "../lib/cn"; +import { Tooltip } from "./ui/Tooltip"; import { CollapsibleMotion } from "./ui/CollapsibleMotion"; import { ToolbarButton } from "./ui/ToolbarButton"; @@ -56,7 +61,7 @@ type SidebarProps = { onPinSession(session: AgentSessionInfo, pinned: boolean): void; onArchiveSession(session: AgentSessionInfo): void; onRestoreSession(session: AgentSessionInfo): void; - onDeleteSession(session: AgentSessionInfo): void; + onDeleteSession(session: AgentSessionInfo, cleanupWorktree?: boolean): void; onListArchivedSessions(workspaceId: string): Promise; onPinProject(id: string, pinned: boolean): void; onRenameProject(id: string, displayName: string): void; @@ -306,7 +311,7 @@ function WorkspaceItem({ onPinSession(session: AgentSessionInfo, pinned: boolean): void; onArchiveSession(session: AgentSessionInfo): void; onRestoreSession(session: AgentSessionInfo): void; - onDeleteSession(session: AgentSessionInfo): void; + onDeleteSession(session: AgentSessionInfo, cleanupWorktree?: boolean): void; onListArchivedSessions(workspaceId: string): Promise; renaming: boolean; onStartRename(): void; @@ -367,18 +372,13 @@ function WorkspaceItem({ event.stopPropagation(); onArchiveSession(session); }} - onDelete={(event) => { - event.stopPropagation(); - onDeleteSession(session); - }} + onDelete={(cleanupWorktree) => onDeleteSession(session, cleanupWorktree)} onPin={(event) => { event.stopPropagation(); onPinSession(session, !session.pinnedAt); }} onSelect={() => onSelectSession(session)} - pinned={Boolean(session.pinnedAt)} - title={session.title} - updatedAt={session.updatedAt} + session={session} /> ))} @@ -409,31 +409,76 @@ function WorkspaceItem({ ); } +function SessionRowTooltip({ + session, + children, +}: { + session: AgentSessionInfo; + children: ReactElement; +}) { + return ( + } + motion="fade" + side="right" + sideOffset={8} + > + {children} + + ); +} + +function SessionHoverDetails({ session }: { session: AgentSessionInfo }) { + const worktree = session.worktree; + const branch = session.branch ?? worktree?.branch; + const folder = worktree?.path ?? session.cwd; + return ( +
+
+ + + {session.title} + +
+
+ + + {branch ?? "Unknown branch"} + +
+
+ + + {folder} + +
+
+ ); +} + function SessionRow({ - title, - updatedAt, + session, isActive, - pinned, activity, onSelect, onPin, onArchive, onDelete, }: { - title: string; - updatedAt: string; + session: AgentSessionInfo; isActive: boolean; - pinned: boolean; activity: SessionActivity | undefined; onSelect(): void; onPin(event: MouseEvent): void; onArchive(event: MouseEvent): void; - onDelete(event: MouseEvent): void; + onDelete(cleanupWorktree?: boolean): void; }) { const [confirmDelete, setConfirmDelete] = useState(false); + const [worktreeDeleteOpen, setWorktreeDeleteOpen] = useState(false); const hasStatus = Boolean( activity && (activity.running || activity.needsInput || activity.unread || activity.failed), ); + const worktree = session.worktree; useEffect(() => { if (!confirmDelete) { return; @@ -442,68 +487,141 @@ function SessionRow({ return () => window.clearTimeout(timeout); }, [confirmDelete]); + function confirmSessionDelete(): void { + setConfirmDelete(false); + if (worktree) { + setWorktreeDeleteOpen(true); + return; + } + onDelete(); + } + return ( - { - if (!event.currentTarget.contains(event.relatedTarget)) { - setConfirmDelete(false); - } - }} - onMouseLeave={() => setConfirmDelete(false)} - transition={{ duration: 0.14, ease: "easeOut" }} - > - - - - {pinned ? : } - - - - - {confirmDelete ? ( - - ) : ( - { - event.stopPropagation(); - setConfirmDelete(true); - }} - > - + {session.title} + {worktree ? ( + + + worktree + + ) : null} + + {formatRelativeTime(session.updatedAt)} + + {hasStatus ? ( + + ) : null} + + + + {session.pinnedAt ? ( + + ) : ( + + )} - )} - - + + + + {confirmDelete ? ( + + ) : ( + { + event.stopPropagation(); + setConfirmDelete(true); + }} + > + + + )} + + + + {worktree ? ( + + + + + + Delete worktree chat? + + + {worktree.branch} + + {worktree.path} + + +

+ Clean up removes this worktree, its branch, and uncommitted changes. +

+
+ + + +
+
+
+
+ ) : null} + ); } diff --git a/apps/desktop/src/shared/contracts.ts b/apps/desktop/src/shared/contracts.ts index 80c91e5..1bdca18 100644 --- a/apps/desktop/src/shared/contracts.ts +++ b/apps/desktop/src/shared/contracts.ts @@ -13,6 +13,8 @@ export type AgentSessionInfo = { workspaceId: string; title: string; cwd: string; + /** Branch checked out for this session, when known. */ + branch?: string; status: "starting" | AgentRunStatus | "idle" | "exited" | "error"; runtime?: "pi-sdk" | "pi-rpc"; model?: string; @@ -22,6 +24,7 @@ export type AgentSessionInfo = { subagentTask?: string; subagentType?: string; subagentReadOnly?: boolean; + worktree?: SessionWorktreeInfo; subagentWorktree?: SubagentWorktreeInfo; pinnedAt?: string; archivedAt?: string; @@ -31,6 +34,14 @@ export type AgentSessionInfo = { export type AgentRunStatus = "running" | "completed" | "failed" | "blocked" | "cancelled"; +export type SessionWorktreeInfo = { + path: string; + branch: string; + baseBranch: string; + baseSha: string; + status: "active" | "cleaned"; +}; + export type SubagentWorktreeInfo = { path: string; branch: string;