diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c18c3d12d..355e5bfa6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -101,6 +101,7 @@ import type { AgentChatSurface, AgentChatSteerArgs, AgentChatSendArgs, + AgentChatSuggestLaneNameArgs, AgentChatCursorConfigOption, AgentChatCursorConfigValue, AgentChatCursorModeSnapshot, @@ -837,6 +838,13 @@ Return only the title text. - No quotes. - No emoji. - No trailing punctuation.`; + +const LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT = `You name git worktree lanes for a software project. +Return only the base name text (no model suffixes). +- Use 2 to 5 words, lowercase except proper nouns if needed. +- Slug-friendly: letters, numbers, spaces, and hyphens only (no slashes). +- Describe the task or feature from the user's message. +- No quotes, no emoji, no trailing punctuation.`; const CODEX_REASONING_EFFORTS: Array<{ effort: string; description: string }> = [ { effort: "low", description: "Fastest turn-around with shallow reasoning." }, { effort: "medium", description: "Balanced reasoning depth and speed." }, @@ -4403,6 +4411,80 @@ export function createAgentChatService(args: { }; }; + const suggestLaneNameFromPrompt = async (args: AgentChatSuggestLaneNameArgs): Promise => { + const prompt = String(args.prompt ?? "").trim(); + const requestedModelId = String(args.modelId ?? "").trim(); + const sourceLaneId = String(args.laneId ?? "").trim(); + const fallback = (): string => { + const collapsed = prompt.replace(/\s+/g, " ").trim(); + if (!collapsed.length) return "parallel-task"; + const words = collapsed.split(/\s+/).filter(Boolean).slice(0, 4); + const slug = words.join("-").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return slug.length ? slug.slice(0, 48) : "parallel-task"; + }; + + if (!prompt.length || !requestedModelId.length || !sourceLaneId.length) { + return fallback(); + } + + let cwd = projectRoot; + try { + ({ laneWorktreePath: cwd } = resolveLaneLaunchContext({ + laneService, + laneId: sourceLaneId, + purpose: "name a lane from prompt", + })); + } catch { + cwd = projectRoot; + } + + try { + const auth = await detectAuth(); + const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); + if (!availableModels.length) return fallback(); + + const config = resolveChatConfig(); + const preferredModelId = + [ + requestedModelId, + config.autoTitleModelId, + 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 resolvedModel = await providerResolver.resolveModel(descriptor.id, auth, { + cwd, + middleware: false, + }); + const result = await generateText({ + model: resolvedModel, + system: LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, + prompt: `User message to parallelize across models:\n${prompt.slice(0, 2000)}`, + }); + const sanitized = sanitizeAutoTitle(result.text.trim(), 56); + if (!sanitized) return fallback(); + return sanitized; + } catch (error) { + logger.warn("agent_chat.suggest_lane_name_failed", { + modelId: requestedModelId, + error: error instanceof Error ? error.message : String(error), + }); + return fallback(); + } + }; + const computeHeadShaBestEffort = async (laneId: string): Promise => { let cwd: string; try { @@ -13458,6 +13540,7 @@ export function createAgentChatService(args: { return { createSession, + suggestLaneNameFromPrompt, handoffSession, sendMessage, runSessionTurn, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 29c99d9ae..0e12f9a28 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -177,6 +177,7 @@ import type { AgentChatRespondToInputArgs, AgentChatResumeArgs, AgentChatSendArgs, + AgentChatSuggestLaneNameArgs, AgentChatSession, AgentChatSessionSummary, AgentChatSubagentSnapshot, @@ -3796,6 +3797,11 @@ export function registerIpc({ return await ctx.agentChatService.createSession(arg); }); + ipcMain.handle(IPC.agentChatSuggestLaneName, async (_event, arg: AgentChatSuggestLaneNameArgs): Promise => { + const ctx = getCtx(); + return await ctx.agentChatService.suggestLaneNameFromPrompt(arg); + }); + ipcMain.handle(IPC.agentChatHandoff, async (_event, arg: AgentChatHandoffArgs): Promise => { const ctx = getCtx(); return await ctx.agentChatService.handoffSession(arg); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 37f2a3383..7af5bc8d5 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -61,6 +61,7 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatCreateArgs, + AgentChatSuggestLaneNameArgs, AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, @@ -831,6 +832,7 @@ declare global { list: (args?: AgentChatListArgs) => Promise; getSummary: (args: AgentChatGetSummaryArgs) => Promise; create: (args: AgentChatCreateArgs) => Promise; + suggestLaneName: (args: AgentChatSuggestLaneNameArgs) => Promise; handoff: (args: AgentChatHandoffArgs) => Promise; send: (args: AgentChatSendArgs) => Promise; steer: (args: AgentChatSteerArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b42c899ac..233cec597 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -226,6 +226,7 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatCreateArgs, + AgentChatSuggestLaneNameArgs, AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, @@ -1123,6 +1124,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatGetSummary, args), create: async (args: AgentChatCreateArgs): Promise => ipcRenderer.invoke(IPC.agentChatCreate, args), + suggestLaneName: async (args: AgentChatSuggestLaneNameArgs): Promise => + ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), handoff: async (args: AgentChatHandoffArgs): Promise => ipcRenderer.invoke(IPC.agentChatHandoff, args), send: async (args: AgentChatSendArgs): Promise => diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ca4429849..61429cc32 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { At, CaretDown, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen } from "@phosphor-icons/react"; +import { At, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen, SquareSplitHorizontal, Plus, Trash } from "@phosphor-icons/react"; import { inferAttachmentType, + PARALLEL_CHAT_MAX_ATTACHMENTS, type AgentChatApprovalDecision, type AgentChatClaudePermissionMode, type AgentChatCursorConfigOption, @@ -49,6 +50,32 @@ type SlashCommandEntry = { source: "sdk" | "local"; }; +/** When set, permission/runtime controls bind to this slot (parallel model row configuration). */ +export type ParallelComposerControlSlot = { + sessionProvider: string; + interactionMode: AgentChatInteractionMode; + claudePermissionMode: AgentChatClaudePermissionMode; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + unifiedPermissionMode: AgentChatUnifiedPermissionMode; + cursorModeSnapshot: AgentChatCursorModeSnapshot | null; + onInteractionModeChange: (mode: AgentChatInteractionMode) => void; + onClaudeModeChange: (mode: AgentChatClaudePermissionMode) => void; + onClaudePermissionModeChange: (mode: AgentChatClaudePermissionMode) => void; + onCodexPresetChange: (next: { + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + }) => void; + onCodexApprovalPolicyChange: (policy: AgentChatCodexApprovalPolicy) => void; + onCodexSandboxChange: (sandbox: AgentChatCodexSandbox) => void; + onCodexConfigSourceChange: (source: AgentChatCodexConfigSource) => void; + onUnifiedPermissionModeChange: (mode: AgentChatUnifiedPermissionMode) => void; + onCursorModeChange: (modeId: string) => void; + onCursorConfigChange: (configId: string, value: string | boolean) => void; +}; + /** Local-only commands that are always available regardless of provider. */ const LOCAL_SLASH_COMMANDS: SlashCommandEntry[] = [ { command: "/clear", label: "Clear", description: "Clear chat history", source: "local" }, @@ -328,6 +355,22 @@ export function AgentChatComposer({ onCancelSteer, onEditSteer, onOpenAiSettings, + parallelChatMode = false, + onParallelChatModeChange, + parallelModelSlots = [], + parallelConfiguringIndex = null, + onParallelConfiguringIndexChange, + onParallelAddModel, + onParallelRemoveModel, + onParallelSlotModelChange, + onParallelSlotReasoningChange, + parallelLaunchBusy = false, + parallelLaunchStatus = null, + parallelControlSlot = null, + parallelSlotExecutionModeOptions = [], + parallelSlotExecutionMode = null, + onParallelSlotExecutionModeChange, + showParallelChatToggle = false, }: { surfaceMode?: ChatSurfaceMode; layoutVariant?: "standard" | "grid-tile"; @@ -398,6 +441,22 @@ export function AgentChatComposer({ onCancelSteer?: (steerId: string) => void; onEditSteer?: (steerId: string, text: string) => void; onOpenAiSettings?: () => void; + parallelChatMode?: boolean; + onParallelChatModeChange?: (enabled: boolean) => void; + parallelModelSlots?: Array<{ modelId: string; reasoningEffort: string | null }>; + parallelConfiguringIndex?: number | null; + onParallelConfiguringIndexChange?: (index: number | null) => void; + onParallelAddModel?: () => void; + onParallelRemoveModel?: (index: number) => void; + onParallelSlotModelChange?: (index: number, modelId: string) => void; + onParallelSlotReasoningChange?: (index: number, effort: string | null) => void; + parallelLaunchBusy?: boolean; + parallelLaunchStatus?: string | null; + parallelControlSlot?: ParallelComposerControlSlot | null; + parallelSlotExecutionModeOptions?: ExecutionModeOption[]; + parallelSlotExecutionMode?: AgentChatExecutionMode | null; + onParallelSlotExecutionModeChange?: (mode: AgentChatExecutionMode) => void; + showParallelChatToggle?: boolean; }) { const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); const [attachmentQuery, setAttachmentQuery] = useState(""); @@ -418,7 +477,10 @@ export function AgentChatComposer({ const uploadInputRef = useRef(null); const textareaRef = useRef(null); const fileAddInProgressRef = useRef(false); - const canAttach = !turnActive; + const canAttach = !turnActive && (!parallelChatMode || attachments.length < PARALLEL_CHAT_MAX_ATTACHMENTS); + const attachBlockedReason = parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS + ? `Maximum ${PARALLEL_CHAT_MAX_ATTACHMENTS} attachments for parallel launch` + : null; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); @@ -452,6 +514,10 @@ export function AgentChatComposer({ return () => window.clearTimeout(timeout); }, [attachmentPickerOpen]); + useEffect(() => { + setAttachmentCursor((c) => Math.min(c, Math.max(attachmentResults.length - 1, 0))); + }, [attachmentResults.length]); + useEffect(() => { if (!attachmentPickerOpen) return; const query = attachmentQuery.trim(); @@ -478,23 +544,37 @@ export function AgentChatComposer({ const selectAttachment = (attachment: AgentChatFileRef) => { setAttachError(null); + if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); + return; + } onAddAttachment(attachment); setAttachmentPickerOpen(false); }; const addFileAttachments = async (files: FileList | null | undefined) => { - if (!canAttach || !files?.length) return; + if (!files?.length) return; + if (turnActive) return; + if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; + if (!parallelChatMode && !canAttach) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; setAttachError(null); try { + let addedInBatch = 0; for (const file of Array.from(files)) { + if (parallelChatMode && attachments.length + addedInBatch >= PARALLEL_CHAT_MAX_ATTACHMENTS) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_ATTACHMENTS} files for parallel launch.`); + break; + } const fileWithPath = file as File & { path?: string }; const hasRealPath = typeof fileWithPath.path === "string" && fileWithPath.path.trim().length > 0; if (hasRealPath) { const filePath = fileWithPath.path!; - onAddAttachment({ path: filePath, type: inferAttachmentType(filePath, file.type) }); + const t = inferAttachmentType(filePath, file.type); + onAddAttachment({ path: filePath, type: t }); + addedInBatch += 1; continue; } @@ -515,7 +595,9 @@ export function AgentChatComposer({ data: base64, filename: file.name || "clipboard.png", }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); + const t = inferAttachmentType(tempPath, file.type); + onAddAttachment({ path: tempPath, type: t }); + addedInBatch += 1; } catch { setAttachError(`Unable to attach "${file.name || "clipboard"}".`); } @@ -536,13 +618,23 @@ export function AgentChatComposer({ }; const nativeControlsDisabled = permissionModeLocked; - const claudeSelectionMode = claudePermissionMode === "plan" || interactionMode === "plan" + const slot = parallelControlSlot; + const sp = slot?.sessionProvider ?? sessionProvider ?? "unified"; + const im = slot?.interactionMode ?? interactionMode ?? "default"; + const cpmUse = slot?.claudePermissionMode ?? claudePermissionMode; + const capUse = slot?.codexApprovalPolicy ?? codexApprovalPolicy; + const csUse = slot?.codexSandbox ?? codexSandbox; + const ccsUse = slot?.codexConfigSource ?? codexConfigSource; + const upmUse = slot?.unifiedPermissionMode ?? unifiedPermissionMode; + const cmsUse = slot?.cursorModeSnapshot ?? cursorModeSnapshot; + + const claudeSelectionMode = cpmUse === "plan" || im === "plan" ? "plan" - : claudePermissionMode ?? "default"; + : cpmUse ?? "default"; const codexPreset = resolveCodexPermissionPreset({ - codexApprovalPolicy, - codexSandbox, - codexConfigSource, + codexApprovalPolicy: capUse, + codexSandbox: csUse, + codexConfigSource: ccsUse, }); const codexPresetOptions = useMemo( () => getPermissionOptions({ family: "openai", isCliWrapped: true }) @@ -568,6 +660,10 @@ export function AgentChatComposer({ codexConfigSource: "flags" as const, }; + if (parallelControlSlot) { + parallelControlSlot.onCodexPresetChange(next); + return; + } if (onCodexPresetChange) { onCodexPresetChange(next); return; @@ -580,15 +676,16 @@ export function AgentChatComposer({ onCodexConfigSourceChange, onCodexPresetChange, onCodexSandboxChange, + parallelControlSlot, ]); const claudeControlDetail = useMemo(() => { - if (sessionProvider !== "claude") return null; + if (sp !== "claude") return null; const option = CLAUDE_MODE_OPTIONS.find((item) => item.value === (hoveredClaudeMode ?? claudeSelectionMode)); return option?.detail ?? null; - }, [claudeSelectionMode, hoveredClaudeMode, sessionProvider]); + }, [claudeSelectionMode, hoveredClaudeMode, sp]); const codexCustomSummary = useMemo(() => { - if (sessionProvider !== "codex" || codexPreset !== "custom") return null; - if (codexConfigSource === "config-toml") { + if (sp !== "codex" || codexPreset !== "custom") return null; + if (ccsUse === "config-toml") { return "Custom Codex mode: config.toml controls approval and sandbox."; } const approvalLabel = { @@ -596,16 +693,16 @@ export function AgentChatComposer({ "on-request": "On request", "on-failure": "Guarded edit", "never": "Full auto", - }[codexApprovalPolicy ?? "on-request"]; + }[capUse ?? "on-request"]; const sandboxLabel = { "read-only": "Read only", "workspace-write": "Workspace write", "danger-full-access": "Danger full access", - }[codexSandbox ?? "workspace-write"]; - return `Custom Codex mode: ${codexConfigSource === "flags" ? "ADE flags" : "config.toml"} · ${approvalLabel} · ${sandboxLabel}`; - }, [codexApprovalPolicy, codexConfigSource, codexPreset, codexSandbox, sessionProvider]); + }[csUse ?? "workspace-write"]; + return `Custom Codex mode: ${ccsUse === "flags" ? "ADE flags" : "config.toml"} · ${approvalLabel} · ${sandboxLabel}`; + }, [capUse, ccsUse, codexPreset, csUse, sp]); const codexControlDetail = useMemo(() => { - if (sessionProvider !== "codex") return null; + if (sp !== "codex") return null; if (hoveredCodexPreset) { return codexPresetOptions.find((option) => option.value === hoveredCodexPreset)?.detail ?? null; } @@ -613,7 +710,7 @@ export function AgentChatComposer({ return codexCustomSummary; } return codexPresetOptions.find((option) => option.value === codexPreset)?.detail ?? null; - }, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sessionProvider]); + }, [codexCustomSummary, codexPreset, codexPresetOptions, hoveredCodexPreset, sp]); const nativeControlPanel = useMemo(() => { const renderButtonGroup = ( label: string, @@ -657,10 +754,20 @@ export function AgentChatComposer({ ); - if (sessionProvider === "claude") { + if (sp === "claude") { return (
{renderButtonGroup("Claude", claudeSelectionMode, CLAUDE_MODE_OPTIONS, (mode) => { + if (parallelControlSlot) { + if (mode === "plan") { + parallelControlSlot.onInteractionModeChange("plan"); + parallelControlSlot.onClaudePermissionModeChange("plan"); + return; + } + parallelControlSlot.onInteractionModeChange("default"); + parallelControlSlot.onClaudePermissionModeChange(mode); + return; + } if (onClaudeModeChange) { onClaudeModeChange(mode); return; @@ -677,7 +784,7 @@ export function AgentChatComposer({ ); } - if (sessionProvider === "codex") { + if (sp === "codex") { return (
@@ -720,20 +827,20 @@ export function AgentChatComposer({ ); } - const cursorModeOption = resolveCursorModeOption(cursorModeSnapshot); - const cursorExtraOptions = (cursorModeSnapshot?.configOptions ?? []).filter((option) => { - if (option.id === cursorModeSnapshot?.modelConfigId) return false; + const cursorModeOption = resolveCursorModeOption(cmsUse); + const cursorExtraOptions = (cmsUse?.configOptions ?? []).filter((option) => { + if (option.id === cmsUse?.modelConfigId) return false; if (option.id === cursorModeOption?.id) return false; return true; }); - if (sessionProvider === "cursor" && (cursorModeSnapshot?.availableModeIds?.length || cursorModeOption)) { + if (sp === "cursor" && (cmsUse?.availableModeIds?.length || cursorModeOption)) { const modeValue = typeof cursorModeOption?.currentValue === "string" ? cursorModeOption.currentValue - : cursorModeSnapshot?.currentModeId ?? ""; + : cmsUse?.currentModeId ?? ""; const modeChoices = cursorModeOption?.options?.length ? cursorModeOption.options.map((option) => ({ value: option.value, label: option.label })) - : (cursorModeSnapshot?.availableModeIds ?? []).map((modeId) => ({ + : (cmsUse?.availableModeIds ?? []).map((modeId) => ({ value: modeId, label: cursorModeLabel(modeId), })); @@ -744,8 +851,11 @@ export function AgentChatComposer({ Mode onCursorConfigChange?.(option.id, event.target.value)} + disabled={nativeControlsDisabled || (!onCursorConfigChange && !parallelControlSlot)} + onChange={(event) => { + if (parallelControlSlot) parallelControlSlot.onCursorConfigChange(option.id, event.target.value); + else onCursorConfigChange?.(option.id, event.target.value); + }} className="min-w-0 bg-transparent font-sans text-[11px] text-fg/82 outline-none disabled:cursor-not-allowed disabled:text-muted-fg/35" > {choices.map((choice) => ( @@ -813,14 +929,18 @@ export function AgentChatComposer({ ); } - const runtimeLabel = sessionProvider === "cursor" ? "Cursor" : "ADE"; + const runtimeLabel = sp === "cursor" ? "Cursor" : "ADE"; return (