diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md index 345e29e71fb..bdda24f1587 100644 --- a/.claude/commands/create-pr.md +++ b/.claude/commands/create-pr.md @@ -13,11 +13,15 @@ Create a pull request for the current branch. ## Step 2: Analyze Changes +First, determine the base branch to diff against: +- Run `git fetch origin main --quiet` to ensure the remote ref is up to date +- Use `origin/main` (not local `main`) as the base for all diff/log commands — local `main` may be stale + Run in parallel: -- `git log main..HEAD --oneline` — commit history -- `git log main..HEAD --format="%B---"` — full commit messages for context -- `git diff main...HEAD --stat` — file change overview -- `git diff main...HEAD` — full diff +- `git log origin/main..HEAD --oneline` — commit history +- `git log origin/main..HEAD --format="%B---"` — full commit messages for context +- `git diff origin/main...HEAD --stat` — file change overview +- `git diff origin/main...HEAD` — full diff Read the diff carefully to understand what changed and why. diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index 288193a1eea..2d6acb51a9d 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -1,3 +1,6 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { observable } from "@trpc/server/observable"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -11,6 +14,49 @@ import { sessionStore, } from "./utils/session-manager"; +interface CommandEntry { + name: string; + description: string; + argumentHint: string; +} + +function scanCustomCommands(cwd: string): CommandEntry[] { + const dirs = [ + join(cwd, ".claude", "commands"), + join(homedir(), ".claude", "commands"), + ]; + const commands: CommandEntry[] = []; + const seen = new Set(); + + for (const dir of dirs) { + if (!existsSync(dir)) continue; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const name = file.replace(/\.md$/, ""); + if (seen.has(name)) continue; + seen.add(name); + const raw = readFileSync(join(dir, file), "utf-8"); + const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/); + const descMatch = fmMatch?.[1]?.match(/^description:\s*(.+)$/m); + const argMatch = fmMatch?.[1]?.match(/^argument-hint:\s*(.+)$/m); + commands.push({ + name, + description: descMatch?.[1]?.trim() ?? "", + argumentHint: argMatch?.[1]?.trim() ?? "", + }); + } + } catch (err) { + console.warn( + `[ai-chat/scanCustomCommands] Failed to read commands from ${dir}:`, + err, + ); + } + } + + return commands; +} + export const createAiChatRouter = () => { return router({ getConfig: publicProcedure.query(() => ({ @@ -21,6 +67,12 @@ export const createAiChatRouter = () => { null, })), + getSlashCommands: publicProcedure + .input(z.object({ cwd: z.string() })) + .query(({ input }) => { + return { commands: scanCustomCommands(input.cwd) }; + }), + startSession: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index 8757e7d0c1b..2f51b7547cf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -29,8 +29,10 @@ import { } from "./components/FileMentionPopover"; import { ModelPicker } from "./components/ModelPicker"; import { PermissionModePicker } from "./components/PermissionModePicker"; +import { SlashCommandInput } from "./components/SlashCommandInput"; import { MODELS } from "./constants"; import { useClaudeCodeHistory } from "./hooks/useClaudeCodeHistory"; +import type { SlashCommand } from "./hooks/useSlashCommands"; import type { ModelOption, PermissionMode } from "./types"; interface ChatInterfaceProps { @@ -269,6 +271,13 @@ export function ChatInterface({ [stop], ); + const handleSlashCommandSend = useCallback( + (command: SlashCommand) => { + handleSend({ text: `/${command.name}` }); + }, + [handleSend], + ); + return (
{connectionStatus !== "connected" && @@ -316,43 +325,48 @@ export function ChatInterface({ )} - - - - - - - - - - - - - -
- - -
-
-
-
+ + + + + + + + + + + + + + +
+ + +
+
+
+
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandInput/SlashCommandInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandInput/SlashCommandInput.tsx new file mode 100644 index 00000000000..54770f49722 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandInput/SlashCommandInput.tsx @@ -0,0 +1,87 @@ +import { usePromptInputController } from "@superset/ui/ai-elements/prompt-input"; +import { Popover, PopoverAnchor } from "@superset/ui/popover"; +import { useCallback } from "react"; +import { + resolveCommandAction, + type SlashCommand, + useSlashCommands, +} from "../../hooks/useSlashCommands"; +import { SlashCommandMenu } from "../SlashCommandMenu"; + +interface SlashCommandInputProps { + onCommandSend: (command: SlashCommand) => void; + cwd: string; + children: React.ReactNode; +} + +export function SlashCommandInput({ + onCommandSend, + cwd, + children, +}: SlashCommandInputProps) { + const { textInput } = usePromptInputController(); + + const slashCommands = useSlashCommands({ + inputValue: textInput.value, + cwd, + }); + + const executeCommand = useCallback( + (command: SlashCommand) => { + const action = resolveCommandAction(command); + if (action.shouldSend) { + onCommandSend(command); + } + textInput.setInput(action.text); + }, + [onCommandSend, textInput], + ); + + const handleKeyDownCapture = useCallback( + (e: React.KeyboardEvent) => { + if (!slashCommands.isOpen) return; + + switch (e.key) { + case "Escape": + e.preventDefault(); + e.stopPropagation(); + textInput.setInput(""); + break; + case "Enter": + case "Tab": { + e.preventDefault(); + e.stopPropagation(); + const cmd = + slashCommands.filteredCommands[slashCommands.selectedIndex]; + if (cmd) executeCommand(cmd); + break; + } + case "ArrowUp": + e.preventDefault(); + e.stopPropagation(); + slashCommands.navigateUp(); + break; + case "ArrowDown": + e.preventDefault(); + e.stopPropagation(); + slashCommands.navigateDown(); + break; + } + }, + [slashCommands, textInput, executeCommand], + ); + + return ( + + +
{children}
+
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandInput/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandInput/index.ts new file mode 100644 index 00000000000..8b4a667d02b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandInput/index.ts @@ -0,0 +1 @@ +export { SlashCommandInput } from "./SlashCommandInput"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx new file mode 100644 index 00000000000..57c5c307183 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx @@ -0,0 +1,72 @@ +import { PopoverContent } from "@superset/ui/popover"; +import { useEffect, useRef } from "react"; +import type { SlashCommand } from "../../hooks/useSlashCommands"; + +interface SlashCommandMenuProps { + commands: SlashCommand[]; + selectedIndex: number; + onSelect: (command: SlashCommand) => void; + onHover: (index: number) => void; +} + +export function SlashCommandMenu({ + commands, + selectedIndex, + onSelect, + onHover, +}: SlashCommandMenuProps) { + const selectedRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: must scroll when selectedIndex changes + useEffect(() => { + selectedRef.current?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (commands.length === 0) return null; + + return ( + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > +
+ {commands.map((cmd, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandMenu/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandMenu/index.ts new file mode 100644 index 00000000000..f8ef97b2bff --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/SlashCommandMenu/index.ts @@ -0,0 +1 @@ +export { SlashCommandMenu } from "./SlashCommandMenu"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useSlashCommands/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useSlashCommands/index.ts new file mode 100644 index 00000000000..55788e4020a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useSlashCommands/index.ts @@ -0,0 +1,5 @@ +export { + resolveCommandAction, + type SlashCommand, + useSlashCommands, +} from "./useSlashCommands"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts new file mode 100644 index 00000000000..f0c1a077401 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export interface SlashCommand { + name: string; + description: string; + argumentHint: string; +} + +const DEFAULT_COMMANDS: SlashCommand[] = []; + +export function useSlashCommands({ + inputValue, + cwd, +}: { + inputValue: string; + cwd: string; +}) { + const utils = electronTrpc.useUtils(); + + const { data } = electronTrpc.aiChat.getSlashCommands.useQuery( + { cwd }, + { staleTime: 5 * 60 * 1000 }, + ); + + const commands = useMemo(() => { + const fetched = data?.commands; + return fetched && fetched.length > 0 ? fetched : DEFAULT_COMMANDS; + }, [data]); + + const [selectedIndex, setSelectedIndex] = useState(0); + + const isOpen = + inputValue.startsWith("/") && + !inputValue.includes("\n") && + !inputValue.includes(" "); + + const query = isOpen ? inputValue.slice(1).toLowerCase() : ""; + + const filteredCommands = useMemo(() => { + if (!isOpen) return []; + if (query === "") return commands; + return commands.filter((cmd) => cmd.name.startsWith(query)); + }, [commands, isOpen, query]); + + const prevQuery = useRef(query); + useEffect(() => { + if (prevQuery.current !== query) { + setSelectedIndex(0); + prevQuery.current = query; + } + }, [query]); + + const prevIsOpen = useRef(false); + useEffect(() => { + if (isOpen && !prevIsOpen.current) { + void utils.aiChat.getSlashCommands.invalidate(); + } + prevIsOpen.current = isOpen; + }, [isOpen, utils]); + + const navigateUp = useCallback(() => { + setSelectedIndex((prev) => + prev <= 0 ? filteredCommands.length - 1 : prev - 1, + ); + }, [filteredCommands.length]); + + const navigateDown = useCallback(() => { + setSelectedIndex((prev) => + prev >= filteredCommands.length - 1 ? 0 : prev + 1, + ); + }, [filteredCommands.length]); + + return { + isOpen: isOpen && filteredCommands.length > 0, + filteredCommands, + selectedIndex, + setSelectedIndex, + navigateUp, + navigateDown, + }; +} + +export function resolveCommandAction(command: SlashCommand): { + text: string; + shouldSend: boolean; +} { + if (command.argumentHint) { + return { text: `/${command.name} `, shouldSend: false }; + } + return { text: "", shouldSend: true }; +} diff --git a/apps/streams/src/sdk-to-ai-chunks.ts b/apps/streams/src/sdk-to-ai-chunks.ts index 4434481fee2..1ec7d333bf3 100644 --- a/apps/streams/src/sdk-to-ai-chunks.ts +++ b/apps/streams/src/sdk-to-ai-chunks.ts @@ -162,11 +162,13 @@ function convertMessage( case "user": return handleUserMessage(message as SDKUserMessage); + case "assistant": + return handleAssistantMessage(state, message); + case "result": return handleResultMessage(state, message as SDKResultMessage); default: - // Skip system, assistant, status, hook, and other message types return []; } } @@ -399,6 +401,38 @@ function handleUserMessage(message: SDKUserMessage): StreamChunk[] { return chunks; } +// ============================================================================ +// Assistant Message Handler (non-streamed responses, e.g. slash command output) +// ============================================================================ + +function handleAssistantMessage( + state: ConversionState, + message: SDKMessage, +): StreamChunk[] { + const msg = message as { + type: "assistant"; + message?: { content?: Array<{ type: string; text?: string }> }; + }; + const content = msg.message?.content; + if (!content || !Array.isArray(content)) return []; + + const now = Date.now(); + const chunks: StreamChunk[] = []; + + for (const block of content) { + if (block.type === "text" && block.text) { + chunks.push({ + type: "TEXT_MESSAGE_CONTENT", + messageId: state.messageId, + delta: block.text, + timestamp: now, + } satisfies StreamChunk); + } + } + + return chunks; +} + // ============================================================================ // Result Message Handler // ============================================================================ @@ -411,6 +445,12 @@ function handleResultMessage( const chunks: StreamChunk[] = []; if (message.subtype?.startsWith("error")) { + chunks.push({ + type: "TEXT_MESSAGE_CONTENT", + messageId: state.messageId, + delta: `Error: ${message.subtype}`, + timestamp: now, + } satisfies StreamChunk); chunks.push({ type: "RUN_ERROR", runId: state.runId, diff --git a/packages/durable-session/src/react/use-durable-chat.ts b/packages/durable-session/src/react/use-durable-chat.ts index 5b749b1d9ba..f27325dd879 100644 --- a/packages/durable-session/src/react/use-durable-chat.ts +++ b/packages/durable-session/src/react/use-durable-chat.ts @@ -179,11 +179,17 @@ export function useDurableChat< } | null>(null); const key = `${clientOptions.sessionId}:${clientOptions.proxyUrl}`; - // Create or recreate client when key changes or client was disposed + // Create or recreate client when key changes or client was disposed. // The isDisposed check handles React Strict Mode: cleanup disposes the client, // so the next render must create a fresh one with a new AbortController. if (providedClient) { if (!clientRef.current || clientRef.current.client !== providedClient) { + const prev = clientRef.current?.client; + if (prev && !prev.isDisposed) { + // Defer disposal via microtask — dispose() triggers collection events + // which call setState, so it must not run during render or useEffect. + queueMicrotask(() => prev.dispose()); + } clientRef.current = { client: providedClient, key: "provided" }; } } else if ( @@ -191,8 +197,10 @@ export function useDurableChat< clientRef.current.key !== key || clientRef.current.client.isDisposed ) { - // Dispose old client if exists (may already be disposed, which is fine) - clientRef.current?.client.dispose(); + const prev = clientRef.current?.client; + if (prev && !prev.isDisposed) { + queueMicrotask(() => prev.dispose()); + } clientRef.current = { client: new DurableChatClient({ ...clientOptions, @@ -227,10 +235,16 @@ export function useDurableChat< }); } - // Cleanup: unsubscribe and dispose (disposal is idempotent) + // Cleanup: defer disposal via microtask — dispose() triggers collection events + // that call setState. When Radix UI uses flushSync for dropdown selection, + // this cleanup runs during a synchronous render which would cause + // "Cannot update a component while rendering" if dispose runs inline. return () => { if (!providedClient) { - client.dispose(); + const c = client; + queueMicrotask(() => { + if (!c.isDisposed) c.dispose(); + }); } }; }, [client, autoConnect, providedClient]);