feat: v0.12.0 — fix streaming tool UI persistence, file tree UX improvements, unify icon library

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)
This commit is contained in:
7418 committed 2026-02-24 12:32:03 +08:00
1 parent 8ee8085930
commit 9e7ccfefaa
27 files changed
+622 -212

No files matched your search

+6 -1
View File
@@ -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;
+3
View File
@@ -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),
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.11.0",
"version": "0.12.0",
"private": true,
"author": {
"name": "op7418",
+29
View File
@@ -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) });
}
}
+1
View File
@@ -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 = {
+29
View File
@@ -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<NextResponse>((resolve) => {
exec(cmd, (err) => {
if (err) {
resolve(NextResponse.json({ error: err.message }, { status: 500 }));
} else {
resolve(NextResponse.json({ ok: true }));
}
});
});
}
+10 -8
View File
@@ -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 },
+3 -2
View File
@@ -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 });
+31 -1
View File
@@ -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<string>('');
const [sessionMode, setSessionMode] = useState<string>('');
const [projectName, setProjectName] = useState<string>('');
const [sessionWorkingDir, setSessionWorkingDir] = useState<string>('');
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState('');
const titleInputRef = useRef<HTMLInputElement>(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 && (
<>
<span className="text-xs text-muted-foreground shrink-0">{projectName}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-xs text-muted-foreground shrink-0 hover:text-foreground transition-colors cursor-pointer"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
onClick={() => {
if (sessionWorkingDir) {
const w = window as unknown as { electronAPI?: { shell?: { openPath: (p: string) => void } } };
if (w.electronAPI?.shell?.openPath) {
w.electronAPI.shell.openPath(sessionWorkingDir);
} else {
fetch('/api/files/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: sessionWorkingDir }),
}).catch(() => {});
}
}
}}
>
{projectName}
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs break-all">{sessionWorkingDir || projectName}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">Click to open in Finder</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground shrink-0">/</span>
</>
)}
+12 -12
View File
@@ -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()}
>
<ChevronRightIcon
<HugeiconsIcon
icon={ArrowRight01Icon}
className={cn(
"size-4 text-muted-foreground transition-transform",
isExpanded && "rotate-90"
@@ -164,9 +166,9 @@ export const FileTreeFolder = ({
</CollapsibleTrigger>
<FileTreeIcon>
{isExpanded ? (
<FolderOpenIcon className="size-4 text-blue-500" />
<HugeiconsIcon icon={FolderOpenIcon} className="size-4 text-muted-foreground" />
) : (
<FolderIcon className="size-4 text-blue-500" />
<HugeiconsIcon icon={Folder01Icon} className="size-4 text-muted-foreground" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
@@ -246,10 +248,8 @@ export const FileTreeFile = ({
>
{children ?? (
<>
{/* Spacer matching folder chevron button (size-4 icon + p-0.5 padding) */}
<span className="size-5 shrink-0" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
{icon ?? <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
{onAdd && (
@@ -259,7 +259,7 @@ export const FileTreeFile = ({
onClick={handleAdd}
title="Add to chat"
>
<PlusIcon className="size-3 text-muted-foreground" />
<HugeiconsIcon icon={PlusSignIcon} className="size-3 text-muted-foreground" />
</button>
)}
</>
@@ -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"
>
<ChevronRightIcon
<HugeiconsIcon
icon={ArrowRight01Icon}
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
expanded && "rotate-90"
+89 -9
View File
@@ -55,12 +55,22 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}).then(() => {
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<AbortController | null>(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<ToolUseInfo[]>([]);
const toolResultsRef = useRef<ToolResultInfo[]>([]);
// Ref for sendMessage to allow self-referencing in timeout auto-retry without circular deps
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(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<string, unknown>) => {
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<string, unknown> } | { 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<Record<string, unknown>> = [];
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 (
<div className="flex h-full min-h-0 flex-col">
<MessageList
@@ -464,6 +533,17 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
loadingMore={loadingMore}
onLoadMore={loadEarlierMessages}
/>
{showStartExecution && (
<div className="flex justify-center py-3 px-4">
<button
onClick={handleStartExecution}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
开始执行
</button>
</div>
)}
<MessageInput
onSend={sendMessage}
onCommand={handleCommand}
+3 -2
View File
@@ -1,6 +1,7 @@
'use client';
import { FileIcon } from 'lucide-react';
import { HugeiconsIcon } from "@hugeicons/react";
import { File01Icon } from "@hugeicons/core-free-icons";
import { formatFileSize } from '@/types';
interface FileCardProps {
@@ -11,7 +12,7 @@ interface FileCardProps {
export function FileCard({ name, size }: FileCardProps) {
return (
<div className="rounded-lg border bg-muted/50 p-3 flex items-center gap-3">
<FileIcon className="h-5 w-5 text-muted-foreground shrink-0" />
<HugeiconsIcon icon={File01Icon} className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{name}</div>
<div className="text-xs text-muted-foreground">{formatFileSize(size)}</div>
+4 -3
View File
@@ -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"
>
<ChevronLeftIcon className="h-6 w-6" />
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-6 w-6" />
</button>
<button
type="button"
onClick={goToNext}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
>
<ChevronRightIcon className="h-6 w-6" />
<HugeiconsIcon icon={ArrowRight01Icon} className="h-6 w-6" />
</button>
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-white/70 text-sm">
{currentIndex + 1} / {images.length}
+49 -72
View File
@@ -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 ? (
<SquareIcon className="size-4" />
<HugeiconsIcon icon={StopIcon} className="size-4" />
) : (
<HugeiconsIcon icon={ArrowUp02Icon} className="h-4 w-4" strokeWidth={2} />
)}
@@ -338,7 +334,6 @@ export function MessageInput({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const modeMenuRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const [popoverMode, setPopoverMode] = useState<PopoverMode>(null);
@@ -346,7 +341,6 @@ export function MessageInput({
const [popoverFilter, setPopoverFilter] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [triggerPos, setTriggerPos] = useState<number | null>(null);
const [modeMenuOpen, setModeMenuOpen] = useState(false);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [badge, setBadge] = useState<CommandBadge | null>(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({
<HugeiconsIcon icon={AtIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : item.builtIn && item.icon ? (
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : !item.builtIn && item.source === 'project' ? (
<HugeiconsIcon icon={FileEditIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : !item.builtIn ? (
<HugeiconsIcon icon={GlobalIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
@@ -843,6 +830,17 @@ export function MessageInput({
})}
</>
)}
{projectItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Project Commands
</div>
{projectItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</>
)}
{skillItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
@@ -906,46 +904,25 @@ export function MessageInput({
{/* Attach file button */}
<AttachFileButton />
{/* Mode selector */}
<div className="relative" ref={modeMenuRef}>
<PromptInputButton
onClick={() => setModeMenuOpen((prev) => !prev)}
>
<span className="text-xs">{currentMode.label}</span>
<HugeiconsIcon icon={ArrowDown01Icon} className={cn("h-2.5 w-2.5 transition-transform duration-200", modeMenuOpen && "rotate-180")} />
</PromptInputButton>
{/* Mode dropdown */}
{modeMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="py-1">
{MODE_OPTIONS.map((opt) => {
const isActive = opt.value === mode;
return (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModeChange?.(opt.value);
setModeMenuOpen(false);
}}
>
<HugeiconsIcon icon={opt.icon} className="h-4 w-4 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="font-medium text-xs">{opt.label}</span>
<span className="text-[10px] text-muted-foreground truncate">
{opt.description}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Mode capsule toggle */}
<div className="flex items-center rounded-full border border-border/60 overflow-hidden h-7">
{MODE_OPTIONS.map((opt) => {
const isActive = opt.value === mode;
return (
<button
key={opt.value}
className={cn(
"px-2.5 py-1 text-xs font-medium transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => onModeChange?.(opt.value)}
>
{opt.label}
</button>
);
})}
</div>
{/* Model selector */}
+6 -5
View File
@@ -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 ? (
<CheckIcon className="h-3 w-3 text-green-500" />
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3 text-green-500" />
) : (
<CopyIcon className="h-3 w-3" />
<HugeiconsIcon icon={Copy01Icon} className="h-3 w-3" />
)}
</button>
);
@@ -287,12 +288,12 @@ export function MessageItem({ message }: MessageItemProps) {
>
{isExpanded ? (
<>
<ChevronUpIcon className="h-3 w-3" />
<HugeiconsIcon icon={ArrowUp01Icon} className="h-3 w-3" />
<span>收起</span>
</>
) : (
<>
<ChevronDownIcon className="h-3 w-3" />
<HugeiconsIcon icon={ArrowDown01Icon} className="h-3 w-3" />
<span>展开</span>
</>
)}
+208 -3
View File
@@ -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<string, unknown>) => void;
permissionResolved?: 'allow' | 'deny' | null;
onForceStop?: () => void;
}
@@ -67,6 +67,185 @@ function ElapsedTimer() {
);
}
function AskUserQuestionUI({
toolInput,
onSubmit,
}: {
toolInput: Record<string, unknown>;
onSubmit: (decision: 'allow', updatedInput: Record<string, unknown>) => void;
}) {
const questions = (toolInput.questions || []) as Array<{
question: string;
options: Array<{ label: string; description?: string }>;
multiSelect: boolean;
header?: string;
}>;
const [selections, setSelections] = useState<Record<string, Set<string>>>({});
const [otherTexts, setOtherTexts] = useState<Record<string, string>>({});
const [useOther, setUseOther] = useState<Record<string, boolean>>({});
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<string, string> = {};
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 (
<div className="space-y-4 py-2">
{questions.map((q, i) => {
const qIdx = String(i);
const selected = selections[qIdx] || new Set<string>();
return (
<div key={qIdx} className="space-y-2">
{q.header && (
<span className="inline-block rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{q.header}
</span>
)}
<p className="text-sm font-medium">{q.question}</p>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => {
const isSelected = selected.has(opt.label);
return (
<button
key={opt.label}
onClick={() => toggleOption(qIdx, opt.label, q.multiSelect)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}`}
title={opt.description}
>
{q.multiSelect && (
<span className="mr-1.5">{isSelected ? '☑' : '☐'}</span>
)}
{opt.label}
</button>
);
})}
{/* Other option */}
<button
onClick={() => toggleOther(qIdx, q.multiSelect)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
useOther[qIdx]
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}`}
>
Other
</button>
</div>
{useOther[qIdx] && (
<input
type="text"
placeholder="Type your answer..."
value={otherTexts[qIdx] || ''}
onChange={(e) => 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
/>
)}
</div>
);
})}
<button
onClick={handleSubmit}
disabled={!hasAnswer}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Submit
</button>
</div>
);
}
function ExitPlanModeUI({
toolInput,
onApprove,
onDeny,
}: {
toolInput: Record<string, unknown>;
onApprove: () => void;
onDeny: () => void;
}) {
const allowedPrompts = (toolInput.allowedPrompts || []) as Array<{
tool: string;
prompt: string;
}>;
return (
<div className="space-y-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span className="text-sm font-medium">Plan complete — ready to execute</span>
</div>
{allowedPrompts.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Requested permissions:</p>
<ul className="space-y-0.5">
{allowedPrompts.map((p, i) => (
<li key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{p.tool}</span>
<span>{p.prompt}</span>
</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<button
onClick={onDeny}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
>
Reject
</button>
<button
onClick={onApprove}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Approve & Execute
</button>
</div>
</div>
);
}
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 && (
<AskUserQuestionUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
onSubmit={(decision, updatedInput) => onPermissionResponse?.(decision, updatedInput)}
/>
)}
{pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Answer submitted</p>
)}
{/* Permission approval — ExitPlanMode gets a dedicated UI */}
{pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
<ExitPlanModeUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
onApprove={() => onPermissionResponse?.('allow')}
onDeny={() => onPermissionResponse?.('deny')}
/>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Plan approved — executing</p>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'deny' && (
<p className="py-1 text-xs text-red-600 dark:text-red-400">Plan rejected</p>
)}
{/* Permission approval — generic confirmation for other tools */}
{(pendingPermission || permissionResolved) && pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && (
<Confirmation
approval={getApproval()}
state={getConfirmationState()}
+36 -14
View File
@@ -111,7 +111,6 @@ function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] {
const MODE_BADGE_CONFIG = {
code: { label: "Code", className: "bg-blue-500/10 text-blue-500" },
plan: { label: "Plan", className: "bg-sky-500/10 text-sky-500" },
ask: { label: "Ask", className: "bg-green-500/10 text-green-500" },
} as const;
export function ChatListPanel({ open, width }: ChatListPanelProps) {
@@ -396,23 +395,40 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
return (
<div key={group.workingDirectory || "__no_project"} className="mt-1 first:mt-0">
{/* Folder header */}
<div
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 cursor-pointer select-none transition-colors",
"hover:bg-accent/50"
)}
onClick={() => toggleProject(group.workingDirectory)}
onMouseEnter={() =>
setHoveredFolder(group.workingDirectory)
}
onMouseLeave={() => setHoveredFolder(null)}
>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 cursor-pointer select-none transition-colors",
"hover:bg-accent/50"
)}
onClick={() => 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)}
>
<HugeiconsIcon
icon={isCollapsed ? ArrowRight01Icon : ArrowDown01Icon}
className="h-3 w-3 shrink-0 text-muted-foreground"
/>
<HugeiconsIcon
icon={Folder01Icon}
icon={isCollapsed ? Folder01Icon : FolderOpenIcon}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<span className="flex-1 truncate text-[12px] font-medium text-sidebar-foreground">
@@ -452,6 +468,12 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
</Tooltip>
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs break-all">{group.workingDirectory || 'No Project'}</p>
{group.workingDirectory && <p className="text-[10px] text-muted-foreground mt-0.5">Double-click to open in Finder</p>}
</TooltipContent>
</Tooltip>
{/* 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 (
<div
+19 -18
View File
@@ -11,15 +11,16 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { HugeiconsIcon } from "@hugeicons/react";
import {
CheckIcon,
XIcon,
MinusIcon,
LoaderIcon,
CircleIcon,
CopyIcon,
DownloadIcon,
} from "lucide-react";
Tick01Icon,
Cancel01Icon,
MinusSignIcon,
Loading02Icon,
RecordIcon,
Copy01Icon,
Download04Icon,
} from "@hugeicons/core-free-icons";
interface InstallProgress {
status: "idle" | "running" | "success" | "failed" | "cancelled";
@@ -75,15 +76,15 @@ function getInstallAPI() {
function StepIcon({ status }: { status: string }) {
switch (status) {
case "success":
return <CheckIcon className="size-4 text-emerald-500" />;
return <HugeiconsIcon icon={Tick01Icon} className="size-4 text-emerald-500" />;
case "running":
return <LoaderIcon className="size-4 text-blue-500 animate-spin" />;
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-blue-500 animate-spin" />;
case "failed":
return <XIcon className="size-4 text-red-500" />;
return <HugeiconsIcon icon={Cancel01Icon} className="size-4 text-red-500" />;
case "skipped":
return <MinusIcon className="size-4 text-muted-foreground" />;
return <HugeiconsIcon icon={MinusSignIcon} className="size-4 text-muted-foreground" />;
default:
return <CircleIcon className="size-3.5 text-muted-foreground/40" />;
return <HugeiconsIcon icon={RecordIcon} className="size-3.5 text-muted-foreground/40" />;
}
}
@@ -290,7 +291,7 @@ export function InstallWizard({
{/* Phase: checking */}
{phase === "checking" && steps.length === 0 && (
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
<HugeiconsIcon icon={Loading02Icon} className="size-4 animate-spin" />
<span>Checking environment...</span>
</div>
)}
@@ -323,7 +324,7 @@ export function InstallWizard({
{/* Phase: already-installed */}
{phase === "already-installed" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
<HugeiconsIcon icon={Tick01Icon} className="size-5 text-emerald-500 shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
Already installed
@@ -338,7 +339,7 @@ export function InstallWizard({
{/* Phase: success */}
{phase === "success" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
<HugeiconsIcon icon={Tick01Icon} className="size-5 text-emerald-500 shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
Installation complete
@@ -372,7 +373,7 @@ export function InstallWizard({
size="sm"
onClick={handleCopyLogs}
>
<CopyIcon />
<HugeiconsIcon icon={Copy01Icon} />
{copied ? "Copied" : "Copy Logs"}
</Button>
)}
@@ -380,7 +381,7 @@ export function InstallWizard({
{/* Confirm phase: single "Install" button */}
{phase === "confirm" && (
<Button size="sm" onClick={handleConfirmInstall}>
<DownloadIcon />
<HugeiconsIcon icon={Download04Icon} />
Install
</Button>
)}
+13 -20
View File
@@ -149,26 +149,9 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2 shrink-0">
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={workingDirectory}>
{workingDirectory || 'No directory selected'}
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={fetchTree}
disabled={loading}
className="h-6 w-6 shrink-0"
>
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
<span className="sr-only">Refresh</span>
</Button>
</div>
{/* Search */}
<div className="px-4 pb-2 shrink-0">
<div className="relative">
{/* Search + Refresh */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0">
<div className="relative flex-1 min-w-0">
<HugeiconsIcon icon={Search01Icon} className="absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
placeholder="Filter files..."
@@ -177,6 +160,16 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree
className="h-7 pl-7 text-xs"
/>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={fetchTree}
disabled={loading}
className="h-7 w-7 shrink-0"
>
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
<span className="sr-only">Refresh</span>
</Button>
</div>
{/* Tree */}
+3 -7
View File
@@ -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" ? (
<HugeiconsIcon icon={GlobeIcon} className="h-2.5 w-2.5 mr-0.5" />
) : skill.source === "installed" ? (
<HugeiconsIcon icon={Download04Icon} className="h-2.5 w-2.5 mr-0.5" />
) : skill.source === "plugin" ? (
<HugeiconsIcon icon={Plug01Icon} className="h-2.5 w-2.5 mr-0.5" />
) : (
<HugeiconsIcon icon={FolderOpenIcon} className="h-2.5 w-2.5 mr-0.5" />
<HugeiconsIcon icon={Plug01Icon} className="h-2.5 w-2.5 mr-0.5" />
)}
{skill.source === "installed" && skill.installedSource
? `installed:${skill.installedSource}`
+16 -30
View File
@@ -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<SkillItem[]>([]);
const [selected, setSelected] = useState<SkillItem | null>(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() {
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-1">
{projectSkills.length > 0 && (
<div className="mb-1">
<span className="px-3 py-1 text-[10px] font-medium uppercase text-muted-foreground">
Project
</span>
{projectSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.installedSource ?? "default"}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source &&
selected?.installedSource === skill.installedSource
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
/>
))}
</div>
)}
{globalSkills.length > 0 && (
<div className="mb-1">
<span className="px-3 py-1 text-[10px] font-medium uppercase text-muted-foreground">
+7
View File
@@ -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),
};
+15
View File
@@ -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<string> {
const {
prompt,
sessionId,
sdkSessionId,
model,
systemPrompt,
@@ -536,6 +538,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
options: queryOptions,
});
registerConversation(sessionId, conversation);
let lastAssistantText = '';
let tokenUsage: TokenUsage | null = null;
@@ -623,6 +627,15 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
tools: sysMsg.tools,
}),
}));
} else if (sysMsg.subtype === 'status') {
// SDK sends status messages when permission mode changes (e.g. ExitPlanMode)
const statusMsg = sysMsg as SDKSystemMessage & { permissionMode?: string };
if (statusMsg.permissionMode) {
controller.enqueue(formatSSE({
type: 'mode_changed',
data: statusMsg.permissionMode,
}));
}
}
}
break;
@@ -705,6 +718,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
controller.enqueue(formatSSE({ type: 'error', data: errorMessage }));
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
} finally {
unregisterConversation(sessionId);
}
},
+22
View File
@@ -0,0 +1,22 @@
import type { Query } from '@anthropic-ai/claude-agent-sdk';
const globalKey = '__activeConversations__' as const;
function getMap(): Map<string, Query> {
if (!(globalThis as Record<string, unknown>)[globalKey]) {
(globalThis as Record<string, unknown>)[globalKey] = new Map<string, Query>();
}
return (globalThis as Record<string, unknown>)[globalKey] as Map<string, Query>;
}
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);
}
+2
View File
@@ -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<string, unknown>;
} | {
behavior: 'deny';
message?: string;