diff --git a/.github/workflows/tagpr.yml b/.github/workflows/tagpr.yml index 525a0af..ca0a102 100644 --- a/.github/workflows/tagpr.yml +++ b/.github/workflows/tagpr.yml @@ -15,6 +15,6 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: token: ${{ secrets.GH_PAT }} - - uses: Songmu/tagpr@7191605433b03e11b313dbbc0efb80185170de4b # v1.9.0 + - uses: Songmu/tagpr@b3fb89424646b06c8aa460f50307c60b6a541425 # v1.17.1 env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} diff --git a/backend/app.ts b/backend/app.ts index 7f4c2c1..ffc5afe 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -15,7 +15,7 @@ import { import { handleProjectsRequest } from "./handlers/projects.ts"; import { handleHistoriesRequest } from "./handlers/histories.ts"; import { handleConversationRequest } from "./handlers/conversations.ts"; -import { handleChatRequest } from "./handlers/chat.ts"; +import { handleChatRequest, handlePermissionResponse } from "./handlers/chat.ts"; import { handleAbortRequest } from "./handlers/abort.ts"; import { logger } from "./utils/logger.ts"; import { readBinaryFile } from "./utils/fs.ts"; @@ -72,6 +72,8 @@ export function createApp( app.post("/api/chat", (c) => handleChatRequest(c, requestAbortControllers)); + app.post("/api/permission", (c) => handlePermissionResponse(c)); + // Static file serving with SPA fallback // Serve static assets (CSS, JS, images, etc.) const serveStatic = runtime.createStaticFileMiddleware({ diff --git a/backend/handlers/chat.ts b/backend/handlers/chat.ts index b0d283e..939cca5 100644 --- a/backend/handlers/chat.ts +++ b/backend/handlers/chat.ts @@ -1,20 +1,26 @@ import { Context } from "hono"; import { query, type PermissionMode } from "@anthropic-ai/claude-code"; -import type { ChatRequest, StreamResponse } from "../../shared/types.ts"; +import type { + ChatRequest, + StreamResponse, + PermissionResponse, +} from "../../shared/types.ts"; import { logger } from "../utils/logger.ts"; +import { + createPermissionRequest, + resolvePermissionRequest, + generateRequestId, +} from "./permission.ts"; + +const TOOLS_REQUIRING_INTERACTION = new Set(["AskUserQuestion"]); + +interface PermissionQueueItem { + type: "permission_request"; + requestId: string; + toolName: string; + toolInput: Record; +} -/** - * Executes a Claude command and yields streaming responses - * @param message - User message or command - * @param requestId - Unique request identifier for abort functionality - * @param requestAbortControllers - Shared map of abort controllers - * @param cliPath - Path to actual CLI script (detected by validateClaudeCli) - * @param sessionId - Optional session ID for conversation continuity - * @param allowedTools - Optional array of allowed tool names - * @param workingDirectory - Optional working directory for Claude execution - * @param permissionMode - Optional permission mode for Claude execution - * @returns AsyncGenerator yielding StreamResponse objects - */ async function* executeClaudeCommand( message: string, requestId: string, @@ -24,71 +30,136 @@ async function* executeClaudeCommand( allowedTools?: string[], workingDirectory?: string, permissionMode?: PermissionMode, + permissionQueue?: PermissionQueueItem[], ): AsyncGenerator { - let abortController: AbortController; + const abortController = new AbortController(); + requestAbortControllers.set(requestId, abortController); + const permissionQueueLocal = permissionQueue || []; - try { - // Process commands that start with '/' - let processedMessage = message; - if (message.startsWith("/")) { - // Remove the '/' and send just the command - processedMessage = message.substring(1); + let processedMessage = message; + if (message.startsWith("/")) { + processedMessage = message.substring(1); + } + + const canUseTool = async ( + toolName: string, + input: Record, + options: { signal: AbortSignal; suggestions?: unknown }, + ): Promise<{ behavior: "allow" | "deny"; updatedInput: Record; message?: string }> => { + const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); + + if (permissionMode === "bypassPermissions" && !requiresInteraction) { + return { behavior: "allow", updatedInput: input }; + } + + if (allowedTools) { + const isAllowed = allowedTools.some( + (tool) => tool === toolName || tool.startsWith(`${toolName}(`), + ); + if (isAllowed && !requiresInteraction) { + return { behavior: "allow", updatedInput: input }; + } + } + + const permRequestId = generateRequestId(); + + permissionQueueLocal.push({ + type: "permission_request", + requestId: permRequestId, + toolName, + toolInput: input, + }); + + try { + const response = await createPermissionRequest( + permRequestId, + toolName, + input, + ); + + if (response.allow) { + if (response.rememberEntry && allowedTools) { + allowedTools.push(response.rememberEntry); + } + return { + behavior: "allow", + updatedInput: response.updatedInput || input, + }; + } + + return { + behavior: "deny", + updatedInput: input, + message: response.message || "User denied tool use", + }; + } catch (error) { + return { + behavior: "deny", + updatedInput: input, + message: error instanceof Error ? error.message : "Permission request failed", + }; } + }; - // Create and store AbortController for this request - abortController = new AbortController(); - requestAbortControllers.set(requestId, abortController); + try { + const queryOptions: Parameters[0]["options"] = { + abortController, + executable: "node" as const, + executableArgs: [], + pathToClaudeCodeExecutable: cliPath, + ...(sessionId ? { resume: sessionId } : {}), + ...(allowedTools ? { allowedTools } : {}), + ...(workingDirectory ? { cwd: workingDirectory } : {}), + ...(permissionMode ? { permissionMode } : {}), + canUseTool, + }; for await (const sdkMessage of query({ prompt: processedMessage, - options: { - abortController, - executable: "node" as const, - executableArgs: [], - pathToClaudeCodeExecutable: cliPath, - ...(sessionId ? { resume: sessionId } : {}), - ...(allowedTools ? { allowedTools } : {}), - ...(workingDirectory ? { cwd: workingDirectory } : {}), - ...(permissionMode ? { permissionMode } : {}), - }, + options: queryOptions, })) { - // Debug logging of raw SDK messages with detailed content logger.chat.debug("Claude SDK Message: {sdkMessage}", { sdkMessage }); + while (permissionQueueLocal.length > 0) { + const permRequest = permissionQueueLocal.shift()!; + yield { + type: "permission_request", + permissionRequestId: permRequest.requestId, + toolName: permRequest.toolName, + toolInput: permRequest.toolInput, + }; + } + yield { type: "claude_json", data: sdkMessage, }; } - yield { type: "done" }; - } catch (error) { - // Check if error is due to abort - // TODO: Re-enable when AbortError is properly exported from Claude SDK - // if (error instanceof AbortError) { - // yield { type: "aborted" }; - // } else { - { - logger.chat.error("Claude Code execution failed: {error}", { error }); + while (permissionQueueLocal.length > 0) { + const permRequest = permissionQueueLocal.shift()!; yield { - type: "error", - error: error instanceof Error ? error.message : String(error), + type: "permission_request", + permissionRequestId: permRequest.requestId, + toolName: permRequest.toolName, + toolInput: permRequest.toolInput, }; } + + yield { type: "done" }; + } catch (error) { + logger.chat.error("Claude Code execution failed: {error}", { error }); + yield { + type: "error", + error: error instanceof Error ? error.message : String(error), + }; } finally { - // Clean up AbortController from map if (requestAbortControllers.has(requestId)) { requestAbortControllers.delete(requestId); } } } -/** - * Handles POST /api/chat requests with streaming responses - * @param c - Hono context object with config variables - * @param requestAbortControllers - Shared map of abort controllers - * @returns Response with streaming NDJSON - */ export async function handleChatRequest( c: Context, requestAbortControllers: Map, @@ -101,6 +172,8 @@ export async function handleChatRequest( chatRequest as unknown as Record, ); + const permissionQueue: PermissionQueueItem[] = []; + const stream = new ReadableStream({ async start(controller) { try { @@ -108,11 +181,12 @@ export async function handleChatRequest( chatRequest.message, chatRequest.requestId, requestAbortControllers, - cliPath, // Use detected CLI path from validateClaudeCli + cliPath, chatRequest.sessionId, chatRequest.allowedTools, chatRequest.workingDirectory, chatRequest.permissionMode, + permissionQueue, )) { const data = JSON.stringify(chunk) + "\n"; controller.enqueue(new TextEncoder().encode(data)); @@ -139,3 +213,19 @@ export async function handleChatRequest( }, }); } + +export async function handlePermissionResponse(c: Context) { + const response: PermissionResponse = await c.req.json(); + + logger.permission.debug("Received permission response: {response}", { + response, + }); + + const success = resolvePermissionRequest(response); + + if (!success) { + return c.json({ error: "No pending permission request found" }, 404); + } + + return c.json({ success: true }); +} \ No newline at end of file diff --git a/backend/handlers/permission.ts b/backend/handlers/permission.ts new file mode 100644 index 0000000..936f3ab --- /dev/null +++ b/backend/handlers/permission.ts @@ -0,0 +1,151 @@ +import type { PermissionResponse, AskUserResponse } from "../../shared/types.ts"; +import { logger } from "../utils/logger.ts"; + +interface PendingPermissionRequest { + resolve: (result: PermissionResponse) => void; + reject: (error: Error) => void; + toolName: string; + toolInput: Record; + createdAt: number; +} + +interface PendingAskUserRequest { + resolve: (result: AskUserResponse) => void; + reject: (error: Error) => void; + question: string; + suggestions?: string[]; + createdAt: number; +} + +const pendingPermissionRequests = new Map(); +const pendingAskUserRequests = new Map(); + +const PERMISSION_TIMEOUT_MS = 300000; + +export function createPermissionRequest( + requestId: string, + toolName: string, + toolInput: Record, +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pendingPermissionRequests.delete(requestId); + reject(new Error("Permission request timed out")); + }, PERMISSION_TIMEOUT_MS); + + pendingPermissionRequests.set(requestId, { + resolve: (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (error) => { + clearTimeout(timeoutId); + reject(error); + }, + toolName, + toolInput, + createdAt: Date.now(), + }); + + logger.permission.debug("Created permission request: {requestId}", { + requestId, + }); + }); +} + +export function createAskUserRequest( + requestId: string, + question: string, + suggestions?: string[], +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pendingAskUserRequests.delete(requestId); + reject(new Error("Ask user request timed out")); + }, PERMISSION_TIMEOUT_MS); + + pendingAskUserRequests.set(requestId, { + resolve: (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (error) => { + clearTimeout(timeoutId); + reject(error); + }, + question, + suggestions, + createdAt: Date.now(), + }); + + logger.permission.debug("Created ask user request: {requestId}", { + requestId, + }); + }); +} + +export function resolvePermissionRequest(response: PermissionResponse): boolean { + const pending = pendingPermissionRequests.get(response.requestId); + if (!pending) { + logger.permission.warn("No pending permission request: {requestId}", { + requestId: response.requestId, + }); + return false; + } + + pending.resolve(response); + pendingPermissionRequests.delete(response.requestId); + logger.permission.debug("Resolved permission request: {requestId}", { + requestId: response.requestId, + }); + return true; +} + +export function resolveAskUserRequest(response: AskUserResponse): boolean { + const pending = pendingAskUserRequests.get(response.requestId); + if (!pending) { + logger.permission.warn("No pending ask user request: {requestId}", { + requestId: response.requestId, + }); + return false; + } + + pending.resolve(response); + pendingAskUserRequests.delete(response.requestId); + logger.permission.debug("Resolved ask user request: {requestId}", { + requestId: response.requestId, + }); + return true; +} + +export function cancelPermissionRequest(requestId: string): void { + const pending = pendingPermissionRequests.get(requestId); + if (pending) { + pending.reject(new Error("Permission request cancelled")); + pendingPermissionRequests.delete(requestId); + } +} + +export function cancelAskUserRequest(requestId: string): void { + const pending = pendingAskUserRequests.get(requestId); + if (pending) { + pending.reject(new Error("Ask user request cancelled")); + pendingAskUserRequests.delete(requestId); + } +} + +export function getPendingPermissionRequest( + requestId: string, +): PendingPermissionRequest | undefined { + return pendingPermissionRequests.get(requestId); +} + +export function getPendingAskUserRequest( + requestId: string, +): PendingAskUserRequest | undefined { + return pendingAskUserRequests.get(requestId); +} + +export function generateRequestId(): string { + return `perm_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; +} \ No newline at end of file diff --git a/backend/history/grouping.ts b/backend/history/grouping.ts index 3770fee..df6f6d4 100644 --- a/backend/history/grouping.ts +++ b/backend/history/grouping.ts @@ -39,14 +39,12 @@ export function groupConversations( } } - // Convert to ConversationSummary format and sort by start time (newest first) const summaries = uniqueConversations.map((conv) => createConversationSummary(conv), ); - // Sort by start time, newest first summaries.sort( - (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(), + (a, b) => new Date(b.lastTime).getTime() - new Date(a.lastTime).getTime(), ); return summaries; diff --git a/backend/package-lock.json b/backend/package-lock.json index 618361c..91e9305 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -983,7 +983,8 @@ "funding": [ "https://github.com/sponsors/dahlia" ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@logtape/pretty": { "version": "1.0.5", @@ -1366,6 +1367,7 @@ "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.11.0" } @@ -1416,6 +1418,7 @@ "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -1749,6 +1752,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2092,6 +2096,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2519,6 +2524,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.7.tgz", "integrity": "sha512-t4Te6ERzIaC48W3x4hJmBwgNlLhmiEdEE5ViYb02ffw4ignHNHa5IBtPjmbKstmtKa8X6C35iWwK4HaqvrzG9w==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3445,6 +3451,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3514,6 +3521,7 @@ "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -3547,6 +3555,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3578,6 +3587,7 @@ "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3694,6 +3704,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/backend/utils/logger.ts b/backend/utils/logger.ts index 1a570b0..599d879 100644 --- a/backend/utils/logger.ts +++ b/backend/utils/logger.ts @@ -62,20 +62,12 @@ export async function setupLogger(debugMode: boolean): Promise { * Centralized loggers for different categories */ export const logger = { - // CLI and startup logging cli: getLogger(["cli"]), - - // Chat handling and streaming chat: getLogger(["chat"]), - - // History and conversation management history: getLogger(["history"]), - - // API handlers api: getLogger(["api"]), - - // General application logging app: getLogger(["app"]), + permission: getLogger(["permission"]), }; /** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58319ae..b26d9e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,8 +10,11 @@ "dependencies": { "@heroicons/react": "^2.2.0", "dayjs": "^1.11.13", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "react": "^19.1.0", "react-dom": "^19.1.1", + "react-i18next": "^16.5.8", "react-router-dom": "^7.6.2" }, "devDependencies": { @@ -89,7 +92,6 @@ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", @@ -105,16 +107,14 @@ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", - "dev": true, + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -208,6 +208,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -231,6 +232,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2186,8 +2188,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.2", @@ -2226,6 +2227,7 @@ "integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.8.0" } @@ -2236,6 +2238,7 @@ "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2246,6 +2249,7 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2296,6 +2300,7 @@ "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.42.0", "@typescript-eslint/types": "8.42.0", @@ -2646,6 +2651,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2696,7 +2702,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3015,8 +3020,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/enhanced-resolve": { "version": "5.18.3", @@ -3112,6 +3116,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3520,6 +3525,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3548,6 +3562,47 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3670,8 +3725,7 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -3692,6 +3746,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -4060,7 +4115,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4442,7 +4496,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4458,7 +4511,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4502,6 +4554,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4511,6 +4564,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -4518,13 +4572,39 @@ "react": "^19.1.1" } }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-router": { "version": "7.6.2", @@ -4940,6 +5020,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5055,6 +5136,7 @@ "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -5086,8 +5168,9 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5137,12 +5220,22 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz", "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5259,6 +5352,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5352,6 +5446,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 69fce55..b769bf6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,8 +23,11 @@ "dependencies": { "@heroicons/react": "^2.2.0", "dayjs": "^1.11.13", + "i18next": "^25.8.18", + "i18next-browser-languagedetector": "^8.2.1", "react": "^19.1.0", "react-dom": "^19.1.1", + "react-i18next": "^16.5.8", "react-router-dom": "^7.6.2" }, "devDependencies": { diff --git a/frontend/src/components/ChatPage.tsx b/frontend/src/components/ChatPage.tsx index a005e87..02664ed 100644 --- a/frontend/src/components/ChatPage.tsx +++ b/frontend/src/components/ChatPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useCallback, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { ChevronLeftIcon } from "@heroicons/react/24/outline"; +import { useTranslation } from "react-i18next"; import type { ChatRequest, ChatMessage, @@ -19,12 +20,14 @@ import { HistoryButton } from "./chat/HistoryButton"; import { ChatInput } from "./chat/ChatInput"; import { ChatMessages } from "./chat/ChatMessages"; import { HistoryView } from "./HistoryView"; -import { getChatUrl, getProjectsUrl } from "../config/api"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import { getChatUrl, getProjectsUrl, getPermissionUrl, getHistoriesUrl } from "../config/api"; import { KEYBOARD_SHORTCUTS } from "../utils/constants"; import { normalizeWindowsPath } from "../utils/pathUtils"; import type { StreamingContext } from "../hooks/streaming/useMessageProcessor"; export function ChatPage() { + const { t } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -84,6 +87,7 @@ export function ChatPage() { } = useAutoHistoryLoader( getEncodedName() || undefined, sessionId || undefined, + false, ); // Initialize chat state with loaded history @@ -209,6 +213,17 @@ export function ChatPage() { shouldAbort = true; await createAbortHandler(requestId)(); }, + onPermissionRequest: async (data) => { + const patterns: string[] = []; + if (data.toolName === "Bash" && data.toolInput.command) { + patterns.push(`Bash(${data.toolInput.command}:*)`); + } else if (data.toolName === "AskUserQuestion") { + patterns.push("AskUserQuestion"); + } else { + patterns.push(data.toolName); + } + showPermissionRequest(data.toolName, patterns, data.requestId); + }, }; while (true) { @@ -259,6 +274,7 @@ export function ChatPage() { processStreamLine, handlePermissionError, createAbortHandler, + showPermissionRequest, ], ); @@ -266,56 +282,67 @@ export function ChatPage() { abortRequest(currentRequestId, isLoading, resetRequestState); }, [abortRequest, currentRequestId, isLoading, resetRequestState]); - // Permission request handlers + const sendPermissionResponse = useCallback( + async (requestId: string, allow: boolean, rememberEntry?: string) => { + try { + await fetch(getPermissionUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId, + allow, + rememberEntry, + }), + }); + } catch (error) { + console.error("Failed to send permission response:", error); + } + }, + [], + ); + const handlePermissionAllow = useCallback(() => { if (!permissionRequest) return; - // Add all patterns temporarily let updatedAllowedTools = allowedTools; permissionRequest.patterns.forEach((pattern) => { updatedAllowedTools = allowToolTemporary(pattern, updatedAllowedTools); }); + sendPermissionResponse(permissionRequest.toolUseId, true); closePermissionRequest(); - - if (currentSessionId) { - sendMessage("continue", updatedAllowedTools, true); - } }, [ permissionRequest, - currentSessionId, - sendMessage, allowedTools, allowToolTemporary, closePermissionRequest, + sendPermissionResponse, ]); const handlePermissionAllowPermanent = useCallback(() => { if (!permissionRequest) return; - // Add all patterns permanently let updatedAllowedTools = allowedTools; permissionRequest.patterns.forEach((pattern) => { updatedAllowedTools = allowToolPermanent(pattern, updatedAllowedTools); }); + const rememberEntry = permissionRequest.patterns[0]; + sendPermissionResponse(permissionRequest.toolUseId, true, rememberEntry); closePermissionRequest(); - - if (currentSessionId) { - sendMessage("continue", updatedAllowedTools, true); - } }, [ permissionRequest, - currentSessionId, - sendMessage, allowedTools, allowToolPermanent, closePermissionRequest, + sendPermissionResponse, ]); const handlePermissionDeny = useCallback(() => { + if (!permissionRequest) return; + sendPermissionResponse(permissionRequest.toolUseId, false); closePermissionRequest(); - }, [closePermissionRequest]); + }, [permissionRequest, closePermissionRequest, sendPermissionResponse]); // Plan mode request handlers const handlePlanAcceptWithEdits = useCallback(() => { @@ -400,6 +427,39 @@ export function ChatPage() { loadProjects(); }, []); + useEffect(() => { + if ( + sessionId || + isHistoryView || + !getEncodedName() || + projects.length === 0 + ) { + return; + } + + const loadLatestConversation = async () => { + const encodedName = getEncodedName(); + if (!encodedName) return; + + try { + const response = await fetch(getHistoriesUrl(encodedName)); + if (response.ok) { + const data = await response.json(); + const conversations = data.conversations || []; + if (conversations.length > 0) { + const searchParams = new URLSearchParams(); + searchParams.set("sessionId", conversations[0].sessionId); + navigate({ search: searchParams.toString() }, { replace: true }); + } + } + } catch (error) { + console.error("Failed to load latest conversation:", error); + } + }; + + loadLatestConversation(); + }, [projects, sessionId, isHistoryView, getEncodedName, navigate]); + const handleBackToChat = useCallback(() => { navigate({ search: "" }); }, [navigate]); @@ -435,78 +495,76 @@ export function ChatPage() { return (
-
+
{/* Header */} -
-
+
+
{isHistoryView && ( )} {isLoadedConversation && ( )} -
+
{workingDirectory && ( -
+
{sessionId && ( - - Session: {sessionId.substring(0, 8)}... + + {sessionId.substring(0, 8)}... )}
)}
-
+
+ {!isHistoryView && }
diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..7c9a69b --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next'; + +export function LanguageSwitcher() { + const { i18n } = useTranslation(); + + const toggleLanguage = () => { + const newLang = i18n.language === 'zh-CN' ? 'en' : 'zh-CN'; + i18n.changeLanguage(newLang); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/chat/ChatMessages.tsx b/frontend/src/components/chat/ChatMessages.tsx index 119c7de..a9c2ee4 100644 --- a/frontend/src/components/chat/ChatMessages.tsx +++ b/frontend/src/components/chat/ChatMessages.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useCallback } from "react"; import type { AllMessage } from "../../types"; import { isChatMessage, @@ -19,7 +19,6 @@ import { TodoMessageComponent, LoadingComponent, } from "../MessageComponents"; -// import { UI_CONSTANTS } from "../../utils/constants"; // Unused for now interface ChatMessagesProps { messages: AllMessage[]; @@ -29,30 +28,26 @@ interface ChatMessagesProps { export function ChatMessages({ messages, isLoading }: ChatMessagesProps) { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); + const initialLoadDoneRef = useRef(false); - // Auto-scroll to bottom - const scrollToBottom = () => { + const scrollToBottom = useCallback(() => { if (messagesEndRef.current && messagesEndRef.current.scrollIntoView) { messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); } - }; - - // Check if user is near bottom of messages (unused but kept for future use) - // const isNearBottom = () => { - // const container = messagesContainerRef.current; - // if (!container) return true; + }, []); - // const { scrollTop, scrollHeight, clientHeight } = container; - // return ( - // scrollHeight - scrollTop - clientHeight < - // UI_CONSTANTS.NEAR_BOTTOM_THRESHOLD_PX - // ); - // }; + useEffect(() => { + if (messages.length > 0 && !initialLoadDoneRef.current) { + initialLoadDoneRef.current = true; + setTimeout(() => scrollToBottom(), 100); + } + }, [messages.length, scrollToBottom]); - // Auto-scroll when messages change useEffect(() => { - scrollToBottom(); - }, [messages]); + if (isLoading) { + scrollToBottom(); + } + }, [isLoading, scrollToBottom]); const renderMessage = (message: AllMessage, index: number) => { // Use timestamp as key for stable rendering, fallback to index if needed diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 2f0999a..490d24c 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -1,4 +1,3 @@ -// API configuration - uses relative paths with Vite proxy in development export const API_CONFIG = { ENDPOINTS: { CHAT: "/api/chat", @@ -6,6 +5,7 @@ export const API_CONFIG = { PROJECTS: "/api/projects", HISTORIES: "/api/projects", CONVERSATIONS: "/api/projects", + PERMISSION: "/api/permission", }, } as const; @@ -35,10 +35,13 @@ export const getHistoriesUrl = (projectPath: string) => { return `${API_CONFIG.ENDPOINTS.HISTORIES}/${encodedPath}/histories`; }; -// Helper function to get conversation URL export const getConversationUrl = ( encodedProjectName: string, sessionId: string, ) => { return `${API_CONFIG.ENDPOINTS.CONVERSATIONS}/${encodedProjectName}/histories/${sessionId}`; }; + +export const getPermissionUrl = () => { + return API_CONFIG.ENDPOINTS.PERMISSION; +}; diff --git a/frontend/src/hooks/streaming/useMessageProcessor.ts b/frontend/src/hooks/streaming/useMessageProcessor.ts index 62bb470..853081b 100644 --- a/frontend/src/hooks/streaming/useMessageProcessor.ts +++ b/frontend/src/hooks/streaming/useMessageProcessor.ts @@ -1,6 +1,12 @@ import type { AllMessage, ChatMessage } from "../../types"; import { useMessageConverter } from "../useMessageConverter"; +export interface PermissionRequestData { + requestId: string; + toolName: string; + toolInput: Record; +} + export interface StreamingContext { currentAssistantMessage: ChatMessage | null; setCurrentAssistantMessage: (msg: ChatMessage | null) => void; @@ -17,6 +23,7 @@ export interface StreamingContext { toolUseId: string, ) => void; onAbortRequest?: () => void; + onPermissionRequest?: (data: PermissionRequestData) => void; } /** diff --git a/frontend/src/hooks/streaming/useStreamParser.ts b/frontend/src/hooks/streaming/useStreamParser.ts index c7bb2d0..d6e754c 100644 --- a/frontend/src/hooks/streaming/useStreamParser.ts +++ b/frontend/src/hooks/streaming/useStreamParser.ts @@ -11,38 +11,27 @@ import { isResultMessage, isUserMessage, } from "../../utils/messageTypes"; -import type { StreamingContext } from "./useMessageProcessor"; +import type { StreamingContext, PermissionRequestData } from "./useMessageProcessor"; import { UnifiedMessageProcessor, type ProcessingContext, } from "../../utils/UnifiedMessageProcessor"; export function useStreamParser() { - // Create a single unified processor instance const processor = useMemo(() => new UnifiedMessageProcessor(), []); - // Convert StreamingContext to ProcessingContext const adaptContext = useCallback( (context: StreamingContext): ProcessingContext => { return { - // Core message handling addMessage: context.addMessage, updateLastMessage: context.updateLastMessage, - - // Current assistant message state currentAssistantMessage: context.currentAssistantMessage, setCurrentAssistantMessage: context.setCurrentAssistantMessage, - - // Session handling onSessionId: context.onSessionId, hasReceivedInit: context.hasReceivedInit, setHasReceivedInit: context.setHasReceivedInit, - - // Init message handling shouldShowInitMessage: context.shouldShowInitMessage, onInitMessageShown: context.onInitMessageShown, - - // Permission/Error handling onPermissionError: context.onPermissionError, onAbortRequest: context.onAbortRequest, }; @@ -54,7 +43,6 @@ export function useStreamParser() { (claudeData: SDKMessage, context: StreamingContext) => { const processingContext = adaptContext(context); - // Validate message types before processing switch (claudeData.type) { case "system": if (!isSystemMessage(claudeData)) { @@ -85,7 +73,6 @@ export function useStreamParser() { return; } - // Process the message using the unified processor processor.processMessage(claudeData, processingContext, { isStreaming: true, }); @@ -99,9 +86,17 @@ export function useStreamParser() { const data: StreamResponse = JSON.parse(line); if (data.type === "claude_json" && data.data) { - // data.data is already an SDKMessage object, no need to parse const claudeData = data.data as SDKMessage; processClaudeData(claudeData, context); + } else if (data.type === "permission_request") { + if (context.onPermissionRequest && data.permissionRequestId) { + const permissionData: PermissionRequestData = { + requestId: data.permissionRequestId, + toolName: data.toolName || "Unknown", + toolInput: data.toolInput || {}, + }; + context.onPermissionRequest(permissionData); + } } else if (data.type === "error") { const errorMessage: SystemMessage = { type: "error", diff --git a/frontend/src/hooks/useHistoryLoader.ts b/frontend/src/hooks/useHistoryLoader.ts index 4e2a1d3..bf58653 100644 --- a/frontend/src/hooks/useHistoryLoader.ts +++ b/frontend/src/hooks/useHistoryLoader.ts @@ -1,8 +1,9 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import type { AllMessage, TimestampedSDKMessage } from "../types"; import type { ConversationHistory } from "../../../shared/types"; import { getConversationUrl } from "../config/api"; import { useMessageConverter } from "./useMessageConverter"; +import { convertConversationHistory as convertMessages } from "../utils/messageConversion"; interface HistoryLoaderState { messages: AllMessage[]; @@ -13,10 +14,10 @@ interface HistoryLoaderState { interface HistoryLoaderResult extends HistoryLoaderState { loadHistory: (projectPath: string, sessionId: string) => Promise; + appendMessages: (newMessages: AllMessage[]) => void; clearHistory: () => void; } -// Type guard to check if a message is a TimestampedSDKMessage function isTimestampedSDKMessage( message: unknown, ): message is TimestampedSDKMessage { @@ -29,9 +30,6 @@ function isTimestampedSDKMessage( ); } -/** - * Hook for loading and converting conversation history from the backend - */ export function useHistoryLoader(): HistoryLoaderResult { const [state, setState] = useState({ messages: [], @@ -71,7 +69,6 @@ export function useHistoryLoader(): HistoryLoaderResult { const conversationHistory: ConversationHistory = await response.json(); - // Validate the response structure if ( !conversationHistory.messages || !Array.isArray(conversationHistory.messages) @@ -79,7 +76,6 @@ export function useHistoryLoader(): HistoryLoaderResult { throw new Error("Invalid conversation history format"); } - // Convert unknown[] to TimestampedSDKMessage[] with type checking const timestampedMessages: TimestampedSDKMessage[] = []; for (const msg of conversationHistory.messages) { if (isTimestampedSDKMessage(msg)) { @@ -89,7 +85,6 @@ export function useHistoryLoader(): HistoryLoaderResult { } } - // Convert to frontend message format const convertedMessages = convertConversationHistory(timestampedMessages); @@ -115,6 +110,15 @@ export function useHistoryLoader(): HistoryLoaderResult { [convertConversationHistory], ); + const appendMessages = useCallback((newMessages: AllMessage[]) => { + if (newMessages.length === 0) return; + + setState((prev) => ({ + ...prev, + messages: [...prev.messages, ...newMessages], + })); + }, []); + const clearHistory = useCallback(() => { setState({ messages: [], @@ -127,28 +131,71 @@ export function useHistoryLoader(): HistoryLoaderResult { return { ...state, loadHistory, + appendMessages, clearHistory, }; } -/** - * Hook for loading conversation history on mount when sessionId is provided - */ +const POLLING_INTERVAL_MS = 3000; + export function useAutoHistoryLoader( encodedProjectName?: string, sessionId?: string, + enablePolling = false, ): HistoryLoaderResult { const historyLoader = useHistoryLoader(); + const lastMessageCountRef = useRef(0); useEffect(() => { if (encodedProjectName && sessionId) { historyLoader.loadHistory(encodedProjectName, sessionId); } else if (!sessionId) { - // Only clear if there's no sessionId - don't clear while waiting for encodedProjectName historyLoader.clearHistory(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [encodedProjectName, sessionId]); + }, [encodedProjectName, sessionId, historyLoader.loadHistory, historyLoader.clearHistory]); + + useEffect(() => { + if (!enablePolling || !encodedProjectName || !sessionId) { + return; + } + + const pollInterval = setInterval(async () => { + try { + const response = await fetch( + getConversationUrl(encodedProjectName, sessionId), + ); + + if (response.ok) { + const conversationHistory: ConversationHistory = await response.json(); + const messages = conversationHistory.messages || []; + const messageCount = messages.length; + + if (messageCount > lastMessageCountRef.current) { + const startIndex = lastMessageCountRef.current; + const newRawMessages = messages.slice(startIndex); + + const timestampedMessages: TimestampedSDKMessage[] = []; + for (const msg of newRawMessages) { + if (isTimestampedSDKMessage(msg)) { + timestampedMessages.push(msg); + } + } + + if (timestampedMessages.length > 0) { + const convertedMessages = convertMessages(timestampedMessages); + historyLoader.appendMessages(convertedMessages); + } + + lastMessageCountRef.current = messageCount; + } + } + } catch (error) { + console.error("Polling error:", error); + } + }, POLLING_INTERVAL_MS); + + return () => clearInterval(pollInterval); + }, [enablePolling, encodedProjectName, sessionId, historyLoader]); return historyLoader; -} +} \ No newline at end of file diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..35ea121 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,33 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import en from './locales/en.json'; +import zhCN from './locales/zh-CN.json'; + +const resources = { + en: { translation: en }, + 'zh-CN': { translation: zhCN }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + supportedLngs: ['en', 'zh-CN'], + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'i18nextLng', + }, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, + }); + +export default i18n; \ No newline at end of file diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..07bac12 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,135 @@ +{ + "app": { + "title": "Claude Code Web UI", + "demo": "(Demo)" + }, + "nav": { + "backToChat": "Back to chat", + "backToHistory": "Back to history", + "backToProjects": "Back to project selection", + "conversationHistory": "History", + "conversation": "Chat" + }, + "session": { + "label": "Session" + }, + "loading": { + "projects": "Loading projects...", + "conversations": "Loading conversations...", + "conversationHistory": "Loading conversation history...", + "project": "Loading project...", + "demo": "Loading demo..." + }, + "error": { + "loadingConversation": "Error Loading Conversation", + "loadingHistory": "Error Loading History", + "failedResponse": "Error: Failed to get response", + "generic": "Error: {{message}}" + }, + "empty": { + "noConversations": "No Conversations Yet", + "startChatting": "Start chatting to see your conversation history here.", + "startConversation": "Start a conversation with Claude", + "typeToBegin": "Type your message below to begin" + }, + "project": { + "select": "Select a Project", + "recent": "Recent Projects" + }, + "settings": { + "title": "Settings", + "close": "Close settings", + "open": "Open settings", + "general": "General Settings", + "theme": "Theme", + "themeToggle": "Theme toggle. Currently set to {{current}} mode. Click to switch to {{other}} mode.", + "lightMode": "Light Mode", + "darkMode": "Dark Mode", + "clickToSwitch": "Click to switch to {{mode}} mode", + "enterKeyBehavior": "Enter Key Behavior", + "enterKeyToggle": "Enter key behavior toggle. Currently set to {{behavior}}. Click to switch behavior.", + "enterToSend": "Enter to Send", + "enterForNewline": "Enter for Newline", + "enterToSendDesc": "Enter sends message, Shift+Enter for newline", + "enterForNewlineDesc": "Enter adds newline, Shift+Enter sends message", + "enterKeyDesc": "Controls how the Enter key behaves when typing messages in the chat input.", + "lightModeEnabled": "Light mode enabled", + "darkModeEnabled": "Dark mode enabled", + "enterSends": "Enter key sends messages", + "enterNewlines": "Enter key creates newlines" + }, + "chat": { + "user": "User", + "claude": "Claude", + "thinking": "Thinking...", + "processing": "Processing...", + "typeMessage": "Type message...", + "send": "Send", + "plan": "Plan", + "stop": "Stop (ESC)", + "viewHistory": "View conversation history" + }, + "permission": { + "title": "Permission Required", + "proceed": "Do you want to proceed? (Press ESC to deny)", + "chooseHow": "Choose how to proceed (Press ESC to keep planning)", + "yes": "Yes", + "no": "No", + "yesAndDontAsk": "Yes, and don't ask again for {{commands}}", + "yesAndAutoAccept": "Yes, and auto-accept edits", + "yesAndManualApprove": "Yes, and manually approve edits", + "keepPlanning": "No, keep planning", + "wantsToUse": "Claude wants to use the following commands:", + "wantsToUseSingle": "Claude wants to use the {{command}} command.", + "wantsToUseBash": "Claude wants to use bash commands, but the specific commands could not be determined." + }, + "message": { + "model": "Model:", + "session": "Session:", + "tools": "Tools:", + "cwd": "CWD:", + "permissionMode": "Permission Mode:", + "apiKeySource": "API Key Source:", + "duration": "Duration:", + "cost": "Cost:", + "tokens": "Tokens:", + "system": "System", + "result": "Result", + "error": "Error", + "message": "Message", + "readyToCode": "Ready to code?", + "hereIsPlan": "Here is Claude's plan:", + "reasoning": "Claude's Reasoning", + "todoUpdated": "Todo List Updated", + "completed": "Completed", + "inProgress": "In progress", + "pending": "Pending", + "ofCompleted": "{{completed}} of {{total}} completed" + }, + "mode": { + "normal": "normal mode", + "plan": "plan mode", + "acceptEdits": "accept edits", + "clickToCycle": "Current: {{mode}} - Click to cycle (Ctrl+Shift+M)" + }, + "demo": { + "controls": "Demo Controls - Scenario:", + "step": "Step:", + "status": "Status:", + "completed": "Completed", + "paused": "Paused", + "typing": "Typing", + "running": "Running", + "resume": "Resume", + "pause": "Pause", + "reset": "Reset Demo", + "restart": "Restart" + }, + "button": { + "newConversation": "Start New Conversation", + "messages": "messages" + }, + "time": { + "sentAt": "Sent at {{time}}" + } +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..dd25878 --- /dev/null +++ b/frontend/src/i18n/locales/zh-CN.json @@ -0,0 +1,135 @@ +{ + "app": { + "title": "Claude Code Web UI", + "demo": "(演示)" + }, + "nav": { + "backToChat": "返回聊天", + "backToHistory": "返回历史", + "backToProjects": "返回项目选择", + "conversationHistory": "历史", + "conversation": "聊天" + }, + "session": { + "label": "会话" + }, + "loading": { + "projects": "加载项目中...", + "conversations": "加载对话中...", + "conversationHistory": "加载对话历史中...", + "project": "加载项目中...", + "demo": "加载演示中..." + }, + "error": { + "loadingConversation": "加载对话失败", + "loadingHistory": "加载历史失败", + "failedResponse": "错误:获取响应失败", + "generic": "错误:{{message}}" + }, + "empty": { + "noConversations": "暂无对话", + "startChatting": "开始聊天后,对话历史将显示在这里。", + "startConversation": "开始与 Claude 对话", + "typeToBegin": "在下方输入消息开始对话" + }, + "project": { + "select": "选择项目", + "recent": "最近项目" + }, + "settings": { + "title": "设置", + "close": "关闭设置", + "open": "打开设置", + "general": "通用设置", + "theme": "主题", + "themeToggle": "主题切换。当前为{{current}}模式。点击切换到{{other}}模式。", + "lightMode": "浅色模式", + "darkMode": "深色模式", + "clickToSwitch": "点击切换到{{mode}}模式", + "enterKeyBehavior": "回车键行为", + "enterKeyToggle": "回车键行为切换。当前设置为{{behavior}}。点击切换行为。", + "enterToSend": "回车发送", + "enterForNewline": "回车换行", + "enterToSendDesc": "回车发送消息,Shift+回车换行", + "enterForNewlineDesc": "回车换行,Shift+回车发送消息", + "enterKeyDesc": "控制输入消息时回车键的行为。", + "lightModeEnabled": "浅色模式已启用", + "darkModeEnabled": "深色模式已启用", + "enterSends": "回车键发送消息", + "enterNewlines": "回车键换行" + }, + "chat": { + "user": "用户", + "claude": "Claude", + "thinking": "思考中...", + "processing": "处理中...", + "typeMessage": "输入消息...", + "send": "发送", + "plan": "计划", + "stop": "停止 (ESC)", + "viewHistory": "查看对话历史" + }, + "permission": { + "title": "需要权限确认", + "proceed": "是否继续?(按 ESC 拒绝)", + "chooseHow": "选择如何继续(按 ESC 继续规划)", + "yes": "是", + "no": "否", + "yesAndDontAsk": "是,且不再询问{{commands}}", + "yesAndAutoAccept": "是,并自动接受编辑", + "yesAndManualApprove": "是,并手动批准编辑", + "keepPlanning": "否,继续规划", + "wantsToUse": "Claude 想要使用以下命令:", + "wantsToUseSingle": "Claude 想要使用 {{command}} 命令。", + "wantsToUseBash": "Claude 想要使用 bash 命令,但无法确定具体命令。" + }, + "message": { + "model": "模型:", + "session": "会话:", + "tools": "工具:", + "cwd": "工作目录:", + "permissionMode": "权限模式:", + "apiKeySource": "API 密钥来源:", + "duration": "耗时:", + "cost": "费用:", + "tokens": "Token:", + "system": "系统", + "result": "结果", + "error": "错误", + "message": "消息", + "readyToCode": "准备好编码了吗?", + "hereIsPlan": "这是 Claude 的计划:", + "reasoning": "Claude 的推理", + "todoUpdated": "待办列表已更新", + "completed": "已完成", + "inProgress": "进行中", + "pending": "待处理", + "ofCompleted": "已完成 {{completed}}/{{total}}" + }, + "mode": { + "normal": "普通模式", + "plan": "计划模式", + "acceptEdits": "接受编辑", + "clickToCycle": "当前:{{mode}} - 点击切换 (Ctrl+Shift+M)" + }, + "demo": { + "controls": "演示控制 - 场景:", + "step": "步骤:", + "status": "状态:", + "completed": "已完成", + "paused": "已暂停", + "typing": "输入中", + "running": "运行中", + "resume": "继续", + "pause": "暂停", + "reset": "重置演示", + "restart": "重新开始" + }, + "button": { + "newConversation": "开始新对话", + "messages": "条消息" + }, + "time": { + "sentAt": "发送于 {{time}}" + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index eff7ccc..a63c795 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; +import "./i18n"; import App from "./App.tsx"; createRoot(document.getElementById("root")!).render( diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0f4e3e3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "claude-code-webui-fixed", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/shared/types.ts b/shared/types.ts index 51b8879..696b736 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,7 +1,12 @@ export interface StreamResponse { - type: "claude_json" | "error" | "done" | "aborted"; - data?: unknown; // SDKMessage object for claude_json type + type: "claude_json" | "error" | "done" | "aborted" | "permission_request" | "ask_user_question"; + data?: unknown; error?: string; + permissionRequestId?: string; + toolName?: string; + toolInput?: Record; + question?: string; + suggestions?: string[]; } export interface ChatRequest { @@ -44,10 +49,23 @@ export interface HistoryListResponse { // Frontend should cast to TimestampedSDKMessage[] (defined in frontend/src/types.ts) export interface ConversationHistory { sessionId: string; - messages: unknown[]; // TimestampedSDKMessage[] in practice, but avoiding frontend type dependency + messages: unknown[]; metadata: { startTime: string; endTime: string; messageCount: number; }; } + +export interface PermissionResponse { + requestId: string; + allow: boolean; + rememberEntry?: string; + updatedInput?: Record; + message?: string; +} + +export interface AskUserResponse { + requestId: string; + answer: string | string[]; +}