Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,6 @@ test-conflict-repo/
# Claude Code session lock (runtime artifact)
.claude/scheduled_tasks.lock
temp/

# Local-only plans (not tracked)
plans/local/
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@superset/desktop",
"productName": "Superset",
"description": "The last developer tool you'll ever need",
"version": "1.5.10",
"version": "1.6.2",
"main": "./dist/main/index.js",
"resources": "src/resources",
"repository": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { chatServiceTrpc } from "@superset/chat/client";
import {
PromptInput,
PromptInputAttachment,
Expand Down Expand Up @@ -115,6 +116,34 @@ export function ChatInputFooter({
setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug));
}, []);

const trpcUtils = chatServiceTrpc.useUtils();
const searchFiles = useCallback(
async (query: string) => {
const results = await trpcUtils.workspace.searchFiles.fetch({
rootPath: cwd,
query,
includeHidden: false,
limit: 20,
});
return results.map((r) => ({
id: r.id,
name: r.name,
relativePath: r.relativePath,
}));
},
[trpcUtils, cwd],
);
const previewSlashCommand = useCallback(
async (text: string) => {
const result = await trpcUtils.workspace.previewSlashCommand.fetch({
cwd,
text,
});
return result ?? null;
},
[trpcUtils, cwd],
);

const handleSend = useCallback(
(message: PromptInputMessage) => {
if (linkedIssues.length === 0) return onSend(message);
Expand Down Expand Up @@ -182,6 +211,8 @@ export function ChatInputFooter({
/>
<TiptapPromptEditor
cwd={cwd}
searchFiles={searchFiles}
previewSlashCommand={previewSlashCommand}
slashCommands={slashCommands}
availableModels={availableModels}
placeholder="Ask to make changes, @mention files, run /commands"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { chatServiceTrpc } from "@superset/chat/client";
import { usePromptInputController } from "@superset/ui/ai-elements/prompt-input";
import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover";
import type { Editor } from "@tiptap/core";
import { useLayoutEffect, useMemo, useRef } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useDebouncedValue } from "renderer/hooks/useDebouncedValue";
import {
normalizeSlashPreviewInput,
parseSlashInput,
resolveSlashCommandDefinition,
} from "../ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model";

export type SlashPreviewResult = {
commandName?: string;
prompt?: string;
} | null;

export type PreviewSlashCommandFn = (
text: string,
) => Promise<SlashPreviewResult>;

interface SlashCommandPreviewPopoverProps {
cwd: string;
previewSlashCommand: PreviewSlashCommandFn;
slashCommands: Array<{
name: string;
aliases: string[];
Expand All @@ -24,6 +33,7 @@ interface SlashCommandPreviewPopoverProps {

export function SlashCommandPreviewPopover({
cwd,
previewSlashCommand,
slashCommands,
editor,
isFocused,
Expand Down Expand Up @@ -59,15 +69,21 @@ export function SlashCommandPreviewPopover({
const parsedInput = useMemo(() => parseSlashInput(inputValue), [inputValue]);
const debouncedSlashPreviewInput = useDebouncedValue(slashPreviewInput, 120);

const { data: slashPreview } =
chatServiceTrpc.workspace.previewSlashCommand.useQuery(
{ cwd, text: debouncedSlashPreviewInput },
{
enabled: debouncedSlashPreviewInput.length > 1 && !!cwd,
staleTime: 250,
placeholderData: (previous) => previous,
},
);
const [slashPreview, setSlashPreview] = useState<SlashPreviewResult>(null);
useEffect(() => {
if (debouncedSlashPreviewInput.length <= 1 || !cwd) return;
let cancelled = false;
previewSlashCommand(debouncedSlashPreviewInput)
.then((result) => {
if (!cancelled) setSlashPreview(result);
})
.catch(() => {
// Empty preview on error — popover degrades gracefully.
});
return () => {
cancelled = true;
};
}, [cwd, debouncedSlashPreviewInput, previewSlashCommand]);

const commandDefinition = useMemo(() => {
if (!parsedInput?.commandName) return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { chatServiceTrpc } from "@superset/chat/client";
import {
usePromptInputAttachments,
usePromptInputController,
Expand Down Expand Up @@ -41,10 +40,14 @@ import { SlashCommandMenu } from "../SlashCommandMenu";
import { FileMentionNode } from "./FileMentionNode";
import { parseTextToEditorContent } from "./parseTextToEditorContent";
import { SlashCommandNode } from "./SlashCommandNode";
import { SlashCommandPreviewPopover } from "./SlashCommandPreviewPopover";
import {
type PreviewSlashCommandFn,
SlashCommandPreviewPopover,
} from "./SlashCommandPreviewPopover";
import { serializeEditorToText } from "./serializeEditorToText";

type FileResult = { id: string; name: string; relativePath: string };
type SearchFilesFn = (query: string) => Promise<FileResult[]>;

type SlashMenuState = {
commands: SlashCommand[];
Expand All @@ -61,6 +64,8 @@ type MentionState = {

export interface TiptapPromptEditorProps {
cwd: string;
searchFiles: SearchFilesFn;
previewSlashCommand?: PreviewSlashCommandFn;
slashCommands: SlashCommand[];
availableModels?: ModelOption[];
placeholder?: string;
Expand All @@ -76,6 +81,8 @@ function getDirectoryPath(relativePath: string): string {

export function TiptapPromptEditor({
cwd,
searchFiles,
previewSlashCommand,
slashCommands,
availableModels,
placeholder = "Ask to make changes, @mention files, run /commands",
Expand Down Expand Up @@ -139,28 +146,25 @@ export function TiptapPromptEditor({
mentionState?.query ?? "",
120,
);
const { data: fileResults } = chatServiceTrpc.workspace.searchFiles.useQuery(
{
rootPath: cwd,
query: debouncedMentionQuery,
includeHidden: false,
limit: 20,
},
{
enabled:
!!mentionState &&
!!cwd &&
debouncedMentionQuery.length > 0 &&
(mentionState?.query?.length ?? 0) > 0,
staleTime: 1000,
placeholderData: (prev) => prev ?? [],
},
);
const isMentionVisible =
mentionState !== null && (mentionState?.query?.length ?? 0) > 0;
const [fileResults, setFileResults] = useState<FileResult[]>([]);
useEffect(() => {
if (!isMentionVisible || !cwd || debouncedMentionQuery.length === 0) return;
let cancelled = false;
searchFiles(debouncedMentionQuery)
.then((results) => {
if (!cancelled) setFileResults(results);
})
.catch(() => {
// Empty results on error — mention popup degrades gracefully.
});
return () => {
cancelled = true;
};
}, [debouncedMentionQuery, cwd, isMentionVisible, searchFiles]);

const mentionFiles: FileResult[] =
mentionState && (mentionState.query?.length ?? 0) > 0
? (fileResults ?? [])
: [];
const mentionFiles: FileResult[] = isMentionVisible ? fileResults : [];
const mentionFilesRef = useRef(mentionFiles);
mentionFilesRef.current = mentionFiles;

Expand Down Expand Up @@ -634,10 +638,13 @@ export function TiptapPromptEditor({

return (
<>
{/* Slash command params popover — anchored to the chip node */}
{editor && (
{/* Slash command params popover — anchored to the chip node.
Only rendered when the parent provides a previewSlashCommand
function; v2 ChatPane uses its own SlashCommandPreview instead. */}
{editor && previewSlashCommand && (
<SlashCommandPreviewPopover
cwd={cwd}
previewSlashCommand={previewSlashCommand}
slashCommands={slashCommands}
editor={editor}
isFocused={chipHovered || chipNodeSelected}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { chatServiceTrpc } from "@superset/chat/client";
import type { AppRouter } from "@superset/host-service";
import {
PromptInputAttachment,
type PromptInputMessage,
Expand All @@ -7,6 +8,7 @@ import {
} from "@superset/ui/ai-elements/prompt-input";
import { workspaceTrpc } from "@superset/workspace-client";
import { useQuery } from "@tanstack/react-query";
import type { inferRouterOutputs } from "@trpc/server";
import type { ChatStatus } from "ai";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -125,7 +127,6 @@ function ChatUploadFooter({
return (
<ChatInputFooter
{...footerProps}
sessionId={sessionId}
workspaceId={workspaceId}
submitDisabled={
Boolean(footerProps.submitDisabled) || (sessionId ? isUploading : false)
Expand Down Expand Up @@ -297,31 +298,40 @@ export function ChatPaneInterface({
[organizationId, sessionId, workspaceId],
);

// Memoize the select mapper so React Query can preserve the result's
// identity across polls — without this, every render produces a new
// mapper, every poll produces a new array, and every consumer of
// `slashCommands` rerenders even when nothing has changed.
const selectSlashCommands = useCallback(
(
commands: NonNullable<
inferRouterOutputs<AppRouter>["chat"]["getSlashCommands"]
>,
) =>
commands.map((command) => ({
...command,
kind:
command.kind === "builtin"
? ("builtin" as const)
: ("custom" as const),
source:
command.kind === "builtin"
? ("builtin" as const)
: ("project" as const),
})),
[],
);

const { data: slashCommands = [] } =
workspaceTrpc.chat.getSlashCommands.useQuery(
{ sessionId: sessionId ?? "", workspaceId },
{
enabled: Boolean(sessionId),
select: (commands) =>
commands.map((command) => ({
...command,
kind:
command.kind === "builtin"
? ("builtin" as const)
: ("custom" as const),
source:
command.kind === "builtin"
? ("builtin" as const)
: ("project" as const),
})),
},
{ workspaceId },
{ select: selectSlashCommands },
);

const chat = useChatDisplay({
sessionId,
workspaceId,
enabled: Boolean(sessionId),
fps: 60,
});
const {
commands,
Expand Down Expand Up @@ -368,38 +378,20 @@ export function ChatPaneInterface({

const sendMessageToSession = useCallback(
async (targetSessionId: string, input: ChatSendMessageInput) => {
const queryInput = {
// Optimistic state for this path lives in `pendingUserTurn` (set by
// the caller in handleSend), NOT in the snapshot cache. Writing to
// the cache here was racing with the 4fps snapshot polls — a poll
// could resolve mid-mutation with the harness's pre-message state
// and clobber the optimistic write, making the user message vanish
// briefly. The pendingUserTurn local state is merged in via
// getVisibleMessagesWithPendingUserTurn so it survives stale polls.
await sendMessageMutation.mutateAsync({
sessionId: targetSessionId,
workspaceId,
};
const optimisticMessage = toOptimisticUserMessage(input);
if (optimisticMessage) {
workspaceTrpcUtils.chat.listMessages.setData(
queryInput,
(existingMessages = []) => [...existingMessages, optimisticMessage],
);
}

try {
await sendMessageMutation.mutateAsync({
sessionId: targetSessionId,
workspaceId,
...input,
});
} catch (error) {
if (optimisticMessage) {
workspaceTrpcUtils.chat.listMessages.setData(
queryInput,
(existingMessages = []) =>
existingMessages.filter(
(message) => message.id !== optimisticMessage.id,
),
);
}
throw error;
}
...input,
});
},
[workspaceTrpcUtils.chat.listMessages, sendMessageMutation, workspaceId],
[sendMessageMutation, workspaceId],
);

const canAbort = Boolean(isRunning);
Expand Down Expand Up @@ -641,7 +633,25 @@ export function ChatPaneInterface({
if (sessionId && targetSessionId === sessionId) {
await commands.sendMessage(sendInput);
} else {
await sendMessageToSession(targetSessionId, sendInput);
// New-session path: the existing-session path's optimistic
// state lives inside useChatDisplay, but we don't have a
// session subscribed there yet. Hold the user message in
// pendingUserTurn so getVisibleMessagesWithPendingUserTurn
// keeps it visible across stale snapshot polls until the
// harness's response includes it.
const optimisticMessage = toOptimisticUserMessage(sendInput);
if (optimisticMessage) {
setPendingUserTurn({
kind: "append",
message: optimisticMessage,
});
}
try {
await sendMessageToSession(targetSessionId, sendInput);
} catch (error) {
setPendingUserTurn(null);
throw error;
}
}
if (content) {
onUserMessageSubmitted?.(content);
Expand Down
Loading
Loading