diff --git a/AGENTS.md b/AGENTS.md index b43e96d..da54419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ Pre-commit hooks (husky) run lint-staged (Prettier) and `npm test` automatically - Preserve existing formatting in touched files unless a formatting change is required for correctness. - Never run project-wide formatting as part of a feature/fix unless the user explicitly requests it. - If formatting is required, scope it to the smallest possible set of changed lines/files. +- Preserve each file's existing line ending style (LF vs CRLF) and never introduce EOL-only churn. ## Release & Versioning diff --git a/src/main/agents.test.ts b/src/main/agents.test.ts index 755435e..7e30bd6 100644 --- a/src/main/agents.test.ts +++ b/src/main/agents.test.ts @@ -124,30 +124,22 @@ mode: gpt-4.5 expect(result.agents).toEqual([]); }); - it('should find gemini and codex agent files', async () => { + it('should find gemini agent file', async () => { mocks.existsSync.mockImplementation((path: string) => { const normalized = normalizePath(path); - return ( - normalized === '/tmp/test-home/.gemini/GEMINI.md' || - normalized === '/tmp/test-home/.codex/AGENTS.md' - ); + return normalized === '/tmp/test-home/.gemini/GEMINI.md'; }); mocks.stat.mockResolvedValue({ isFile: () => true }); - mocks.readFile.mockImplementation((path: string) => { - if (normalizePath(path).includes('/.gemini/')) { - return Promise.resolve(`--- -name: gemini-agent ----`); - } + mocks.readFile.mockImplementation(() => { return Promise.resolve(`--- -name: codex-agent +name: gemini-agent ---`); }); const result = await getAllAgents(); - expect(result.agents.length).toBe(2); - const sources = result.agents.map((agent) => agent.source).sort(); - expect(sources).toEqual(['codex', 'gemini']); + expect(result.agents.length).toBe(1); + const sources = result.agents.map((agent) => agent.source); + expect(sources).toEqual(['gemini']); }); }); diff --git a/src/main/agents.ts b/src/main/agents.ts index c02ff3e..e992a68 100644 --- a/src/main/agents.ts +++ b/src/main/agents.ts @@ -278,7 +278,6 @@ export async function getAllAgents(projectRoot?: string, cwd?: string): Promise< const personalFiles = [ { path: join(homePath, '.gemini', 'GEMINI.md'), source: 'gemini' as const }, - { path: join(homePath, '.codex', 'AGENTS.md'), source: 'codex' as const }, ]; for (const { path, source } of personalFiles) { @@ -295,8 +294,6 @@ export async function getAllAgents(projectRoot?: string, cwd?: string): Promise< for (const { path, source } of projectDirs) { addResults(await scanAgentsDirectory(path, 'project', source)); } - - addResults(await scanAgentFile(join(projectRoot, 'AGENTS.md'), 'project', 'codex')); } if (cwd && cwd !== projectRoot) { @@ -309,8 +306,6 @@ export async function getAllAgents(projectRoot?: string, cwd?: string): Promise< for (const { path, source } of cwdDirs) { addResults(await scanAgentsDirectory(path, 'project', source)); } - - addResults(await scanAgentFile(join(cwd, 'AGENTS.md'), 'project', 'codex')); } return { agents, errors }; diff --git a/src/main/main.ts b/src/main/main.ts index b328244..a292c90 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -27,6 +27,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { createServer, Server } from 'http'; const execAsync = promisify(exec); +const COOPER_CLIENT_NAME = 'cooper'; // Get augmented PATH that includes common CLI tool locations // This is needed because packaged Electron apps don't inherit the user's shell PATH @@ -664,6 +665,7 @@ interface SessionState { allowedPaths: Set; // Per-session allowed out-of-scope paths (parent directories) isProcessing: boolean; // Whether the session is currently waiting for a response yoloMode: boolean; // Auto-approve all permission requests without prompting + activeAgentName: string | null; // Selected main agent name for this session } const sessions = new Map(); const sessionSawDelta = new Map(); @@ -1049,6 +1051,7 @@ async function resumeDisconnectedSession( const client = await getClientForCwd(sessionState.cwd); const projectRoot = await getProjectRootForCwd(sessionState.cwd); const mcpDiscovery = await discoverMcpServers({ projectRoot }); + const customAgents = await loadCustomAgents(sessionState.cwd); // Create browser tools for resumed session const browserTools = createBrowserTools(sessionId); @@ -1058,13 +1061,16 @@ async function resumeDisconnectedSession( ); const resumedSession = await client.resumeSession(sessionId, { + clientName: COOPER_CLIENT_NAME, mcpServers: mcpDiscovery.effectiveServers, tools: browserTools, + customAgents, onPermissionRequest: (request, invocation) => handlePermissionRequest(request, invocation, sessionId), }); registerSessionEventForwarding(sessionId, resumedSession); + await enforceSelectedAgent(sessionId, { ...sessionState, session: resumedSession }); // Update session state with new session object sessionState.session = resumedSession; @@ -1168,6 +1174,7 @@ async function startEarlySessionResumption(): Promise { const agentResult = await getAllAgents(undefined, sessionCwd); const customAgents: CustomAgentConfig[] = []; for (const agent of agentResult.agents) { + if (agent.source === 'codex') continue; try { const content = await readFile(agent.path, 'utf-8'); const metadata = parseAgentFrontmatter(content); @@ -1185,6 +1192,7 @@ async function startEarlySessionResumption(): Promise { const projectRoot = await getProjectRootForCwd(sessionCwd); const mcpDiscovery = await discoverMcpServers({ projectRoot }); const session = await sessionClient.resumeSession(sessionId, { + clientName: COOPER_CLIENT_NAME, mcpServers: mcpDiscovery.effectiveServers, tools: createBrowserTools(sessionId), customAgents, @@ -1285,6 +1293,7 @@ async function startEarlySessionResumption(): Promise { allowedPaths: new Set(), isProcessing: false, yoloMode: yoloMode || false, + activeAgentName: null, }); console.log(`Early resumed session ${sessionId}`); @@ -1969,6 +1978,43 @@ async function getProjectRootForCwd(cwd: string): Promise { return gitRoot || undefined; } +async function loadCustomAgents(sessionCwd: string): Promise { + const agentResult = await getAllAgents(undefined, sessionCwd); + const customAgents: CustomAgentConfig[] = []; + for (const agent of agentResult.agents) { + if (agent.source === 'codex') continue; + try { + const content = await readFile(agent.path, 'utf-8'); + const metadata = parseAgentFrontmatter(content); + customAgents.push({ + name: metadata.name || agent.name, + displayName: agent.name, + description: metadata.description, + tools: null, + prompt: content, + }); + } catch (error) { + log.warn('Failed to load agent prompt:', agent.path, error); + } + } + return customAgents; +} + +async function enforceSelectedAgent(sessionId: string, sessionState: SessionState): Promise { + if (!sessionState.activeAgentName) return; + log.info(`[${sessionId}] [AgentSelection] enforcing agent=${sessionState.activeAgentName}`); + await sessionState.session.rpc.agent.select({ name: sessionState.activeAgentName }); + const { agent } = await sessionState.session.rpc.agent.getCurrent(); + log.info( + `[${sessionId}] [AgentSelection] current agent after enforce=${agent?.name ?? 'default'}` + ); + if (agent?.name !== sessionState.activeAgentName) { + throw new Error( + `Selected agent '${sessionState.activeAgentName}' is not active (active: '${agent?.name ?? 'default'}').` + ); + } +} + // Create a new session and return its ID async function createNewSession(model?: string, cwd?: string): Promise { const sessionModel = model || (store.get('model') as string); @@ -1988,6 +2034,7 @@ async function createNewSession(model?: string, cwd?: string): Promise { const agentMcpServers: Record = {}; for (const agent of agentResult.agents) { + if (agent.source === 'codex') continue; try { const content = await readFile(agent.path, 'utf-8'); const metadata = parseAgentFrontmatter(content); @@ -2036,6 +2083,7 @@ async function createNewSession(model?: string, cwd?: string): Promise { const subagentPrompt = buildSubagentPrompt(); const newSession = await client.createSession({ + clientName: COOPER_CLIENT_NAME, sessionId: generatedSessionId, model: sessionModel, mcpServers: mcpDiscovery.effectiveServers, @@ -2095,6 +2143,7 @@ Browser tools available: browser_navigate, browser_click, browser_fill, browser_ allowedPaths: new Set(), isProcessing: false, yoloMode: false, + activeAgentName: null, }); activeSessionId = sessionId; @@ -2260,7 +2309,10 @@ async function initCopilot(): Promise { .filter((s) => !openSessionIds.includes(s.sessionId)) .map((s) => ({ sessionId: s.sessionId, - name: sessionNames[s.sessionId] || s.summary || undefined, + name: resolveSessionName({ + storedName: sessionNames[s.sessionId], + summary: s.summary, + }), modifiedTime: s.modifiedTime.toISOString(), cwd: sessionCwds[s.sessionId], markedForReview: sessionMarks[s.sessionId]?.markedForReview, @@ -2298,6 +2350,7 @@ async function initCopilot(): Promise { const agentResult = await getAllAgents(undefined, sessionCwd); const customAgents: CustomAgentConfig[] = []; for (const agent of agentResult.agents) { + if (agent.source === 'codex') continue; try { const content = await readFile(agent.path, 'utf-8'); const metadata = parseAgentFrontmatter(content); @@ -2315,6 +2368,7 @@ async function initCopilot(): Promise { const projectRoot = await getProjectRootForCwd(sessionCwd); const mcpDiscovery = await discoverMcpServers({ projectRoot }); const session = await client.resumeSession(sessionId, { + clientName: COOPER_CLIENT_NAME, mcpServers: mcpDiscovery.effectiveServers, tools: createBrowserTools(sessionId), customAgents, @@ -2415,6 +2469,7 @@ async function initCopilot(): Promise { allowedPaths: new Set(), isProcessing: false, yoloMode: storedSession?.yoloMode || false, + activeAgentName: null, }); const resumed = { @@ -2668,6 +2723,7 @@ ipcMain.handle( } try { + await enforceSelectedAgent(data.sessionId, sessionState); const messageId = await sessionState.session.send(messageOptions); return messageId; } catch (error) { @@ -2683,6 +2739,7 @@ ipcMain.handle( try { // Try to resume the session await resumeDisconnectedSession(data.sessionId, sessionState); + await enforceSelectedAgent(data.sessionId, sessionState); // Retry the send const messageId = await sessionState.session.send(messageOptions); @@ -2718,6 +2775,7 @@ ipcMain.handle( }; try { + await enforceSelectedAgent(data.sessionId, sessionState); const response = await sessionState.session.sendAndWait(messageOptions); sessionState.isProcessing = false; return response?.data?.content || ''; @@ -2733,6 +2791,7 @@ ipcMain.handle( try { await resumeDisconnectedSession(data.sessionId, sessionState); + await enforceSelectedAgent(data.sessionId, sessionState); const response = await sessionState.session.sendAndWait(messageOptions); sessionState.isProcessing = false; return response?.data?.content || ''; @@ -2825,7 +2884,9 @@ ipcMain.handle('copilot:generateTitle', async (_event, data: { conversation: str // Create a temporary session with the cheapest model for title generation const tempSession = await defaultClient.createSession({ + clientName: COOPER_CLIENT_NAME, model: quickModel, + onPermissionRequest: () => ({ kind: 'approved' }), systemMessage: { mode: 'append', content: @@ -2862,7 +2923,9 @@ ipcMain.handle('git:generateCommitMessage', async (_event, data: { diff: string const quickModel = await getQuickTasksModel(defaultClient); const tempSession = await defaultClient.createSession({ + clientName: COOPER_CLIENT_NAME, model: quickModel, + onPermissionRequest: () => ({ kind: 'approved' }), systemMessage: { mode: 'append', content: @@ -2900,7 +2963,9 @@ ipcMain.handle('copilot:detectChoices', async (_event, data: { message: string } const quickModel = await getQuickTasksModel(defaultClient); const tempSession = await defaultClient.createSession({ + clientName: COOPER_CLIENT_NAME, model: quickModel, + onPermissionRequest: () => ({ kind: 'approved' }), systemMessage: { mode: 'replace', content: `You analyze messages to detect if they ask the user to choose between options. @@ -3123,6 +3188,7 @@ ipcMain.handle( // Capture yoloMode before destroying old session const preserveYoloMode = sessionState.yoloMode; + const preserveActiveAgentName = sessionState.activeAgentName; // Destroy the old session await sessionState.session.destroy(); @@ -3134,6 +3200,10 @@ ipcMain.handle( // Preserve yoloMode in the new session newSessionState.yoloMode = preserveYoloMode; + newSessionState.activeAgentName = preserveActiveAgentName; + if (preserveActiveAgentName) { + await newSessionState.session.rpc.agent.select({ name: preserveActiveAgentName }); + } log.info( `[${newSessionId}] New session created for model switch: ${previousModel} → ${data.model}` @@ -3162,6 +3232,7 @@ ipcMain.handle( const agentResult = await getAllAgents(undefined, cwd); const customAgents: CustomAgentConfig[] = []; for (const agent of agentResult.agents) { + if (agent.source === 'codex') continue; try { const content = await readFile(agent.path, 'utf-8'); const metadata = parseAgentFrontmatter(content); @@ -3177,6 +3248,7 @@ ipcMain.handle( } } const resumedSession = await client.resumeSession(data.sessionId, { + clientName: COOPER_CLIENT_NAME, model: data.model, mcpServers: mcpDiscovery.effectiveServers, tools: browserTools, @@ -3184,6 +3256,9 @@ ipcMain.handle( onPermissionRequest: (request, invocation) => handlePermissionRequest(request, invocation, resumedSession.sessionId), }); + if (sessionState.activeAgentName) { + await resumedSession.rpc.agent.select({ name: sessionState.activeAgentName }); + } const resumedSessionId = resumedSession.sessionId; registerSessionEventForwarding(resumedSessionId, resumedSession); @@ -3197,6 +3272,7 @@ ipcMain.handle( allowedPaths: new Set(sessionState.allowedPaths), isProcessing: false, yoloMode: sessionState.yoloMode, + activeAgentName: sessionState.activeAgentName, }); activeSessionId = resumedSessionId; @@ -3218,114 +3294,53 @@ ipcMain.handle( throw new Error(`Session not found: ${data.sessionId}`); } - const { cwd, client, model } = sessionState; - - // Try to call the undocumented session.selectAgent RPC method - // This may not exist in all SDK versions - try { - if (data.agentName) { - // @ts-ignore - accessing internal connection to call undocumented RPC - await sessionState.session.connection?.sendRequest?.('session.selectAgent', { - sessionId: data.sessionId, - agentName: data.agentName, - }); - console.log(`Selected agent ${data.agentName} via RPC`); - } else { - // @ts-ignore - accessing internal connection to call undocumented RPC - await sessionState.session.connection?.sendRequest?.('session.clearAgent', { - sessionId: data.sessionId, - }); - console.log(`Cleared agent selection via RPC`); - } - return { sessionId: data.sessionId, model, cwd }; - } catch (rpcError) { - console.log(`RPC method not available, falling back to destroy+resume: ${rpcError}`); - } - - // Fallback: destroy+resume approach (preserves history but less efficient) - // If session has no messages, just create a new session - if (!data.hasMessages) { - console.log(`Creating new session with agent ${data.agentName || 'none'} (empty session)`); - - // Capture yoloMode before destroying old session - const preserveYoloMode = sessionState.yoloMode; - - // Destroy the old session - await sessionState.session.destroy(); - sessions.delete(data.sessionId); - - // Create a brand new session with the same model - const newSessionId = await createNewSession(model, cwd); - const newSessionState = sessions.get(newSessionId)!; - - // Preserve yoloMode in the new session - newSessionState.yoloMode = preserveYoloMode; - - return { - sessionId: newSessionId, - model, - cwd, - newSession: true, - }; + const { cwd, model } = sessionState; + log.info( + `[${data.sessionId}] [AgentSelection] set requested=${data.agentName ?? 'default'} hasMessages=${data.hasMessages}` + ); + if (data.agentName) { + await sessionState.session.rpc.agent.select({ name: data.agentName }); + } else { + await sessionState.session.rpc.agent.deselect(); } - - // Session has messages - resume to preserve conversation history - console.log(`Switching to agent ${data.agentName || 'none'} for session ${data.sessionId}`); - await sessionState.session.destroy(); - sessions.delete(data.sessionId); - - const projectRoot = await getProjectRootForCwd(cwd); - const mcpDiscovery = await discoverMcpServers({ projectRoot }); - const browserTools = createBrowserTools(data.sessionId); - - // Build customAgents list for the session - const agentResult = await getAllAgents(undefined, cwd); - const customAgents: CustomAgentConfig[] = []; - for (const agent of agentResult.agents) { - try { - const content = await readFile(agent.path, 'utf-8'); - const metadata = parseAgentFrontmatter(content); - customAgents.push({ - name: metadata.name || agent.name, - displayName: agent.name, - description: metadata.description, - tools: null, - prompt: content, - }); - } catch (error) { - log.warn('Failed to load agent prompt:', agent.path, error); - } + const { agent } = await sessionState.session.rpc.agent.getCurrent(); + log.info(`[${data.sessionId}] [AgentSelection] set result active=${agent?.name ?? 'default'}`); + if (data.agentName && agent?.name !== data.agentName) { + throw new Error( + `Requested agent '${data.agentName}' was not applied. Current agent is '${agent?.name ?? 'default'}'.` + ); } + sessionState.activeAgentName = agent?.name ?? null; + return { sessionId: data.sessionId, model, cwd, activeAgent: agent ?? null }; + } +); - // Resume the same session — preserves conversation context - const resumedSession = await client.resumeSession(data.sessionId, { - model, - mcpServers: mcpDiscovery.effectiveServers, - tools: browserTools, - customAgents, - onPermissionRequest: (request, invocation) => - handlePermissionRequest(request, invocation, resumedSession.sessionId), - }); - - const resumedSessionId = resumedSession.sessionId; - registerSessionEventForwarding(resumedSessionId, resumedSession); +ipcMain.handle('copilot:getSessionAgents', async (_event, sessionId: string) => { + const sessionState = sessions.get(sessionId); + if (!sessionState) { + throw new Error(`Session not found: ${sessionId}`); + } + const { agents } = await sessionState.session.rpc.agent.list(); + return agents; +}); - sessions.set(resumedSessionId, { - session: resumedSession, - client, - model, - cwd, - alwaysAllowed: new Set(sessionState.alwaysAllowed), - allowedPaths: new Set(sessionState.allowedPaths), - isProcessing: false, - yoloMode: sessionState.yoloMode, - }); - activeSessionId = resumedSessionId; +ipcMain.handle('copilot:getActiveAgent', async (_event, sessionId: string) => { + const sessionState = sessions.get(sessionId); + if (!sessionState) { + throw new Error(`Session not found: ${sessionId}`); + } + const { agent } = await sessionState.session.rpc.agent.getCurrent(); + return agent ?? null; +}); - console.log(`Session ${resumedSessionId} resumed with agent ${data.agentName || 'none'}`); - return { sessionId: resumedSessionId, model, cwd }; +ipcMain.handle('copilot:compactSession', async (_event, sessionId: string) => { + const sessionState = sessions.get(sessionId); + if (!sessionState) { + throw new Error(`Session not found: ${sessionId}`); } -); + await sessionState.session.rpc.compaction.compact(); + return { success: true }; +}); ipcMain.handle('copilot:getModels', async () => { const currentModel = store.get('model') as string; @@ -4958,10 +4973,13 @@ ipcMain.handle( // Load MCP servers config const projectRoot = await getProjectRootForCwd(sessionCwd); const mcpDiscovery = await discoverMcpServers({ projectRoot }); + const customAgents = await loadCustomAgents(sessionCwd); const session = await client.resumeSession(sessionId, { + clientName: COOPER_CLIENT_NAME, mcpServers: mcpDiscovery.effectiveServers, tools: createBrowserTools(sessionId), + customAgents, onPermissionRequest: (request, invocation) => handlePermissionRequest(request, invocation, sessionId), }); @@ -5105,6 +5123,7 @@ ipcMain.handle( allowedPaths: new Set(), isProcessing: false, yoloMode: false, + activeAgentName: null, }); activeSessionId = sessionId; @@ -5726,7 +5745,7 @@ ipcMain.handle('pty:clearBuffer', async (_event, sessionId: string) => { }); ipcMain.handle('pty:close', async (_event, sessionId: string) => { - return ptyManager.closePty(sessionId, mainWindow); + return ptyManager.closePty(sessionId); }); ipcMain.handle('pty:exists', async (_event, sessionId: string) => { diff --git a/src/main/utils/sessionRestore.test.ts b/src/main/utils/sessionRestore.test.ts index ca7962f..7c2f87d 100644 --- a/src/main/utils/sessionRestore.test.ts +++ b/src/main/utils/sessionRestore.test.ts @@ -20,6 +20,16 @@ describe('resolveSessionName', () => { }) ).toBe('Renamed Session'); }); + + it("treats 'Unknown' placeholders as missing", () => { + expect( + resolveSessionName({ + storedName: 'Unknown', + persistedName: 'unknown', + summary: 'Unknown', + }) + ).toBeUndefined(); + }); }); describe('mergeSessionCwds', () => { diff --git a/src/main/utils/sessionRestore.ts b/src/main/utils/sessionRestore.ts index a6789da..4fb9ae6 100644 --- a/src/main/utils/sessionRestore.ts +++ b/src/main/utils/sessionRestore.ts @@ -7,7 +7,16 @@ export const resolveSessionName = ({ persistedName?: string; summary?: string; }): string | undefined => { - return storedName || persistedName || summary || undefined; + const isUsableName = (value?: string): value is string => { + if (!value) return false; + const normalized = value.trim(); + return normalized.length > 0 && normalized.toLowerCase() !== 'unknown'; + }; + + if (isUsableName(storedName)) return storedName; + if (isUsableName(persistedName)) return persistedName; + if (isUsableName(summary)) return summary; + return undefined; }; export const mergeSessionCwds = ( diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 25f8e2d..7c100e6 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -243,9 +243,27 @@ const electronAPI = { sessionId: string, agentName: string | undefined, hasMessages: boolean - ): Promise<{ sessionId: string; model: string; cwd?: string }> => { + ): Promise<{ + sessionId: string; + model: string; + cwd?: string; + activeAgent?: { name: string; displayName: string; description?: string } | null; + }> => { return ipcRenderer.invoke('copilot:setActiveAgent', { sessionId, agentName, hasMessages }); }, + getSessionAgents: ( + sessionId: string + ): Promise> => { + return ipcRenderer.invoke('copilot:getSessionAgents', sessionId); + }, + getActiveAgent: ( + sessionId: string + ): Promise<{ name: string; displayName: string; description?: string } | null> => { + return ipcRenderer.invoke('copilot:getActiveAgent', sessionId); + }, + compactSession: (sessionId: string): Promise<{ success: boolean }> => { + return ipcRenderer.invoke('copilot:compactSession', sessionId); + }, getModels: (): Promise<{ models: { id: string; name: string; multiplier: number }[]; current: string; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 962ab93..2910dae 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -98,6 +98,11 @@ import { LONG_OUTPUT_LINE_THRESHOLD } from './utils/cliOutputCompression'; import { isAsciiDiagram, extractTextContent } from './utils/isAsciiDiagram'; import { isCliCommand } from './utils/isCliCommand'; import { groupAgents } from './utils/agentPicker'; +import { + buildFallbackSessionAgents, + getActivePrimaryAgentLabel, + type SessionAgentOption, +} from './utils/primaryAgent'; import { parseGitHubIssueUrl } from './utils/parseGitHubIssueUrl'; import { useClickOutside, useResponsive, useVoiceSpeech } from './hooks'; import buildInfo from './build-info.json'; @@ -261,6 +266,9 @@ const App: React.FC = () => { } }); const [selectedAgentByTab, setSelectedAgentByTab] = useState>({}); + const [sessionAgentsByTab, setSessionAgentsByTab] = useState< + Record + >({}); // Copilot Instructions state const [instructions, setInstructions] = useState([]); @@ -886,6 +894,12 @@ const App: React.FC = () => { // Get the active tab (defined early for use in effects below) const activeTab = tabs.find((t) => t.id === activeTabId); + const loadSessionAgents = useCallback(async (sessionId: string) => { + const sessionAgents = await window.electronAPI.copilot.getSessionAgents(sessionId); + setSessionAgentsByTab((prev) => ({ ...prev, [sessionId]: sessionAgents })); + return sessionAgents; + }, []); + // Fetch model capabilities when active tab changes useEffect(() => { if (activeTab && activeTab.model && !modelCapabilities[activeTab.model]) { @@ -904,6 +918,21 @@ const App: React.FC = () => { } }, [activeTab?.model]); + useEffect(() => { + if (!activeTab?.id) return; + void loadSessionAgents(activeTab.id).catch((error) => { + console.error('Failed to load available session agents:', error); + }); + window.electronAPI.copilot + .getActiveAgent(activeTab.id) + .then((agent) => { + setSelectedAgentByTab((prev) => ({ ...prev, [activeTab.id]: agent?.name ?? null })); + }) + .catch((error) => { + console.error('Failed to fetch active session agent:', error); + }); + }, [activeTab?.id, loadSessionAgents]); + // Save draft state to departing tab and restore from arriving tab on tab switch useEffect(() => { // Save current input state to the previous tab's draftInput (if it still exists) @@ -969,10 +998,7 @@ const App: React.FC = () => { // Save message attachments whenever tabs/messages change useEffect(() => { tabs.forEach((tab) => { - const canonicalMessages = tab.messages.filter( - (msg) => msg.role === 'user' || msg.role === 'assistant' - ); - const attachments = canonicalMessages + const attachments = tab.messages .map((msg, index) => ({ messageIndex: index, imageAttachments: msg.imageAttachments, @@ -2977,9 +3003,10 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO [commitModal] ); - // Handle image click + // Handle image click (for now, just a no-op - can be extended for image viewer modal) const handleImageClick = useCallback((src: string, alt: string) => { - setLightboxImage({ src, alt }); + // Placeholder for future image viewer modal + console.log('Image clicked:', src, alt); }, []); // Handle confirmation from shrink modal @@ -4214,6 +4241,76 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO } }; + const handleAgentChange = async (agentName: string | null): Promise => { + if (!activeTab) return; + const currentAgentName = selectedAgentByTab[activeTab.id] ?? null; + if (currentAgentName === agentName) return; + + setStatus('connecting'); + try { + const previousTabId = activeTab.id; + const hasMessages = activeTab.messages.length > 0; + console.info( + `[AgentSelection][${previousTabId}] request agent=${agentName ?? 'default'} hasMessages=${hasMessages}` + ); + const result = await window.electronAPI.copilot.setActiveAgent( + previousTabId, + agentName || undefined, + hasMessages + ); + console.info( + `[AgentSelection][${previousTabId}] response active=${result.activeAgent?.name ?? 'default'} session=${result.sessionId}` + ); + if (agentName && result.activeAgent?.name !== agentName) { + throw new Error( + `Selected agent '${agentName}' could not be activated (active: ${result.activeAgent?.name ?? 'default'})` + ); + } + + setSelectedAgentByTab((prev) => { + const next = { ...prev, [result.sessionId]: result.activeAgent?.name ?? null }; + if (result.sessionId !== previousTabId) { + delete next[previousTabId]; + } + return next; + }); + setSessionAgentsByTab((prev) => { + if (result.sessionId === previousTabId || !prev[previousTabId]) { + return prev; + } + const next = { ...prev, [result.sessionId]: prev[previousTabId] }; + delete next[previousTabId]; + return next; + }); + setTabs((prev) => + prev.map((tab) => + tab.id === previousTabId + ? { + ...tab, + id: result.sessionId, + cwd: result.cwd || tab.cwd, + activeAgentName: result.activeAgent?.displayName, + } + : tab + ) + ); + setActiveTabId(result.sessionId); + } catch (error) { + console.error('Failed to change active agent:', error); + } finally { + setStatus('connected'); + } + }; + + const handleCompactSession = async (): Promise => { + if (!activeTab || activeTab.compactionStatus === 'compacting') return; + try { + await window.electronAPI.copilot.compactSession(activeTab.id); + } catch (error) { + console.error('Failed to compact session:', error); + } + }; + const handleToggleFavoriteModel = async (modelId: string) => { const isFavorite = favoriteModels.includes(modelId); try { @@ -4264,14 +4361,20 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO return groupAgents(allAgents, favoriteAgents); }, [allAgents, favoriteAgents]); - const activeAgentPath = activeTab - ? (selectedAgentByTab[activeTab.id] ?? COOPER_DEFAULT_AGENT.path) - : null; - const activeAgent = useMemo(() => { - return activeAgentPath - ? allAgents.find((agent) => agent.path === activeAgentPath) || null - : null; - }, [allAgents, activeAgentPath]); + const fallbackSessionAgents = useMemo( + () => buildFallbackSessionAgents(groupedAgents, COOPER_DEFAULT_AGENT.path), + [groupedAgents] + ); + + const availableSessionAgents = activeTab ? sessionAgentsByTab[activeTab.id] || [] : []; + const selectableAgents = + availableSessionAgents.length > 0 ? availableSessionAgents : fallbackSessionAgents; + const activePrimaryAgentName = activeTab ? (selectedAgentByTab[activeTab.id] ?? null) : null; + const activePrimaryAgentLabel = getActivePrimaryAgentLabel( + selectableAgents, + activePrimaryAgentName, + COOPER_DEFAULT_AGENT.name + ); // Callbacks for TerminalProvider const handleOpenTerminal = useCallback(() => { @@ -6011,6 +6114,113 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO )} + {/* Agents Selector */} +
+ + {openTopBarSelector === 'agents' && ( +
+
{ + void handleAgentChange(null); + setOpenTopBarSelector(null); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void handleAgentChange(null); + setOpenTopBarSelector(null); + } + }} + className={`w-full px-3 py-2 text-xs text-left hover:bg-copilot-surface-hover transition-colors ${ + !activePrimaryAgentName + ? 'text-copilot-accent bg-copilot-surface' + : 'text-copilot-text' + }`} + > + {!activePrimaryAgentName && ( + + )} + {COOPER_DEFAULT_AGENT.name} +
+ {selectableAgents.length > 0 && ( +
+ {selectableAgents.map((agent) => ( +
{ + void handleAgentChange(agent.name); + setOpenTopBarSelector(null); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void handleAgentChange(agent.name); + setOpenTopBarSelector(null); + } + }} + className={`w-full px-3 py-2 text-xs hover:bg-copilot-surface-hover transition-colors ${ + activePrimaryAgentName === agent.name + ? 'text-copilot-accent bg-copilot-surface' + : 'text-copilot-text' + }`} + title={agent.description || agent.displayName} + > +
+ {activePrimaryAgentName === agent.name && ( + + )} + {agent.displayName} +
+ {agent.description && ( +
+ {agent.description} +
+ )} +
+ ))} +
+ )} +
+ )} +
+ {/* Loops Selector */}
{activeTab.compactionStatus === 'compacting' ? ( <> diff --git a/src/renderer/utils/primaryAgent.test.ts b/src/renderer/utils/primaryAgent.test.ts new file mode 100644 index 0000000..3192b5e --- /dev/null +++ b/src/renderer/utils/primaryAgent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { buildFallbackSessionAgents, getActivePrimaryAgentLabel } from './primaryAgent'; +import type { AgentSection } from './agentPicker'; + +const groupedAgents: AgentSection[] = [ + { + id: 'favorites', + label: 'Favorites', + agents: [ + { name: 'Fast Agent', path: '/agents/fast.agent.md', type: 'project', source: 'copilot' }, + { name: 'AGENTS', path: '/repo/AGENTS.md', type: 'project', source: 'codex' }, + ], + }, + { + id: 'system', + label: 'System', + agents: [ + { + name: 'Cooper (default)', + path: 'system:cooper-default', + type: 'system', + source: 'copilot', + }, + ], + }, +]; + +describe('buildFallbackSessionAgents', () => { + it('builds fallback options excluding default system agent path', () => { + const result = buildFallbackSessionAgents(groupedAgents, 'system:cooper-default'); + expect(result).toEqual([ + { name: 'Fast Agent', displayName: 'Fast Agent', description: undefined }, + ]); + }); +}); + +describe('getActivePrimaryAgentLabel', () => { + it('returns selected agent display label when active agent exists', () => { + const label = getActivePrimaryAgentLabel( + [{ name: 'sdk-specialist', displayName: 'SDK Specialist' }], + 'sdk-specialist', + 'Cooper (default)' + ); + expect(label).toBe('SDK Specialist'); + }); + + it('falls back to default label when no active agent is selected', () => { + const label = getActivePrimaryAgentLabel( + [{ name: 'sdk-specialist', displayName: 'SDK Specialist' }], + null, + 'Cooper (default)' + ); + expect(label).toBe('Cooper (default)'); + }); +}); diff --git a/src/renderer/utils/primaryAgent.ts b/src/renderer/utils/primaryAgent.ts new file mode 100644 index 0000000..35b964b --- /dev/null +++ b/src/renderer/utils/primaryAgent.ts @@ -0,0 +1,29 @@ +import type { AgentSection } from './agentPicker'; + +export interface SessionAgentOption { + name: string; + displayName: string; + description?: string; +} + +export function buildFallbackSessionAgents( + groupedAgents: AgentSection[], + defaultAgentPath: string +): SessionAgentOption[] { + return groupedAgents + .flatMap((section) => section.agents) + .filter((agent) => agent.path !== defaultAgentPath && agent.source !== 'codex') + .map((agent) => ({ + name: agent.name, + displayName: agent.name, + description: agent.description, + })); +} + +export function getActivePrimaryAgentLabel( + agents: SessionAgentOption[], + activeAgentName: string | null, + defaultLabel: string +): string { + return agents.find((agent) => agent.name === activeAgentName)?.displayName || defaultLabel; +}