mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-21 16:17:34 +00:00
feature: Ask User Question implementation for Claude Code & upgrade claude agent sdk to 0.1.71 to support the tool
This commit is contained in:
96
package-lock.json
generated
96
package-lock.json
generated
@@ -7,9 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"license": "MIT",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -111,9 +111,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||||
"version": "0.1.29",
|
"version": "0.1.71",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.29.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.71.tgz",
|
||||||
"integrity": "sha512-VbR2ybPdJHVKAD3pQdruVw8LdXoPbk5J59xU/bQoMNzAsBckHrD2LhupMJrBxLUWxLaPkIUlNKquGBRbkoK84Q==",
|
"integrity": "sha512-O34SQCEsdU11Z2uy30GaJGRLdRbEwEvaQs8APywHVOW/EdIGE0rS/AE4V6p9j45/j5AFwh2USZWlbz5NTlLnrw==",
|
||||||
"license": "SEE LICENSE IN README.md",
|
"license": "SEE LICENSE IN README.md",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -124,10 +124,88 @@
|
|||||||
"@img/sharp-linux-arm": "^0.33.5",
|
"@img/sharp-linux-arm": "^0.33.5",
|
||||||
"@img/sharp-linux-arm64": "^0.33.5",
|
"@img/sharp-linux-arm64": "^0.33.5",
|
||||||
"@img/sharp-linux-x64": "^0.33.5",
|
"@img/sharp-linux-x64": "^0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "^0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "^0.33.5",
|
||||||
"@img/sharp-win32-x64": "^0.33.5"
|
"@img/sharp-win32-x64": "^0.33.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -12508,9 +12586,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
"author": "Claude Code UI Contributors",
|
"author": "Claude Code UI Contributors",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
|||||||
@@ -13,41 +13,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
// Used to mint unique approval request IDs when randomUUID is not available.
|
|
||||||
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||||
|
|
||||||
// Session tracking: Map of session IDs to active query instances
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
// In-memory registry of pending tool approvals keyed by requestId.
|
|
||||||
// This does not persist approvals or share across processes; it exists so the
|
|
||||||
// SDK can pause tool execution while the UI decides what to do.
|
|
||||||
const pendingToolApprovals = new Map();
|
const pendingToolApprovals = new Map();
|
||||||
|
|
||||||
// Default approval timeout kept under the SDK's 60s control timeout.
|
|
||||||
// This does not change SDK limits; it only defines how long we wait for the UI,
|
|
||||||
// introduced to avoid hanging the run when no decision arrives.
|
|
||||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||||
|
|
||||||
// Generate a stable request ID for UI approval flows.
|
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
|
||||||
// This does not encode tool details or get shown to users; it exists so the UI
|
|
||||||
// can respond to the correct pending request without collisions.
|
|
||||||
function createRequestId() {
|
function createRequestId() {
|
||||||
// if clause is used because randomUUID is not available in older Node.js versions
|
|
||||||
if (typeof crypto.randomUUID === 'function') {
|
if (typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
return crypto.randomBytes(16).toString('hex');
|
return crypto.randomBytes(16).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for a UI approval decision, honoring SDK cancellation.
|
|
||||||
// This does not auto-approve or auto-deny; it only resolves with UI input,
|
|
||||||
// and it cleans up the pending map to avoid leaks, introduced to prevent
|
|
||||||
// replying after the SDK cancels the control request.
|
|
||||||
function waitForToolApproval(requestId, options = {}) {
|
function waitForToolApproval(requestId, options = {}) {
|
||||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||||
|
|
||||||
@@ -61,24 +46,25 @@ function waitForToolApproval(requestId, options = {}) {
|
|||||||
resolve(decision);
|
resolve(decision);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let timeout;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
pendingToolApprovals.delete(requestId);
|
pendingToolApprovals.delete(requestId);
|
||||||
clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
if (signal && abortHandler) {
|
if (signal && abortHandler) {
|
||||||
signal.removeEventListener('abort', abortHandler);
|
signal.removeEventListener('abort', abortHandler);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Timeout is local to this process; it does not override SDK timing.
|
// timeoutMs 0 = wait indefinitely (interactive tools)
|
||||||
// It exists to prevent the UI prompt from lingering indefinitely.
|
if (timeoutMs > 0) {
|
||||||
const timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
onCancel?.('timeout');
|
onCancel?.('timeout');
|
||||||
finalize(null);
|
finalize(null);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
// If the SDK cancels the control request, stop waiting to avoid
|
|
||||||
// replying after the process is no longer ready for writes.
|
|
||||||
onCancel?.('cancelled');
|
onCancel?.('cancelled');
|
||||||
finalize({ cancelled: true });
|
finalize({ cancelled: true });
|
||||||
};
|
};
|
||||||
@@ -98,9 +84,6 @@ function waitForToolApproval(requestId, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a pending approval. This does not validate the decision payload;
|
|
||||||
// validation and tool matching remain in canUseTool, which keeps this as a
|
|
||||||
// lightweight WebSocket -> SDK relay.
|
|
||||||
function resolveToolApproval(requestId, decision) {
|
function resolveToolApproval(requestId, decision) {
|
||||||
const resolver = pendingToolApprovals.get(requestId);
|
const resolver = pendingToolApprovals.get(requestId);
|
||||||
if (resolver) {
|
if (resolver) {
|
||||||
@@ -175,9 +158,6 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
sdkOptions.permissionMode = 'bypassPermissions';
|
sdkOptions.permissionMode = 'bypassPermissions';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map allowed tools (always set to avoid implicit "allow all" defaults).
|
|
||||||
// This does not grant permissions by itself; it just configures the SDK,
|
|
||||||
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
|
|
||||||
let allowedTools = [...(settings.allowedTools || [])];
|
let allowedTools = [...(settings.allowedTools || [])];
|
||||||
|
|
||||||
// Add plan mode default tools
|
// Add plan mode default tools
|
||||||
@@ -192,8 +172,11 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
|
|
||||||
sdkOptions.allowedTools = allowedTools;
|
sdkOptions.allowedTools = allowedTools;
|
||||||
|
|
||||||
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
|
// Use the tools preset to make all default built-in tools available (including AskUserQuestion).
|
||||||
// This does not override allowlists; it only feeds the canUseTool gate.
|
// This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
|
||||||
|
// but being explicit ensures forward compatibility and clarity.
|
||||||
|
sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
|
||||||
|
|
||||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||||
|
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
@@ -267,9 +250,7 @@ function getAllSessions() {
|
|||||||
* @returns {Object} Transformed message ready for WebSocket
|
* @returns {Object} Transformed message ready for WebSocket
|
||||||
*/
|
*/
|
||||||
function transformMessage(sdkMessage) {
|
function transformMessage(sdkMessage) {
|
||||||
// SDK messages are already in a format compatible with the frontend
|
// Pass-through; SDK messages match frontend format.
|
||||||
// The CLI sends them wrapped in {type: 'claude-response', data: message}
|
|
||||||
// We'll do the same here to maintain compatibility
|
|
||||||
return sdkMessage;
|
return sdkMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,27 +471,27 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
tempImagePaths = imageResult.tempImagePaths;
|
tempImagePaths = imageResult.tempImagePaths;
|
||||||
tempDir = imageResult.tempDir;
|
tempDir = imageResult.tempDir;
|
||||||
|
|
||||||
// Gate tool usage with explicit UI approval when not auto-approved.
|
|
||||||
// This does not render UI or persist permissions; it only bridges to the UI
|
|
||||||
// via WebSocket and waits for the response, introduced so tool calls pause
|
|
||||||
// instead of auto-running when the allowlist is empty.
|
|
||||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||||
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||||
return { behavior: 'allow', updatedInput: input };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
if (!requiresInteraction) {
|
||||||
matchesToolPermission(entry, toolName, input)
|
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||||
);
|
return { behavior: 'allow', updatedInput: input };
|
||||||
if (isDisallowed) {
|
}
|
||||||
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||||
matchesToolPermission(entry, toolName, input)
|
matchesToolPermission(entry, toolName, input)
|
||||||
);
|
);
|
||||||
if (isAllowed) {
|
if (isDisallowed) {
|
||||||
return { behavior: 'allow', updatedInput: input };
|
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
||||||
|
matchesToolPermission(entry, toolName, input)
|
||||||
|
);
|
||||||
|
if (isAllowed) {
|
||||||
|
return { behavior: 'allow', updatedInput: input };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = createRequestId();
|
const requestId = createRequestId();
|
||||||
@@ -522,9 +503,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
|
|
||||||
// This does not retry or resurface the prompt; it just reflects the cancellation.
|
|
||||||
const decision = await waitForToolApproval(requestId, {
|
const decision = await waitForToolApproval(requestId, {
|
||||||
|
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||||
signal: context?.signal,
|
signal: context?.signal,
|
||||||
onCancel: (reason) => {
|
onCancel: (reason) => {
|
||||||
ws.send({
|
ws.send({
|
||||||
@@ -544,8 +524,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (decision.allow) {
|
if (decision.allow) {
|
||||||
// rememberEntry only updates this run's in-memory allowlist to prevent
|
|
||||||
// repeated prompts in the same session; persistence is handled by the UI.
|
|
||||||
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
||||||
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
||||||
sdkOptions.allowedTools.push(decision.rememberEntry);
|
sdkOptions.allowedTools.push(decision.rememberEntry);
|
||||||
@@ -560,12 +538,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create SDK query instance
|
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously)
|
||||||
|
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||||
|
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||||
|
|
||||||
const queryInstance = query({
|
const queryInstance = query({
|
||||||
prompt: finalCommand,
|
prompt: finalCommand,
|
||||||
options: sdkOptions
|
options: sdkOptions
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore immediately — Query constructor already captured the value
|
||||||
|
if (prevStreamTimeout !== undefined) {
|
||||||
|
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
|
||||||
|
} else {
|
||||||
|
delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
// Track the query instance for abort capability
|
// Track the query instance for abort capability
|
||||||
if (capturedSessionId) {
|
if (capturedSessionId) {
|
||||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { memo, useMemo, useCallback } from 'react';
|
import React, { memo, useMemo, useCallback } from 'react';
|
||||||
import { getToolConfig } from './configs/toolConfigs';
|
import { getToolConfig } from './configs/toolConfigs';
|
||||||
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components';
|
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
|
|
||||||
type DiffLine = {
|
type DiffLine = {
|
||||||
@@ -31,6 +31,7 @@ function getToolCategory(toolName: string): string {
|
|||||||
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
|
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
|
||||||
if (toolName === 'Task') return 'agent'; // Subagent task
|
if (toolName === 'Task') return 'agent'; // Subagent task
|
||||||
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
|
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
|
||||||
|
if (toolName === 'AskUserQuestion') return 'question';
|
||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +157,15 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
contentComponent = <TaskListContent content={contentProps.content || ''} />;
|
contentComponent = <TaskListContent content={contentProps.content || ''} />;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'question-answer':
|
||||||
|
contentComponent = (
|
||||||
|
<QuestionAnswerContent
|
||||||
|
questions={contentProps.questions || []}
|
||||||
|
answers={contentProps.answers || {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'text':
|
case 'text':
|
||||||
contentComponent = (
|
contentComponent = (
|
||||||
<TextContent
|
<TextContent
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const borderColorMap: Record<string, string> = {
|
|||||||
task: 'border-l-violet-500 dark:border-l-violet-400',
|
task: 'border-l-violet-500 dark:border-l-violet-400',
|
||||||
agent: 'border-l-purple-500 dark:border-l-purple-400',
|
agent: 'border-l-purple-500 dark:border-l-purple-400',
|
||||||
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
|
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
|
||||||
|
question: 'border-l-blue-500 dark:border-l-blue-400',
|
||||||
default: 'border-l-gray-300 dark:border-l-gray-600',
|
default: 'border-l-gray-300 dark:border-l-gray-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { Question } from '../../../types/types';
|
||||||
|
|
||||||
|
interface QuestionAnswerContentProps {
|
||||||
|
questions: Question[];
|
||||||
|
answers: Record<string, string>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state.
|
||||||
|
export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (!questions || questions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyAnswer = Object.keys(answers || {}).length > 0;
|
||||||
|
const total = questions.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 ${className}`}>
|
||||||
|
{questions.map((q, idx) => {
|
||||||
|
const answer = answers?.[q.question];
|
||||||
|
const answerLabels = answer ? answer.split(', ') : [];
|
||||||
|
const skipped = !answer;
|
||||||
|
const isExpanded = expandedIdx === idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||||
|
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${
|
||||||
|
answerLabels.length > 0
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800'
|
||||||
|
}`}>
|
||||||
|
{answerLabels.length > 0 ? (
|
||||||
|
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{q.header && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40">
|
||||||
|
{q.header}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{total > 1 && (
|
||||||
|
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
|
||||||
|
{idx + 1}/{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug">
|
||||||
|
{q.question}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isExpanded && answerLabels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{answerLabels.map((lbl) => {
|
||||||
|
const isCustom = !q.options.some(o => o.label === lbl);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={lbl}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium"
|
||||||
|
>
|
||||||
|
{lbl}
|
||||||
|
{isCustom && (
|
||||||
|
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isExpanded && skipped && hasAnyAnswer && (
|
||||||
|
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic">
|
||||||
|
Skipped
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${
|
||||||
|
isExpanded ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40">
|
||||||
|
<div className="space-y-1 ml-6.5">
|
||||||
|
{q.options.map((opt) => {
|
||||||
|
const wasSelected = answerLabels.includes(opt.label);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.label}
|
||||||
|
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${
|
||||||
|
wasSelected
|
||||||
|
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${
|
||||||
|
wasSelected
|
||||||
|
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{wasSelected && (
|
||||||
|
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
{opt.description && (
|
||||||
|
<span className={`block text-[11px] mt-0.5 ${
|
||||||
|
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{opt.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||||
|
<div
|
||||||
|
key={lbl}
|
||||||
|
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40"
|
||||||
|
>
|
||||||
|
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}>
|
||||||
|
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span>
|
||||||
|
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{skipped && hasAnyAnswer && (
|
||||||
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1">
|
||||||
|
No answer provided
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!hasAnyAnswer && total === 1 && (
|
||||||
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
|
||||||
|
Skipped
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,3 +3,4 @@ export { FileListContent } from './FileListContent';
|
|||||||
export { TodoListContent } from './TodoListContent';
|
export { TodoListContent } from './TodoListContent';
|
||||||
export { TaskListContent } from './TaskListContent';
|
export { TaskListContent } from './TaskListContent';
|
||||||
export { TextContent } from './TextContent';
|
export { TextContent } from './TextContent';
|
||||||
|
export { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import type { PermissionPanelProps } from '../../configs/permissionPanelRegistry';
|
||||||
|
import type { Question } from '../../../types/types';
|
||||||
|
|
||||||
|
export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
||||||
|
request,
|
||||||
|
onDecision,
|
||||||
|
}) => {
|
||||||
|
const input = request.input as { questions?: Question[] } | undefined;
|
||||||
|
const questions: Question[] = input?.questions || [];
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [selections, setSelections] = useState<Map<number, Set<string>>>(() => new Map());
|
||||||
|
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(() => new Map());
|
||||||
|
const [otherActive, setOtherActive] = useState<Map<number, boolean>>(() => new Map());
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => setMounted(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus the container for keyboard events when step changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!otherActive.get(currentStep)) {
|
||||||
|
containerRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [currentStep, otherActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (otherActive.get(currentStep)) {
|
||||||
|
otherInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [otherActive, currentStep]);
|
||||||
|
|
||||||
|
const toggleOption = useCallback((qIdx: number, label: string, multiSelect: boolean) => {
|
||||||
|
setSelections(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = new Set(next.get(qIdx) || []);
|
||||||
|
if (multiSelect) {
|
||||||
|
if (current.has(label)) current.delete(label);
|
||||||
|
else current.add(label);
|
||||||
|
} else {
|
||||||
|
current.clear();
|
||||||
|
current.add(label);
|
||||||
|
setOtherActive(p => { const n = new Map(p); n.set(qIdx, false); return n; });
|
||||||
|
}
|
||||||
|
next.set(qIdx, current);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleOther = useCallback((qIdx: number, multiSelect: boolean) => {
|
||||||
|
setOtherActive(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const wasActive = next.get(qIdx) || false;
|
||||||
|
next.set(qIdx, !wasActive);
|
||||||
|
if (!multiSelect && !wasActive) {
|
||||||
|
setSelections(p => { const n = new Map(p); n.set(qIdx, new Set()); return n; });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setOtherText = useCallback((qIdx: number, text: string) => {
|
||||||
|
setOtherTexts(prev => { const next = new Map(prev); next.set(qIdx, text); return next; });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildAnswers = useCallback(() => {
|
||||||
|
const answers: Record<string, string> = {};
|
||||||
|
questions.forEach((q, idx) => {
|
||||||
|
const selected = Array.from(selections.get(idx) || []);
|
||||||
|
const isOther = otherActive.get(idx) || false;
|
||||||
|
const otherText = (otherTexts.get(idx) || '').trim();
|
||||||
|
if (isOther && otherText) selected.push(otherText);
|
||||||
|
if (selected.length > 0) answers[q.question] = selected.join(', ');
|
||||||
|
});
|
||||||
|
return answers;
|
||||||
|
}, [questions, selections, otherActive, otherTexts]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: buildAnswers() } });
|
||||||
|
}, [onDecision, request.requestId, input, buildAnswers]);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: {} } });
|
||||||
|
}, [onDecision, request.requestId, input]);
|
||||||
|
|
||||||
|
// Keyboard handler for number keys and navigation
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
// Don't capture keys when typing in the "Other" input
|
||||||
|
if (e.target instanceof HTMLInputElement) return;
|
||||||
|
|
||||||
|
const q = questions[currentStep];
|
||||||
|
if (!q) return;
|
||||||
|
const multi = q.multiSelect || false;
|
||||||
|
const optCount = q.options.length;
|
||||||
|
|
||||||
|
// Number keys 1-9 for options
|
||||||
|
const num = parseInt(e.key);
|
||||||
|
if (!isNaN(num) && num >= 1 && num <= optCount) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleOption(currentStep, q.options[num - 1].label, multi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 for "Other"
|
||||||
|
if (e.key === '0') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleOther(currentStep, multi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter to advance / submit
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const isLast = currentStep === questions.length - 1;
|
||||||
|
if (isLast) handleSubmit();
|
||||||
|
else setCurrentStep(s => s + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to skip
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [currentStep, questions, toggleOption, toggleOther, handleSubmit, handleSkip]);
|
||||||
|
|
||||||
|
if (questions.length === 0) return null;
|
||||||
|
|
||||||
|
const total = questions.length;
|
||||||
|
const isSingle = total === 1;
|
||||||
|
const q = questions[currentStep];
|
||||||
|
const multi = q.multiSelect || false;
|
||||||
|
const selected = selections.get(currentStep) || new Set<string>();
|
||||||
|
const isOtherOn = otherActive.get(currentStep) || false;
|
||||||
|
const isLast = currentStep === total - 1;
|
||||||
|
const isFirst = currentStep === 0;
|
||||||
|
const hasCurrentSelection = selected.size > 0 || (isOtherOn && (otherTexts.get(currentStep) || '').trim().length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`w-full outline-none transition-all duration-500 ease-out ${
|
||||||
|
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl">
|
||||||
|
{/* Accent line */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
|
||||||
|
|
||||||
|
{/* Header + Question — compact */}
|
||||||
|
<div className="px-4 pt-3.5 pb-2">
|
||||||
|
<div className="flex items-center gap-2.5 mb-1.5">
|
||||||
|
{/* Question icon */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center">
|
||||||
|
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500">
|
||||||
|
Claude needs your input
|
||||||
|
</span>
|
||||||
|
{q.header && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-800/50">
|
||||||
|
{q.header}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step counter */}
|
||||||
|
{!isSingle && (
|
||||||
|
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
|
{currentStep + 1}/{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress dots (multi-question) */}
|
||||||
|
{!isSingle && (
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
{questions.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentStep(i)}
|
||||||
|
className={`h-[3px] rounded-full transition-all duration-300 ${
|
||||||
|
i === currentStep
|
||||||
|
? 'w-5 bg-blue-500 dark:bg-blue-400'
|
||||||
|
: i < currentStep
|
||||||
|
? 'w-2.5 bg-blue-300 dark:bg-blue-600'
|
||||||
|
: 'w-2.5 bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Question text */}
|
||||||
|
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{q.question}
|
||||||
|
</p>
|
||||||
|
{multi && (
|
||||||
|
<span className="text-[10px] text-gray-400 dark:text-gray-500">Select all that apply</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options — tight spacing */}
|
||||||
|
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{q.options.map((opt, optIdx) => {
|
||||||
|
const isSelected = selected.has(opt.label);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleOption(currentStep, opt.label, multi)}
|
||||||
|
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
|
||||||
|
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Keyboard hint */}
|
||||||
|
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{optIdx + 1}
|
||||||
|
</kbd>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-[13px] leading-tight transition-colors duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? 'text-gray-900 dark:text-gray-100 font-medium'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
{opt.description && (
|
||||||
|
<div className={`text-[11px] leading-snug transition-colors duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? 'text-blue-600/70 dark:text-blue-300/70'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{opt.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection check */}
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* "Other" option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleOther(currentStep, multi)}
|
||||||
|
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
|
||||||
|
isOtherOn
|
||||||
|
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
|
||||||
|
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
|
||||||
|
isOtherOn
|
||||||
|
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
0
|
||||||
|
</kbd>
|
||||||
|
<span className={`text-[13px] leading-tight transition-colors ${
|
||||||
|
isOtherOn
|
||||||
|
? 'text-gray-900 dark:text-gray-100 font-medium'
|
||||||
|
: 'text-gray-500 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
Other...
|
||||||
|
</span>
|
||||||
|
{isOtherOn && (
|
||||||
|
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Other text input — inline */}
|
||||||
|
{isOtherOn && (
|
||||||
|
<div className="pl-[30px] pr-0.5">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={otherInputRef}
|
||||||
|
type="text"
|
||||||
|
value={otherTexts.get(currentStep) || ''}
|
||||||
|
onChange={(e) => setOtherText(currentStep, e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isLast) handleSubmit();
|
||||||
|
else setCurrentStep(s => s + 1);
|
||||||
|
}
|
||||||
|
// Prevent container keydown from firing
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
placeholder="Type your answer..."
|
||||||
|
className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200"
|
||||||
|
/>
|
||||||
|
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
Enter
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — compact */}
|
||||||
|
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{isSingle ? 'Skip' : 'Skip all'}
|
||||||
|
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{!isSingle && !isFirst && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentStep(s => s - 1)}
|
||||||
|
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLast ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentStep(s => s + 1)}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AskUserQuestionPanel } from './AskUserQuestionPanel';
|
||||||
@@ -3,3 +3,4 @@ export { DiffViewer } from './DiffViewer';
|
|||||||
export { OneLineDisplay } from './OneLineDisplay';
|
export { OneLineDisplay } from './OneLineDisplay';
|
||||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||||
export * from './ContentRenderers';
|
export * from './ContentRenderers';
|
||||||
|
export * from './InteractiveRenderers';
|
||||||
|
|||||||
25
src/components/chat/tools/configs/permissionPanelRegistry.ts
Normal file
25
src/components/chat/tools/configs/permissionPanelRegistry.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { PendingPermissionRequest } from '../../types/types';
|
||||||
|
|
||||||
|
export interface PermissionPanelProps {
|
||||||
|
request: PendingPermissionRequest;
|
||||||
|
onDecision: (
|
||||||
|
requestIds: string | string[],
|
||||||
|
decision: { allow?: boolean; message?: string; updatedInput?: unknown },
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry: Record<string, ComponentType<PermissionPanelProps>> = {};
|
||||||
|
|
||||||
|
export function registerPermissionPanel(
|
||||||
|
toolName: string,
|
||||||
|
component: ComponentType<PermissionPanelProps>,
|
||||||
|
): void {
|
||||||
|
registry[toolName] = component;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPermissionPanel(
|
||||||
|
toolName: string,
|
||||||
|
): ComponentType<PermissionPanelProps> | null {
|
||||||
|
return registry[toolName] || null;
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export interface ToolDisplayConfig {
|
|||||||
// Collapsible config
|
// Collapsible config
|
||||||
title?: string | ((input: any) => string);
|
title?: string | ((input: any) => string);
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
|
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer';
|
||||||
getContentProps?: (input: any, helpers?: any) => any;
|
getContentProps?: (input: any, helpers?: any) => any;
|
||||||
actionButton?: 'file-button' | 'none';
|
actionButton?: 'file-button' | 'none';
|
||||||
};
|
};
|
||||||
@@ -35,7 +35,7 @@ export interface ToolDisplayConfig {
|
|||||||
title?: string | ((result: any) => string);
|
title?: string | ((result: any) => string);
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
// Special result handlers
|
// Special result handlers
|
||||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
|
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer';
|
||||||
getMessage?: (result: any) => string;
|
getMessage?: (result: any) => string;
|
||||||
getContentProps?: (result: any) => any;
|
getContentProps?: (result: any) => any;
|
||||||
};
|
};
|
||||||
@@ -453,6 +453,34 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERACTIVE TOOLS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
AskUserQuestion: {
|
||||||
|
input: {
|
||||||
|
type: 'collapsible',
|
||||||
|
title: (input: any) => {
|
||||||
|
const count = input.questions?.length || 0;
|
||||||
|
const hasAnswers = input.answers && Object.keys(input.answers).length > 0;
|
||||||
|
if (count === 1) {
|
||||||
|
const header = input.questions[0]?.header || 'Question';
|
||||||
|
return hasAnswers ? `${header} — answered` : header;
|
||||||
|
}
|
||||||
|
return hasAnswers ? `${count} questions — answered` : `${count} questions`;
|
||||||
|
},
|
||||||
|
defaultOpen: true,
|
||||||
|
contentType: 'question-answer',
|
||||||
|
getContentProps: (input: any) => ({
|
||||||
|
questions: input.questions || [],
|
||||||
|
answers: input.answers || {}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
hideOnSuccess: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PLAN TOOLS
|
// PLAN TOOLS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ export interface PendingPermissionRequest {
|
|||||||
receivedAt?: Date;
|
receivedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
question: string;
|
||||||
|
header?: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
multiSelect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatInterfaceProps {
|
export interface ChatInterfaceProps {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
|
|||||||
@@ -402,11 +402,26 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
|||||||
content.startsWith('[Request interrupted');
|
content.startsWith('[Request interrupted');
|
||||||
|
|
||||||
if (!shouldSkip) {
|
if (!shouldSkip) {
|
||||||
converted.push({
|
// Parse <task-notification> blocks into compact system messages
|
||||||
type: 'user',
|
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
|
||||||
content: unescapeWithMathProtection(content),
|
const taskNotifMatch = taskNotifRegex.exec(content);
|
||||||
timestamp: message.timestamp || new Date().toISOString(),
|
if (taskNotifMatch) {
|
||||||
});
|
const status = taskNotifMatch[1]?.trim() || 'completed';
|
||||||
|
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
|
||||||
|
converted.push({
|
||||||
|
type: 'assistant',
|
||||||
|
content: summary,
|
||||||
|
timestamp: message.timestamp || new Date().toISOString(),
|
||||||
|
isTaskNotification: true,
|
||||||
|
taskStatus: status,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
converted.push({
|
||||||
|
type: 'user',
|
||||||
|
content: unescapeWithMathProtection(content),
|
||||||
|
timestamp: message.timestamp || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,16 +157,23 @@ export default function ChatComposer({
|
|||||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Detect if the AskUserQuestion interactive panel is active
|
||||||
|
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||||
|
(r) => r.toolName === 'AskUserQuestion'
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
|
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
|
||||||
<div className="flex-1">
|
{!hasQuestionPanel && (
|
||||||
<ClaudeStatus
|
<div className="flex-1">
|
||||||
status={claudeStatus}
|
<ClaudeStatus
|
||||||
isLoading={isLoading}
|
status={claudeStatus}
|
||||||
onAbort={onAbortSession}
|
isLoading={isLoading}
|
||||||
provider={provider}
|
onAbort={onAbortSession}
|
||||||
/>
|
provider={provider}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto mb-3">
|
<div className="max-w-4xl mx-auto mb-3">
|
||||||
<PermissionRequestsBanner
|
<PermissionRequestsBanner
|
||||||
@@ -175,7 +182,7 @@ export default function ChatComposer({
|
|||||||
handleGrantToolPermission={handleGrantToolPermission}
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatInputControls
|
{!hasQuestionPanel && <ChatInputControls
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={onModeSwitch}
|
onModeSwitch={onModeSwitch}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
@@ -189,10 +196,10 @@ export default function ChatComposer({
|
|||||||
isUserScrolledUp={isUserScrolledUp}
|
isUserScrolledUp={isUserScrolledUp}
|
||||||
hasMessages={hasMessages}
|
hasMessages={hasMessages}
|
||||||
onScrollToBottom={onScrollToBottom}
|
onScrollToBottom={onScrollToBottom}
|
||||||
/>
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
|
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
|
||||||
{isDragActive && (
|
{isDragActive && (
|
||||||
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
|
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
||||||
@@ -340,7 +347,7 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : message.isTaskNotification ? (
|
||||||
|
/* Compact task notification on the left */
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-2 py-0.5">
|
||||||
|
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Claude/Error/Tool messages on the left */
|
/* Claude/Error/Tool messages on the left */
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import React from 'react';
|
|||||||
import type { PendingPermissionRequest } from '../../types/types';
|
import type { PendingPermissionRequest } from '../../types/types';
|
||||||
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
|
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
|
||||||
import { getClaudeSettings } from '../../utils/chatStorage';
|
import { getClaudeSettings } from '../../utils/chatStorage';
|
||||||
|
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
|
||||||
|
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
|
||||||
|
|
||||||
|
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
|
||||||
|
|
||||||
interface PermissionRequestsBannerProps {
|
interface PermissionRequestsBannerProps {
|
||||||
pendingPermissionRequests: PendingPermissionRequest[];
|
pendingPermissionRequests: PendingPermissionRequest[];
|
||||||
@@ -24,6 +28,17 @@ export default function PermissionRequestsBanner({
|
|||||||
return (
|
return (
|
||||||
<div className="mb-3 space-y-2">
|
<div className="mb-3 space-y-2">
|
||||||
{pendingPermissionRequests.map((request) => {
|
{pendingPermissionRequests.map((request) => {
|
||||||
|
const CustomPanel = getPermissionPanel(request.toolName);
|
||||||
|
if (CustomPanel) {
|
||||||
|
return (
|
||||||
|
<CustomPanel
|
||||||
|
key={request.requestId}
|
||||||
|
request={request}
|
||||||
|
onDecision={handlePermissionDecision}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rawInput = formatToolInputForDisplay(request.input);
|
const rawInput = formatToolInputForDisplay(request.input);
|
||||||
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
|
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
|
||||||
const settings = getClaudeSettings();
|
const settings = getClaudeSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user