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:
1 parent
8ee8085930
commit
9e7ccfefaa
27 files changed
+622
-212
No files matched your search
+6
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user