mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
Compare commits
5 Commits
fix/shell-
...
feat/pendi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8298e5df5 | ||
|
|
43d3269b38 | ||
|
|
16be1d0f7b | ||
|
|
4a503b1dc8 | ||
|
|
63a4869325 |
@@ -171,6 +171,62 @@ function buildShellCommand(
|
||||
return command;
|
||||
}
|
||||
|
||||
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
||||
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
|
||||
return resolvedKey ? env[resolvedKey] : undefined;
|
||||
}
|
||||
|
||||
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
||||
}
|
||||
|
||||
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
|
||||
const pathKey = getPathEnvKey(env);
|
||||
const currentPath = env[pathKey];
|
||||
if (!currentPath) {
|
||||
return { key: pathKey, value: currentPath };
|
||||
}
|
||||
|
||||
const delimiter = path.delimiter;
|
||||
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
||||
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
|
||||
const appData = readEnvValue(env, 'APPDATA');
|
||||
const candidates = [
|
||||
npmPrefix || '',
|
||||
npmPrefix ? path.join(npmPrefix, 'bin') : '',
|
||||
appData ? path.join(appData, 'npm') : '',
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
].filter(Boolean);
|
||||
|
||||
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
|
||||
const preferredEntries = candidates.filter((candidate, index) => {
|
||||
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
|
||||
return (
|
||||
candidates.indexOf(candidate) === index &&
|
||||
normalizedPathEntries.includes(normalizedCandidate)
|
||||
);
|
||||
});
|
||||
|
||||
if (preferredEntries.length === 0) {
|
||||
return { key: pathKey, value: currentPath };
|
||||
}
|
||||
|
||||
const normalizedPreferredEntries = preferredEntries.map((entry) =>
|
||||
os.platform() === 'win32' ? entry.toLowerCase() : entry
|
||||
);
|
||||
|
||||
const value = [
|
||||
...preferredEntries,
|
||||
...pathEntries.filter((entry) => {
|
||||
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
|
||||
return !normalizedPreferredEntries.includes(normalizedEntry);
|
||||
}),
|
||||
].join(delimiter);
|
||||
|
||||
return { key: pathKey, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles websocket connections used by the standalone shell terminal UI.
|
||||
*/
|
||||
@@ -284,6 +340,7 @@ export function handleShellConnection(
|
||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||
const termCols = readNumber(data.cols, 80);
|
||||
const termRows = readNumber(data.rows, 24);
|
||||
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
|
||||
|
||||
shellProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -292,6 +349,7 @@ export function handleShellConnection(
|
||||
cwd: resolvedProjectPath,
|
||||
env: {
|
||||
...process.env,
|
||||
[prioritizedPath.key]: prioritizedPath.value,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
|
||||
@@ -114,7 +114,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
|
||||
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
|
||||
|
||||
const lastProviderRef = useRef(provider);
|
||||
const providerModelsRequestIdRef = useRef(0);
|
||||
|
||||
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
|
||||
@@ -344,14 +343,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||||
}, [provider, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastProviderRef.current === provider) {
|
||||
return;
|
||||
}
|
||||
setPendingPermissionRequests([]);
|
||||
lastProviderRef.current = provider;
|
||||
}, [provider]);
|
||||
|
||||
// Permission prompts belong to a session, not to the transient provider
|
||||
// selection that is synchronized after navigation.
|
||||
useEffect(() => {
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
||||
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||
import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound';
|
||||
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest } from '../types/types';
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
const isActionablePermissionRequest = (request: { toolName?: unknown } | null | undefined): boolean => {
|
||||
return request?.toolName !== 'ExitPlanMode' && request?.toolName !== 'exit_plan_mode';
|
||||
};
|
||||
|
||||
const hasActionablePermissionRequests = (requests: Array<{ toolName?: unknown }> | null | undefined): boolean => {
|
||||
return Array.isArray(requests) && requests.some((request) => isActionablePermissionRequest(request));
|
||||
};
|
||||
|
||||
interface UseChatRealtimeHandlersArgs {
|
||||
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
||||
provider: LLMProvider;
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||
pendingPermissionRequests: PendingPermissionRequest[];
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
@@ -52,6 +61,7 @@ export function useChatRealtimeHandlers({
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setTokenBudget,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
@@ -62,13 +72,29 @@ export function useChatRealtimeHandlers({
|
||||
onWebSocketReconnect,
|
||||
sessionStore,
|
||||
}: UseChatRealtimeHandlersArgs) {
|
||||
// Session switches can send `chat.subscribe` before this effect has a chance
|
||||
// to rebind the websocket listener. Read the visible session id from a ref
|
||||
// so a fast `chat_subscribed` ack is matched against the current view, not
|
||||
// the previous render's closed-over selection.
|
||||
const activeViewSessionIdRef = useRef<string | null>(selectedSession?.id || currentSessionId || null);
|
||||
activeViewSessionIdRef.current = selectedSession?.id || currentSessionId || null;
|
||||
|
||||
// Keep the latest pending-permission snapshot available to the websocket
|
||||
// listener so back-to-back permission events can dedupe and re-arm the
|
||||
// notification sound before React finishes a rerender.
|
||||
const pendingPermissionRequestsRef = useRef(pendingPermissionRequests);
|
||||
|
||||
useEffect(() => {
|
||||
pendingPermissionRequestsRef.current = pendingPermissionRequests;
|
||||
}, [pendingPermissionRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEvent = (msg: ServerEvent) => {
|
||||
if (!msg.kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
||||
const activeViewSessionId = activeViewSessionIdRef.current;
|
||||
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
||||
|
||||
// Record replay progress for every sequenced live event.
|
||||
@@ -101,7 +127,16 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
const isViewedSession = sid === activeViewSessionId;
|
||||
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
|
||||
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
|
||||
const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[];
|
||||
const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current);
|
||||
const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests);
|
||||
|
||||
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
||||
setPendingPermissionRequests(nextPendingPermissionRequests);
|
||||
|
||||
if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) {
|
||||
void playNotificationSound();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -203,6 +238,7 @@ export function useChatRealtimeHandlers({
|
||||
// hides it immediately and atomically.
|
||||
onSessionIdle?.(sid);
|
||||
if (sid === activeViewSessionId) {
|
||||
pendingPermissionRequestsRef.current = [];
|
||||
setPendingPermissionRequests([]);
|
||||
}
|
||||
|
||||
@@ -234,10 +270,14 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
case 'permission_request': {
|
||||
if (!msg.requestId) break;
|
||||
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
|
||||
void playNotificationSound();
|
||||
}
|
||||
|
||||
if (sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests((prev) => {
|
||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||
return [...prev, {
|
||||
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
|
||||
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
|
||||
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
|
||||
requestId: msg.requestId as string,
|
||||
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||
input: msg.input,
|
||||
@@ -245,7 +285,10 @@ export function useChatRealtimeHandlers({
|
||||
sessionId: sid || null,
|
||||
receivedAt: new Date(),
|
||||
}];
|
||||
});
|
||||
|
||||
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
||||
setPendingPermissionRequests(nextPendingPermissionRequests);
|
||||
}
|
||||
}
|
||||
if (sid) {
|
||||
onSessionProcessing?.(sid);
|
||||
@@ -255,7 +298,12 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
case 'permission_cancelled': {
|
||||
if (msg.requestId && sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
|
||||
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
|
||||
);
|
||||
|
||||
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
|
||||
setPendingPermissionRequests(nextPendingPermissionRequests);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -286,6 +334,7 @@ export function useChatRealtimeHandlers({
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setTokenBudget,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
|
||||
@@ -239,6 +239,7 @@ function ChatInterface({
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setTokenBudget,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"sound": {
|
||||
"title": "Sound",
|
||||
"description": "Play a short tone when a chat run finishes.",
|
||||
"description": "Play a short tone when a chat run finishes or needs tool approval.",
|
||||
"enabled": "Enabled",
|
||||
"test": "Test sound"
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ const playTone = (
|
||||
oscillator.stop(startsAt + duration + 0.02);
|
||||
};
|
||||
|
||||
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
|
||||
export const playNotificationSound = async ({ force = false } = {}): Promise<void> => {
|
||||
if (!force && !isNotificationSoundEnabled()) {
|
||||
return;
|
||||
}
|
||||
@@ -81,3 +81,5 @@ export const playChatCompletionSound = async ({ force = false } = {}): Promise<v
|
||||
console.warn('Unable to play notification sound:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);
|
||||
|
||||
Reference in New Issue
Block a user