From 2891d4bc9038fd1ec69146dcf4d1e5eb7b8e7aa7 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 20:28:08 +0100 Subject: [PATCH] refactor: move session controls from activity panel to chat input Move interactive tool toggles and auto-approval overrides from the ActivityPanel side panel into compact pill-style popovers above the chat composer in ChatPane. This cleanly separates concerns: - ActivityPanel is now purely read-only (agents + timeline) - ChatPane owns all session-level interactive controls - Tools pill: popover with MCP/LSP enable/disable toggles - Auto-approval pill: popover with grouped tool auto-approval overrides including session override state and reset action Follows the existing InlineModelPill/InlineThinkingPill pattern for scratchpad sessions, giving non-scratchpad sessions the same kind of inline configuration experience. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 33 +-- src/renderer/components/ActivityPanel.tsx | 307 +------------------- src/renderer/components/ChatPane.tsx | 330 +++++++++++++++++++++- 3 files changed, 344 insertions(+), 326 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 55647c0..6b0bf29 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -263,35 +263,32 @@ export default function App() { reasoningEffort: config.reasoningEffort, }) } + onUpdateSessionTooling={(selection) => { + void api.updateSessionTooling({ + sessionId: selectedSession.id, + enabledMcpServerIds: selection.enabledMcpServerIds, + enabledLspProfileIds: selection.enabledLspProfileIds, + }); + }} + onUpdateSessionApprovalSettings={(settings) => { + void api.updateSessionApprovalSettings({ + sessionId: selectedSession.id, + autoApprovedToolNames: settings.autoApprovedToolNames, + }); + }} availableModels={availableModels} pattern={patternForSession} project={projectForSession} + runtimeTools={sidecarCapabilities?.runtimeTools} session={selectedSession} + toolingSettings={workspace.settings.tooling} /> ); detailPanel = ( { - void api.updateSessionTooling({ - sessionId: selectedSession.id, - enabledMcpServerIds: selection.enabledMcpServerIds, - enabledLspProfileIds: selection.enabledLspProfileIds, - }); - }} - onUpdateSessionApprovalSettings={(settings) => { - void api.updateSessionApprovalSettings({ - sessionId: selectedSession.id, - autoApprovedToolNames: settings.autoApprovedToolNames, - }); - }} pattern={patternForSession} - projectIsScratchpad={isScratchpadProject(projectForSession)} session={selectedSession} /> ); diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index eb2f9e6..f036820 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactNode } from 'react'; -import { Activity, Clock, RotateCcw, Server, Code, ShieldAlert, ShieldCheck, Sparkles, Users } from 'lucide-react'; +import { Activity, Clock, ShieldAlert, Sparkles, Users } from 'lucide-react'; import { buildAgentActivityRows, @@ -12,19 +12,7 @@ import { import { RunTimeline } from '@renderer/components/RunTimeline'; import { inferProvider } from '@shared/domain/models'; import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern'; -import { - resolveSessionToolingSelection, - type SessionRecord, -} from '@shared/domain/session'; -import type { - LspProfileDefinition, - McpServerDefinition, - RuntimeToolDefinition, - SessionToolingSelection, - WorkspaceToolingSettings, -} from '@shared/domain/tooling'; -import { listApprovalToolDefinitions, type ApprovalToolDefinition, type ApprovalToolKind } from '@shared/domain/tooling'; -import type { SessionApprovalSettings } from '@shared/domain/approval'; +import type { SessionRecord } from '@shared/domain/session'; import { ProviderIcon } from './ProviderIcons'; /* ── Mode accent colours ───────────────────────────────────── */ @@ -161,57 +149,27 @@ function AgentRow({ interface ActivityPanelProps { activity?: SessionActivityState; - lspProfiles: LspProfileDefinition[]; - mcpServers: McpServerDefinition[]; - toolingSettings: WorkspaceToolingSettings; - runtimeTools?: ReadonlyArray; onJumpToMessage?: (messageId: string) => void; - onUpdateSessionTooling: (selection: SessionToolingSelection) => void; - onUpdateSessionApprovalSettings: (settings: { autoApprovedToolNames?: string[] }) => void; pattern: PatternDefinition; - projectIsScratchpad: boolean; session: SessionRecord; } export function ActivityPanel({ activity, - lspProfiles, - mcpServers, - toolingSettings, - runtimeTools, onJumpToMessage, - onUpdateSessionTooling, - onUpdateSessionApprovalSettings, pattern, - projectIsScratchpad, session, }: ActivityPanelProps) { const activityRows = useMemo( () => buildAgentActivityRows(activity, pattern.agents), [activity, pattern.agents], ); - const selection = useMemo(() => resolveSessionToolingSelection(session), [session]); - const approvalTools = useMemo( - () => listApprovalToolDefinitions(toolingSettings, runtimeTools), - [runtimeTools, toolingSettings], - ); - - const isOverridden = session.approvalSettings !== undefined; - const effectiveAutoApproved = new Set( - isOverridden - ? session.approvalSettings!.autoApprovedToolNames - : pattern.approvalPolicy?.autoApprovedToolNames ?? [], - ); const isBusy = session.status === 'running'; const hasPendingApproval = session.pendingApproval?.status === 'pending'; const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length; const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount; - const toolsDisabled = isBusy || projectIsScratchpad; - const approvalDisabled = isBusy || projectIsScratchpad; const accent = modeAccent[pattern.mode] ?? modeAccent.single; - const hasTools = mcpServers.length > 0 || lspProfiles.length > 0; - const hasToolCallApproval = pattern.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false; return (
@@ -281,271 +239,10 @@ export function ActivityPanel({
- {/* ── Tools section ────────────────────────────────── */} -
- - - Tools - {toolsDisabled && ( - - {projectIsScratchpad ? 'Scratchpad' : 'Running'} - - )} - -
- {projectIsScratchpad ? ( -

- Start a project-backed session to use MCPs or LSPs. -

- ) : !hasTools ? ( -

- Add MCP servers or LSP profiles in Settings to enable them here. -

- ) : ( -
- {mcpServers.map((server) => ( - } - key={server.id} - label={server.name} - onToggle={() => - onUpdateSessionTooling({ - ...selection, - enabledMcpServerIds: toggleId(selection.enabledMcpServerIds, server.id), - }) - } - /> - ))} - {lspProfiles.map((profile) => ( - } - key={profile.id} - label={profile.name} - onToggle={() => - onUpdateSessionTooling({ - ...selection, - enabledLspProfileIds: toggleId(selection.enabledLspProfileIds, profile.id), - }) - } - /> - ))} -
- )} -
-
- - {/* ── Auto-approval overrides section ──────────────── */} - {hasToolCallApproval && !projectIsScratchpad && ( -
- - - Auto-Approval - - {effectiveAutoApproved.size}/{approvalTools.length} - - {approvalDisabled && ( - - Running - - )} - - -
- {approvalTools.length === 0 ? ( -

- No tools available yet. Connect MCP servers or wait for runtime capabilities to load. -

- ) : ( - <> - {/* Override state badge + reset action */} -
- - {isOverridden ? 'Session override' : 'Using pattern defaults'} - - {isOverridden && ( - - )} -
- - { - const next = new Set(effectiveAutoApproved); - if (next.has(toolId)) { - next.delete(toolId); - } else { - next.add(toolId); - } - onUpdateSessionApprovalSettings({ - autoApprovedToolNames: [...next], - }); - }} - tools={approvalTools} - /> - - )} -
-
- )} ); } -function ToolToggleRow({ - label, - detail, - icon, - enabled, - disabled, - onToggle, -}: { - label: string; - detail?: string; - icon: ReactNode; - enabled: boolean; - disabled: boolean; - onToggle: () => void; -}) { - return ( - - ); -} -function ToggleSwitch({ enabled }: { enabled: boolean }) { - return ( - - - - ); -} - -function toggleId(current: string[], id: string): string[] { - return current.includes(id) - ? current.filter((currentId) => currentId !== id) - : [...current, id]; -} - -/* ── Approval override grouped list ─────────────────────────── */ - -const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed']; -const approvalKindLabels: Record = { - builtin: 'Built-in', - mcp: 'MCP Servers', - lsp: 'Language Servers', - mixed: 'Other', -}; - -function ApprovalOverrideGroupedList({ - tools, - effectiveAutoApproved, - approvalDisabled, - onToggle, -}: { - tools: ApprovalToolDefinition[]; - effectiveAutoApproved: Set; - approvalDisabled: boolean; - onToggle: (toolId: string) => void; -}) { - const groups = approvalKindOrder - .map((kind) => ({ kind, tools: tools.filter((t) => t.kind === kind) })) - .filter((g) => g.tools.length > 0); - const showHeaders = groups.length > 1; - - return ( -
- {groups.map((group, i) => ( -
- {showHeaders && ( -
0 ? 'mt-2' : ''} mb-1`}> - {approvalKindLabels[group.kind]} -
- )} - {group.tools.map((tool) => ( - onToggle(tool.id)} - tool={tool} - /> - ))} -
- ))} -
- ); -} - -function ApprovalOverrideRow({ - tool, - enabled, - disabled, - onToggle, -}: { - tool: ApprovalToolDefinition; - enabled: boolean; - disabled: boolean; - onToggle: () => void; -}) { - const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined); - return ( - - ); -} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 265ebbe..81b556e 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,5 +1,5 @@ -import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, Check, ChevronDown, Circle, GitBranch, Loader2, ShieldAlert, ShieldCheck, Sparkles, User, X } from 'lucide-react'; +import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { AlertCircle, ArrowUp, Bot, Check, ChevronDown, Circle, GitBranch, Loader2, RotateCcw, Server, ShieldAlert, ShieldCheck, Sparkles, User, X } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; @@ -15,7 +15,17 @@ import { } from '@shared/domain/models'; import { reasoningEffortOptions, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; -import type { SessionRecord } from '@shared/domain/session'; +import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session'; +import { + listApprovalToolDefinitions, + type ApprovalToolDefinition, + type ApprovalToolKind, + type LspProfileDefinition, + type McpServerDefinition, + type RuntimeToolDefinition, + type SessionToolingSelection, + type WorkspaceToolingSettings, +} from '@shared/domain/tooling'; function ThinkingDots() { return ( @@ -222,6 +232,269 @@ function InlineThinkingPill({ ); } +/* ── Inline tools pill with popover ─────────────────────────── */ + +function InlineToolsPill({ + mcpServers, + lspProfiles, + selection, + disabled, + onToggle, +}: { + mcpServers: ReadonlyArray; + lspProfiles: ReadonlyArray; + selection: SessionToolingSelection; + disabled: boolean; + onToggle: (selection: SessionToolingSelection) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length; + const totalCount = mcpServers.length + lspProfiles.length; + + return ( +
+ + + {open && !disabled && ( +
+ {mcpServers.length > 0 && ( +
+
+ MCP Servers +
+ {mcpServers.map((server) => ( + + onToggle({ + ...selection, + enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id), + }) + } + /> + ))} +
+ )} + {lspProfiles.length > 0 && ( +
+
+ Language Servers +
+ {lspProfiles.map((profile) => ( + + onToggle({ + ...selection, + enabledLspProfileIds: toggleInArray(selection.enabledLspProfileIds, profile.id), + }) + } + /> + ))} +
+ )} +
+ )} +
+ ); +} + +/* ── Inline auto-approval pill with popover ────────────────── */ + +const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed']; +const approvalKindLabels: Record = { + builtin: 'Built-in', + mcp: 'MCP Servers', + lsp: 'Language Servers', + mixed: 'Other', +}; + +function InlineApprovalPill({ + approvalTools, + effectiveAutoApproved, + isOverridden, + disabled, + onUpdate, +}: { + approvalTools: ApprovalToolDefinition[]; + effectiveAutoApproved: Set; + isOverridden: boolean; + disabled: boolean; + onUpdate: (settings: { autoApprovedToolNames?: string[] }) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + function toggleTool(toolId: string) { + const next = new Set(effectiveAutoApproved); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + onUpdate({ autoApprovedToolNames: [...next] }); + } + + const groups = approvalKindOrder + .map((kind) => ({ kind, tools: approvalTools.filter((t) => t.kind === kind) })) + .filter((g) => g.tools.length > 0); + const showHeaders = groups.length > 1; + + return ( +
+ + + {open && !disabled && ( +
+ {/* Override state + reset */} +
+ + {isOverridden ? 'Session override' : 'Pattern defaults'} + + {isOverridden && ( + + )} +
+ + {/* Tool list grouped by kind */} +
+ {groups.map((group, i) => ( +
+ {showHeaders && ( +
0 ? 'pt-2' : 'pt-1'} text-[9px] font-semibold uppercase tracking-wider text-zinc-600`}> + {approvalKindLabels[group.kind]} +
+ )} + {group.tools.map((tool) => { + const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined); + return ( + toggleTool(tool.id)} + /> + ); + })} +
+ ))} +
+
+ )} +
+ ); +} + +/* ── Shared popover toggle row + helpers ───────────────────── */ + +function PopoverToggleRow({ + label, + detail, + enabled, + onToggle, +}: { + label: string; + detail?: string; + enabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function toggleInArray(current: string[], id: string): string[] { + return current.includes(id) + ? current.filter((currentId) => currentId !== id) + : [...current, id]; +} + /* ── Approval banner ────────────────────────────────────────── */ function ApprovalBanner({ @@ -378,12 +651,16 @@ interface ChatPaneProps { pattern: PatternDefinition; session: SessionRecord; availableModels: ReadonlyArray; + toolingSettings: WorkspaceToolingSettings; + runtimeTools?: ReadonlyArray; onSend: (content: string) => Promise; onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise; onUpdateScratchpadConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; }) => Promise; + onUpdateSessionTooling?: (selection: SessionToolingSelection) => void; + onUpdateSessionApprovalSettings?: (settings: { autoApprovedToolNames?: string[] }) => void; } export function ChatPane({ @@ -391,9 +668,13 @@ export function ChatPane({ pattern, session, availableModels, + toolingSettings, + runtimeTools, onSend, onResolveApproval, onUpdateScratchpadConfig, + onUpdateSessionTooling, + onUpdateSessionApprovalSettings, }: ChatPaneProps) { const [input, setInput] = useState(''); const [configError, setConfigError] = useState(); @@ -414,6 +695,25 @@ export function ChatPane({ const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig; + const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); + const mcpServers = toolingSettings.mcpServers; + const lspProfiles = toolingSettings.lspProfiles; + const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0; + const hasToolCallApproval = pattern.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false; + const approvalTools = useMemo( + () => listApprovalToolDefinitions(toolingSettings, runtimeTools), + [runtimeTools, toolingSettings], + ); + const isApprovalOverridden = session.approvalSettings !== undefined; + const effectiveAutoApproved = useMemo( + () => new Set( + isApprovalOverridden + ? session.approvalSettings!.autoApprovedToolNames + : pattern.approvalPolicy?.autoApprovedToolNames ?? [], + ), + [isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy], + ); + useEffect(() => { transcriptRef.current?.scrollTo({ top: transcriptRef.current.scrollHeight, @@ -697,6 +997,30 @@ export function ChatPane({ )} + {/* Session config pills — tool & approval controls */} + {!isScratchpad && (hasConfigurableTools || hasToolCallApproval) && ( +
+ {hasConfigurableTools && onUpdateSessionTooling && ( + + )} + {hasToolCallApproval && onUpdateSessionApprovalSettings && approvalTools.length > 0 && ( + + )} +
+ )} +