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
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 @@ -110,6 +111,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 @@ -177,6 +206,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,3 +1,4 @@
import type { AppRouter } from "@superset/host-service";
import {
PromptInputAttachment,
type PromptInputMessage,
Expand All @@ -6,6 +7,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 @@ -118,7 +120,6 @@ function ChatUploadFooter({
return (
<ChatInputFooter
{...footerProps}
sessionId={sessionId}
workspaceId={workspaceId}
submitDisabled={sessionId ? isUploading : false}
renderAttachment={renderAttachment}
Expand Down Expand Up @@ -260,31 +261,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 @@ -331,38 +341,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({
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: sendMessageToSession now assumes callers manage optimistic user-turn state, but other callers (like auto-launch) still invoke it without setting pendingUserTurn, causing inconsistent message visibility behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx, line 351:

<comment>`sendMessageToSession` now assumes callers manage optimistic user-turn state, but other callers (like auto-launch) still invoke it without setting `pendingUserTurn`, causing inconsistent message visibility behavior.</comment>

<file context>
@@ -343,63 +341,20 @@ export function ChatPaneInterface({
+			// 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,
</file context>
Fix with Cubic

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 @@ -600,7 +592,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