diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1ec8cd968..f8fd2cb56 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -194,6 +194,7 @@ type PersistedChatState = { preferredExecutionLaneId?: string | null; selectedExecutionLaneId?: string | null; lastLaneDirectiveKey?: string | null; + manuallyNamed?: boolean; updatedAt: string; }; @@ -566,6 +567,7 @@ type ManagedChatSession = { autoTitleSeed: string | null; autoTitleStage: "none" | "initial" | "final"; autoTitleInFlight: boolean; + manuallyNamed: boolean; summaryInFlight: boolean; activeAssistantMessageId: string | null; lastActivitySignature: string | null; @@ -2844,6 +2846,7 @@ export function createAgentChatService(args: { ): Promise => { const config = resolveChatConfig(); if (!config.autoTitleEnabled) return; + if (managed.manuallyNamed) return; if (managed.autoTitleInFlight) return; if (args.stage === "initial" && managed.autoTitleStage !== "none") return; if (args.stage === "final") { @@ -2909,6 +2912,8 @@ export function createAgentChatService(args: { titleContext.join("\n"), ].join("\n\n"), }); + // Re-check after async — user may have manually renamed while the request was in flight. + if (managed.manuallyNamed) return; const nextTitle = setManagedSessionTitle(managed, result.text); if (!nextTitle) return; managed.autoTitleStage = args.stage; @@ -3178,6 +3183,7 @@ export function createAgentChatService(args: { ...(managed.preferredExecutionLaneId ? { preferredExecutionLaneId: managed.preferredExecutionLaneId } : {}), ...(managed.selectedExecutionLaneId ? { selectedExecutionLaneId: managed.selectedExecutionLaneId } : {}), ...(managed.lastLaneDirectiveKey ? { lastLaneDirectiveKey: managed.lastLaneDirectiveKey } : {}), + manuallyNamed: Boolean(managed.manuallyNamed), updatedAt: nowIso() }; @@ -3293,6 +3299,7 @@ export function createAgentChatService(args: { ...(typeof record.lastLaneDirectiveKey === "string" && record.lastLaneDirectiveKey.trim().length ? { lastLaneDirectiveKey: record.lastLaneDirectiveKey.trim() } : {}), + ...(record.manuallyNamed === true ? { manuallyNamed: true } : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; hydrateNativePermissionControls(hydrated as Parameters[0]); @@ -4121,6 +4128,7 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: hasCustomChatSessionTitle(row.title, provider) ? "initial" : "none", autoTitleInFlight: false, + manuallyNamed: persisted?.manuallyNamed === true, summaryInFlight: false, continuitySummary: persisted?.continuitySummary ?? null, continuitySummaryUpdatedAt: persisted?.continuitySummaryUpdatedAt ?? null, @@ -7303,6 +7311,13 @@ export function createAgentChatService(args: { return; } + // Apply permission mode before the first interaction so the session + // starts with the correct approval behaviour selected in the rebase tab. + const initialPermissionMode = resolveClaudeTurnPermissionMode(managed); + if (typeof runtime.v2Session.setPermissionMode === "function") { + await runtime.v2Session.setPermissionMode(initialPermissionMode); + } + await runtime.v2Session.send("System initialization check. Respond with only the word READY."); for await (const msg of runtime.v2Session.stream()) { if (runtime.v2WarmupCancelled) break; @@ -7456,6 +7471,7 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: "none", autoTitleInFlight: false, + manuallyNamed: false, summaryInFlight: false, continuitySummary: null, continuitySummaryUpdatedAt: null, @@ -7613,11 +7629,13 @@ export function createAgentChatService(args: { automationId, automationRunId, computerUse, + requestedCwd, }: AgentChatCreateArgs): Promise => { const launchContext = resolveLaneLaunchContext({ laneService, laneId, purpose: "start this chat", + requestedCwd, }); const sessionId = randomUUID(); const startedAt = nowIso(); @@ -7763,6 +7781,7 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: "none", autoTitleInFlight: false, + manuallyNamed: false, summaryInFlight: false, continuitySummary: null, continuitySummaryUpdatedAt: null, @@ -8113,6 +8132,14 @@ export function createAgentChatService(args: { if (reasoningEffort) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); } + // Re-sync permission mode so mid-session changes take effect on this turn. + if (managed.runtime?.kind === "unified") { + const chatConfig = resolveChatConfig(); + managed.runtime.permissionMode = resolveSessionUnifiedPermissionMode( + managed.session, + chatConfig.unifiedPermissionMode, + ); + } await runTurn(managed, { promptText, displayText: visibleText, @@ -8133,6 +8160,20 @@ export function createAgentChatService(args: { managed.session.reasoningEffort = DEFAULT_REASONING_EFFORT; } + // Re-sync codex approval policy so mid-session changes take effect on this turn. + if (runtime.threadResumed) { + const prevApproval = managed.session.codexApprovalPolicy; + const prevSandbox = managed.session.codexSandbox; + resolveCodexThreadParams(managed); + if ( + managed.session.codexApprovalPolicy !== prevApproval + || managed.session.codexSandbox !== prevSandbox + ) { + // Policy drifted — force a re-resume so the codex server picks up the new settings. + runtime.threadResumed = false; + } + } + if (!runtime.threadResumed) { const threadIdToResume = managed.session.threadId || readPersistedState(sessionId)?.threadId; const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); @@ -8487,6 +8528,11 @@ export function createAgentChatService(args: { await startFreshCodexThread(managed, runtime, codexPolicy, mcpServers); } } + // Re-sync codex approval policy from persisted/config settings + managed.session.codexApprovalPolicy = persisted?.codexApprovalPolicy ?? managed.session.codexApprovalPolicy; + managed.session.codexSandbox = persisted?.codexSandbox ?? managed.session.codexSandbox; + managed.session.codexConfigSource = persisted?.codexConfigSource ?? managed.session.codexConfigSource; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; } else if (managed.runtime?.kind === "unified" || (managed.session.modelId && !providerResolver.isModelCliWrapped(managed.session.modelId))) { // Unified runtime resume — re-resolve the model const result = await startUnifiedSession(managed); @@ -8509,11 +8555,21 @@ export function createAgentChatService(args: { } // Fallthrough to Claude — SDK manages history via sdkSessionId ensureClaudeSessionRuntime(managed); + // Re-sync permission mode from persisted/config settings + const fallbackPermMode = resolveClaudeTurnPermissionMode(managed); + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && typeof managed.runtime.v2Session.setPermissionMode === "function") { + await managed.runtime.v2Session.setPermissionMode(fallbackPermMode); + } sessionService.setResumeCommand(sessionId, `chat:claude:${sessionId}`); } } else { // Claude — SDK manages history via sdkSessionId ensureClaudeSessionRuntime(managed); + // Re-sync permission mode from persisted/config settings + const claudePermMode = resolveClaudeTurnPermissionMode(managed); + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && typeof managed.runtime.v2Session.setPermissionMode === "function") { + await managed.runtime.v2Session.setPermissionMode(claudePermMode); + } sessionService.setResumeCommand(sessionId, `chat:claude:${sessionId}`); } @@ -8948,6 +9004,7 @@ export function createAgentChatService(args: { const updateSession = async ({ sessionId, title, + manuallyNamed, modelId, reasoningEffort, interactionMode, @@ -9157,10 +9214,20 @@ export function createAgentChatService(args: { if (title !== undefined) { const normalizedTitle = String(title ?? "").trim(); + const hasExplicitTitle = normalizedTitle.length > 0; sessionService.updateMeta({ sessionId, - title: normalizedTitle.length ? normalizedTitle : defaultChatSessionTitle(managed.session.provider), + title: hasExplicitTitle ? normalizedTitle : defaultChatSessionTitle(managed.session.provider), }); + if (manuallyNamed !== undefined) { + managed.manuallyNamed = manuallyNamed && hasExplicitTitle; + } else if (!hasExplicitTitle) { + managed.manuallyNamed = false; + } + } + // Allow resetting manuallyNamed independently when no title change is provided + if (manuallyNamed !== undefined && title === undefined) { + managed.manuallyNamed = manuallyNamed; } persistChatState(managed); diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index dcc7e82b2..0a3661806 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -608,7 +608,18 @@ export function createGitOperationsService({ } else if (normalizedBehind > 0 && normalizedAhead === 0) { recommendedAction = "pull"; } else if (diverged) { - recommendedAction = "pull"; + // Check if local HEAD contains the upstream tip. When both sides have + // unique commits, the upstream tip is almost never an ancestor of HEAD + // (that only happens after a local rebase that replayed upstream), so + // the safest default for genuine divergence is "pull" (merge upstream + // into local). We only suggest force-push when we can confirm the + // upstream tip IS already an ancestor — meaning the local branch was + // rebased on top of upstream and only needs a force-push to publish. + const mergeBaseRes = await runGit(["merge-base", "--is-ancestor", upstreamRef, "HEAD"], { + cwd: lane.worktreePath, + timeoutMs: 5_000 + }); + recommendedAction = mergeBaseRes.exitCode === 0 ? "force_push_lease" : "pull"; } return { diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts new file mode 100644 index 000000000..9f631de0b --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + statSync: vi.fn(), + realpathSync: vi.fn(), + resolvePathWithinRoot: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + default: { + statSync: mocks.statSync, + realpathSync: mocks.realpathSync, + }, + statSync: mocks.statSync, + realpathSync: mocks.realpathSync, +})); + +vi.mock("../shared/utils", () => ({ + resolvePathWithinRoot: mocks.resolvePathWithinRoot, +})); + +// --------------------------------------------------------------------------- +// Import the module under test AFTER mocks are set up +// --------------------------------------------------------------------------- + +import { resolveLaneLaunchContext } from "./laneLaunchContext"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeLaneService(worktreePath: string) { + return { + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/test", + worktreePath, + laneType: "standard" as const, + })), + } as unknown as Parameters[0]["laneService"]; +} + +function setupDirectoryExists(realPath: string) { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync.mockReturnValue(realPath); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("resolveLaneLaunchContext", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("happy path: no custom cwd", () => { + it("returns lane root as both laneWorktreePath and cwd", () => { + setupDirectoryExists("/real/lane/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root", + }); + }); + + it("treats null requestedCwd the same as no cwd", () => { + setupDirectoryExists("/real/lane/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: null, + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root", + }); + }); + + it("treats empty-string requestedCwd the same as no cwd", () => { + setupDirectoryExists("/real/lane/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: " ", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root", + }); + }); + }); + + describe("happy path: valid relative cwd inside worktree", () => { + it("resolves relative cwd within lane root", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync + .mockReturnValueOnce("/real/lane/root") // first ensureDirectoryExists (lane root) + .mockReturnValueOnce("/real/lane/root/src"); // second ensureDirectoryExists (cwd) + mocks.resolvePathWithinRoot.mockReturnValue("/real/lane/root/src"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "src", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root/src", + }); + expect(mocks.resolvePathWithinRoot).toHaveBeenCalledOnce(); + }); + }); + + describe("happy path: valid absolute cwd inside worktree", () => { + it("resolves absolute cwd within lane root", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync + .mockReturnValueOnce("/real/lane/root") // first ensureDirectoryExists (lane root) + .mockReturnValueOnce("/real/lane/root/packages/core"); // second ensureDirectoryExists (cwd) + mocks.resolvePathWithinRoot.mockReturnValue("/real/lane/root/packages/core"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "/real/lane/root/packages/core", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root/packages/core", + }); + expect(mocks.resolvePathWithinRoot).toHaveBeenCalledWith( + "/real/lane/root", + "/real/lane/root/packages/core", + ); + }); + }); + + describe("error: lane has no worktree configured", () => { + it("throws when worktreePath is empty string", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(""), + laneId: "lane-orphan", + purpose: "launch terminal", + }), + ).toThrow("Lane 'lane-orphan' has no worktree configured"); + }); + + it("throws when worktreePath is whitespace-only", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(" "), + laneId: "lane-ws", + purpose: "launch terminal", + }), + ).toThrow("Lane 'lane-ws' has no worktree configured"); + }); + + it("includes the purpose in the error message", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(""), + laneId: "lane-1", + purpose: "run tests", + }), + ).toThrow("ADE cannot run tests outside the selected lane"); + }); + }); + + describe("error: lane worktree directory doesn't exist", () => { + it("throws when statSync fails (directory missing)", () => { + mocks.statSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/gone/lane"), + laneId: "lane-gone", + purpose: "deploy", + }), + ).toThrow("worktree is unavailable"); + }); + + it("throws when path is not a directory", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => false }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/some/file.txt"), + laneId: "lane-file", + purpose: "build", + }), + ).toThrow("worktree is unavailable"); + }); + + it("throws when realpathSync fails after stat succeeds", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync.mockImplementation(() => { + throw new Error("EACCES"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/broken/symlink"), + laneId: "lane-broken", + purpose: "launch agent", + }), + ).toThrow("worktree is unavailable"); + }); + }); + + describe("error: requested cwd escapes lane root (path traversal)", () => { + it("throws with descriptive message when resolvePathWithinRoot detects traversal", () => { + setupDirectoryExists("/real/lane/root"); + mocks.resolvePathWithinRoot.mockImplementation(() => { + throw new Error("Path escapes root"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "../../etc/passwd", + purpose: "start agent", + }), + ).toThrow("escapes lane 'lane-1'"); + }); + + it("re-throws non-traversal errors from resolvePathWithinRoot", () => { + setupDirectoryExists("/real/lane/root"); + mocks.resolvePathWithinRoot.mockImplementation(() => { + throw new Error("Permission denied"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "src", + purpose: "start agent", + }), + ).toThrow("Permission denied"); + }); + }); + + describe("error: requested cwd doesn't exist inside worktree", () => { + it("throws when cwd directory does not exist after path validation", () => { + // First ensureDirectoryExists (for lane root) succeeds + setupDirectoryExists("/real/lane/root"); + mocks.resolvePathWithinRoot.mockReturnValue("/real/lane/root/nonexistent"); + + // Second ensureDirectoryExists (for resolved cwd) fails + let callCount = 0; + mocks.statSync.mockImplementation(() => { + callCount++; + if (callCount <= 1) { + // First call: lane root check succeeds + return { isDirectory: () => true }; + } + // Second call: cwd check fails + throw new Error("ENOENT"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "nonexistent", + purpose: "start agent", + }), + ).toThrow("is not an existing directory inside lane"); + }); + }); + + describe("edge cases", () => { + it("trims laneId whitespace", () => { + setupDirectoryExists("/real/lane/root"); + + const laneService = makeLaneService("/projects/my-lane"); + resolveLaneLaunchContext({ + laneService, + laneId: " lane-1 ", + purpose: "test", + }); + + // Verify getLaneBaseAndBranch was called with the trimmed laneId + expect(laneService.getLaneBaseAndBranch).toHaveBeenCalledWith("lane-1"); + }); + + it("uses 'launch work' as default purpose when purpose is empty", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(""), + laneId: "lane-1", + purpose: "", + }), + ).toThrow("ADE cannot launch work outside the selected lane"); + }); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.ts index b248300da..d7006f766 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.ts @@ -144,6 +144,7 @@ export async function launchRebaseResolutionChat( unifiedPermissionMode: mapPermissionMode(args.permissionMode) as import("../../../shared/types").AgentChatUnifiedPermissionMode, surface: "work", sessionProfile: "workflow", + requestedCwd: lane.worktreePath, }); deps.sessionService.updateMeta({ sessionId: session.id, title }); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 63d327b8b..19c7fb8a1 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -465,7 +465,7 @@ describe("ptyService", () => { }); mockPty._emitter.emit("data", "generated enough output for a better title"); - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(6000); expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/tmp/test-worktree/subdir" }), diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index bddb3ca92..d749a2e6b 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -239,6 +239,18 @@ export function createPtyService({ return ai?.sessionIntelligence; }; + const isTitleGenerationEnabled = (): boolean => { + const si = getSessionIntelligence(); + const ai = projectConfigService?.get().effective.ai; + return si?.titles?.enabled ?? (ai?.chat as any)?.autoTitleEnabled ?? true; + }; + + const resolveTitleModelId = (): string | undefined => { + const si = getSessionIntelligence(); + const raw = si?.titles?.modelId; + return typeof raw === "string" && raw.trim().length ? raw.trim() : undefined; + }; + /** Only orchestrated worker sessions auto-close after the wrapped CLI exits back to shell. */ const TOOL_TYPES_WITH_AUTO_CLOSE = new Set([ "claude-orchestrated", @@ -337,33 +349,88 @@ export function createPtyService({ sessionService.setSummary(sessionId, summary); const si = getSessionIntelligence(); - if (si?.summaries?.enabled === false) return; - if (!aiIntegrationService || aiIntegrationService.getMode() === "guest") return; - - const prompt = [ - "You are ADE's terminal summary assistant.", - "Rewrite this terminal session into a concise 1-3 sentence summary with outcome and next action.", - "Do not invent commands or outcomes.", - "", - "Deterministic summary:", - summary, - "", - "Terminal transcript tail:", - transcript.slice(-18_000) - ].join("\n"); - - const summaryModelId = typeof si?.summaries?.modelId === "string" && si.summaries.modelId.trim().length - ? si.summaries.modelId.trim() - : undefined; - - const aiSummary = await aiIntegrationService.summarizeTerminal({ - cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, - prompt, - ...(summaryModelId ? { model: summaryModelId } : {}), - }); - const text = aiSummary.text.trim(); - if (text.length) { - sessionService.setSummary(sessionId, text); + const hasAi = Boolean(aiIntegrationService && aiIntegrationService.getMode() !== "guest"); + + // AI-enhanced summary (only when summaries are enabled and AI is available) + if (si?.summaries?.enabled !== false && hasAi) { + try { + const prompt = [ + "You are ADE's terminal summary assistant.", + "Rewrite this terminal session into a concise 1-3 sentence summary with outcome and next action.", + "Do not invent commands or outcomes.", + "", + "Deterministic summary:", + summary, + "", + "Terminal transcript tail:", + transcript.slice(-18_000) + ].join("\n"); + + const summaryModelId = typeof si?.summaries?.modelId === "string" && si.summaries.modelId.trim().length + ? si.summaries.modelId.trim() + : undefined; + + const aiSummary = await aiIntegrationService!.summarizeTerminal({ + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, + prompt, + ...(summaryModelId ? { model: summaryModelId } : {}), + }); + const text = aiSummary.text.trim(); + if (text.length) { + sessionService.setSummary(sessionId, text); + } + } catch (err) { + logger.warn("pty.ai_summary_failed", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Refresh title on complete — runs independently of AI summaries toggle + if (hasAi) { + const refreshOnComplete = getSessionIntelligence()?.titles?.refreshOnComplete + ?? (projectConfigService?.get().effective.ai?.chat as any)?.autoTitleRefreshOnComplete + ?? true; + if (refreshOnComplete && isTitleGenerationEnabled()) { + try { + const titlePrompt = [ + "Generate a concise final title for this completed terminal session.", + "Return only plain text, max 80 characters, no punctuation at the end.", + "", + `Session type: ${session.toolType ?? "terminal"}`, + `Initial title: ${session.title}`, + session.goal ? `Current goal: ${session.goal}` : null, + `Exit code: ${session.exitCode ?? "unknown"}`, + "", + "Terminal transcript tail:", + transcript.slice(-2000), + ].filter(Boolean).join("\n"); + + const titleModelId = resolveTitleModelId(); + const titleResult = await aiIntegrationService!.summarizeTerminal({ + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, + prompt: titlePrompt, + timeoutMs: 8_000, + ...(titleModelId ? { model: titleModelId } : {}), + }); + const finalTitle = titleResult.text.trim().replace(/\s+/g, " ").slice(0, 80); + if (finalTitle) { + // Guard: skip if user renamed the session while the AI call was in-flight + const current = sessionService.get(sessionId); + if (current && current.title !== session.title) { + logger.info("pty.session_title_refresh_skipped_user_renamed", { sessionId }); + } else { + sessionService.updateMeta({ sessionId, title: finalTitle }); + } + } + } catch (err) { + logger.warn("pty.session_title_refresh_failed", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } } }) .catch(() => { @@ -741,7 +808,7 @@ export function createPtyService({ // Accumulate initial output for session title generation if (!titleBufferFull) { titleOutputBuffer += data; - if (titleOutputBuffer.length >= 500) { + if (titleOutputBuffer.length >= 800) { titleBufferFull = true; } } @@ -770,14 +837,13 @@ export function createPtyService({ } } - // Fire-and-forget: after 4s, attempt AI title generation for non-shell sessions - if (aiIntegrationService && aiIntegrationService.getMode() === "subscription") { + // Fire-and-forget: after 6s, attempt AI title generation for non-shell sessions + if (aiIntegrationService && aiIntegrationService.getMode() !== "guest") { const capturedAi = aiIntegrationService; setTimeout(() => { if (entry.disposed) return; - const si = getSessionIntelligence(); - if (si?.titles?.enabled === false) return; + if (!isTitleGenerationEnabled()) return; const strippedOutput = stripAnsi(titleOutputBuffer).trim(); if (strippedOutput.length < 10) return; @@ -793,13 +859,10 @@ export function createPtyService({ "Return only plain text, max 80 characters, no punctuation at the end.", "", "Initial output:", - strippedOutput.slice(0, 500) + strippedOutput.slice(0, 800) ].join("\n"); - const titleModelId = typeof si?.titles?.modelId === "string" && si.titles.modelId.trim().length - ? si.titles.modelId.trim() - : undefined; - + const titleModelId = resolveTitleModelId(); capturedAi .summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, @@ -810,7 +873,13 @@ export function createPtyService({ .then((result) => { const title = result.text.trim().replace(/\s+/g, " ").slice(0, 80); if (title) { - sessionService.updateMeta({ sessionId, goal: title }); + // Guard: skip if user renamed the session while the AI call was in-flight + const current = sessionService.get(sessionId); + if (current && current.title !== session.title) { + logger.info("pty.session_title_skipped_user_renamed", { sessionId }); + } else { + sessionService.updateMeta({ sessionId, title }); + } } }) .catch((err) => { @@ -819,7 +888,7 @@ export function createPtyService({ error: err instanceof Error ? err.message : String(err) }); }); - }, 4000); + }, 6000); } logger.info("pty.create", { ptyId, sessionId, laneId, cwd, shell: selectedShell?.file ?? "unknown" }); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index bbe87f620..fca0f150c 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -384,6 +384,7 @@ function parseAgentChatUpdateSessionArgs(value: Record): AgentC if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; if ("unifiedPermissionMode" in value) parsed.unifiedPermissionMode = value.unifiedPermissionMode == null ? undefined : asTrimmedString(value.unifiedPermissionMode) as AgentChatUpdateSessionArgs["unifiedPermissionMode"]; if ("computerUse" in value) parsed.computerUse = value.computerUse == null ? null : value.computerUse as AgentChatUpdateSessionArgs["computerUse"]; + if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; return parsed; } diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 7ef4a5770..2172f86d5 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -891,6 +891,13 @@ export function LaneGitActionsPane({ detail: `${syncStatus.ahead} local commit${syncStatus.ahead === 1 ? "" : "s"} are ready to send to remote.` }; } + if (syncStatus.recommendedAction === "force_push_lease") { + return { + action: "force_push_lease", + label: "Force push (lease)", + detail: "Local history was rewritten (e.g. after a rebase). Force push to update the remote branch." + }; + } if (syncStatus.recommendedAction === "pull") { if (syncStatus.diverged) { return { @@ -1358,14 +1365,14 @@ export function LaneGitActionsPane({ type="button" style={{ ...outlineButton({ height: 30, padding: "0 10px", fontSize: 10, borderRadius: 6 }), - ...(nextActionHint?.action === "push" ? { color: COLORS.accent, border: `1px solid ${COLORS.accent}40`, background: `${COLORS.accent}08` } : {}), + ...(nextActionHint?.action === "push" || nextActionHint?.action === "force_push_lease" ? { color: COLORS.accent, border: `1px solid ${COLORS.accent}40`, background: `${COLORS.accent}08` } : {}), }} disabled={!laneId || busyAction != null} - onClick={() => runPush(false)} - title={getPushSummary(syncStatus)} + onClick={() => runPush(nextActionHint?.action === "force_push_lease")} + title={nextActionHint?.action === "force_push_lease" ? "Force push (lease) — history was rewritten" : getPushSummary(syncStatus)} > - {syncStatus?.hasUpstream === false ? "PUBLISH" : "PUSH"} + {syncStatus?.hasUpstream === false ? "PUBLISH" : nextActionHint?.action === "force_push_lease" ? "FORCE PUSH" : "PUSH"} {lane?.parentLaneId ? ( + )} + {isRunning && session.ptyId && !isChat ? (