From 9e7ccfefaa5eeae7d25021a29d28eed26d33dfe8 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Tue, 24 Feb 2026 12:32:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.12.0=20=E2=80=94=20fix=20streaming?= =?UTF-8?q?=20tool=20UI=20persistence,=20file=20tree=20UX=20improvements,?= =?UTF-8?q?=20unify=20icon=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming tool UI fix (ChatView.tsx): - Add toolUsesRef/toolResultsRef to reliably track tool data across closures - Serialize tool_use/tool_result into JSON content-blocks array when building assistant message after stream ends, matching backend storage format - MessageItem.parseToolBlocks() now renders tool UI from history messages - Clean up refs in finally block and timeout auto-retry path File tree UX (FileTree.tsx, file-tree.tsx): - Remove folder path header from right panel, merge search + refresh into one row - Remove file icon spacer (size-5) so file icons align flush with search box - Change folder icon color from blue to muted-foreground Chat list panel (ChatListPanel.tsx): - Folder header: expanded uses FolderOpenIcon, collapsed uses Folder01Icon - Add tooltip showing full working directory path on folder hover - Double-click folder header opens in system file manager Title bar folder name (chat/[id]/page.tsx): - Add tooltip with full path on project name hover - Click opens folder in Finder/Explorer via Electron IPC or API fallback Open folder support: - electron/main.ts: add shell:open-path IPC handler via shell.openPath - electron/preload.ts: expose electronAPI.shell.openPath to renderer - api/files/open/route.ts: fallback API route using open/explorer/xdg-open Unify icon library — migrate app-level components from Lucide to Hugeicons: - file-tree.tsx: folder, file, chevron, plus icons - tool-actions-group.tsx: chevron expand icon - MessageItem.tsx: copy, check, expand/collapse icons - MessageInput.tsx: stop button (SquareIcon → StopIcon) - FileCard.tsx: file icon - ImageLightbox.tsx: left/right navigation arrows - InstallWizard.tsx: status icons (check, x, minus, loader, circle, copy, download) - Keep Lucide in ui/ and ai-elements/ base layers (shadcn standard) --- electron/main.ts | 7 +- electron/preload.ts | 3 + package-lock.json | 4 +- package.json | 2 +- src/app/api/chat/mode/route.ts | 29 +++ src/app/api/chat/permission/route.ts | 1 + src/app/api/files/open/route.ts | 29 +++ src/app/api/skills/[name]/route.ts | 18 +- src/app/api/skills/route.ts | 5 +- src/app/chat/[id]/page.tsx | 32 ++- src/components/ai-elements/file-tree.tsx | 24 +- .../ai-elements/tool-actions-group.tsx | 5 +- src/components/chat/ChatView.tsx | 98 +++++++- src/components/chat/FileCard.tsx | 5 +- src/components/chat/ImageLightbox.tsx | 7 +- src/components/chat/MessageInput.tsx | 121 ++++------ src/components/chat/MessageItem.tsx | 11 +- src/components/chat/StreamingMessage.tsx | 211 +++++++++++++++++- src/components/layout/ChatListPanel.tsx | 50 +++-- src/components/layout/InstallWizard.tsx | 37 +-- src/components/project/FileTree.tsx | 33 ++- src/components/skills/SkillListItem.tsx | 10 +- src/components/skills/SkillsManager.tsx | 46 ++-- src/hooks/useSSEStream.ts | 7 + src/lib/claude-client.ts | 15 ++ src/lib/conversation-registry.ts | 22 ++ src/types/index.ts | 2 + 27 files changed, 622 insertions(+), 212 deletions(-) create mode 100644 src/app/api/chat/mode/route.ts create mode 100644 src/app/api/files/open/route.ts create mode 100644 src/lib/conversation-registry.ts diff --git a/electron/main.ts b/electron/main.ts index 9122169..33bdfef 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain } from 'electron'; +import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain, shell } from 'electron'; import path from 'path'; import { execFileSync, spawn, ChildProcess } from 'child_process'; import fs from 'fs'; @@ -707,6 +707,11 @@ app.whenReady().then(async () => { // --- End install wizard IPC handlers --- + // Open a folder in the system file manager (Finder / Explorer) + ipcMain.handle('shell:open-path', async (_event: Electron.IpcMainInvokeEvent, folderPath: string) => { + return shell.openPath(folderPath); + }); + try { let port: number; diff --git a/electron/preload.ts b/electron/preload.ts index dfb4adc..31a823e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld('electronAPI', { node: process.versions.node, chrome: process.versions.chrome, }, + shell: { + openPath: (folderPath: string) => ipcRenderer.invoke('shell:open-path', folderPath), + }, install: { checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'), start: (options?: { includeNode?: boolean }) => ipcRenderer.invoke('install:start', options), diff --git a/package-lock.json b/package-lock.json index bfdcabf..59f3dbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/package.json b/package.json index fd7d0a9..f766700 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.11.0", + "version": "0.12.0", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/chat/mode/route.ts b/src/app/api/chat/mode/route.ts new file mode 100644 index 0000000..f880b2f --- /dev/null +++ b/src/app/api/chat/mode/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConversation } from '@/lib/conversation-registry'; +import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const { sessionId, mode } = await request.json(); + + if (!sessionId || !mode) { + return NextResponse.json({ error: 'sessionId and mode are required' }, { status: 400 }); + } + + const conversation = getConversation(sessionId); + if (!conversation) { + return NextResponse.json({ applied: false }); + } + + const permissionMode: PermissionMode = mode === 'code' ? 'acceptEdits' : 'plan'; + await conversation.setPermissionMode(permissionMode); + + return NextResponse.json({ applied: true }); + } catch (error) { + console.error('[mode] Failed to switch mode:', error); + return NextResponse.json({ applied: false, error: String(error) }); + } +} diff --git a/src/app/api/chat/permission/route.ts b/src/app/api/chat/permission/route.ts index c2385c9..5382b4d 100644 --- a/src/app/api/chat/permission/route.ts +++ b/src/app/api/chat/permission/route.ts @@ -23,6 +23,7 @@ export async function POST(request: NextRequest) { result = { behavior: 'allow', updatedPermissions: decision.updatedPermissions as unknown as PermissionUpdate[], + ...(decision.updatedInput ? { updatedInput: decision.updatedInput } : {}), }; } else { result = { diff --git a/src/app/api/files/open/route.ts b/src/app/api/files/open/route.ts new file mode 100644 index 0000000..7c78bd0 --- /dev/null +++ b/src/app/api/files/open/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { exec } from 'child_process'; + +export async function POST(req: NextRequest) { + const { path } = await req.json(); + if (!path || typeof path !== 'string') { + return NextResponse.json({ error: 'Missing path' }, { status: 400 }); + } + + const platform = process.platform; + let cmd: string; + if (platform === 'darwin') { + cmd = `open "${path}"`; + } else if (platform === 'win32') { + cmd = `explorer "${path}"`; + } else { + cmd = `xdg-open "${path}"`; + } + + return new Promise((resolve) => { + exec(cmd, (err) => { + if (err) { + resolve(NextResponse.json({ error: err.message }, { status: 500 })); + } else { + resolve(NextResponse.json({ ok: true })); + } + }); + }); +} diff --git a/src/app/api/skills/[name]/route.ts b/src/app/api/skills/[name]/route.ts index aec503d..2e1faa8 100644 --- a/src/app/api/skills/[name]/route.ts +++ b/src/app/api/skills/[name]/route.ts @@ -8,8 +8,8 @@ function getGlobalCommandsDir(): string { return path.join(os.homedir(), ".claude", "commands"); } -function getProjectCommandsDir(): string { - return path.join(process.cwd(), ".claude", "commands"); +function getProjectCommandsDir(cwd?: string): string { + return path.join(cwd || process.cwd(), ".claude", "commands"); } function getInstalledSkillsDir(): string { @@ -149,7 +149,7 @@ function findInstalledSkillMatches( function findSkillFile( name: string, - options?: { installedSource?: InstalledSource; installedOnly?: boolean } + options?: { installedSource?: InstalledSource; installedOnly?: boolean; cwd?: string } ): | SkillMatch | { conflict: true; sources: InstalledSource[] } @@ -158,7 +158,7 @@ function findSkillFile( if (!options?.installedOnly) { // Check project first, then global, then installed (~/.agents/skills/ and ~/.claude/skills/) - const projectPath = path.join(getProjectCommandsDir(), `${name}.md`); + const projectPath = path.join(getProjectCommandsDir(options?.cwd), `${name}.md`); if (fs.existsSync(projectPath)) { return { filePath: projectPath, source: "project" }; } @@ -211,6 +211,7 @@ export async function GET( const { name } = await params; const url = new URL(_request.url); const sourceParam = url.searchParams.get("source"); + const cwdParam = url.searchParams.get("cwd") || undefined; const installedSource = sourceParam === "agents" || sourceParam === "claude" ? (sourceParam as InstalledSource) @@ -223,8 +224,8 @@ export async function GET( } const found = installedSource - ? findSkillFile(name, { installedSource, installedOnly: true }) - : findSkillFile(name); + ? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam }) + : findSkillFile(name, { cwd: cwdParam }); if (found && "conflict" in found) { return NextResponse.json( { error: "Multiple skills with different content", sources: found.sources }, @@ -327,6 +328,7 @@ export async function DELETE( const { name } = await params; const url = new URL(_request.url); const sourceParam = url.searchParams.get("source"); + const cwdParam = url.searchParams.get("cwd") || undefined; const installedSource = sourceParam === "agents" || sourceParam === "claude" ? (sourceParam as InstalledSource) @@ -339,8 +341,8 @@ export async function DELETE( } const found = installedSource - ? findSkillFile(name, { installedSource, installedOnly: true }) - : findSkillFile(name); + ? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam }) + : findSkillFile(name, { cwd: cwdParam }); if (found && "conflict" in found) { return NextResponse.json( { error: "Multiple skills with different content", sources: found.sources }, diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 8641f1e..21c9b4e 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -283,10 +283,11 @@ export async function GET(request: NextRequest) { export async function POST(request: Request) { try { const body = await request.json(); - const { name, content, scope } = body as { + const { name, content, scope, cwd } = body as { name: string; content: string; scope: "global" | "project"; + cwd?: string; }; if (!name || typeof name !== "string") { @@ -306,7 +307,7 @@ export async function POST(request: Request) { } const dir = - scope === "project" ? getProjectCommandsDir() : getGlobalCommandsDir(); + scope === "project" ? getProjectCommandsDir(cwd) : getGlobalCommandsDir(); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 7bb717a..1e6ae81 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -7,6 +7,7 @@ import { ChatView } from '@/components/chat/ChatView'; import { HugeiconsIcon } from "@hugeicons/react"; import { Loading02Icon, PencilEdit01Icon } from "@hugeicons/core-free-icons"; import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { usePanel } from '@/hooks/usePanel'; interface ChatSessionPageProps { @@ -23,6 +24,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionModel, setSessionModel] = useState(''); const [sessionMode, setSessionMode] = useState(''); const [projectName, setProjectName] = useState(''); + const [sessionWorkingDir, setSessionWorkingDir] = useState(''); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editTitle, setEditTitle] = useState(''); const titleInputRef = useRef(null); @@ -80,6 +82,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const data: { session: ChatSession } = await res.json(); if (data.session.working_directory) { setWorkingDirectory(data.session.working_directory); + setSessionWorkingDir(data.session.working_directory); localStorage.setItem("codepilot:last-working-directory", data.session.working_directory); window.dispatchEvent(new Event('refresh-file-tree')); } @@ -168,7 +171,34 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { > {projectName && ( <> - {projectName} + + + + + +

{sessionWorkingDir || projectName}

+

Click to open in Finder

+
+
/ )} diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index 8516447..b2b7015 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -8,13 +8,14 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; +import { HugeiconsIcon } from "@hugeicons/react"; import { - ChevronRightIcon, - FileIcon, - FolderIcon, + Folder01Icon, FolderOpenIcon, - PlusIcon, -} from "lucide-react"; + File01Icon, + ArrowRight01Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; import { createContext, useCallback, @@ -154,7 +155,8 @@ export const FileTreeFolder = ({ className="shrink-0 rounded p-0.5 hover:bg-muted" onClick={(e) => e.stopPropagation()} > - {isExpanded ? ( - + ) : ( - + )} {name} @@ -246,10 +248,8 @@ export const FileTreeFile = ({ > {children ?? ( <> - {/* Spacer matching folder chevron button (size-4 icon + p-0.5 padding) */} - - {icon ?? } + {icon ?? } {name} {onAdd && ( @@ -259,7 +259,7 @@ export const FileTreeFile = ({ onClick={handleAdd} title="Add to chat" > - + )} diff --git a/src/components/ai-elements/tool-actions-group.tsx b/src/components/ai-elements/tool-actions-group.tsx index 24a8471..6604f5c 100644 --- a/src/components/ai-elements/tool-actions-group.tsx +++ b/src/components/ai-elements/tool-actions-group.tsx @@ -13,8 +13,8 @@ import { Loading02Icon, CheckmarkCircle02Icon, CancelCircleIcon, + ArrowRight01Icon, } from "@hugeicons/core-free-icons"; -import { ChevronRightIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; // --------------------------------------------------------------------------- @@ -229,7 +229,8 @@ export function ToolActionsGroup({ onClick={handleToggle} className="flex w-full items-center gap-2 py-1 text-xs rounded-sm hover:bg-muted/30 transition-colors" > - { window.dispatchEvent(new CustomEvent('session-updated')); }).catch(() => { /* silent */ }); + + // Try to switch SDK permission mode in real-time (works if streaming) + fetch('/api/chat/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, mode: newMode }), + }).catch(() => { /* silent — will apply on next message */ }); } }, [sessionId]); const abortControllerRef = useRef(null); // Ref to keep accumulated streaming content in sync regardless of React batching const accumulatedRef = useRef(''); + // Refs to track tool data reliably across closures (state reads can be stale) + const toolUsesRef = useRef([]); + const toolResultsRef = useRef([]); // Ref for sendMessage to allow self-referencing in timeout auto-retry without circular deps const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise>(undefined); @@ -128,10 +138,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal abortControllerRef.current = null; }, []); - const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => { + const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record) => { if (!pendingPermission) return; - const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = { + const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[]; updatedInput?: Record } | { behavior: 'deny'; message?: string } } = { permissionRequestId: pendingPermission.permissionRequestId, decision: decision === 'deny' ? { behavior: 'deny', message: 'User denied permission' } @@ -140,6 +150,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal ...(decision === 'allow_session' && pendingPermission.suggestions ? { updatedPermissions: pendingPermission.suggestions } : {}), + ...(updatedInput ? { updatedInput } : {}), }, }; @@ -156,10 +167,18 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Best effort - the stream will handle timeout } - // Clear permission state after a short delay so user sees the feedback + // Clear permission state after a short delay so user sees the feedback. + // Only clear if no new permission request has arrived in the meantime. + const answeredId = pendingPermission.permissionRequestId; setTimeout(() => { - setPendingPermission(null); - setPermissionResolved(null); + setPendingPermission((current) => { + if (current?.permissionRequestId === answeredId) { + // Same request — safe to clear both + setPermissionResolved(null); + return null; + } + return current; // A new request arrived — keep it + }); }, 1000); }, [pendingPermission, setPendingApprovalSessionId]); @@ -229,12 +248,18 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal setStreamingToolOutput(''); setToolUses((prev) => { if (prev.some((t) => t.id === tool.id)) return prev; - return [...prev, tool]; + const next = [...prev, tool]; + toolUsesRef.current = next; + return next; }); }, onToolResult: (res) => { setStreamingToolOutput(''); - setToolResults((prev) => [...prev, res]); + setToolResults((prev) => { + const next = [...prev, res]; + toolResultsRef.current = next; + return next; + }); // Refresh file tree after each tool completes — file writes, // deletions, and other FS operations are done via tools. window.dispatchEvent(new Event('refresh-file-tree')); @@ -265,6 +290,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal onToolTimeout: (toolName, elapsedSeconds) => { toolTimeoutRef.current = { toolName, elapsedSeconds }; }, + onModeChanged: (sdkMode) => { + // Map SDK permissionMode to UI mode + const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; + handleModeChange(uiMode); + }, onError: (acc) => { accumulated = acc; accumulatedRef.current = acc; @@ -274,13 +304,37 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal accumulated = result.accumulated; + // Build the assistant message content. + // When tools were used, serialize as a JSON content-blocks array + // (same format the backend API route stores), so MessageItem's + // parseToolBlocks() can render tool UI from history. + const finalToolUses = toolUsesRef.current; + const finalToolResults = toolResultsRef.current; + const hasTools = finalToolUses.length > 0 || finalToolResults.length > 0; + + let messageContent = accumulated.trim(); + if (hasTools && messageContent) { + const contentBlocks: Array> = []; + if (accumulated.trim()) { + contentBlocks.push({ type: 'text', text: accumulated.trim() }); + } + for (const tu of finalToolUses) { + contentBlocks.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input }); + const tr = finalToolResults.find(r => r.tool_use_id === tu.id); + if (tr) { + contentBlocks.push({ type: 'tool_result', tool_use_id: tr.tool_use_id, content: tr.content }); + } + } + messageContent = JSON.stringify(contentBlocks); + } + // Add the assistant message to the list - if (accumulated.trim()) { + if (messageContent) { const assistantMessage: Message = { id: 'temp-assistant-' + Date.now(), session_id: sessionId, role: 'assistant', - content: accumulated.trim(), + content: messageContent, created_at: new Date().toISOString(), token_usage: result.tokenUsage ? JSON.stringify(result.tokenUsage) : null, }; @@ -308,6 +362,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal setStreamingSessionId(''); setStreamingContent(''); accumulatedRef.current = ''; + toolUsesRef.current = []; + toolResultsRef.current = []; setToolUses([]); setToolResults([]); setStreamingToolOutput(''); @@ -354,6 +410,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal setStreamingSessionId(''); setStreamingContent(''); accumulatedRef.current = ''; + toolUsesRef.current = []; + toolResultsRef.current = []; setToolUses([]); setToolResults([]); setStreamingToolOutput(''); @@ -446,6 +504,17 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal } }, [sessionId, sendMessage]); + // Determine if "Start Execution" button should show + const showStartExecution = mode === 'plan' && !isStreaming && messages.length > 0 && messages[messages.length - 1]?.role === 'assistant'; + + const handleStartExecution = useCallback(() => { + handleModeChange('code'); + // Small delay to let mode change propagate before sending + setTimeout(() => { + sendMessage('请按照上面的计划开始执行。'); + }, 100); + }, [handleModeChange, sendMessage]); + return (
+ {showStartExecution && ( +
+ +
+ )} - +
{name}
{formatFileSize(size)}
diff --git a/src/components/chat/ImageLightbox.tsx b/src/components/chat/ImageLightbox.tsx index 50153ec..c756e6d 100644 --- a/src/components/chat/ImageLightbox.tsx +++ b/src/components/chat/ImageLightbox.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useCallback } from 'react'; -import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; import { Dialog, DialogContent, @@ -65,14 +66,14 @@ export function ImageLightbox({ images, initialIndex, open, onOpenChange }: Imag onClick={goToPrev} className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition" > - +
{currentIndex + 1} / {images.length} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index c8a6bb9..7084236 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -4,8 +4,6 @@ import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type Form import { HugeiconsIcon } from "@hugeicons/react"; import { AtIcon, - Wrench01Icon, - ClipboardIcon, HelpCircleIcon, ArrowDown01Icon, ArrowUp02Icon, @@ -20,6 +18,7 @@ import { SearchList01Icon, BrainIcon, GlobalIcon, + StopIcon, } from "@hugeicons/core-free-icons"; import { cn } from '@/lib/utils'; import { @@ -31,7 +30,6 @@ import { PromptInputSubmit, usePromptInputAttachments, } from '@/components/ai-elements/prompt-input'; -import { SquareIcon } from 'lucide-react'; import type { ChatStatus } from 'ai'; import type { FileAttachment } from '@/types'; import { nanoid } from 'nanoid'; @@ -70,6 +68,7 @@ interface PopoverItem { builtIn?: boolean; immediate?: boolean; installedSource?: "agents" | "claude"; + source?: "global" | "project" | "plugin" | "installed"; icon?: typeof CommandLineIcon; } @@ -106,14 +105,11 @@ const BUILT_IN_COMMANDS: PopoverItem[] = [ interface ModeOption { value: string; label: string; - icon: typeof Wrench01Icon; - description: string; } const MODE_OPTIONS: ModeOption[] = [ - { value: 'code', label: 'Code', icon: Wrench01Icon, description: 'Read, write files & run commands' }, - { value: 'plan', label: 'Plan', icon: ClipboardIcon, description: 'Analyze & plan without executing' }, - { value: 'ask', label: 'Ask', icon: HelpCircleIcon, description: 'Answer questions only' }, + { value: 'code', label: 'Code' }, + { value: 'plan', label: 'Plan' }, ]; // Default Claude model options — used as fallback when API is unavailable @@ -174,7 +170,7 @@ function FileAwareSubmitButton({ className="rounded-full" > {isStreaming ? ( - + ) : ( )} @@ -338,7 +334,6 @@ export function MessageInput({ const textareaRef = useRef(null); const popoverRef = useRef(null); const searchInputRef = useRef(null); - const modeMenuRef = useRef(null); const modelMenuRef = useRef(null); const [popoverMode, setPopoverMode] = useState(null); @@ -346,7 +341,6 @@ export function MessageInput({ const [popoverFilter, setPopoverFilter] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [triggerPos, setTriggerPos] = useState(null); - const [modeMenuOpen, setModeMenuOpen] = useState(false); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [badge, setBadge] = useState(null); @@ -410,17 +404,19 @@ export function MessageInput({ const fetchSkills = useCallback(async () => { let apiSkills: PopoverItem[] = []; try { - const res = await fetch('/api/skills'); + const cwdParam = workingDirectory ? `?cwd=${encodeURIComponent(workingDirectory)}` : ''; + const res = await fetch(`/api/skills${cwdParam}`); if (res.ok) { const data = await res.json(); const skills = data.skills || []; apiSkills = skills - .map((s: { name: string; description: string; source?: string; installedSource?: "agents" | "claude" }) => ({ + .map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed"; installedSource?: "agents" | "claude" }) => ({ label: s.name, value: `/${s.name}`, description: s.description || "", builtIn: false, installedSource: s.installedSource, + source: s.source, })); } } catch { @@ -432,7 +428,7 @@ export function MessageInput({ const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label)); return [...BUILT_IN_COMMANDS, ...uniqueSkills]; - }, []); + }, [workingDirectory]); // Close popover const closePopover = useCallback(() => { @@ -570,11 +566,12 @@ export function MessageInput({ if (badge.isSkill) { // Fetch skill content from API try { - const sourceParam = badge.installedSource - ? `?source=${badge.installedSource}` - : ""; + const detailParams = new URLSearchParams(); + if (badge.installedSource) detailParams.set("source", badge.installedSource); + if (workingDirectory) detailParams.set("cwd", workingDirectory); + const qs = detailParams.toString(); const res = await fetch( - `/api/skills/${encodeURIComponent(badge.label)}${sourceParam}` + `/api/skills/${encodeURIComponent(badge.label)}${qs ? `?${qs}` : ""}` ); if (res.ok) { const data = await res.json(); @@ -699,18 +696,6 @@ export function MessageInput({ return () => document.removeEventListener('mousedown', handler); }, [popoverMode, closePopover]); - // Click outside to close mode menu - useEffect(() => { - if (!modeMenuOpen) return; - const handler = (e: MouseEvent) => { - if (modeMenuRef.current && !modeMenuRef.current.contains(e.target as Node)) { - setModeMenuOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [modeMenuOpen]); - // Click outside to close model menu useEffect(() => { if (!modelMenuOpen) return; @@ -729,7 +714,6 @@ export function MessageInput({ const currentModelValue = modelName || 'sonnet'; const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0]; - const currentMode = MODE_OPTIONS.find((m) => m.value === mode) || MODE_OPTIONS[0]; // Map isStreaming to ChatStatus for PromptInputSubmit const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready'; @@ -741,7 +725,8 @@ export function MessageInput({ {/* Popover */} {popoverMode && filteredItems.length > 0 && (() => { const builtInItems = filteredItems.filter(item => item.builtIn); - const skillItems = filteredItems.filter(item => !item.builtIn); + const projectItems = filteredItems.filter(item => !item.builtIn && item.source === 'project'); + const skillItems = filteredItems.filter(item => !item.builtIn && item.source !== 'project'); let globalIdx = 0; const renderItem = (item: PopoverItem, idx: number) => ( @@ -759,6 +744,8 @@ export function MessageInput({ ) : item.builtIn && item.icon ? ( + ) : !item.builtIn && item.source === 'project' ? ( + ) : !item.builtIn ? ( ) : ( @@ -843,6 +830,17 @@ export function MessageInput({ })} )} + {projectItems.length > 0 && ( + <> +
+ Project Commands +
+ {projectItems.map((item) => { + const idx = globalIdx++; + return renderItem(item, idx); + })} + + )} {skillItems.length > 0 && ( <>
@@ -906,46 +904,25 @@ export function MessageInput({ {/* Attach file button */} - {/* Mode selector */} -
- setModeMenuOpen((prev) => !prev)} - > - {currentMode.label} - - - - {/* Mode dropdown */} - {modeMenuOpen && ( -
-
- {MODE_OPTIONS.map((opt) => { - const isActive = opt.value === mode; - return ( - - ); - })} -
-
- )} + {/* Mode capsule toggle */} +
+ {MODE_OPTIONS.map((opt) => { + const isActive = opt.value === mode; + return ( + + ); + })}
{/* Model selector */} diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 2fac247..991b258 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -8,7 +8,8 @@ import { MessageResponse, } from '@/components/ai-elements/message'; import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group'; -import { CopyIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Copy01Icon, Tick01Icon, ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons"; import { FileAttachmentDisplay } from './FileAttachmentDisplay'; interface MessageItemProps { @@ -175,9 +176,9 @@ function CopyButton({ text }: { text: string }) { title="Copy" > {copied ? ( - + ) : ( - + )} ); @@ -287,12 +288,12 @@ export function MessageItem({ message }: MessageItemProps) { > {isExpanded ? ( <> - + 收起 ) : ( <> - + 展开 )} diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index 2062482..b64128f 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -40,7 +40,7 @@ interface StreamingMessageProps { streamingToolOutput?: string; statusText?: string; pendingPermission?: PermissionRequestEvent | null; - onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void; + onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record) => void; permissionResolved?: 'allow' | 'deny' | null; onForceStop?: () => void; } @@ -67,6 +67,185 @@ function ElapsedTimer() { ); } +function AskUserQuestionUI({ + toolInput, + onSubmit, +}: { + toolInput: Record; + onSubmit: (decision: 'allow', updatedInput: Record) => void; +}) { + const questions = (toolInput.questions || []) as Array<{ + question: string; + options: Array<{ label: string; description?: string }>; + multiSelect: boolean; + header?: string; + }>; + + const [selections, setSelections] = useState>>({}); + const [otherTexts, setOtherTexts] = useState>({}); + const [useOther, setUseOther] = useState>({}); + + const toggleOption = (qIdx: string, label: string, multi: boolean) => { + setSelections((prev) => { + const current = new Set(prev[qIdx] || []); + if (multi) { + current.has(label) ? current.delete(label) : current.add(label); + } else { + current.clear(); + current.add(label); + } + return { ...prev, [qIdx]: current }; + }); + // Deselect "Other" when picking a regular option + setUseOther((prev) => ({ ...prev, [qIdx]: false })); + }; + + const toggleOther = (qIdx: string, multi: boolean) => { + if (!multi) { + setSelections((prev) => ({ ...prev, [qIdx]: new Set() })); + } + setUseOther((prev) => ({ ...prev, [qIdx]: !prev[qIdx] })); + }; + + const handleSubmit = () => { + const answers: Record = {}; + questions.forEach((q, i) => { + const qIdx = String(i); + const selected = Array.from(selections[qIdx] || []); + if (useOther[qIdx] && otherTexts[qIdx]?.trim()) { + selected.push(otherTexts[qIdx].trim()); + } + answers[q.question] = selected.join(', '); + }); + onSubmit('allow', { questions: toolInput.questions, answers }); + }; + + const hasAnswer = questions.some((_, i) => { + const qIdx = String(i); + return (selections[qIdx]?.size || 0) > 0 || (useOther[qIdx] && otherTexts[qIdx]?.trim()); + }); + + return ( +
+ {questions.map((q, i) => { + const qIdx = String(i); + const selected = selections[qIdx] || new Set(); + return ( +
+ {q.header && ( + + {q.header} + + )} +

{q.question}

+
+ {q.options.map((opt) => { + const isSelected = selected.has(opt.label); + return ( + + ); + })} + {/* Other option */} + +
+ {useOther[qIdx] && ( + setOtherTexts((prev) => ({ ...prev, [qIdx]: e.target.value }))} + className="w-full rounded-md border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none" + autoFocus + /> + )} +
+ ); + })} + +
+ ); +} + +function ExitPlanModeUI({ + toolInput, + onApprove, + onDeny, +}: { + toolInput: Record; + onApprove: () => void; + onDeny: () => void; +}) { + const allowedPrompts = (toolInput.allowedPrompts || []) as Array<{ + tool: string; + prompt: string; + }>; + + return ( +
+
+ + Plan complete — ready to execute +
+ {allowedPrompts.length > 0 && ( +
+

Requested permissions:

+
    + {allowedPrompts.map((p, i) => ( +
  • + {p.tool} + {p.prompt} +
  • + ))} +
+
+ )} +
+ + +
+
+ ); +} + function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string; onForceStop?: () => void }) { const displayText = statusText || 'Thinking'; @@ -185,8 +364,34 @@ export function StreamingMessage({ /> )} - {/* Permission approval confirmation */} - {(pendingPermission || permissionResolved) && ( + {/* Permission approval — AskUserQuestion gets a dedicated UI */} + {pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && ( + } + onSubmit={(decision, updatedInput) => onPermissionResponse?.(decision, updatedInput)} + /> + )} + {pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && ( +

Answer submitted

+ )} + + {/* Permission approval — ExitPlanMode gets a dedicated UI */} + {pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && ( + } + onApprove={() => onPermissionResponse?.('allow')} + onDeny={() => onPermissionResponse?.('deny')} + /> + )} + {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && ( +

Plan approved — executing

+ )} + {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'deny' && ( +

Plan rejected

+ )} + + {/* Permission approval — generic confirmation for other tools */} + {(pendingPermission || permissionResolved) && pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && ( {/* Folder header */} -
toggleProject(group.workingDirectory)} - onMouseEnter={() => - setHoveredFolder(group.workingDirectory) - } - onMouseLeave={() => setHoveredFolder(null)} - > + + +
toggleProject(group.workingDirectory)} + onDoubleClick={(e) => { + e.stopPropagation(); + if (group.workingDirectory) { + const w = window as unknown as { electronAPI?: { shell?: { openPath: (p: string) => void } } }; + if (w.electronAPI?.shell?.openPath) { + w.electronAPI.shell.openPath(group.workingDirectory); + } else { + fetch('/api/files/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: group.workingDirectory }), + }).catch(() => {}); + } + } + }} + onMouseEnter={() => + setHoveredFolder(group.workingDirectory) + } + onMouseLeave={() => setHoveredFolder(null)} + > @@ -452,6 +468,12 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { )}
+
+ +

{group.workingDirectory || 'No Project'}

+ {group.workingDirectory &&

Double-click to open in Finder

} +
+
{/* Session items */} {!isCollapsed && ( @@ -465,7 +487,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const needsApproval = pendingApprovalSessionId === session.id; const mode = session.mode || "code"; - const badgeCfg = MODE_BADGE_CONFIG[mode]; + const badgeCfg = MODE_BADGE_CONFIG[mode as keyof typeof MODE_BADGE_CONFIG] || MODE_BADGE_CONFIG.code; return (
; + return ; case "running": - return ; + return ; case "failed": - return ; + return ; case "skipped": - return ; + return ; default: - return ; + return ; } } @@ -290,7 +291,7 @@ export function InstallWizard({ {/* Phase: checking */} {phase === "checking" && steps.length === 0 && (
- + Checking environment...
)} @@ -323,7 +324,7 @@ export function InstallWizard({ {/* Phase: already-installed */} {phase === "already-installed" && (
- +

Already installed @@ -338,7 +339,7 @@ export function InstallWizard({ {/* Phase: success */} {phase === "success" && (

- +

Installation complete @@ -372,7 +373,7 @@ export function InstallWizard({ size="sm" onClick={handleCopyLogs} > - + {copied ? "Copied" : "Copy Logs"} )} @@ -380,7 +381,7 @@ export function InstallWizard({ {/* Confirm phase: single "Install" button */} {phase === "confirm" && ( )} diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 2b427d5..c7bac17 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -149,26 +149,9 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree return (

- {/* Header */} -
-

- {workingDirectory || 'No directory selected'} -

- -
- - {/* Search */} -
-
+ {/* Search + Refresh */} +
+
+
{/* Tree */} diff --git a/src/components/skills/SkillListItem.tsx b/src/components/skills/SkillListItem.tsx index 8746964..99e806f 100644 --- a/src/components/skills/SkillListItem.tsx +++ b/src/components/skills/SkillListItem.tsx @@ -1,7 +1,7 @@ "use client"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ZapIcon, Delete02Icon, GlobeIcon, FolderOpenIcon, Plug01Icon, Download04Icon } from "@hugeicons/core-free-icons"; +import { ZapIcon, Delete02Icon, GlobeIcon, Plug01Icon, Download04Icon } from "@hugeicons/core-free-icons"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -76,19 +76,15 @@ export function SkillListItem({ ? "border-green-500/40 text-green-600 dark:text-green-400" : skill.source === "installed" ? "border-orange-500/40 text-orange-600 dark:text-orange-400" - : skill.source === "plugin" - ? "border-indigo-500/40 text-indigo-600 dark:text-indigo-400" - : "border-blue-500/40 text-blue-600 dark:text-blue-400" + : "border-indigo-500/40 text-indigo-600 dark:text-indigo-400" )} > {skill.source === "global" ? ( ) : skill.source === "installed" ? ( - ) : skill.source === "plugin" ? ( - ) : ( - + )} {skill.source === "installed" && skill.installedSource ? `installed:${skill.installedSource}` diff --git a/src/components/skills/SkillsManager.tsx b/src/components/skills/SkillsManager.tsx index becb0b2..471bdea 100644 --- a/src/components/skills/SkillsManager.tsx +++ b/src/components/skills/SkillsManager.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { usePanel } from "@/hooks/usePanel"; import { HugeiconsIcon } from "@hugeicons/react"; import { PlusSignIcon, Search01Icon, ZapIcon, Loading02Icon } from "@hugeicons/core-free-icons"; @@ -12,6 +13,7 @@ import { CreateSkillDialog } from "./CreateSkillDialog"; import type { SkillItem } from "./SkillListItem"; export function SkillsManager() { + const { workingDirectory } = usePanel(); const [skills, setSkills] = useState([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); @@ -20,17 +22,18 @@ export function SkillsManager() { const fetchSkills = useCallback(async () => { try { - const res = await fetch("/api/skills"); + const cwdParam = workingDirectory ? `?cwd=${encodeURIComponent(workingDirectory)}` : ''; + const res = await fetch(`/api/skills${cwdParam}`); if (res.ok) { const data = await res.json(); - setSkills(data.skills || []); + setSkills((data.skills || []).filter((s: SkillItem) => s.source !== "project")); } } catch { // ignore } finally { setLoading(false); } - }, []); + }, [workingDirectory]); useEffect(() => { fetchSkills(); @@ -41,7 +44,7 @@ export function SkillsManager() { const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, content, scope }), + body: JSON.stringify({ name, content, scope, cwd: workingDirectory || undefined }), }); if (!res.ok) { const data = await res.json(); @@ -51,16 +54,20 @@ export function SkillsManager() { setSkills((prev) => [...prev, data.skill]); setSelected(data.skill); }, - [] + [workingDirectory] ); const buildSkillUrl = useCallback((skill: SkillItem) => { - const base = `/api/skills/${encodeURIComponent(skill.name)}`; + const params = new URLSearchParams(); if (skill.source === "installed" && skill.installedSource) { - return `${base}?source=${skill.installedSource}`; + params.set("source", skill.installedSource); } - return base; - }, []); + if (workingDirectory) { + params.set("cwd", workingDirectory); + } + const qs = params.toString(); + return `/api/skills/${encodeURIComponent(skill.name)}${qs ? `?${qs}` : ""}`; + }, [workingDirectory]); const handleSave = useCallback( async (skill: SkillItem, content: string) => { @@ -123,7 +130,6 @@ export function SkillsManager() { ); const globalSkills = filtered.filter((s) => s.source === "global"); - const projectSkills = filtered.filter((s) => s.source === "project"); const installedSkills = filtered.filter((s) => s.source === "installed"); const pluginSkills = filtered.filter((s) => s.source === "plugin"); @@ -166,26 +172,6 @@ export function SkillsManager() {
- {projectSkills.length > 0 && ( -
- - Project - - {projectSkills.map((skill) => ( - setSelected(skill)} - onDelete={handleDelete} - /> - ))} -
- )} {globalSkills.length > 0 && (
diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index 9b16f45..417967e 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -22,6 +22,7 @@ export interface SSECallbacks { onResult: (usage: TokenUsage | null) => void; onPermissionRequest: (data: PermissionRequestEvent) => void; onToolTimeout: (toolName: string, elapsedSeconds: number) => void; + onModeChanged: (mode: string) => void; onError: (accumulated: string) => void; } @@ -129,6 +130,11 @@ function handleSSEEvent( return accumulated; } + case 'mode_changed': { + callbacks.onModeChanged(event.data); + return accumulated; + } + case 'error': { const next = accumulated + '\n\n**Error:** ' + event.data; callbacks.onError(next); @@ -213,6 +219,7 @@ export function useSSEStream() { onResult: (u) => callbacksRef.current?.onResult(u), onPermissionRequest: (d) => callbacksRef.current?.onPermissionRequest(d), onToolTimeout: (n, s) => callbacksRef.current?.onToolTimeout(n, s), + onModeChanged: (m) => callbacksRef.current?.onModeChanged(m), onError: (a) => callbacksRef.current?.onError(a), }; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index b9dddab..366dab1 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -18,6 +18,7 @@ import type { import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment } from '@/types'; import { isImageFile } from '@/types'; import { registerPendingPermission } from './permission-registry'; +import { registerConversation, unregisterConversation } from './conversation-registry'; import { getSetting, getActiveProvider } from './db'; import { findClaudeBinary, findGitBash, getExpandedPath } from './platform'; import os from 'os'; @@ -226,6 +227,7 @@ function getUploadedFilePaths(files: FileAttachment[], workDir: string): string[ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { const { prompt, + sessionId, sdkSessionId, model, systemPrompt, @@ -536,6 +538,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { + if (!(globalThis as Record)[globalKey]) { + (globalThis as Record)[globalKey] = new Map(); + } + return (globalThis as Record)[globalKey] as Map; +} + +export function registerConversation(sessionId: string, conversation: Query): void { + getMap().set(sessionId, conversation); +} + +export function unregisterConversation(sessionId: string): void { + getMap().delete(sessionId); +} + +export function getConversation(sessionId: string): Query | undefined { + return getMap().get(sessionId); +} diff --git a/src/types/index.ts b/src/types/index.ts index 06e26f1..48fea69 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -311,6 +311,7 @@ export type SSEEventType = | 'result' // final result with usage stats | 'error' // error occurred | 'permission_request' // permission approval needed + | 'mode_changed' // SDK permission mode changed (e.g. plan → code) | 'done'; // stream complete export interface SSEEvent { @@ -345,6 +346,7 @@ export interface PermissionResponseRequest { decision: { behavior: 'allow'; updatedPermissions?: PermissionSuggestion[]; + updatedInput?: Record; } | { behavior: 'deny'; message?: string;