-
- {getTitleForNotification(notification)}
+ {(notification) => {
+ const expanded = createMemo(() => isExpanded(notification.id))
+ return (
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ {(q, index) => (
+
+
Question
+
+
+
+
0}>
+ Options
+
+
+ {(opt) => (
+ -
+ {opt.label}
+
+ {opt.description}
+
+
+ )}
+
+
+
+
+ )}
+
+
+
+
+
+
+ Type:
+
+ {getPermissionKind(notification.permissionData!.permission)}
+
+
+
+
Resources:
+
+
+ {(pattern) => (
+ {pattern}
+ )}
+
+
+
+
+
+
+
-
-
-
- )}
+ )
+ }}
diff --git a/packages/ui/src/lib/mcp-bridge.ts b/packages/ui/src/lib/mcp-bridge.ts
index 11034a36..0f4afc67 100644
--- a/packages/ui/src/lib/mcp-bridge.ts
+++ b/packages/ui/src/lib/mcp-bridge.ts
@@ -1,6 +1,8 @@
import type { QuestionAnswer } from '../types/question.js';
import { addQuestionToQueueWithSource, handleQuestionFailure } from '../stores/questions.js';
import { activeInstanceId, instances } from '../stores/instances';
+import { preferences } from '../stores/preferences';
+import { createEffect } from 'solid-js';
import { showToastNotification } from './notifications';
/**
@@ -27,7 +29,7 @@ const cleanupFunctions = new Map
void>();
/**
* Track active listeners per channel to prevent duplicates
*/
-const activeListeners = new Map void>();
+// const activeListeners = new Map void>();
/**
* Track processed questions to prevent duplicates from multiple handlers
@@ -68,25 +70,25 @@ function isQuestionProcessed(instanceId: string, requestId: string): boolean {
return getProcessedQuestions(instanceId).has(requestId);
}
-function ensureSingleListener(channel: string, handler: (payload: any) => void): () => void {
- const existingCleanup = activeListeners.get(channel);
- if (existingCleanup) {
- if (import.meta.env.DEV) {
- console.log(`[MCP Bridge UI] Removing existing listener for ${channel}`);
- }
- existingCleanup();
- activeListeners.delete(channel);
- }
-
- const electronAPI = (window as any).electronAPI;
- const cleanup = electronAPI.mcpOn(channel, handler);
- activeListeners.set(channel, cleanup);
-
- return () => {
- cleanup();
- activeListeners.delete(channel);
- };
-}
+// function ensureSingleListener(channel: string, handler: (payload: any) => void): () => void {
+// const existingCleanup = activeListeners.get(channel);
+// if (existingCleanup) {
+// if (import.meta.env.DEV) {
+// console.log(`[MCP Bridge UI] Removing existing listener for ${channel}`);
+// }
+// existingCleanup();
+// activeListeners.delete(channel);
+// }
+//
+// const electronAPI = (window as any).electronAPI;
+// const cleanup = electronAPI.mcpOn(channel, handler);
+// activeListeners.set(channel, cleanup);
+//
+// return () => {
+// cleanup();
+// activeListeners.delete(channel);
+// };
+// }
/**
* Send answer to main process (for MCP questions)
@@ -105,6 +107,24 @@ export function sendMcpAnswer(requestId: string, answers: QuestionAnswer[]): voi
} catch (error) {
console.error('[MCP Bridge UI] Failed to send answer:', error);
}
+
+}
+
+/**
+ * Send configuration update to main process
+ */
+export function sendMcpConfig(config: { requestTimeout?: number }): void {
+ if (import.meta.env.DEV) {
+ console.log(`[MCP Bridge UI] Sending config update:`, config);
+ }
+
+ try {
+ if (isElectronEnvironment()) {
+ (window as any).electronAPI.mcpSend('mcp:config', config);
+ }
+ } catch (error) {
+ console.error('[MCP Bridge UI] Failed to send config:', error);
+ }
}
/**
@@ -204,8 +224,28 @@ export function initMcpBridge(instanceId: string): void {
console.log('[MCP Bridge UI] Setting up IPC listeners');
}
+ // Send initial config
+ sendMcpConfig({ requestTimeout: preferences().askUserTimeout });
+
+ // Watch for changes (this runs in the reactive context of the component calling initMcpBridge)
+ createEffect(() => {
+ const timeout = preferences().askUserTimeout;
+ sendMcpConfig({ requestTimeout: timeout });
+ });
+
+
+ // Send initial config
+ sendMcpConfig({ requestTimeout: preferences().askUserTimeout });
+
+ // Watch for changes (this runs in the reactive context of the component calling initMcpBridge)
+ createEffect(() => {
+ const timeout = preferences().askUserTimeout;
+ sendMcpConfig({ requestTimeout: timeout });
+ });
+
// Listen for questions from MCP server (via main process)
- const cleanup = ensureSingleListener('ask_user.asked', (payload: any) => {
+ // const electronAPI = (window as any).electronAPI; // Already defined above
+ const cleanup = electronAPI.mcpOn('ask_user.asked', (payload: any) => {
const { requestId, questions, source } = payload;
if (import.meta.env.DEV) {
console.log('[MCP Bridge UI] ask_user.asked received in renderer', {
@@ -284,7 +324,7 @@ export function initMcpBridge(instanceId: string): void {
});
// Listen for question rejections from MCP server (timeout, cancel, session-stop)
- const cleanupRejected = ensureSingleListener('ask_user.rejected', (payload: any) => {
+ const cleanupRejected = electronAPI.mcpOn('ask_user.rejected', (payload: any) => {
const { requestId, timedOut, cancelled, reason } = payload;
if (import.meta.env.DEV) {
console.log('[MCP Bridge UI] Received question rejection:', payload);
@@ -292,7 +332,12 @@ export function initMcpBridge(instanceId: string): void {
// Check if this is a timeout and we haven't retried yet
const currentRetries = retryAttempts.get(requestId) ?? 0;
- if (timedOut && currentRetries < 1) {
+ // NOTE: We disable retry for timeout because the server has already rejected the request.
+ // Retrying with the same ID would be futile as the server won't accept answers for a rejected ID.
+ // This prevents the wizard from getting stuck with a dead question.
+ const shouldRetry = timedOut && currentRetries < 1 && false;
+
+ if (shouldRetry) {
// Retry once: route to active instance again
retryAttempts.set(requestId, currentRetries + 1);
diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx
index e7280f18..e2ee5911 100644
--- a/packages/ui/src/stores/preferences.tsx
+++ b/packages/ui/src/stores/preferences.tsx
@@ -48,6 +48,7 @@ export interface Preferences {
showUsageMetrics: boolean
autoCleanupBlankSessions: boolean
listeningMode: ListeningMode
+ askUserTimeout: number
}
@@ -83,6 +84,7 @@ const defaultPreferences: Preferences = {
showUsageMetrics: true,
autoCleanupBlankSessions: true,
listeningMode: "local",
+ askUserTimeout: 300000,
}
@@ -132,6 +134,7 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
+ askUserTimeout: sanitized.askUserTimeout ?? defaultPreferences.askUserTimeout,
}
}
@@ -378,6 +381,10 @@ function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
}
+function setAskUserTimeout(timeout: number): void {
+ updatePreferences({ askUserTimeout: timeout })
+}
+
function toggleShowTimelineTools(): void {
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
}
@@ -514,6 +521,7 @@ interface ConfigContextValue {
addRecentModelPreference: typeof addRecentModelPreference
setAgentModelPreference: typeof setAgentModelPreference
getAgentModelPreference: typeof getAgentModelPreference
+ setAskUserTimeout: typeof setAskUserTimeout
}
const ConfigContext = createContext()
@@ -550,6 +558,7 @@ const configContextValue: ConfigContextValue = {
addRecentModelPreference,
setAgentModelPreference,
getAgentModelPreference,
+ setAskUserTimeout,
}
const ConfigProvider: ParentComponent = (props) => {
@@ -616,4 +625,5 @@ export {
themePreference,
setThemePreference,
recordWorkspaceLaunch,
+ setAskUserTimeout,
}
diff --git a/packages/ui/src/styles/components/failed-notification.css b/packages/ui/src/styles/components/failed-notification.css
index d4cb5bce..1c9c14c0 100644
--- a/packages/ui/src/styles/components/failed-notification.css
+++ b/packages/ui/src/styles/components/failed-notification.css
@@ -57,9 +57,10 @@
.failed-notification-panel {
position: relative;
- width: 90%;
- max-width: 600px;
- max-height: 80vh;
+ width: fit-content;
+ min-width: min(600px, calc(100vw - 3rem));
+ max-width: calc(100vw - 3rem);
+ max-height: 85vh;
background-color: var(--surface-base);
border: 1px solid var(--border-base);
border-radius: 12px;
@@ -228,6 +229,159 @@
color: var(--text-primary);
}
+.failed-notification-card-expand {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.failed-notification-card-expand:hover {
+ background-color: var(--surface-primary);
+ color: var(--text-primary);
+}
+
+.failed-notification-card-expand[data-expanded="true"] {
+ color: var(--accent-primary);
+}
+
+.failed-notification-card-details {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border-base);
+ font-size: 13px;
+ color: var(--text-secondary);
+ animation: fade-in 0.2s ease;
+}
+
+.failed-notification-card-question-section {
+ margin-bottom: 12px;
+}
+
+.failed-notification-card-question-section:last-child {
+ margin-bottom: 0;
+}
+
+.failed-notification-card-question-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 6px;
+}
+
+.failed-notification-card-question-text {
+ font-weight: 500;
+ color: var(--text-primary);
+ line-height: 1.5;
+ /* Let markdown component handle whitespace/pre-wrap */
+}
+
+/* Markdown overrides specifically for notification card */
+.failed-notification-card-question-text .markdown-body {
+ font-size: 13px;
+ background: transparent !important;
+}
+
+.failed-notification-card-question-text .markdown-body p {
+ margin-bottom: 0.5em;
+}
+
+.failed-notification-card-question-text .markdown-body p:last-child {
+ margin-bottom: 0;
+}
+
+.failed-notification-card-question-text .markdown-body pre {
+ background-color: var(--surface-primary) !important;
+ border: 1px solid var(--border-base);
+ border-radius: 6px;
+ margin: 8px 0;
+}
+
+
+.failed-notification-card-options {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.failed-notification-card-option {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 10px;
+ background-color: var(--surface-primary);
+ border-radius: 6px;
+ border: 1px solid var(--border-base);
+}
+
+.failed-notification-card-option-label {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.failed-notification-card-option-desc {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ line-height: 1.4;
+}
+
+.failed-notification-card-permission {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.failed-notification-card-permission-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+}
+
+.failed-notification-card-permission-label {
+ min-width: 70px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ font-weight: 500;
+}
+
+.failed-notification-card-permission-value {
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ word-break: break-all;
+}
+
+.failed-notification-card-patterns {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.failed-notification-card-pattern {
+ padding: 4px 8px;
+ background-color: var(--surface-primary);
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-primary);
+ border: 1px solid var(--border-base);
+}
+
/* Animations */
@keyframes fade-in {