From 57eba4879bec52daf15470e326fafe498867856c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 05:26:42 +0000 Subject: [PATCH 1/4] feat(desktop): parallel multi-model chat launches child lanes - Add IPC suggestLaneName for AI-derived lane name prefixes from the prompt - Work new-chat draft: optional parallel mode with per-model configure (model, reasoning, permissions, Codex execution mode) - On send: create child lanes per model, open chats, send prompt, navigate to Lanes with multi-select - Lanes: laneIds + workFocus query for split columns and work-emphasis tiling (layout v6) Co-authored-by: Arul Sharma --- .../main/services/chat/agentChatService.ts | 83 ++++ .../src/main/services/ipc/registerIpc.ts | 6 + apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 3 + .../components/chat/AgentChatComposer.tsx | 332 ++++++++++++--- .../components/chat/AgentChatPane.tsx | 379 +++++++++++++++++- .../renderer/components/lanes/LanesPage.tsx | 42 +- .../components/lanes/laneUtils.test.ts | 10 +- .../renderer/components/lanes/laneUtils.ts | 24 +- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/chat.ts | 9 + 11 files changed, 833 insertions(+), 58 deletions(-) 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..5ca8af8e5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -49,6 +49,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 +354,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 +440,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 +476,7 @@ export function AgentChatComposer({ const uploadInputRef = useRef(null); const textareaRef = useRef(null); const fileAddInProgressRef = useRef(false); - const canAttach = !turnActive; + const canAttach = !turnActive && !parallelChatMode; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); @@ -536,13 +594,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 +636,10 @@ export function AgentChatComposer({ codexConfigSource: "flags" as const, }; + if (parallelControlSlot) { + parallelControlSlot.onCodexPresetChange(next); + return; + } if (onCodexPresetChange) { onCodexPresetChange(next); return; @@ -580,15 +652,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 +669,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 +686,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 +730,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 +760,7 @@ export function AgentChatComposer({ ); } - if (sessionProvider === "codex") { + if (sp === "codex") { return (
@@ -720,20 +803,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 +827,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 +905,18 @@ export function AgentChatComposer({ ); } - const runtimeLabel = sessionProvider === "cursor" ? "Cursor" : "ADE"; + const runtimeLabel = sp === "cursor" ? "Cursor" : "ADE"; return ( + ) : null} + {parallelChatMode ? ( +
+ +
+ {parallelModelSlots.map((slotRow, idx) => { + const desc = getModelById(slotRow.modelId); + const configuring = parallelConfiguringIndex === idx; + return ( +
+ + Model {idx + 1} + + + {(desc?.displayName ?? slotRow.modelId) || "Select model"} + + + {parallelModelSlots.length > 2 ? ( + + ) : null} +
+ ); + })} +
+ + {parallelLaunchBusy && parallelLaunchStatus ? ( +
{parallelLaunchStatus}
+ ) : null} +
+ ) : null} +
{/* Left: permission + model controls */}
- {nativeControlPanel} + {parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] + ? nativeControlPanel + : !parallelChatMode + ? nativeControlPanel + : null} + {parallelChatMode && parallelConfiguringIndex != null && parallelSlotExecutionModeOptions.length > 0 ? ( +
+ {parallelSlotExecutionModeOptions.map((option) => { + const active = parallelSlotExecutionMode === option.value; + return ( + + ); + })} +
+ ) : null} + {parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? ( + onParallelSlotModelChange?.(parallelConfiguringIndex, next)} + availableModelIds={availableModelIds} + catalogMode={restrictModelCatalogToAvailable ? "available-only" : "all"} + disabled={parallelLaunchBusy} + showReasoning + reasoningEffort={parallelModelSlots[parallelConfiguringIndex]!.reasoningEffort} + onReasoningEffortChange={(effort) => onParallelSlotReasoningChange?.(parallelConfiguringIndex, effort)} + onOpenAiSettings={onOpenAiSettings} + /> + ) : !parallelChatMode ? ( + ) : null}
{/* Right: attachment, commands, proof, context, send */} @@ -1277,15 +1501,31 @@ export function AgentChatComposer({ ? "border-white/[0.04] text-muted-fg/12" : "border-[color:color-mix(in_srgb,var(--chat-accent)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_12%,transparent)] text-[var(--chat-accent)] hover:bg-[color:color-mix(in_srgb,var(--chat-accent)_20%,transparent)]", )} - disabled={busy || !draft.trim().length || !modelId} + disabled={ + busy + || parallelLaunchBusy + || !draft.trim().length + || (parallelChatMode ? parallelModelSlots.length < 2 : !modelId) + } onClick={submitComposerDraft} - title={!modelId ? "Select a model first" : "Send"} + title={ + parallelChatMode + ? parallelModelSlots.length < 2 + ? "Add at least two models" + : "Send to all lanes" + : !modelId + ? "Select a model first" + : "Send" + } > - Send + + {parallelChatMode ? "Send to lanes" : "Send"} + )}
+
} > @@ -1345,10 +1585,12 @@ export function AgentChatComposer({ if (slashPickerOpen && !val.startsWith("/")) { setSlashPickerOpen(false); setSlashQuery(""); } if (val.startsWith("/")) { setSlashQuery(val.slice(1)); setSlashCursor(0); } }} + disabled={parallelLaunchBusy} className={cn( "min-h-[44px] w-full bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", layoutVariant === "grid-tile" ? "resize-y" : "max-h-[200px] resize-none", dragActive ? "opacity-30" : "", + parallelLaunchBusy ? "cursor-not-allowed opacity-50" : "", )} style={layoutVariant === "grid-tile" && composerMaxHeightPx != null ? { maxHeight: `${composerMaxHeightPx}px` } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 8340d840f..ce6345720 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -36,7 +36,7 @@ import { import { filterChatModelIdsForSession } from "../../../shared/chatModelSwitching"; import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; -import { AgentChatComposer } from "./AgentChatComposer"; +import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; import { AgentChatMessageList } from "./AgentChatMessageList"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { isChatToolType } from "../../lib/sessions"; @@ -159,6 +159,12 @@ type NativeControlState = { cursorConfigValues: Record; }; +type ParallelModelRowState = NativeControlState & { + modelId: string; + reasoningEffort: string | null; + executionMode: AgentChatExecutionMode; +}; + function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState { if (profile === "persistent_identity") { return { @@ -199,6 +205,40 @@ function runtimeFacingModelId(desc: ModelDescriptor | null | undefined, registry return desc.shortId ?? registryModelId; } +function nativeControlSliceFromParallelSlot(slot: ParallelModelRowState): NativeControlState { + return { + interactionMode: slot.interactionMode, + claudePermissionMode: slot.claudePermissionMode, + codexApprovalPolicy: slot.codexApprovalPolicy, + codexSandbox: slot.codexSandbox, + codexConfigSource: slot.codexConfigSource, + unifiedPermissionMode: slot.unifiedPermissionMode, + cursorModeId: slot.cursorModeId, + cursorConfigValues: slot.cursorConfigValues, + }; +} + +function cloneParallelSlotFromComposer(args: { + native: NativeControlState; + modelId: string; + reasoningEffort: string | null; + executionMode: AgentChatExecutionMode; +}): ParallelModelRowState { + return { + interactionMode: args.native.interactionMode, + claudePermissionMode: args.native.claudePermissionMode, + codexApprovalPolicy: args.native.codexApprovalPolicy, + codexSandbox: args.native.codexSandbox, + codexConfigSource: args.native.codexConfigSource, + unifiedPermissionMode: args.native.unifiedPermissionMode, + cursorModeId: args.native.cursorModeId, + cursorConfigValues: { ...args.native.cursorConfigValues }, + modelId: args.modelId, + reasoningEffort: args.reasoningEffort, + executionMode: args.executionMode, + }; +} + function summarizeNativeControls( provider: AgentChatSessionSummary["provider"] | "claude" | "codex" | "unified" | "cursor", controls: NativeControlState, @@ -496,6 +536,16 @@ function preferredChatLabel(raw: string | null | undefined): string | null { return stripOutcomePrefix(normalized); } +function parallelLaneModelSuffix(descriptor: ModelDescriptor | null | undefined): string { + if (!descriptor) return "model"; + if (descriptor.family === "openai" || descriptor.cliCommand === "codex") return "codex"; + if (descriptor.family === "anthropic" || descriptor.cliCommand === "claude") return "claude"; + if (descriptor.family === "cursor") return "cursor"; + const raw = (descriptor.displayName ?? descriptor.shortId ?? descriptor.id).trim(); + const slug = raw.replace(/\s+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "").replace(/-+/g, "-"); + return slug.slice(0, 28) || "model"; +} + function chatSessionTitle(session: AgentChatSessionSummary): string { const explicitTitle = preferredChatLabel(session.title); if (explicitTitle) return explicitTitle; @@ -566,6 +616,10 @@ export function AgentChatPane({ navigate("/settings?tab=ai#ai-providers"); }, [navigate]); const selectLane = useAppStore((s) => s.selectLane); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const setWorkViewState = useAppStore((s) => s.setWorkViewState); + const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); + const refreshLanesStore = useAppStore((s) => s.refreshLanes); const lockedSingleSessionMode = Boolean(lockSessionId && hideSessionTabs && initialSessionSummary); const forceDraft = forceDraftMode || forceNewSession; const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession; @@ -620,6 +674,11 @@ export function AgentChatPane({ const [handoffOpen, setHandoffOpen] = useState(false); const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); + const [parallelChatMode, setParallelChatMode] = useState(false); + const [parallelModelSlots, setParallelModelSlots] = useState([]); + const [parallelConfiguringIndex, setParallelConfiguringIndex] = useState(null); + const [parallelLaunchBusy, setParallelLaunchBusy] = useState(false); + const [parallelLaunchStatus, setParallelLaunchStatus] = useState(null); const shellRef = useRef(null); const [composerMaxHeightPx, setComposerMaxHeightPx] = useState(null); const composerMaxHeightPxRef = useRef(null); @@ -720,6 +779,76 @@ export function AgentChatPane({ }; }, [cursorConfigValues, cursorModeId, selectedSession?.cursorModeSnapshot, sessionProvider]); + const patchParallelSlot = useCallback((index: number, patch: Partial) => { + setParallelModelSlots((prev) => { + if (index < 0 || index >= prev.length) return prev; + const next = [...prev]; + next[index] = { ...next[index]!, ...patch }; + return next; + }); + }, []); + + const parallelSlotCursorSnapshot = useMemo(() => { + if (parallelConfiguringIndex == null) return null; + const row = parallelModelSlots[parallelConfiguringIndex]; + if (!row) return null; + if (resolveChatRuntimeProvider(getModelById(row.modelId)) !== "cursor") return null; + const base = buildFallbackCursorModeSnapshot(row.cursorModeId); + return { + ...base, + currentModeId: row.cursorModeId ?? base.currentModeId, + configOptions: base.configOptions?.map((option) => { + if (option.id === base.modeConfigId) { + return { ...option, currentValue: row.cursorModeId ?? option.currentValue }; + } + if (Object.prototype.hasOwnProperty.call(row.cursorConfigValues, option.id)) { + return { ...option, currentValue: row.cursorConfigValues[option.id] ?? option.currentValue }; + } + return option; + }), + }; + }, [parallelConfiguringIndex, parallelModelSlots]); + + const parallelComposerControlSlot = useMemo((): ParallelComposerControlSlot | null => { + if (parallelConfiguringIndex == null) return null; + const row = parallelModelSlots[parallelConfiguringIndex]; + if (!row) return null; + const idx = parallelConfiguringIndex; + const prov = resolveChatRuntimeProvider(getModelById(row.modelId)); + return { + sessionProvider: prov, + interactionMode: row.interactionMode, + claudePermissionMode: row.claudePermissionMode, + codexApprovalPolicy: row.codexApprovalPolicy, + codexSandbox: row.codexSandbox, + codexConfigSource: row.codexConfigSource, + unifiedPermissionMode: row.unifiedPermissionMode, + cursorModeSnapshot: parallelSlotCursorSnapshot, + onInteractionModeChange: (mode) => patchParallelSlot(idx, { interactionMode: mode }), + onClaudeModeChange: (mode) => patchParallelSlot(idx, { claudePermissionMode: mode }), + onClaudePermissionModeChange: (mode) => patchParallelSlot(idx, { claudePermissionMode: mode }), + onCodexPresetChange: (next) => patchParallelSlot(idx, { + codexApprovalPolicy: next.codexApprovalPolicy, + codexSandbox: next.codexSandbox, + codexConfigSource: next.codexConfigSource, + }), + onCodexApprovalPolicyChange: (policy) => patchParallelSlot(idx, { codexApprovalPolicy: policy }), + onCodexSandboxChange: (sandbox) => patchParallelSlot(idx, { codexSandbox: sandbox }), + onCodexConfigSourceChange: (source) => patchParallelSlot(idx, { codexConfigSource: source }), + onUnifiedPermissionModeChange: (mode) => patchParallelSlot(idx, { unifiedPermissionMode: mode }), + onCursorModeChange: (modeId) => patchParallelSlot(idx, { cursorModeId: modeId }), + onCursorConfigChange: (configId, value) => patchParallelSlot(idx, { + cursorConfigValues: { ...row.cursorConfigValues, [configId]: value }, + }), + }; + }, [parallelConfiguringIndex, parallelModelSlots, parallelSlotCursorSnapshot, patchParallelSlot]); + + const parallelConfiguringRow = parallelConfiguringIndex != null ? parallelModelSlots[parallelConfiguringIndex] ?? null : null; + const parallelSlotExecutionModeOptions = useMemo( + () => getExecutionModeOptions(parallelConfiguringRow ? getModelById(parallelConfiguringRow.modelId) : null), + [parallelConfiguringRow], + ); + const syncComposerToSession = useCallback((session: AgentChatSessionSummary | null) => { if (!session) { setInteractionMode(initialNativeControls.interactionMode); @@ -1588,12 +1717,39 @@ export function AgentChatPane({ nativeControlsRef.current = currentNativeControls; }, [currentNativeControls]); + useEffect(() => { + if (!parallelChatMode) return; + if (parallelModelSlots.length >= 2) return; + setParallelModelSlots([ + cloneParallelSlotFromComposer({ + native: currentNativeControls, + modelId, + reasoningEffort, + executionMode, + }), + cloneParallelSlotFromComposer({ + native: currentNativeControls, + modelId, + reasoningEffort, + executionMode, + }), + ]); + }, [parallelChatMode, parallelModelSlots.length, currentNativeControls, modelId, reasoningEffort, executionMode]); + const buildNativeControlPayload = useCallback((provider: ChatRuntimeProviderKey) => { return { ...summarizeNativeControls(provider, currentNativeControls), ...(provider === "cursor" ? { cursorConfigValues: currentNativeControls.cursorConfigValues } : {}), }; }, [currentNativeControls]); + + const buildNativeControlPayloadForSlot = useCallback((slot: ParallelModelRowState, provider: ChatRuntimeProviderKey) => { + const native = nativeControlSliceFromParallelSlot(slot); + return { + ...summarizeNativeControls(provider, native), + ...(provider === "cursor" ? { cursorConfigValues: slot.cursorConfigValues } : {}), + }; + }, []); const buildModelSelectionSnapshot = useCallback((nextModelId: string) => { const nextDesc = getModelById(nextModelId); const nextProvider = resolveChatRuntimeProvider(nextDesc); @@ -1703,7 +1859,149 @@ export function AgentChatPane({ }, [preferencesReady, laneId, modelId, selectedSessionId, lockSessionId, initialSessionId, forceDraft, createSession]); const submit = useCallback(async () => { - if (submitInFlightRef.current || busy) return; + if (submitInFlightRef.current || busy || parallelLaunchBusy) return; + + const isParallelLaunch = + !lockSessionId + && !initialSessionId + && forceDraft + && embeddedWorkLayout + && parallelChatMode + && selectedSessionId == null; + + if (isParallelLaunch) { + const text = draft.trim(); + if (!text.length || !laneId || !projectRoot) return; + if (parallelModelSlots.length < 2) { + setError("Add at least two models for a parallel launch."); + return; + } + const modelKeys = parallelModelSlots.map((s) => s.modelId); + if (new Set(modelKeys).size !== modelKeys.length) { + setError("Each parallel lane needs a different model."); + return; + } + + const draftSnapshot = draft; + const includeDocsSnapshot = includeProjectDocs; + submitInFlightRef.current = true; + setParallelLaunchBusy(true); + setParallelLaunchStatus("Naming lanes…"); + setError(null); + try { + const baseName = await window.ade.agentChat.suggestLaneName({ + laneId, + prompt: text, + modelId: parallelModelSlots[0]!.modelId, + }); + setParallelLaunchStatus(`Creating ${parallelModelSlots.length} child lanes…`); + const createdLaneIds: string[] = []; + const sessionByLane = new Map(); + + for (const slot of parallelModelSlots) { + const desc = getModelById(slot.modelId); + const suffix = parallelLaneModelSuffix(desc); + const laneName = `${baseName}-${suffix}`; + const childLane = await window.ade.lanes.createChild({ parentLaneId: laneId, name: laneName }); + createdLaneIds.push(childLane.id); + const provider = resolveChatRuntimeProvider(desc); + const model = provider === "unified" ? slot.modelId : runtimeFacingModelId(desc, slot.modelId); + const created = await window.ade.agentChat.create({ + laneId: childLane.id, + provider, + model, + modelId: slot.modelId, + sessionProfile: resolveChatSessionProfile(computerUsePolicy), + reasoningEffort: slot.reasoningEffort, + ...buildNativeControlPayloadForSlot(slot, provider), + computerUse: computerUsePolicy, + }); + sessionByLane.set(childLane.id, created.id); + } + + await refreshLanesStore(); + + let finalText = text; + if (!text.startsWith("/") && includeDocsSnapshot) { + const docPaths = [".ade/context/PRD.ade.md", ".ade/context/ARCHITECTURE.ade.md"]; + const docNote = [ + "[Project Context — generated from main branch, may not reflect in-progress lane work]", + "The following project-level docs are available for reference. Read them with read_file if you need project context:", + ...docPaths.map((p) => `- ${p}`), + ].join("\n"); + finalText = `${docNote}\n\n---\n\n${finalText}`; + } + + setParallelLaunchStatus("Sending prompt to each lane…"); + for (let idx = 0; idx < parallelModelSlots.length; idx += 1) { + const slot = parallelModelSlots[idx]!; + const childLaneId = createdLaneIds[idx]; + const sessionId = childLaneId ? sessionByLane.get(childLaneId) : undefined; + if (!sessionId) continue; + const desc = getModelById(slot.modelId); + const provider = resolveChatRuntimeProvider(desc); + try { + await window.ade.agentChat.send({ + sessionId, + text: finalText, + displayText: text, + attachments: [], + reasoningEffort: slot.reasoningEffort, + executionMode: slot.executionMode, + interactionMode: provider === "claude" ? slot.interactionMode : null, + }); + } catch (sendError) { + const sendMsg = sendError instanceof Error ? sendError.message : String(sendError); + const isBusyErr = /turn is already active|already active/i.test(sendMsg); + if (isBusyErr) { + await window.ade.agentChat.steer({ sessionId, text: finalText }); + } else { + throw sendError; + } + } + if (desc?.isCliWrapped && (desc.family === "anthropic" || desc.family === "cursor")) { + window.ade.agentChat.warmupModel({ sessionId, modelId: slot.modelId }).catch(() => {}); + } + } + + setWorkViewState(projectRoot, (prev) => { + let nextOpen = [...prev.openItemIds]; + for (const sid of sessionByLane.values()) { + if (!nextOpen.includes(sid)) nextOpen.push(sid); + } + return { ...prev, openItemIds: nextOpen }; + }); + for (const [childLaneId, sid] of sessionByLane) { + setLaneWorkViewState(projectRoot, childLaneId, { + activeItemId: sid, + selectedItemId: sid, + draftKind: "chat", + viewMode: "tabs", + }); + } + + setDraft(""); + setParallelChatMode(false); + setParallelModelSlots([]); + setParallelConfiguringIndex(null); + if (includeDocsSnapshot) setIncludeProjectDocs(false); + + const q = new URLSearchParams(); + q.set("laneIds", createdLaneIds.join(",")); + q.set("workFocus", "1"); + navigate(`/lanes?${q.toString()}`); + } catch (submitError) { + const message = submitError instanceof Error ? submitError.message : String(submitError); + setDraft((current) => (current.trim().length ? current : draftSnapshot)); + setError(message); + } finally { + submitInFlightRef.current = false; + setParallelLaunchBusy(false); + setParallelLaunchStatus(null); + } + return; + } + if (!modelId) return; const text = draft.trim(); if (!text.length || !laneId) return; @@ -1854,7 +2152,22 @@ export function AgentChatPane({ sessionProvider, touchSession, turnActive, - turnActiveBySession + turnActiveBySession, + parallelLaunchBusy, + parallelChatMode, + parallelModelSlots, + lockSessionId, + initialSessionId, + forceDraft, + embeddedWorkLayout, + projectRoot, + navigate, + buildNativeControlPayloadForSlot, + computerUsePolicy, + refreshLanesStore, + setWorkViewState, + setLaneWorkViewState, + includeProjectDocs, ]); const interrupt = useCallback(async () => { @@ -2399,6 +2712,66 @@ export function AgentChatPane({ void window.ade.agentChat.editSteer({ sessionId: selectedSessionId, steerId, text }); } }} + showParallelChatToggle={Boolean( + embeddedWorkLayout && forceDraft && !lockSessionId && !initialSessionId && selectedSessionId == null, + )} + parallelChatMode={parallelChatMode} + onParallelChatModeChange={(enabled) => { + setParallelChatMode(enabled); + if (!enabled) { + setParallelModelSlots([]); + setParallelConfiguringIndex(null); + } + }} + parallelModelSlots={parallelModelSlots} + parallelConfiguringIndex={parallelConfiguringIndex} + onParallelConfiguringIndexChange={setParallelConfiguringIndex} + onParallelAddModel={() => { + setParallelModelSlots((prev) => [ + ...prev, + cloneParallelSlotFromComposer({ + native: nativeControlsRef.current, + modelId, + reasoningEffort, + executionMode, + }), + ]); + }} + onParallelRemoveModel={(index) => { + setParallelModelSlots((prev) => prev.filter((_, i) => i !== index)); + setParallelConfiguringIndex((cur) => { + if (cur == null) return cur; + if (cur === index) return null; + if (cur > index) return cur - 1; + return cur; + }); + }} + onParallelSlotModelChange={(index, nextModelId) => { + const desc = getModelById(nextModelId); + const tiers = desc?.reasoningTiers ?? []; + const preferred = readLastUsedReasoningEffort({ laneId, modelId: nextModelId }); + const nextEffort = selectReasoningEffort({ tiers, preferred }); + const nextExecOpts = getExecutionModeOptions(desc); + patchParallelSlot(index, { + modelId: nextModelId, + reasoningEffort: nextEffort, + executionMode: nextExecOpts.some((o) => o.value === parallelModelSlots[index]?.executionMode) + ? parallelModelSlots[index]!.executionMode + : (nextExecOpts[0]?.value ?? "focused"), + }); + }} + onParallelSlotReasoningChange={(index, effort) => { + patchParallelSlot(index, { reasoningEffort: effort }); + }} + parallelLaunchBusy={parallelLaunchBusy} + parallelLaunchStatus={parallelLaunchStatus} + parallelControlSlot={parallelComposerControlSlot} + parallelSlotExecutionModeOptions={parallelSlotExecutionModeOptions} + parallelSlotExecutionMode={parallelConfiguringRow?.executionMode ?? null} + onParallelSlotExecutionModeChange={(mode) => { + if (parallelConfiguringIndex == null) return; + patchParallelSlot(parallelConfiguringIndex, { executionMode: mode }); + }} /> ); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index a191f0ed9..634c9d623 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -28,6 +28,7 @@ import { laneMatchesFilter, chipLabel, LANES_TILING_TREE, + LANES_TILING_WORK_FOCUS_TREE, LANES_TILING_LAYOUT_VERSION, GIT_ACTIONS_FULLSCREEN_TREE, RESIZE_TARGET_MINIMUM_SIZE, @@ -340,6 +341,19 @@ export function LanesPage() { [activeWithPins, lanesById, filteredSet] ); + const workFocusTiling = useMemo(() => { + if (params.get("workFocus") !== "1") return false; + const laneIdsParam = params.get("laneIds"); + if (!laneIdsParam) return false; + const ids = laneIdsParam.split(",").map((s) => s.trim()).filter(Boolean); + if (ids.length < 2) return false; + const visibleSet = new Set(visibleLaneIds); + return ids.every((id) => visibleSet.has(id)); + }, [params, visibleLaneIds]); + + const laneTilingTree = workFocusTiling ? LANES_TILING_WORK_FOCUS_TREE : LANES_TILING_TREE; + const laneTilingLayoutSuffix = workFocusTiling ? ":wf" : ""; + const managedLane = selectedLaneId ? lanesById.get(selectedLaneId) ?? null : null; const managedLanes = useMemo( () => managedLaneIds.map((id) => lanesById.get(id)).filter((l): l is LaneSummary => l != null && l.laneType !== "primary"), @@ -1131,13 +1145,25 @@ export function LanesPage() { // doesn't reopen and reset the dialog when lanes refresh. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { + const laneIdsRaw = params.get("laneIds"); const laneId = params.get("laneId"); const sessionId = params.get("sessionId"); const inspectorTabParam = params.get("inspectorTab"); if (params.get("action") === "create") { prepareCreateDialog(); } - if (laneId) { + if (laneIdsRaw) { + const parsed = laneIdsRaw.split(",").map((s) => s.trim()).filter(Boolean); + const valid = parsed.filter((id) => lanesById.has(id)); + if (valid.length) { + selectLane(valid[0]!); + setActiveLaneIds(valid); + setPinnedLaneIds(new Set()); + if (inspectorTabParam && valid[0]) { + setLaneInspectorTab(valid[0], inspectorTabParam as LaneInspectorTab); + } + } + } else if (laneId) { selectLane(laneId); if (params.get("focus") === "single") { setActiveLaneIds([laneId]); @@ -1147,7 +1173,7 @@ export function LanesPage() { } } if (sessionId) focusSession(sessionId); - }, [params, selectLane, focusSession, setLaneInspectorTab]); + }, [params, selectLane, focusSession, setLaneInspectorTab, lanesById]); const handleCreateDialogOpenChange = useCallback((open: boolean) => { if (!open && createBusy) return; @@ -1894,8 +1920,8 @@ export function LanesPage() {
) : visibleLaneIds.length === 1 ? ( @@ -1913,8 +1939,8 @@ export function LanesPage() { @@ -1952,8 +1978,8 @@ export function LanesPage() { diff --git a/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts b/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts index 5fbc2a2f6..142a3f47c 100644 --- a/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts @@ -3,6 +3,7 @@ import type { LaneSummary } from "../../../shared/types"; import { LANES_TILING_LAYOUT_VERSION, LANES_TILING_TREE, + LANES_TILING_WORK_FOCUS_TREE, isMissionLaneHiddenByDefault, laneMatchesFilter, } from "./laneUtils"; @@ -47,7 +48,14 @@ describe("laneUtils tiling defaults", () => { }); it("bumps the persisted tiling layout version", () => { - expect(LANES_TILING_LAYOUT_VERSION).toBe("v5"); + expect(LANES_TILING_LAYOUT_VERSION).toBe("v6"); + }); + + it("work-focus layout emphasizes the work pane", () => { + expect(LANES_TILING_WORK_FOCUS_TREE.children[1]?.defaultSize).toBeGreaterThan(40); + expect(LANES_TILING_WORK_FOCUS_TREE.children[2]?.defaultSize).toBeLessThan( + (LANES_TILING_TREE.children[2]?.defaultSize ?? 0), + ); }); it("hides non-result mission lanes by default but reveals them with mission filters", () => { diff --git a/apps/desktop/src/renderer/components/lanes/laneUtils.ts b/apps/desktop/src/renderer/components/lanes/laneUtils.ts index e5aac1043..174b096ae 100644 --- a/apps/desktop/src/renderer/components/lanes/laneUtils.ts +++ b/apps/desktop/src/renderer/components/lanes/laneUtils.ts @@ -157,7 +157,29 @@ export const LANES_TILING_TREE: PaneSplit = { ] }; -export const LANES_TILING_LAYOUT_VERSION = "v5"; +/** Emphasize the Work pane after parallel multi-model launches (stack + diff + git smaller). */ +export const LANES_TILING_WORK_FOCUS_TREE: PaneSplit = { + type: "split", + direction: "horizontal", + children: [ + { + node: { + type: "split", + direction: "vertical", + children: [ + { node: { type: "pane", id: "stack" }, defaultSize: 50, minSize: 12 }, + { node: { type: "pane", id: "diff-viewer" }, defaultSize: 50, minSize: 12 } + ] + }, + defaultSize: 12, + minSize: 8 + }, + { node: { type: "pane", id: "work" }, defaultSize: 58, minSize: 32 }, + { node: { type: "pane", id: "git-actions" }, defaultSize: 30, minSize: 14 } + ] +}; + +export const LANES_TILING_LAYOUT_VERSION = "v6"; export const GIT_ACTIONS_FULLSCREEN_TREE: PaneSplit = { type: "split", diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index bd925ee1c..9ee10c979 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -99,6 +99,7 @@ export const IPC = { agentChatList: "ade.agentChat.list", agentChatGetSummary: "ade.agentChat.getSummary", agentChatCreate: "ade.agentChat.create", + agentChatSuggestLaneName: "ade.agentChat.suggestLaneName", agentChatHandoff: "ade.agentChat.handoff", agentChatSend: "ade.agentChat.send", agentChatSteer: "ade.agentChat.steer", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index df5d2fd49..15adba274 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -609,6 +609,15 @@ export type AgentChatListArgs = { includeAutomation?: boolean; }; +export type AgentChatSuggestLaneNameArgs = { + /** Lane the user is launching from (worktree path for the naming model call). */ + laneId: string; + /** User prompt for the parallel chat launch (used to derive a short lane name prefix). */ + prompt: string; + /** Registry model ID used to run the naming call (e.g. first selected model). */ + modelId: string; +}; + export type AgentChatGetSummaryArgs = { sessionId: string; }; From 9c47f2510ef673541aaf900b9f271cb855453e02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Apr 2026 05:32:59 +0000 Subject: [PATCH 2/4] feat(desktop): parallel chat image attachments and UI polish - Allow images in parallel mode (same refs sent to every child session); cap via PARALLEL_CHAT_MAX_IMAGES - Filter @ picker to images; file input accept=image/*; validate paste/drop - Refined parallel launch card, entry button, send enables with images-only prompt - Steer fallback includes attachment paths; image-only send uses short default line Co-authored-by: Arul Sharma --- .../components/chat/AgentChatComposer.tsx | 217 +++++++++++++----- .../components/chat/AgentChatPane.tsx | 42 +++- apps/desktop/src/shared/types/chat.ts | 3 + 3 files changed, 200 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 5ca8af8e5..15173cdb9 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_IMAGES, type AgentChatApprovalDecision, type AgentChatClaudePermissionMode, type AgentChatCursorConfigOption, @@ -476,7 +477,14 @@ export function AgentChatComposer({ const uploadInputRef = useRef(null); const textareaRef = useRef(null); const fileAddInProgressRef = useRef(false); - const canAttach = !turnActive && !parallelChatMode; + const parallelImageCount = useMemo( + () => attachments.filter((a) => a.type === "image").length, + [attachments], + ); + const canAttach = !turnActive && (!parallelChatMode || parallelImageCount < PARALLEL_CHAT_MAX_IMAGES); + const attachBlockedReason = parallelChatMode && parallelImageCount >= PARALLEL_CHAT_MAX_IMAGES + ? `Maximum ${PARALLEL_CHAT_MAX_IMAGES} images for parallel launch` + : null; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); @@ -497,6 +505,11 @@ export function AgentChatComposer({ ); }, [slashQuery, effectiveSlashCommands]); + const attachmentResultsForPicker = useMemo(() => { + if (!parallelChatMode) return attachmentResults; + return attachmentResults.filter((r) => r.type === "image"); + }, [parallelChatMode, attachmentResults]); + /* ── Attachment picker effects ── */ useEffect(() => { if (!attachmentPickerOpen) { @@ -510,6 +523,10 @@ export function AgentChatComposer({ return () => window.clearTimeout(timeout); }, [attachmentPickerOpen]); + useEffect(() => { + setAttachmentCursor((c) => Math.min(c, Math.max(attachmentResultsForPicker.length - 1, 0))); + }, [attachmentResultsForPicker.length]); + useEffect(() => { if (!attachmentPickerOpen) return; const query = attachmentQuery.trim(); @@ -536,23 +553,47 @@ export function AgentChatComposer({ const selectAttachment = (attachment: AgentChatFileRef) => { setAttachError(null); + if (parallelChatMode) { + if (attachment.type !== "image") { + setAttachError("Parallel launch supports images only. Remove non-image attachments or turn off parallel mode."); + return; + } + if (parallelImageCount >= PARALLEL_CHAT_MAX_IMAGES) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_IMAGES} images 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 && parallelImageCount >= PARALLEL_CHAT_MAX_IMAGES) return; + if (!parallelChatMode && !canAttach) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; setAttachError(null); try { + let imageCount = parallelImageCount; for (const file of Array.from(files)) { + if (parallelChatMode && imageCount >= PARALLEL_CHAT_MAX_IMAGES) { + setAttachError(`You can attach up to ${PARALLEL_CHAT_MAX_IMAGES} images 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); + if (parallelChatMode && t !== "image") { + setAttachError("Parallel launch supports images only."); + continue; + } + onAddAttachment({ path: filePath, type: t }); + if (parallelChatMode && t === "image") imageCount += 1; continue; } @@ -573,7 +614,13 @@ export function AgentChatComposer({ data: base64, filename: file.name || "clipboard.png", }); - onAddAttachment({ path: tempPath, type: inferAttachmentType(tempPath, file.type) }); + const t = inferAttachmentType(tempPath, file.type); + if (parallelChatMode && t !== "image") { + setAttachError("Parallel launch supports images only."); + continue; + } + onAddAttachment({ path: tempPath, type: t }); + if (parallelChatMode) imageCount += 1; } catch { setAttachError(`Unable to attach "${file.name || "clipboard"}".`); } @@ -1041,14 +1088,17 @@ export function AgentChatComposer({ return; } if (parallelChatMode) { - if (busy || parallelLaunchBusy || !draft.trim().length) return; + if (busy || parallelLaunchBusy) return; if (parallelModelSlots.length < 2) return; + const hasPrompt = draft.trim().length > 0; + const hasImages = attachments.some((a) => a.type === "image"); + if (!hasPrompt && !hasImages) return; onSubmit(); return; } if (busy || !modelId || !draft.trim().length) return; onSubmit(); - }, [busy, draft, modelId, onApproval, onDraftChange, onSubmit, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); + }, [attachments, busy, draft, modelId, onApproval, onDraftChange, onSubmit, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); const pendingQuestionCount = getPendingInputQuestionCount(pendingInput); const showPendingInputOptionsHint = hasPendingInputOptions(pendingInput); @@ -1159,6 +1209,7 @@ export function AgentChatComposer({ ref={uploadInputRef} type="file" multiple + accept={parallelChatMode ? "image/*" : undefined} className="hidden" onChange={(event) => { void addFileAttachments(event.target.files); @@ -1205,10 +1256,14 @@ export function AgentChatComposer({ className="h-5 flex-1 bg-transparent font-mono text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/25" onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setAttachmentPickerOpen(false); return; } - if (event.key === "ArrowDown") { event.preventDefault(); setAttachmentCursor((v) => Math.min(v + 1, Math.max(attachmentResults.length - 1, 0))); return; } + if (event.key === "ArrowDown") { + event.preventDefault(); + setAttachmentCursor((v) => Math.min(v + 1, Math.max(attachmentResultsForPicker.length - 1, 0))); + return; + } if (event.key === "ArrowUp") { event.preventDefault(); setAttachmentCursor((v) => Math.max(v - 1, 0)); return; } if (event.key === "Enter") { - const candidate = attachmentResults[attachmentCursor]; + const candidate = attachmentResultsForPicker[attachmentCursor]; if (candidate) { event.preventDefault(); selectAttachment(candidate); } } }} @@ -1219,8 +1274,8 @@ export function AgentChatComposer({
Type to search files...
) : attachmentBusy ? (
Searching...
- ) : attachmentResults.length ? ( - attachmentResults.map((result, index) => ( + ) : attachmentResultsForPicker.length ? ( + attachmentResultsForPicker.map((result, index) => ( ) : null} {parallelChatMode ? ( -
-
@@ -1321,7 +1293,7 @@ export function AgentChatComposer({ Parallel models - Same prompt (and images) in one child lane per model + Same prompt and attachments in one child lane per model @@ -1332,7 +1304,7 @@ export function AgentChatComposer({
Parallel launch

- Configure each model, then send once. Images attach to every lane (max {PARALLEL_CHAT_MAX_IMAGES}). + Configure each model, then send once. Attachments go to every lane (max {PARALLEL_CHAT_MAX_ATTACHMENTS}).