diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 4ec3b37d..dbd8367c 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -7,7 +7,17 @@ import { type ClaudeStartupProbeResult, } from "./packagedRuntimeSmokeShared"; -const PTY_PROBE_TIMEOUT_MS = process.platform === "win32" ? 15_000 : 4_000; +function positiveIntegerEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +const PTY_PROBE_TIMEOUT_MS = positiveIntegerEnv( + "ADE_PACKAGED_PTY_PROBE_TIMEOUT_MS", + process.platform === "win32" ? 15_000 : 4_000, +); const CLAUDE_PROBE_TIMEOUT_MS = 20_000; async function probePty(): Promise<{ ok: true; output: string }> { @@ -16,7 +26,7 @@ async function probePty(): Promise<{ ok: true; output: string }> { let output = ""; const shellSpec = process.platform === "win32" - ? { file: "powershell.exe", args: ["-NoProfile", "-Command", 'Write-Output "ADE_PTY_OK"'] } + ? { file: "cmd.exe", args: ["/d", "/s", "/c", "echo ADE_PTY_OK"] } : { file: "/bin/sh", args: ["-lc", 'printf "ADE_PTY_OK\\n"'] }; const term = pty.spawn(shellSpec.file, shellSpec.args, { name: "xterm-256color", @@ -32,7 +42,7 @@ async function probePty(): Promise<{ ok: true; output: string }> { } catch { // ignore best-effort cleanup } - reject(new Error(`PTY probe timed out after ${PTY_PROBE_TIMEOUT_MS}ms`)); + reject(new Error(`PTY probe timed out after ${PTY_PROBE_TIMEOUT_MS}ms using ${shellSpec.file}`)); }, PTY_PROBE_TIMEOUT_MS); term.onData((chunk) => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts b/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts index c0fa9409..3f06e614 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { runOpenCodeTextPrompt } from "../opencode/openCodeRuntime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@opencode-ai/sdk", () => ({ @@ -298,9 +297,12 @@ function createMockSessionService() { } as any; } -function createMockProjectConfigService(options: { titleGenerationEnabled?: boolean } = {}) { - const sessionIntelligence = typeof options.titleGenerationEnabled === "boolean" - ? { titles: { enabled: options.titleGenerationEnabled } } +function createMockProjectConfigService(options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}) { + const titleOptions: Record = {}; + if (typeof options.titleGenerationEnabled === "boolean") titleOptions.enabled = options.titleGenerationEnabled; + if (options.titleModelId) titleOptions.modelId = options.titleModelId; + const sessionIntelligence = Object.keys(titleOptions).length + ? { titles: titleOptions } : {}; return { get: vi.fn(() => ({ @@ -390,7 +392,7 @@ function createMockIssueInventoryService() { } as any; } -function createService(options: { titleGenerationEnabled?: boolean } = {}) { +function createService(options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}) { const logger = createLogger(); const laneService = createMockLaneService(); const sessionService = createMockSessionService(); @@ -408,6 +410,7 @@ function createService(options: { titleGenerationEnabled?: boolean } = {}) { getSettings: vi.fn(() => ({})), updateSettings: vi.fn(), getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "Parallel Task" })), }; const service = createAgentChatService({ @@ -424,7 +427,7 @@ function createService(options: { titleGenerationEnabled?: boolean } = {}) { getDirtyFileTextForPath: () => undefined, }); - return { service, logger }; + return { service, logger, aiIntegrationService }; } // --------------------------------------------------------------------------- @@ -437,7 +440,6 @@ beforeEach(() => { fs.mkdirSync(path.join(tmpRoot, ".ade", "transcripts", "chat"), { recursive: true }); mockState.sessions.clear(); mockState.uuidCounter = 0; - vi.mocked(runOpenCodeTextPrompt).mockReset(); vi.mocked(detectAllAuth).mockResolvedValue([]); }); @@ -531,9 +533,9 @@ describe("suggestLaneNameFromPrompt", () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); - vi.mocked(runOpenCodeTextPrompt).mockRejectedValue(new Error("API rate limited")); - const { service, logger } = createService(); + const { service, logger, aiIntegrationService } = createService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockRejectedValue(new Error("API rate limited")); const result = await service.suggestLaneNameFromPrompt({ prompt: "Write a test suite", modelId: "anthropic/claude-haiku-4-5", @@ -549,17 +551,12 @@ describe("suggestLaneNameFromPrompt", () => { ); }); - it("uses the explicit fallback name when title generation is disabled", async () => { + it("keeps the prompt fallback readable while adding the temporary suffix when title generation is disabled", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); - vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ - text: "Login Bug Fix", - inputTokens: 10, - outputTokens: 5, - } as any); - const { service } = createService({ titleGenerationEnabled: false }); + const { service, aiIntegrationService } = createService({ titleGenerationEnabled: false }); const result = await service.suggestLaneNameFromPrompt({ prompt: "Fix the authentication login failure in the dashboard", modelId: "anthropic/claude-haiku-4-5", @@ -567,21 +564,33 @@ describe("suggestLaneNameFromPrompt", () => { fallbackName: "chat-20260514-010203", }); + expect(result).toBe("fix-the-authentication-login-20260514-010203"); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + }); + + it("uses the explicit fallback directly when the prompt fallback is generic", async () => { + const { service } = createService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "!!!", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + fallbackName: "chat-20260514-010203", + }); + expect(result).toBe("chat-20260514-010203"); - expect(runOpenCodeTextPrompt).not.toHaveBeenCalled(); }); it("uses AI-generated name when the model runtime succeeds", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); - vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ + const { service, aiIntegrationService } = createService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ text: "Login Bug Fix", inputTokens: 10, outputTokens: 5, } as any); - const { service } = createService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "Fix the authentication login failure in the dashboard", modelId: "anthropic/claude-haiku-4-5", @@ -592,17 +601,48 @@ describe("suggestLaneNameFromPrompt", () => { expect(result).toBe("login-bug-fix"); }); + it("retries the configured title model when the requested Codex model cannot name the lane", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, + ]); + const { service, aiIntegrationService } = createService({ titleModelId: "openai/gpt-5.4-mini" }); + vi.mocked(aiIntegrationService.summarizeTerminal) + .mockRejectedValueOnce(new Error("GPT-5.5 unavailable for title task")) + .mockResolvedValueOnce({ + text: "Auto Create Lane Fix", + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix auto create lane routing and naming", + modelId: "openai/gpt-5.5", + laneId: "lane-1", + fallbackName: "chat-20260514-010203", + }); + + expect(result).toBe("auto-create-lane-fix"); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + model: "openai/gpt-5.5", + taskType: "session_title", + })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(2, expect.objectContaining({ + model: "openai/gpt-5.4-mini", + taskType: "session_title", + })); + }); + it("normalizes AI-generated name: strips special chars and lowercases", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); - vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ + const { service, aiIntegrationService } = createService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ text: "JWT Auth Refactor!", inputTokens: 10, outputTokens: 5, } as any); - const { service } = createService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "Refactor auth to use JWT", modelId: "anthropic/claude-haiku-4-5", @@ -618,13 +658,13 @@ describe("suggestLaneNameFromPrompt", () => { { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); const longName = "a".repeat(70); - vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ + const { service, aiIntegrationService } = createService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ text: longName, inputTokens: 10, outputTokens: 5, } as any); - const { service } = createService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "Do a very long task", modelId: "anthropic/claude-haiku-4-5", @@ -638,13 +678,13 @@ describe("suggestLaneNameFromPrompt", () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); - vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ + const { service, aiIntegrationService } = createService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ text: `${"a".repeat(55)}- tail`, inputTokens: 10, outputTokens: 5, } as any); - const { service } = createService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "Trim the generated lane name", modelId: "anthropic/claude-haiku-4-5", @@ -659,13 +699,13 @@ describe("suggestLaneNameFromPrompt", () => { { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); // Return only emoji/special chars that sanitizeAutoTitle will strip - vi.mocked(runOpenCodeTextPrompt).mockResolvedValue({ + const { service, aiIntegrationService } = createService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ text: "!!!", inputTokens: 10, outputTokens: 5, } as any); - const { service } = createService(); const result = await service.suggestLaneNameFromPrompt({ prompt: "Something useful", modelId: "anthropic/claude-haiku-4-5", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ffcde16a..4336103d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -239,7 +239,6 @@ import { openCodeEventStream, refreshOpenCodeSessionToolSelection, resolveOpenCodeModelSelection, - runOpenCodeTextPrompt, startOpenCodeSession, type DiscoveredLocalModelEntry, type OpenCodeSessionHandle, @@ -2479,9 +2478,11 @@ function sanitizeAutoTitle(raw: string, maxChars = AUTO_TITLE_MAX_CHARS): string return normalized.length > maxChars ? normalized.slice(0, maxChars).trimEnd() : normalized; } +const GENERIC_PROMPT_LANE_NAME = "parallel-task"; + function fallbackLaneNameFromPrompt(prompt: string): string { const collapsed = prompt.replace(/\s+/g, " "); - if (!collapsed.length) return "parallel-task"; + if (!collapsed.length) return GENERIC_PROMPT_LANE_NAME; const words = collapsed.split(/\s+/).filter(Boolean).slice(0, 4); const slug = words .join("-") @@ -2489,7 +2490,28 @@ function fallbackLaneNameFromPrompt(prompt: string): string { .replace(/[^a-z0-9-]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); - return slug.length ? slug.slice(0, 48) : "parallel-task"; + return slug.length ? slug.slice(0, 48) : GENERIC_PROMPT_LANE_NAME; +} + +function uniquePromptFallbackLaneName(promptFallback: string, explicitFallback: string | null): string { + if (promptFallback === GENERIC_PROMPT_LANE_NAME) { + return explicitFallback ?? promptFallback; + } + if (!explicitFallback) return promptFallback; + + const uniqueSuffix = explicitFallback + .replace(/^chat-?/u, "") + .replace(/[^a-z0-9-]+/giu, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase(); + if (!uniqueSuffix.length) return promptFallback; + + const maxPrefixLength = Math.max(1, 60 - uniqueSuffix.length - 1); + const prefix = promptFallback + .slice(0, maxPrefixLength) + .replace(/-+$/g, ""); + return `${prefix}-${uniqueSuffix}`; } function normalizeSuggestedLaneName(raw: string): string | null { @@ -6576,7 +6598,8 @@ export function createAgentChatService(args: { const explicitFallback = typeof args.fallbackName === "string" ? normalizeSuggestedLaneName(args.fallbackName) : null; - const fallback = () => explicitFallback ?? fallbackLaneNameFromPrompt(prompt); + const promptFallback = fallbackLaneNameFromPrompt(prompt); + const fallback = () => uniquePromptFallbackLaneName(promptFallback, explicitFallback); if (!prompt.length) { return fallback(); @@ -6594,43 +6617,53 @@ export function createAgentChatService(args: { } try { + const config = resolveChatConfig(); + if (config.titleGenerationEnabled === false) return fallback(); + const auth = await detectAuth(); const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); if (!availableModels.length) return fallback(); + const availableIds = new Set(availableModels.map((descriptor) => descriptor.id)); + const candidateModelIds = [ + requestedModelId, + config.titleModelId, + DEFAULT_AUTO_TITLE_MODEL_ID, + "anthropic/claude-haiku-4-5", + "openai/gpt-5.4-mini", + "openai/gpt-5.2", + "openai/gpt-5.4", + availableModels[0]?.id, + ].reduce((acc, candidate) => { + const modelId = typeof candidate === "string" ? candidate.trim() : ""; + if (!modelId || acc.includes(modelId) || !availableIds.has(modelId)) return acc; + return [...acc, modelId]; + }, []); - const config = resolveChatConfig(); - if (explicitFallback && config.titleGenerationEnabled === false) return fallback(); - const preferredModelId = - [ - requestedModelId, - config.titleModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - "anthropic/claude-haiku-4-5", - "openai/gpt-5.4-mini", - "openai/gpt-5.2", - "openai/gpt-5.4", - availableModels[0]?.id, - ].find((candidate) => { - const modelId = typeof candidate === "string" ? candidate.trim() : ""; - return modelId.length > 0 && availableModels.some((descriptor) => descriptor.id === modelId); - }) ?? null; - - if (!preferredModelId) return fallback(); - - const descriptor = getModelById(preferredModelId); - if (!descriptor) return fallback(); - - const result = await runOpenCodeTextPrompt({ - directory: cwd, - title: "ADE lane name from prompt", - modelDescriptor: descriptor, - system: LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, - prompt: `User message for the new lane:\n${prompt.slice(0, 2000)}`, - projectConfig: projectConfigService.get().effective, - }); - return normalizeSuggestedLaneName(result.text) ?? fallback(); + for (const candidateModelId of candidateModelIds) { + const descriptor = getModelById(candidateModelId); + if (!descriptor) continue; + try { + const result = await runSessionIntelligencePrompt({ + cwd, + modelId: descriptor.id, + systemPrompt: LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, + prompt: `User message for the new lane:\n${prompt.slice(0, 2000)}`, + taskType: "session_title", + }); + const normalized = normalizeSuggestedLaneName(result.text); + if (normalized) return normalized; + } catch (error) { + logger.warn("agent_chat.suggest_lane_name_failed", { + modelId: candidateModelId, + requestedModelId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return fallback(); } catch (error) { - logger.warn("agent_chat.suggest_lane_name_failed", { + logger.warn("agent_chat.suggest_lane_name_unavailable", { modelId: requestedModelId, error: error instanceof Error ? error.message : String(error), }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 7ca926f8..ff17e2b5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -16,7 +16,11 @@ import type { import { getModelById } from "../../../shared/modelRegistry"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { useAppStore } from "../../state/appStore"; -import { AgentChatPane, isMatchingOptimisticUserMessage } from "./AgentChatPane"; +import { + AgentChatPane, + isMatchingOptimisticUserMessage, + type AgentChatSessionCreatedOptions, +} from "./AgentChatPane"; vi.mock("../terminals/TerminalView", () => { const ReactMod = require("react") as typeof import("react"); @@ -131,6 +135,7 @@ function installAdeMocks(options?: { sendError?: Error; steerError?: Error; listError?: Error; + createError?: Error; handoffResult?: { session: AgentChatSession; usedFallbackSummary: boolean }; sessions?: AgentChatSessionSummary[]; includeClaudeModel?: boolean; @@ -150,10 +155,31 @@ function installAdeMocks(options?: { session: buildCreatedSession("handoff-session-1"), usedFallbackSummary: false, }); - const create = vi.fn().mockResolvedValue(buildCreatedSession("created-session")); + const create = options?.createError + ? vi.fn().mockRejectedValue(options.createError) + : vi.fn().mockImplementation(async (args: Record = {}) => { + const overrides: Partial = { + laneId: typeof args.laneId === "string" ? args.laneId : "lane-1", + reasoningEffort: (args.reasoningEffort as string | null | undefined) ?? "xhigh", + }; + if (typeof args.provider === "string") overrides.provider = args.provider as AgentChatSession["provider"]; + if (typeof args.model === "string") overrides.model = args.model; + if (typeof args.modelId === "string") overrides.modelId = args.modelId; + return buildCreatedSession("created-session", overrides); + }); + const createLane = vi.fn().mockResolvedValue({ + id: "lane-created", + name: "auto-created-lane", + laneType: "worktree", + branchRef: "refs/heads/auto-created-lane", + worktreePath: "/tmp/project-under-test/auto-created-lane", + parentLaneId: "lane-primary", + }); const suggestLaneName = vi.fn().mockResolvedValue("parallel-task"); const parallelLaunchStateGet = vi.fn().mockResolvedValue(options?.parallelLaunchState ?? null); const parallelLaunchStateSet = vi.fn().mockResolvedValue(undefined); + const deleteChat = vi.fn().mockResolvedValue(undefined); + const deleteLane = vi.fn().mockResolvedValue(undefined); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); const sessionChangeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); @@ -207,6 +233,7 @@ function installAdeMocks(options?: { warmupModel: vi.fn().mockResolvedValue(undefined), fileSearch: vi.fn().mockResolvedValue([]), create, + delete: deleteChat, dispose: vi.fn().mockResolvedValue(undefined), }, sessions: { @@ -230,8 +257,9 @@ function installAdeMocks(options?: { lanes: { list: vi.fn().mockResolvedValue([]), listSnapshots: vi.fn().mockResolvedValue([]), + create: createLane, createChild: vi.fn(), - delete: vi.fn().mockResolvedValue(undefined), + delete: deleteLane, }, git: { listBranches: vi.fn().mockResolvedValue([]), @@ -269,6 +297,9 @@ function installAdeMocks(options?: { steer, list, create, + createLane, + deleteChat, + deleteLane, suggestLaneName, parallelLaunchStateGet, parallelLaunchStateSet, @@ -401,6 +432,59 @@ function renderParallelDraftPane(args?: { ); } +function renderAutoCreateDraftPane(args?: { + onSessionCreated?: ( + session: AgentChatSession, + options?: AgentChatSessionCreatedOptions, + ) => void | Promise; +}) { + const lanes = [ + { + id: "lane-primary", + name: "Primary", + laneType: "primary", + branchRef: "refs/heads/main", + worktreePath: "/tmp/project-under-test", + }, + { + id: "lane-1", + name: "current-lane", + laneType: "worktree", + branchRef: "refs/heads/current-lane", + worktreePath: "/tmp/project-under-test/current-lane", + parentLaneId: "lane-primary", + }, + ] as any[]; + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes, + selectedLaneId: "lane-1", + }); + + return render( + + + + + + + )} + /> + + , + ); +} + async function clickEnabledModelOption(name: RegExp | string) { const options = await screen.findAllByRole("option", { name }); const enabledOption = options.find((option) => option.getAttribute("aria-disabled") !== "true"); @@ -1700,6 +1784,268 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("logs synchronous session-created callback failures without blocking the first send", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const onSessionCreated = vi.fn(() => { + throw new Error("callback exploded"); + }); + const { send } = installAdeMocks({ sessions: [] }); + + try { + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Keep sending despite callback failure." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "created-session" })); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + text: "Keep sending despite callback failure.", + })); + }); + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith("notifySessionCreated failed:", expect.any(Error)); + }); + } finally { + consoleError.mockRestore(); + } + }); + + it("foreground auto-create opens the new chat in Work instead of routing to Lanes", async () => { + const onSessionCreated = vi.fn(); + const { send, create, createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + suggestLaneName.mockResolvedValue("fix-auto-create-flow"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "fix-auto-create-flow", + laneType: "worktree", + branchRef: "refs/heads/fix-auto-create-flow", + worktreePath: "/tmp/project-under-test/fix-auto-create-flow", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane({ onSessionCreated }); + + const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Fix auto create lane routing." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-primary", + prompt: "Fix auto create lane routing.", + modelId: "openai/gpt-5.4", + fallbackName: expect.stringMatching(/^chat-\d{8}-\d{6}$/), + })); + expect(createLane).toHaveBeenCalledWith({ + name: "fix-auto-create-flow", + parentLaneId: "lane-primary", + }); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-created" })); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + text: "Fix auto create lane routing.", + })); + expect(onSessionCreated).toHaveBeenCalledWith( + expect.objectContaining({ id: "created-session", laneId: "lane-created" }), + { activate: false, source: "draft-launch" }, + ); + }); + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/work?laneId=lane-created&sessionId=created-session"); + }); + }); + + it("background auto-create reports the new chat without stealing focus and shows a dismissible notice", async () => { + const onSessionCreated = vi.fn(); + const { createLane, suggestLaneName } = installAdeMocks({ sessions: [] }); + suggestLaneName.mockResolvedValue("background-lane"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "background-lane", + laneType: "worktree", + branchRef: "refs/heads/background-lane", + worktreePath: "/tmp/project-under-test/background-lane", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane({ onSessionCreated }); + + const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Launch this in the background." } }); + fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + + await waitFor(() => { + expect(onSessionCreated).toHaveBeenCalledWith( + expect.objectContaining({ id: "created-session", laneId: "lane-created" }), + { activate: false, source: "draft-launch" }, + ); + expect(screen.getByText("Launched in background-lane")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss launched chat notice" })).toBeTruthy(); + }); + expect(screen.getByTestId("location").textContent).toBe("/work"); + + fireEvent.click(screen.getByRole("button", { name: "Open" })); + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/work?laneId=lane-created&sessionId=created-session"); + }); + }); + + it("rolls back the created session and auto-created lane when the first draft send fails", async () => { + const onSessionCreated = vi.fn(); + const { send, createLane, suggestLaneName, deleteChat, deleteLane } = installAdeMocks({ + sessions: [], + sendError: new Error("send failed"), + }); + suggestLaneName.mockResolvedValue("failing-draft-lane"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "failing-draft-lane", + laneType: "worktree", + branchRef: "refs/heads/failing-draft-lane", + worktreePath: "/tmp/project-under-test/failing-draft-lane", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane({ onSessionCreated }); + + const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "This first send will fail." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + text: "This first send will fail.", + })); + expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" }); + expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }); + expect(onSessionCreated).not.toHaveBeenCalled(); + }); + }); + + it("deletes an auto-created draft lane when session creation fails", async () => { + const onSessionCreated = vi.fn(); + const { send, createLane, suggestLaneName, deleteChat, deleteLane } = installAdeMocks({ + sessions: [], + createError: new Error("create failed"), + }); + suggestLaneName.mockResolvedValue("session-create-fails"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "session-create-fails", + laneType: "worktree", + branchRef: "refs/heads/session-create-fails", + worktreePath: "/tmp/project-under-test/session-create-fails", + parentLaneId: "lane-primary", + }); + + renderAutoCreateDraftPane({ onSessionCreated }); + + const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "This session create will fail." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }); + expect(deleteChat).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + expect(onSessionCreated).not.toHaveBeenCalled(); + }); + }); + + it("keeps the draft box editable while auto-create launch disables send actions", async () => { + const { suggestLaneName } = installAdeMocks({ sessions: [] }); + let resolveSuggestedName!: (value: string) => void; + suggestLaneName.mockImplementation(() => new Promise((resolve) => { + resolveSuggestedName = resolve; + })); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Launch this and let me keep typing." } }); + fireEvent.click(await screen.findByRole("button", { name: "Launch in background" })); + + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalled(); + expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(true); + }); + expect((textbox as HTMLTextAreaElement).disabled).toBe(false); + + fireEvent.change(textbox, { target: { value: "Next thought while it launches." } }); + expect((textbox as HTMLTextAreaElement).value).toBe("Next thought while it launches."); + + resolveSuggestedName("still-editable-lane"); + await waitFor(() => { + expect(screen.getByText("Launched in auto-created-lane")).toBeTruthy(); + expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("Next thought while it launches."); + }); + }); + it("launches a tracked CLI session from the Work draft composer instead of creating an ADE chat", async () => { const { send, create } = installAdeMocks({ sessions: [] }); const onLaunchCliSession = vi.fn().mockResolvedValue({ sessionId: "terminal-1", ptyId: "pty-1" }); @@ -2251,6 +2597,7 @@ describe("AgentChatPane submit recovery", () => { laneId: "lane-1", prompt: "Fix the login bug", modelId: "openai/gpt-5.4", + fallbackName: expect.stringMatching(/^chat-\d{8}-\d{6}$/), })); expect(createChild).toHaveBeenCalledTimes(2); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 43c59ca6..9bbf016a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { Cube, Desktop, DeviceMobile, Lightning, Plus, Terminal, TreeStructure } from "@phosphor-icons/react"; +import { Cube, Desktop, DeviceMobile, Lightning, Plus, Terminal, TreeStructure, X } from "@phosphor-icons/react"; import { inferAttachmentType, PARALLEL_CHAT_MAX_ATTACHMENTS, @@ -257,6 +257,11 @@ function createTemporaryAutoLaneName(date = new Date()): string { ].join("-"); } +export type AgentChatSessionCreatedOptions = { + activate?: boolean; + source?: "chat" | "draft-launch" | "handoff"; +}; + function buildDraftLaunchNamingSeed(snapshot: DraftLaunchSnapshot): string { if (snapshot.text.length) return snapshot.text; const imageCount = snapshot.attachments.filter((attachment) => attachment.type === "image").length; @@ -1456,7 +1461,7 @@ export function AgentChatPane({ shouldAutofocusComposer?: boolean; initialLinearIssueContext?: LaneLinearIssue | null; onInitialLinearIssueContextConsumed?: () => void; - onSessionCreated?: (session: AgentChatSession) => void | Promise; + onSessionCreated?: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; workDraftKind?: "chat" | "cli"; onLaunchCliSession?: (args: { laneId: string; @@ -4144,9 +4149,15 @@ export function AgentChatPane({ setOpenCodePermissionMode(targetOpenCodeMode); } }, [initialNativeControls.opencodePermissionMode]); - const notifySessionCreated = useCallback((session: AgentChatSession) => { + const notifySessionCreated = useCallback((session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => { if (!onSessionCreated) return; - void Promise.resolve(onSessionCreated(session)).catch((err) => { console.error("notifySessionCreated failed:", err); }); + void Promise.resolve() + .then(() => ( + options === undefined + ? onSessionCreated(session) + : onSessionCreated(session, options) + )) + .catch((err) => { console.error("notifySessionCreated failed:", err); }); }, [onSessionCreated]); const draftLaunchTargetIsAutoCreate = draftLaunchTargetId === AUTO_CREATE_LANE_OPTION_ID; const launchShellForDraftLane = useCallback(async () => { @@ -4164,7 +4175,7 @@ export function AgentChatPane({ const createSessionForLane = useCallback(async ( targetLaneId: string, - options: { select?: boolean; notify?: boolean } = {}, + options: { select?: boolean; notify?: boolean; notifyOptions?: AgentChatSessionCreatedOptions } = {}, ): Promise => { const desc = getModelById(modelId); const permissionDesc = getModelDescriptorForPermissionMode(modelId); @@ -4222,7 +4233,7 @@ export function AgentChatPane({ if (targetLaneId === laneId) void refreshSessions(); }).catch(() => { /* warmup is best-effort */ }); } - if (options.notify) notifySessionCreated(created); + if (options.notify) notifySessionCreated(created, options.notifyOptions); if (targetLaneId === laneId) void refreshSessions().catch(() => {}); return created; }, [buildNativeControlPayload, codexFastMode, currentNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); @@ -4341,6 +4352,10 @@ export function AgentChatPane({ openItemIds: prev.openItemIds.includes(launch.sessionId) ? prev.openItemIds : [...prev.openItemIds, launch.sessionId], + activeItemId: launch.sessionId, + selectedItemId: launch.sessionId, + draftKind: "chat", + viewMode: "tabs", })); setLaneWorkViewState(projectRoot, launch.laneId, (prev) => ({ ...prev, @@ -4353,8 +4368,12 @@ export function AgentChatPane({ viewMode: "tabs", })); } + if (embeddedWorkLayout) { + navigate(`/work?laneId=${encodeURIComponent(launch.laneId)}&sessionId=${encodeURIComponent(launch.sessionId)}`); + return; + } navigate(`/lanes?laneId=${encodeURIComponent(launch.laneId)}&sessionId=${encodeURIComponent(launch.sessionId)}&focus=single`); - }, [navigate, projectRoot, setLaneWorkViewState, setWorkViewState]); + }, [embeddedWorkLayout, navigate, projectRoot, setLaneWorkViewState, setWorkViewState]); const resolveDraftLaunchLane = useCallback(async (snapshot: DraftLaunchSnapshot): Promise<{ laneId: string; laneName: string }> => { if (draftLaunchTargetIsAutoCreate) { @@ -4363,15 +4382,16 @@ export function AgentChatPane({ ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary") ?? null; if (!primaryLane) throw new Error("Auto-create requires a primary lane."); - const fallbackName = createTemporaryAutoLaneName(); const laneName = await window.ade.agentChat.suggestLaneName({ laneId: primaryLane.id, prompt: buildDraftLaunchNamingSeed(snapshot), modelId, - fallbackName, + fallbackName: createTemporaryAutoLaneName(), }); const createdLane = await window.ade.lanes.create({ name: laneName, parentLaneId: primaryLane.id }); - await refreshLanesStore(); + await refreshLanesStore().catch((refreshError: unknown) => { + console.warn("draft chat launch lane refresh failed", refreshError); + }); return { laneId: createdLane.id, laneName: createdLane.name }; } if (!laneId) throw new Error("Select a lane before launching chat."); @@ -4400,25 +4420,26 @@ export function AgentChatPane({ setMacosVmContextItems([]); draftSelectionLockedRef.current = mode === "background"; + let targetLane: { laneId: string; laneName: string } | null = null; + let createdSession: AgentChatSession | null = null; + try { - const targetLane = await resolveDraftLaunchLane(snapshot); + targetLane = await resolveDraftLaunchLane(snapshot); const prepared = await prepareDraftLaunchForSend(snapshot, targetLane.laneId); - const created = await createSessionForLane(targetLane.laneId, { - select: false, - notify: mode === "foreground", - }); - touchSession(created.id); + createdSession = await createSessionForLane(targetLane.laneId, { select: false }); + touchSession(createdSession.id); await window.ade.agentChat.send({ - sessionId: created.id, + sessionId: createdSession.id, text: prepared.finalText, displayText: prepared.finalDisplayText || "Selected visual app context", attachments: prepared.selectedAttachments, contextAttachments: prepared.selectedContextAttachments, reasoningEffort, executionMode, - interactionMode: created.provider === "claude" ? interactionMode : null, - ...(created.provider === "cursor" ? { runtime: "local" as const } : {}), + interactionMode: createdSession.provider === "claude" ? interactionMode : null, + ...(createdSession.provider === "cursor" ? { runtime: "local" as const } : {}), }); + notifySessionCreated(createdSession, { activate: false, source: "draft-launch" }); invalidateSessionListCache(); if (targetLane.laneId === laneId) { void refreshSessions().catch(() => {}); @@ -4426,7 +4447,7 @@ export function AgentChatPane({ const launch = { laneId: targetLane.laneId, laneName: targetLane.laneName, - sessionId: created.id, + sessionId: createdSession.id, }; if (mode === "foreground") { openLaunchedDraftChat(launch); @@ -4435,6 +4456,25 @@ export function AgentChatPane({ setBackgroundLaunchNotice(launch); } } catch (launchError) { + if (createdSession) { + await window.ade.agentChat.delete({ sessionId: createdSession.id }).catch((cleanupError: unknown) => { + console.warn("draft chat launch session cleanup failed", cleanupError); + }); + loadedHistoryRef.current.delete(createdSession.id); + localTouchBySessionRef.current.delete(createdSession.id); + optimisticSessionIdsRef.current.delete(createdSession.id); + knownSessionIdsRef.current.delete(createdSession.id); + invalidateSessionListCache(); + if (targetLane?.laneId === laneId) { + await refreshSessions().catch(() => undefined); + } + } + if (draftLaunchTargetIsAutoCreate && targetLane) { + await window.ade.lanes.delete({ laneId: targetLane.laneId, force: true }).catch((cleanupError: unknown) => { + console.warn("draft chat launch lane cleanup failed", cleanupError); + }); + await refreshLanesStore().catch(() => undefined); + } restoreDraftLaunchSnapshot(snapshot); const message = launchError instanceof Error ? launchError.message : String(launchError); setError(message); @@ -4448,6 +4488,7 @@ export function AgentChatPane({ buildDraftLaunchSnapshotForCurrentState, busy, createSessionForLane, + draftLaunchTargetIsAutoCreate, executionMode, interactionMode, laneId, @@ -4456,6 +4497,7 @@ export function AgentChatPane({ parallelLaunchBusy, prepareDraftLaunchForSend, reasoningEffort, + refreshLanesStore, refreshSessions, resolveDraftLaunchLane, restoreDraftLaunchSnapshot, @@ -4670,6 +4712,7 @@ export function AgentChatPane({ laneId, prompt: namingSeed, modelId: parallelModelSlots[0]!.modelId, + fallbackName: createTemporaryAutoLaneName(), }); setParallelLaunchStatus(`Creating ${parallelModelSlots.length} child lanes…`); @@ -6614,13 +6657,23 @@ export function AgentChatPane({ Launched in {backgroundLaunchNotice.laneName} - +
+ + +
) : null} {composerElement} diff --git a/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx b/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx index 5cc4e25d..6df9eb91 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx @@ -167,7 +167,9 @@ export function LaneCombobox({ useEffect(() => { if (!open || !listRef.current) return; const el = listRef.current.children[highlightedIndex] as HTMLElement | undefined; - el?.scrollIntoView({ block: "nearest" }); + if (typeof el?.scrollIntoView === "function") { + el.scrollIntoView({ block: "nearest" }); + } }, [open, highlightedIndex]); // Position popover below trigger diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx new file mode 100644 index 00000000..b6851f76 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -0,0 +1,228 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentChatSession, LaneSummary } from "../../../shared/types"; +import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; +import { TerminalsPage } from "./TerminalsPage"; + +const workMocks = vi.hoisted(() => { + const makeChatSession = (id: string, laneId: string): AgentChatSession => ({ + id, + laneId, + provider: "codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", + status: "idle", + sessionProfile: "workflow", + reasoningEffort: "xhigh", + executionMode: "focused", + createdAt: "2026-05-14T18:00:00.000Z", + lastActivityAt: "2026-05-14T18:00:00.000Z", + }); + const laneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }; + const makeLane = (id: string, name: string, laneType: LaneSummary["laneType"] = "worktree"): LaneSummary => ({ + id, + name, + description: null, + laneType, + baseRef: "main", + branchRef: id === "lane-primary" ? "main" : `ade/${id}`, + worktreePath: `/tmp/${id}`, + attachedRootPath: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + parentLaneId: null, + color: null, + icon: null, + tags: [], + folder: null, + missionId: null, + laneRole: null, + status: laneStatus, + createdAt: "2026-05-14T18:00:00.000Z", + archivedAt: null, + activeBranchProfile: null, + linearIssue: null, + }); + + const fns = { + selectLane: vi.fn(), + focusSession: vi.fn(), + openSessionTab: vi.fn(), + upsertOptimisticChatSession: vi.fn(), + refresh: vi.fn().mockResolvedValue(undefined), + }; + + const baseWork = { + lanes: [ + makeLane("lane-primary", "Primary", "primary"), + makeLane("lane-background", "Background lane"), + ], + sessions: [], + visibleSessions: [], + tabGroups: [], + tabVisibleSessionIds: [], + runningFiltered: [], + awaitingInputFiltered: [], + endedFiltered: [], + runningSessions: [], + filtered: [], + sessionsGroupedByLane: [], + loading: false, + gridLayoutId: "work-grid", + activeItemId: null, + selectedSessionId: null, + viewMode: "tabs", + draftKind: "chat", + draftLaneId: null, + filterLaneId: "all", + filterStatus: "all", + q: "", + sessionListOrganization: "by-lane", + workCollapsedLaneIds: [], + workCollapsedSectionIds: [], + workFocusSessionsHidden: false, + workSidebarOpen: false, + workSidebarTab: "git", + workSidebarWidthPct: 36, + pinnedSessionIds: [], + closingPtyIds: new Set(), + setSelectedSessionId: vi.fn(), + setActiveItemId: vi.fn(), + setViewMode: vi.fn(), + closeTab: vi.fn(), + launchPtySession: vi.fn(), + setDraftLaneId: vi.fn(), + showDraftKind: vi.fn(), + toggleWorkTabGroupCollapsed: vi.fn(), + setFilterLaneId: vi.fn(), + setFilterStatus: vi.fn(), + setQ: vi.fn(), + setSessionListOrganization: vi.fn(), + toggleWorkLaneCollapsed: vi.fn(), + toggleWorkSectionCollapsed: vi.fn(), + stopRuntime: vi.fn().mockResolvedValue(undefined), + removeSessionFromList: vi.fn(), + setWorkFocusSessionsHidden: vi.fn(), + setWorkSidebarOpen: vi.fn(), + setWorkSidebarTab: vi.fn(), + setWorkSidebarWidthPct: vi.fn(), + reorderLaneSessions: vi.fn(), + togglePinnedSession: vi.fn(), + ...fns, + }; + + return { + backgroundSession: makeChatSession("chat-background", "lane-background"), + foregroundSession: makeChatSession("chat-foreground", "lane-primary"), + currentWork: baseWork, + fns, + }; +}); + +vi.mock("../../state/appStore", () => ({ + useAppStore: (selector: (state: { selectedLaneId: string }) => T): T => + selector({ selectedLaneId: "lane-primary" }), +})); + +vi.mock("./useWorkSessions", () => ({ + useWorkSessions: () => workMocks.currentWork, +})); + +vi.mock("../ui/PaneTilingLayout", () => ({ + PaneTilingLayout: ({ panes }: { panes: Record }) => ( +
+ {Object.entries(panes).map(([id, pane]) => ( +
{pane.children}
+ ))} +
+ ), +})); + +vi.mock("./SessionListPane", () => ({ + SessionListPane: () =>
, +})); + +vi.mock("./WorkSidebar", () => ({ + WorkSidebar: () =>
, +})); + +vi.mock("./SessionContextMenu", () => ({ + SessionContextMenu: () => null, +})); + +vi.mock("./SessionInfoPopover", () => ({ + SessionInfoPopover: () => null, +})); + +vi.mock("./WorkViewArea", () => ({ + WorkViewArea: (props: { + onOpenChatSession: ( + session: AgentChatSession, + options?: AgentChatSessionCreatedOptions, + ) => void | Promise; + }) => ( +
+ + +
+ ), +})); + +describe("TerminalsPage chat session activation", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("tracks background-created chats without stealing Work focus", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "create background chat" })); + + await waitFor(() => { + expect(workMocks.fns.upsertOptimisticChatSession).toHaveBeenCalledWith(workMocks.backgroundSession); + expect(workMocks.fns.refresh).toHaveBeenCalledWith({ showLoading: false, force: true }); + }); + expect(workMocks.fns.selectLane).not.toHaveBeenCalled(); + expect(workMocks.fns.focusSession).not.toHaveBeenCalled(); + expect(workMocks.fns.openSessionTab).not.toHaveBeenCalled(); + }); + + it("opens foreground-created chats in the active Work tab", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "create foreground chat" })); + + await waitFor(() => { + expect(workMocks.fns.upsertOptimisticChatSession).toHaveBeenCalledWith(workMocks.foregroundSession); + expect(workMocks.fns.selectLane).toHaveBeenCalledWith("lane-primary"); + expect(workMocks.fns.focusSession).toHaveBeenCalledWith("chat-foreground"); + expect(workMocks.fns.openSessionTab).toHaveBeenCalledWith("chat-foreground"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 1b0fbd8d..36d69d3b 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -8,6 +8,7 @@ import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; import type { AgentChatPermissionMode, AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; @@ -160,14 +161,16 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { ); const handleOpenChatSession = useCallback( - (session: AgentChatSession) => { + (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => { // Invalidate all cache entries so other views (e.g. Lanes tab) pick up // the new session on their next refresh. invalidateSessionListCache(); - work.selectLane(session.laneId); work.upsertOptimisticChatSession(session); - work.focusSession(session.id); - work.openSessionTab(session.id); + if (options?.activate !== false) { + work.selectLane(session.laneId); + work.focusSession(session.id); + work.openSessionTab(session.id); + } void work.refresh({ showLoading: false, force: true }).catch((err: unknown) => { console.error("[TerminalsPage] refresh after opening chat session failed", { sessionId: session.id, diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 44a64f4d..a6f19d80 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -5,14 +5,14 @@ import { import type { AgentChatSession, LaneLinearIssue, LaneSummary } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; import { useAppStore } from "../../state/appStore"; -import { AgentChatPane } from "../chat/AgentChatPane"; +import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import type { LaunchProfile } from "./cliLaunch"; type WorkStartSurfaceProps = { draftKind: WorkDraftKind; draftLaneId?: string | null; lanes: LaneSummary[]; - onOpenChatSession: (session: AgentChatSession) => void | Promise; + onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onLaunchPtySession: (args: { laneId: string; profile: LaunchProfile; diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 562deab6..a2ec1710 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -40,7 +40,7 @@ import type { WorkDraftKind, WorkViewMode } from "../../state/appStore"; import { TerminalView } from "./TerminalView"; import { ToolLogo } from "./ToolLogos"; import { LaneChip } from "./LaneChip"; -import { AgentChatPane } from "../chat/AgentChatPane"; +import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { ChatCommandMenu, handleCommandMenuKeyDown, type ChatCommandMenuHandle, type ChatCommandMenuItem } from "../chat/ChatCommandMenu"; import { ChatComposerShell } from "../chat/ChatComposerShell"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; @@ -855,7 +855,7 @@ function SessionSurface({ onContextMenu?: (session: TerminalSessionSummary, event: React.MouseEvent) => void; onStopRunningSession?: (session: TerminalSessionSummary) => void; stopping?: boolean; - onOpenChatSession: (session: AgentChatSession) => void | Promise; + onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onContinueCliSession?: (session: TerminalSessionSummary, text: string, options?: WorkCliContinuationOptions) => Promise | void; }) { const isChat = isChatToolType(session.toolType); @@ -1365,7 +1365,7 @@ export function WorkViewArea({ setViewMode: (mode: WorkViewMode) => void; onSelectItem: (sessionId: string) => void; onCloseItem: (sessionId: string) => void; - onOpenChatSession: (session: AgentChatSession) => void | Promise; + onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onLaunchPtySession: (args: { laneId: string; profile: LaunchProfile; diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index e19335fd..0a11b874 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,7 +11,7 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for parallel launch. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so bundled Agent Skills roots are lane-scoped in persistent system/developer prompts and any provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks. Large orchestrator file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for auto-created / parallel lanes. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so bundled Agent Skills roots are lane-scoped in persistent system/developer prompts and any provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks. Large orchestrator file. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Spoofs a desktop Chrome `User-Agent` and the matching `Sec-CH-UA*` client hints on every request through `webRequest.onBeforeSendHeaders` so external sign-in flows (Google, etc.) treat the embedded view as a normal desktop Chrome instead of refusing to load — the previous "open Google sign-in in the system browser" branch was removed because the spoofed UA stops Google from blocking the page in the first place. Window-open requests are forwarded into a fresh tab with `openPanel: true` so the Work sidebar Browser tab pops automatically. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | @@ -130,11 +130,15 @@ render them, but neither one *runs* them. command runs in the **active runtime** — a remote-bound desktop window installs / logs in on the remote machine. See [Agents](../agents/README.md#agent-cli-install--auth-from-chat). -- **Parallel multi-model launch.** From an empty embedded Work composer, - the user can enable parallel mode, select two or more model/control - slots, and send one prompt. ADE creates child lanes, starts one chat - in each lane, sends the same prompt and attachments to every session, - then opens the Lanes view focused on the new lane set. +- **Work draft launches.** From an empty embedded Work composer, the + user can auto-create a lane for a single foreground/background chat, + or enable parallel mode, select two or more model/control slots, and + send one prompt. Foreground auto-create opens the new chat in Work. + Background auto-create records the session without stealing focus and + shows a dismissible launch notice. Parallel launch still creates + child lanes, starts one chat in each lane, sends the same prompt and + attachments to every session, then opens the Lanes view focused on + the new lane set. - **Built-in browser.** The main process owns a persistent `persist:ade-browser` partition with multiple `WebContentsView` tabs. The Work right-edge sidebar's `browser` tab renders this surface @@ -202,10 +206,12 @@ Parallel launch is a renderer-orchestrated workflow layered on the same session primitives: 1. `AgentChatPane` asks `ade.agentChat.suggestLaneName` for a slug base - derived from the user's prompt. The service uses a lightweight - OpenCode text call when an eligible model is available and falls back - to a deterministic first-four-words slug (`parallel-task` for empty - prompts). + derived from the user's prompt. The service runs the shared + session-intelligence title prompt against the requested model, the + configured title model, and fallback title models. If no model can + produce a usable name, it keeps the first-four-words prompt slug and + appends the renderer's temporary fallback suffix for uniqueness; the + generic empty-prompt fallback is `parallel-task`. 2. The pane creates one child lane per selected model slot using a unique `-` style name and persists progress under `agent-chat-parallel-launch::` in `kv`. @@ -259,7 +265,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.list` | invoke | List sessions with optional `includeIdentity`, `includeAutomation`. | | `ade.agentChat.getSummary` | invoke | Fetch `AgentChatSessionSummary` for a single session. | | `ade.agentChat.create` | invoke | Create a new session; returns the `AgentChatSession`. Accepts `codexFastMode?: boolean` for Codex sessions to start with the `serviceTier: "fast"` default. | -| `ade.agentChat.suggestLaneName` | invoke | Derive a slug-safe base lane name from a parallel prompt using a lightweight model call with deterministic fallback. | +| `ade.agentChat.suggestLaneName` | invoke | Derive a slug-safe lane name from a Work launch prompt using the session-intelligence title prompt, with a prompt-slug + optional unique temporary fallback. | | `ade.agentChat.parallelLaunchState.get` / `.set` | invoke | Read/write crash-recovery state for renderer-orchestrated parallel launches. State is scoped by project root and parent lane id. | | `ade.agentChat.handoff` | invoke | Create a handoff session with summarized context. Forwards `codexFastMode` when the target provider is Codex. | | `ade.agentChat.send` | invoke | Dispatch a user message + attachments. If the session has ended, sending is the continuation path. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index c6996a41..0f33b8ac 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -177,6 +177,15 @@ and a footer that contains the composer. sent to every child lane, so the cap is enforced both when toggling parallel mode and when adding files. +- **Work auto-create launch behavior.** The embedded draft composer can + ask the main process for a lane name before creating a new Work lane. + The request includes a temporary `chat-YYYYMMDD-HHMMSS` fallback so + prompt-derived fallback names remain unique when model naming is + unavailable. Foreground launches call `onSessionCreated` with + `{ activate: true, source: "draft-launch" }` so Work selects the new + lane/session; background launches pass `activate: false`, keep the + current Work focus, and show a dismissible notice with an Open action. + - **Border beam.** On standard (non-grid-tile) layout the composer shell is wrapped in `BorderBeam` (`colorVariant="colorful"` at rest, `"ocean"` with a slower duration while a turn is active). `active` diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index bdf216bb..bd56ea38 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -130,6 +130,11 @@ Renderer surfaces: entry surface with `PaneTilingLayout` (sessions list + work view). Owns the multi-select state (`selectedSessionIds`, shift/ctrl anchor, bulk close and bulk delete handlers) that the sidebar forwards into. + It consumes `AgentChatPane` session-created options from + `WorkStartSurface` / `WorkViewArea`: foreground Work chat launches + select the lane, focus the session, and open the session tab, while + background launches upsert the optimistic chat session and refresh the + list without stealing focus. Also owns the right-edge `WorkSidebar` toggle and resizer: when the sidebar is open and the view mode is not `grid`, the work view area shares its row with `WorkSidebar` via a flex container with a @@ -214,7 +219,11 @@ Renderer surfaces: `primarySessionLabel` / `sessionStatusBucket` so chat and CLI rows read consistently. - `apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx` — - empty-state "start new chat / terminal" surface. + empty-state "start new chat / terminal" surface. It mounts + `AgentChatPane` in embedded draft mode and forwards + `onSessionCreated(session, options)` so foreground draft launches can + open in Work while background launches stay quiet and surface their + dismissible launch notice inside the pane. - `apps/desktop/src/renderer/components/terminals/TerminalView.tsx` — xterm.js wrapper; WebGL renderer with DOM fallback, fit retries, health counters.