From 60a2ff98ea968af6e1c16752753b4305f4b58845 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Feb 2026 12:24:37 -0800 Subject: [PATCH] feat(desktop): add chat pane UI and tabs rework - Add chat pages (list + detail view) with message rendering - Add ChatPane component for workspace tab integration - Add ChatInputButton to top bar - Rework tabs store to support chat pane type - Add "chat" pane type to ui-state schema - Update CSP to allow localhost connections for streams - Add STREAMS_URL env variable - Remove old chat-panel-state store - Bump @tanstack/db, electric-db-collection, react-db versions --- apps/desktop/electron.vite.config.ts | 4 + apps/desktop/package.json | 8 +- .../src/lib/trpc/routers/ui-state/index.ts | 3 +- apps/desktop/src/renderer/env.renderer.ts | 2 + apps/desktop/src/renderer/index.html | 4 +- .../src/renderer/lib/workspace-utils.ts | 19 + .../_dashboard/chats/$chatId/page.tsx | 96 +++++ .../components/ChatMessage/ChatMessage.tsx | 157 +++++++ .../chats/components/ChatMessage/index.ts | 2 + .../ChatMessageList/ChatMessageList.tsx | 79 ++++ .../chats/components/ChatMessageList/index.ts | 6 + .../chats/components/ChatView/ChatView.tsx | 147 +++++++ .../chats/components/ChatView/index.ts | 2 + .../components/ToolCallPart/ToolCallPart.tsx | 59 +++ .../chats/components/ToolCallPart/index.ts | 1 + .../_authenticated/_dashboard/chats/page.tsx | 89 ++++ .../_dashboard/chats/stores/chatStore.ts | 90 ++++ .../ChatInputButton/ChatInputButton.tsx | 38 ++ .../components/ChatInputButton/index.ts | 1 + .../workspace/$workspaceId/page.tsx | 21 - .../WorkspaceSidebarHeader.tsx | 51 ++- .../TabsContent/GroupStrip/GroupStrip.tsx | 162 ++++--- .../TabsContent/TabView/ChatPane/ChatPane.tsx | 408 ++++++++++++++++++ .../TabsContent/TabView/ChatPane/index.ts | 1 + .../ContentView/TabsContent/TabView/index.tsx | 21 + .../TabsContent/TabView/mosaic-theme.css | 3 + .../components/PRButton/PRButton.tsx | 33 +- .../src/renderer/stores/chat-panel-state.ts | 52 --- .../desktop/src/renderer/stores/tabs/store.ts | 80 ++++ .../desktop/src/renderer/stores/tabs/types.ts | 9 + .../desktop/src/renderer/stores/tabs/utils.ts | 31 ++ apps/desktop/src/shared/hotkeys.ts | 6 - apps/desktop/src/shared/tabs-types.ts | 11 +- 33 files changed, 1515 insertions(+), 181 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/workspace-utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/index.ts delete mode 100644 apps/desktop/src/renderer/stores/chat-panel-state.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 912e8a51f3..9e3557b051 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -144,6 +144,10 @@ export default defineConfig({ process.env.NEXT_PUBLIC_DOCS_URL, "https://docs.superset.sh", ), + "process.env.NEXT_PUBLIC_STREAMS_URL": defineEnv( + process.env.NEXT_PUBLIC_STREAMS_URL, + "http://localhost:8080", + ), "import.meta.env.DEV_SERVER_PORT": defineEnv(String(DEV_SERVER_PORT)), "import.meta.env.NEXT_PUBLIC_POSTHOG_KEY": defineEnv( process.env.NEXT_PUBLIC_POSTHOG_KEY, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81cd213366..66337adf13 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@better-auth/stripe": "1.4.17", + "@durable-streams/client": "^0.2.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -45,6 +46,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@sentry/electron": "^7.7.0", + "@superset/ai-chat": "workspace:*", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/local-db": "workspace:*", @@ -52,9 +54,9 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-core": "^0.13.8", - "@tanstack/db": "0.5.22", - "@tanstack/electric-db-collection": "0.2.27", - "@tanstack/react-db": "0.1.66", + "@tanstack/db": "^0.5.24", + "@tanstack/electric-db-collection": "0.2.30", + "@tanstack/react-db": "^0.1.68", "@tanstack/react-query": "^5.90.19", "@tanstack/react-router": "^1.147.3", "@tanstack/react-table": "^8.21.3", diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index e9f1904328..17b97abbdd 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -33,7 +33,7 @@ const fileViewerStateSchema = z.object({ const paneSchema = z.object({ id: z.string(), tabId: z.string(), - type: z.enum(["terminal", "webview", "file-viewer"]), + type: z.enum(["terminal", "webview", "file-viewer", "chat"]), name: z.string(), isNew: z.boolean().optional(), status: z.enum(["idle", "working", "permission", "review"]).optional(), @@ -43,6 +43,7 @@ const paneSchema = z.object({ cwd: z.string().nullable().optional(), cwdConfirmed: z.boolean().optional(), fileViewer: fileViewerStateSchema.optional(), + chat: z.object({ sessionId: z.string() }).optional(), }); /** diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index 0e41dea4a6..0103fab71e 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -17,6 +17,7 @@ const envSchema = z.object({ .default("development"), NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + NEXT_PUBLIC_STREAMS_URL: z.url().default("http://localhost:8080"), NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN_DESKTOP: z.string().optional(), @@ -33,6 +34,7 @@ const rawEnv = { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, + NEXT_PUBLIC_STREAMS_URL: process.env.NEXT_PUBLIC_STREAMS_URL, NEXT_PUBLIC_POSTHOG_KEY: import.meta.env.NEXT_PUBLIC_POSTHOG_KEY as | string | undefined, diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 5340b8f15b..84ef48def0 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -11,11 +11,11 @@ - default-src 'self': Only allow resources from same origin - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API (includes Electric proxy) + PostHog + Sentry + - connect-src 'self' ws: wss: http://localhost:* %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API (includes Electric proxy) + PostHog + Sentry + localhost for stream server - img-src 'self' data: %NEXT_PUBLIC_API_URL% https://*.public.blob.vercel-storage.com https://github.com https://avatars.githubusercontent.com: Allow images from same origin + data URIs + API (Linear image proxy) + Vercel blob storage + GitHub avatars - font-src 'self': Allow fonts from same origin --> - + diff --git a/apps/desktop/src/renderer/lib/workspace-utils.ts b/apps/desktop/src/renderer/lib/workspace-utils.ts new file mode 100644 index 0000000000..d23982acfe --- /dev/null +++ b/apps/desktop/src/renderer/lib/workspace-utils.ts @@ -0,0 +1,19 @@ +/** + * Get the most recently opened workspace path from grouped workspaces. + */ +export function getMostRecentWorkspacePath( + groups: Array<{ + workspaces: Array<{ + worktreePath: string; + lastOpenedAt: number; + }>; + }>, +): string | null { + const allWorkspaces = groups.flatMap((g) => g.workspaces); + if (allWorkspaces.length === 0) return null; + + const sorted = [...allWorkspaces].sort( + (a, b) => b.lastOpenedAt - a.lastOpenedAt, + ); + return sorted[0].worktreePath || null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx new file mode 100644 index 0000000000..158b7a2754 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx @@ -0,0 +1,96 @@ +import { createStream } from "@superset/ai-chat/stream"; +import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect } from "react"; +import { env } from "renderer/env.renderer"; +import { ChatView } from "../components/ChatView"; +import { useChatStore } from "../stores/chatStore"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/chats/$chatId/", +)({ + component: ChatDetailPage, +}); + +function ChatDetailPage() { + const { chatId } = Route.useParams(); + const navigate = useNavigate(); + const { sessions, createSession } = useChatStore(); + + // Ensure stream exists when page loads + useEffect(() => { + createStream(env.NEXT_PUBLIC_STREAMS_URL, chatId).catch((err) => { + console.error("[chats] Failed to ensure stream exists on load:", err); + }); + }, [chatId]); + + const handleCreateChat = useCallback(async () => { + const session = createSession(); + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, session.id); + } catch (err) { + console.error("[chats] Failed to create stream:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId: session.id } }); + }, [navigate, createSession]); + + const handleSelectChat = useCallback( + async (id: string) => { + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, id); + } catch (err) { + console.error("[chats] Failed to ensure stream exists:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId: id } }); + }, + [navigate], + ); + + return ( +
+ {/* Sidebar */} +
+
+ +
+ + +
+ {sessions.length === 0 ? ( +
+ No chats yet. +
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+
+
+ + {/* Chat View */} +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx new file mode 100644 index 0000000000..b779959c25 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx @@ -0,0 +1,157 @@ +/** + * Individual chat message component + */ + +import type { BetaContentBlock, ToolResult } from "@superset/ai-chat/stream"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { LuChevronRight } from "react-icons/lu"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import { ToolCallPart } from "../ToolCallPart"; + +export interface ChatMessageProps { + role?: "user" | "assistant"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; + timestamp?: Date; + isStreaming?: boolean; +} + +function ThinkingBlock({ thinking }: { thinking: string }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + Thinking + + +
+ {thinking} +
+
+
+ ); +} + +function AssistantContent({ + content, + contentBlocks, + toolResults, +}: { + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; +}) { + if (!contentBlocks || contentBlocks.length === 0) { + return ( +
+ + {content} + +
+ ); + } + + return ( +
+ {contentBlocks.map((block, index) => { + const key = `${block.type}-${index}`; + switch (block.type) { + case "text": + return ( +
+ + {block.text} + +
+ ); + case "tool_use": + return ( + + ); + case "thinking": + return ; + default: + return ( +
+ {block.type} block +
+ ); + } + })} +
+ ); +} + +export function ChatMessage({ + role = "assistant", + content, + contentBlocks, + toolResults, + timestamp, + isStreaming, +}: ChatMessageProps) { + const isUser = role === "user"; + + return ( +
+
+ {isUser ? ( +

{content}

+ ) : ( + + )} + {timestamp && ( + + {timestamp.toLocaleTimeString()} + + )} + {isStreaming && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts new file mode 100644 index 0000000000..33fe9a96de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts @@ -0,0 +1,2 @@ +export type { ChatMessageProps } from "./ChatMessage"; +export { ChatMessage } from "./ChatMessage"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx new file mode 100644 index 0000000000..c53c8cc6d1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx @@ -0,0 +1,79 @@ +/** + * Chat message list with auto-scroll + */ + +import type { BetaContentBlock, ToolResult } from "@superset/ai-chat/stream"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { useEffect, useRef } from "react"; +import { ChatMessage } from "../ChatMessage"; + +export interface Message { + id: string; + role: "user" | "assistant"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; + createdAt: Date; +} + +export interface StreamingMessage { + type: "streaming"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; +} + +export interface ChatMessageListProps { + messages: Array; + className?: string; + autoScroll?: boolean; +} + +export function ChatMessageList({ + messages, + className, + autoScroll = true, +}: ChatMessageListProps) { + const scrollRef = useRef(null); + const bottomRef = useRef(null); + + useEffect(() => { + if (autoScroll && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [autoScroll, messages]); + + return ( + +
+ {messages.map((msg, index) => { + if ("type" in msg && msg.type === "streaming") { + return ( + + ); + } + + const message = msg as Message; + return ( + + ); + })} +
+
+ + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts new file mode 100644 index 0000000000..10ab1635ba --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts @@ -0,0 +1,6 @@ +export type { + ChatMessageListProps, + Message, + StreamingMessage, +} from "./ChatMessageList"; +export { ChatMessageList } from "./ChatMessageList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx new file mode 100644 index 0000000000..ecfbf4ab4d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx @@ -0,0 +1,147 @@ +/** + * Chat View - desktop app chat interface + * + * Messages are materialized from the durable stream via useChatSession. + * This provides persistence and multi-client sync. + */ + +import { ChatInput, PresenceBar } from "@superset/ai-chat/components"; +import { useChatSession } from "@superset/ai-chat/stream"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useMemo, useState } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getMostRecentWorkspacePath } from "renderer/lib/workspace-utils"; +import { + ChatMessageList, + type Message, + type StreamingMessage, +} from "../ChatMessageList"; + +export interface ChatViewProps { + sessionId: string; + className?: string; +} + +export function ChatView({ sessionId, className }: ChatViewProps) { + const { data: session } = authClient.useSession(); + const user = session?.user + ? { userId: session.user.id, name: session.user.name ?? "Unknown" } + : null; + + const { users, messages, streamingMessage, draft, setDraft, sendMessage } = + useChatSession({ + proxyUrl: env.NEXT_PUBLIC_STREAMS_URL, + sessionId, + user, + autoConnect: !!user, + }); + + const startSessionMutation = electronTrpc.aiChat.startSession.useMutation(); + const { data: isActive, refetch: refetchIsActive } = + electronTrpc.aiChat.isSessionActive.useQuery({ sessionId }); + + // Query workspaces to get the most recently opened workspace path for cwd + const { data: workspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + const mostRecentWorkspacePath = useMemo( + () => + workspaceGroups ? getMostRecentWorkspacePath(workspaceGroups) : null, + [workspaceGroups], + ); + + const handleStartSession = useCallback(async () => { + if (!mostRecentWorkspacePath) { + console.error("[ChatView] No workspace available to start session"); + return; + } + await startSessionMutation.mutateAsync({ + sessionId, + cwd: mostRecentWorkspacePath, + }); + await refetchIsActive(); + }, [ + sessionId, + startSessionMutation, + refetchIsActive, + mostRecentWorkspacePath, + ]); + + const [isSending, setIsSending] = useState(false); + const handleSend = useCallback( + async (content: string) => { + setIsSending(true); + setDraft(""); + try { + await sendMessage(content); + } finally { + setIsSending(false); + } + }, + [sendMessage, setDraft], + ); + + const allMessages = useMemo((): Array => { + const result: Array = messages.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + contentBlocks: m.contentBlocks, + toolResults: m.toolResults, + createdAt: m.createdAt, + })); + if (streamingMessage) { + result.push({ + type: "streaming", + content: streamingMessage.content, + contentBlocks: streamingMessage.contentBlocks, + toolResults: streamingMessage.toolResults, + }); + } + return result; + }, [messages, streamingMessage]); + + return ( +
+ + + + +
+ {isActive ? ( + + ) : ( +
+ + {mostRecentWorkspacePath + ? "Session not active" + : "No workspace available"} + + +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts new file mode 100644 index 0000000000..a6ee6b7ea8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts @@ -0,0 +1,2 @@ +export type { ChatViewProps } from "./ChatView"; +export { ChatView } from "./ChatView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx new file mode 100644 index 0000000000..b2ae47bf16 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx @@ -0,0 +1,59 @@ +import type { BetaToolUseBlock, ToolResult } from "@superset/ai-chat/stream"; +import { Badge } from "@superset/ui/badge"; +import { cn } from "@superset/ui/utils"; + +interface ToolCallPartProps { + block: BetaToolUseBlock; + result?: ToolResult; +} + +function getToolState(result?: ToolResult) { + if (!result) return { label: "Running", variant: "secondary" as const }; + if (result.isError) + return { label: "Error", variant: "destructive" as const }; + return { label: "Completed", variant: "default" as const }; +} + +export function ToolCallPart({ block, result }: ToolCallPartProps) { + const state = getToolState(result); + + return ( +
+
+ {block.name} + + {state.label} + +
+ {block.input != null && + typeof block.input === "object" && + Object.keys(block.input).length > 0 && ( +
+

+ Input +

+
+							{JSON.stringify(block.input, null, 2)}
+						
+
+ )} + {result && ( +
+

+ Output +

+
+						{result.output || "(empty)"}
+					
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts new file mode 100644 index 0000000000..3ef8afecd2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts @@ -0,0 +1 @@ +export { ToolCallPart } from "./ToolCallPart"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx new file mode 100644 index 0000000000..9f59779cc8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx @@ -0,0 +1,89 @@ +import { createStream } from "@superset/ai-chat/stream"; +import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { useChatStore } from "./stores/chatStore"; + +export const Route = createFileRoute("/_authenticated/_dashboard/chats/")({ + component: ChatIndexPage, +}); + +function ChatIndexPage() { + const navigate = useNavigate(); + const { sessions, createSession } = useChatStore(); + + const handleCreateChat = useCallback(async () => { + const session = createSession(); + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, session.id); + } catch (err) { + console.error("[chats] Failed to create stream:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId: session.id } }); + }, [navigate, createSession]); + + const handleSelectChat = useCallback( + async (chatId: string) => { + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, chatId); + } catch (err) { + console.error("[chats] Failed to ensure stream exists:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId } }); + }, + [navigate], + ); + + return ( +
+ {/* Sidebar */} +
+
+ +
+ + +
+ {sessions.length === 0 ? ( +
+ No chats yet. +
+ Click "+ New Chat" to start. +
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+
+
+ + {/* Main area - empty state */} +
+
+

💬

+

Select a chat or create a new one

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts new file mode 100644 index 0000000000..28dd5d8db2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts @@ -0,0 +1,90 @@ +/** + * Local Chat Store + * + * Stores chat session metadata using zustand with localStorage persistence. + * Also syncs sessions with the streams server for cross-device access. + */ + +import { env } from "renderer/env.renderer"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export interface ChatSession { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} + +interface ChatStore { + sessions: ChatSession[]; + createSession: (name?: string) => ChatSession; + deleteSession: (id: string) => void; + renameSession: (id: string, name: string) => void; +} + +/** + * Sync session to streams server (fire-and-forget) + */ +function syncToStreamsServer(session: ChatSession): void { + const streamsUrl = env.NEXT_PUBLIC_STREAMS_URL; + if (!streamsUrl) return; + + fetch(`${streamsUrl}/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: session.id, + title: session.name, + }), + }).catch((err) => { + console.warn("[chatStore] Failed to sync session to streams server:", err); + }); +} + +export const useChatStore = create()( + persist( + (set, get) => ({ + sessions: [], + + createSession: (name?: string) => { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const session: ChatSession = { + id, + name: name ?? `Chat ${get().sessions.length + 1}`, + createdAt: now, + updatedAt: now, + }; + + set((state) => ({ + sessions: [session, ...state.sessions], + })); + + // Sync to streams server for mobile access + syncToStreamsServer(session); + + return session; + }, + + deleteSession: (id: string) => { + set((state) => ({ + sessions: state.sessions.filter((s) => s.id !== id), + })); + }, + + renameSession: (id: string, name: string) => { + set((state) => ({ + sessions: state.sessions.map((s) => + s.id === id + ? { ...s, name, updatedAt: new Date().toISOString() } + : s, + ), + })); + }, + }), + { + name: "chat-sessions", + }, + ), +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx new file mode 100644 index 0000000000..ce17ffc611 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx @@ -0,0 +1,38 @@ +import { createStream } from "@superset/ai-chat/stream"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { Sparkles } from "lucide-react"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { useChatStore } from "../../../../chats/stores/chatStore"; + +const STREAM_SERVER_URL = env.NEXT_PUBLIC_STREAMS_URL; + +export function ChatInputButton() { + const navigate = useNavigate(); + const { createSession } = useChatStore(); + + const handleClick = useCallback(async () => { + const session = createSession(); + await createStream(STREAM_SERVER_URL, session.id); + navigate({ to: "/chats/$chatId", params: { chatId: session.id } }); + }, [navigate, createSession]); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts new file mode 100644 index 0000000000..1217b5c688 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts @@ -0,0 +1 @@ +export { ChatInputButton } from "./ChatInputButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 5d9001a35e..f45a23b142 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,4 +1,3 @@ -import { toast } from "@superset/ui/sonner"; import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -9,7 +8,6 @@ import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/work import { NotFound } from "renderer/routes/not-found"; import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; import { WorkspaceLayout } from "renderer/screens/main/components/WorkspaceView/WorkspaceLayout"; -import { usePRStatus } from "renderer/screens/main/hooks"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { SidebarMode, useSidebarStore } from "renderer/stores/sidebar-state"; import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; @@ -278,25 +276,6 @@ function WorkspacePage() { [workspace?.worktreePath], ); - // Open PR shortcut (⌘⇧P) - const { pr } = usePRStatus({ workspaceId }); - const createPRMutation = electronTrpc.changes.createPR.useMutation({ - onSuccess: () => toast.success("Opening GitHub..."), - onError: (error) => toast.error(`Failed: ${error.message}`), - }); - useAppHotkey( - "OPEN_PR", - () => { - if (pr?.url) { - window.open(pr.url, "_blank"); - } else if (workspace?.worktreePath) { - createPRMutation.mutate({ worktreePath: workspace.worktreePath }); - } - }, - undefined, - [pr?.url, workspace?.worktreePath], - ); - // Toggle changes sidebar (⌘L) useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ toggleSidebar, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx index 36db8570ed..f3aee925d8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx @@ -1,8 +1,10 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; -import { LuLayers } from "react-icons/lu"; +import { LuLayers, LuMessageSquare } from "react-icons/lu"; import { GATED_FEATURES, usePaywall, @@ -20,9 +22,11 @@ export function WorkspaceSidebarHeader({ const navigate = useNavigate(); const matchRoute = useMatchRoute(); const { gateFeature } = usePaywall(); + const showChat = useFeatureFlagEnabled(FEATURE_FLAGS.AI_CHAT); const isWorkspacesListOpen = !!matchRoute({ to: "/workspaces" }); const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true }); + const isChatOpen = !!matchRoute({ to: "/chats" }); const handleWorkspacesClick = () => { if (isWorkspacesListOpen) { @@ -39,6 +43,10 @@ export function WorkspaceSidebarHeader({ }); }; + const handleChatClick = () => { + navigate({ to: "/chats" }); + }; + if (isCollapsed) { return (
@@ -81,6 +89,29 @@ export function WorkspaceSidebarHeader({ Tasks + {showChat && ( + + + + + Chat + + )} +
); @@ -123,6 +154,24 @@ export function WorkspaceSidebarHeader({ Tasks + {showChat && ( + + )} +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 6924e6c957..cb32a2c9d9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -1,3 +1,4 @@ +import { createStream } from "@superset/ai-chat/stream"; import { Button } from "@superset/ui/button"; import { DropdownMenu, @@ -8,6 +9,7 @@ import { } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { MessageCircle } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { HiMiniChevronDown, @@ -21,7 +23,9 @@ import { useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { env } from "renderer/env.renderer"; import { usePresets } from "renderer/react-query/presets"; +import { useChatStore } from "renderer/routes/_authenticated/_dashboard/chats/stores/chatStore"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { @@ -33,6 +37,9 @@ import { PresetMenuItemShortcut } from "./components/PresetMenuItemShortcut"; import { GroupItem } from "./GroupItem"; import { NewTabDropZone } from "./NewTabDropZone"; +const STREAM_SERVER_URL = + env.NEXT_PUBLIC_STREAMS_URL || "http://localhost:8080"; + export function GroupStrip() { const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); @@ -47,7 +54,9 @@ export function GroupStrip() { const movePaneToTab = useTabsStore((s) => s.movePaneToTab); const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const reorderTabs = useTabsStore((s) => s.reorderTabs); + const addChatPane = useTabsStore((s) => s.addChatPane); + const { createSession } = useChatStore(); const { presets } = usePresets(); const isDark = useIsDarkTheme(); const navigate = useNavigate(); @@ -89,6 +98,16 @@ export function GroupStrip() { addTab(activeWorkspaceId); }; + const handleAddChat = useCallback(async () => { + if (!activeWorkspaceId) return; + const session = createSession(); + await createStream(STREAM_SERVER_URL, session.id); + addChatPane(activeWorkspaceId, { + sessionId: session.id, + name: session.name, + }); + }, [activeWorkspaceId, createSession, addChatPane]); + const handleSelectPreset = (preset: Parameters[1]) => { if (!activeWorkspaceId) return; openPreset(activeWorkspaceId, preset); @@ -180,75 +199,92 @@ export function GroupStrip() { onDrop={(paneId) => movePaneToNewTab(paneId)} isLastPaneInTab={checkIsLastPaneInTab} > - -
- - +
+ + + + + + New Chat + + + +
+ + + + + + + + + - - - - - - -
+ + {presets.length > 0 && ( + <> + {presets.map((preset, index) => { + const presetIcon = getPresetIcon(preset.name, isDark); + return ( + handleSelectPreset(preset)} + className="gap-2" + > + {presetIcon ? ( + + ) : ( + + )} + + {preset.name || "default"} + + {preset.isDefault && ( + + )} + + + ); + })} + + + )} + - - - -
- - {presets.length > 0 && ( - <> - {presets.map((preset, index) => { - const presetIcon = getPresetIcon(preset.name, isDark); - return ( - handleSelectPreset(preset)} - className="gap-2" - > - {presetIcon ? ( - - ) : ( - - )} - - {preset.name || "default"} - - {preset.isDefault && ( - - )} - - - ); - })} - - - )} - - - Configure Presets - - - + + Configure Presets + + + +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx new file mode 100644 index 0000000000..188f72c557 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -0,0 +1,408 @@ +/** + * Chat Pane - workspace chat interface using AI elements + * + * Messages are materialized from the durable stream via useChatSession. + * This mirrors the dashboard ChatView but with improved UI using AI elements. + */ + +import type { BetaContentBlock, ToolResult } from "@superset/ai-chat/stream"; +import { useChatSession } from "@superset/ai-chat/stream"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@superset/ui/ai-elements/conversation"; +import { Loader } from "@superset/ui/ai-elements/loader"; +import { + Message, + MessageContent, + MessageResponse, +} from "@superset/ui/ai-elements/message"; +import { Button } from "@superset/ui/button"; +import { CornerDownLeft, MessageCircle, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MosaicBranch } from "react-mosaic-component"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getMostRecentWorkspacePath } from "renderer/lib/workspace-utils"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { BasePaneWindow, PaneToolbarActions } from "../components"; + +interface ChatPaneProps { + paneId: string; + path: MosaicBranch[]; + isActive: boolean; + tabId: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + splitPaneHorizontal: ( + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + splitPaneVertical: ( + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; + availableTabs: Tab[]; + onMoveToTab: (targetTabId: string) => void; + onMoveToNewTab: () => void; +} + +interface ChatMessageItem { + id: string; + role: "user" | "assistant"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; + isStreaming?: boolean; +} + +function ChatMessageBlock({ msg }: { msg: ChatMessageItem }) { + if (msg.role === "user") { + return ( + + {msg.content} + + ); + } + + // Extract text from content blocks, falling back to content string + const textContent = + msg.contentBlocks + ?.filter( + (b): b is BetaContentBlock & { type: "text" } => b.type === "text", + ) + .map((b) => b.text) + .join("\n") || msg.content; + + return ( + + + + {textContent} + + + + ); +} + +export function ChatPane({ + paneId, + path, + isActive, + tabId, + splitPaneAuto, + splitPaneHorizontal: _splitPaneHorizontal, + splitPaneVertical: _splitPaneVertical, + removePane, + setFocusedPane, + availableTabs: _availableTabs, + onMoveToTab: _onMoveToTab, + onMoveToNewTab: _onMoveToNewTab, +}: ChatPaneProps) { + const sessionId = useTabsStore((s) => s.panes[paneId]?.chat?.sessionId); + const paneName = useTabsStore((s) => s.panes[paneId]?.name); + + console.log(`[ChatPane] Render paneId=${paneId} sessionId=${sessionId}`); + + const { data: session } = authClient.useSession(); + const user = session?.user + ? { userId: session.user.id, name: session.user.name ?? "Unknown" } + : null; + + // Hook up to durable stream - same as dashboard ChatView + const { + messages, + streamingMessage, + draft, + setDraft, + sendMessage, + connectionStatus, + isLoading, + } = useChatSession({ + proxyUrl: env.NEXT_PUBLIC_STREAMS_URL, + sessionId: sessionId ?? "", + user, + autoConnect: !!user && !!sessionId, + }); + + console.log( + `[ChatPane] connectionStatus=${connectionStatus} isLoading=${isLoading} messages=${messages.length} streaming=${!!streamingMessage}`, + ); + + const startSessionMutation = electronTrpc.aiChat.startSession.useMutation(); + const { data: isSessionActive, refetch: refetchIsActive } = + electronTrpc.aiChat.isSessionActive.useQuery( + { sessionId: sessionId ?? "" }, + { enabled: !!sessionId }, + ); + + console.log( + `[ChatPane] isSessionActive=${isSessionActive} user=${!!user}`, + ); + + const { data: workspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + const mostRecentWorkspacePath = useMemo( + () => + workspaceGroups ? getMostRecentWorkspacePath(workspaceGroups) : null, + [workspaceGroups], + ); + + // Auto-start session when pane mounts and session is not active + const hasAutoStarted = useRef(false); + useEffect(() => { + if ( + hasAutoStarted.current || + !sessionId || + isSessionActive || + isSessionActive === undefined || + !mostRecentWorkspacePath || + startSessionMutation.isPending + ) { + return; + } + + hasAutoStarted.current = true; + console.log( + `[ChatPane] Auto-starting session ${sessionId} in ${mostRecentWorkspacePath}`, + ); + startSessionMutation + .mutateAsync({ sessionId, cwd: mostRecentWorkspacePath }) + .then(() => { + console.log(`[ChatPane] Session started successfully`); + return refetchIsActive(); + }) + .catch((err) => + console.error("[ChatPane] Failed to auto-start session:", err), + ); + }, [ + sessionId, + isSessionActive, + mostRecentWorkspacePath, + startSessionMutation, + refetchIsActive, + ]); + + const [isAwaitingResponse, setIsAwaitingResponse] = useState(false); + + const handleSend = useCallback( + async (content: string) => { + console.log(`[ChatPane] Sending message: "${content.slice(0, 50)}..."`); + setIsAwaitingResponse(true); + setDraft(""); + try { + await sendMessage(content); + console.log(`[ChatPane] Message sent successfully`); + } catch (err) { + console.error("[ChatPane] Failed to send message:", err); + setIsAwaitingResponse(false); + } + }, + [sendMessage, setDraft], + ); + + // Clear awaiting state when streaming starts + useEffect(() => { + if (streamingMessage) { + console.log(`[ChatPane] Streaming started, clearing awaiting state`); + setIsAwaitingResponse(false); + } + }, [streamingMessage]); + + // Handle case where response completes so fast it skips streaming + const prevAssistantCount = useRef(0); + useEffect(() => { + const assistantCount = messages.filter((m) => m.role === "assistant").length; + if (assistantCount > prevAssistantCount.current && isAwaitingResponse) { + console.log(`[ChatPane] New assistant message appeared, clearing awaiting state`); + setIsAwaitingResponse(false); + } + prevAssistantCount.current = assistantCount; + }, [messages, isAwaitingResponse]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (draft.trim() && isSessionActive && !isAwaitingResponse) { + handleSend(draft); + } + } + }, + [draft, isSessionActive, isAwaitingResponse, handleSend], + ); + + // Build allMessages array - same pattern as dashboard ChatView + const allMessages = useMemo((): ChatMessageItem[] => { + console.log( + `[ChatPane] Building allMessages: messages=${messages.length} streamingMessage=${streamingMessage ? `id=${streamingMessage.id.slice(0, 8)} role=${streamingMessage.role} content="${streamingMessage.content.slice(0, 80)}" blocks=${streamingMessage.contentBlocks.length} isComplete=${streamingMessage.isComplete} isStreaming=${streamingMessage.isStreaming}` : "null"}`, + ); + const result: ChatMessageItem[] = messages.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + contentBlocks: m.contentBlocks, + toolResults: m.toolResults, + })); + if (streamingMessage) { + result.push({ + id: "streaming", + role: "assistant", + content: streamingMessage.content, + contentBlocks: streamingMessage.contentBlocks, + toolResults: streamingMessage.toolResults, + isStreaming: true, + }); + } + console.log( + `[ChatPane] allMessages: ${result.length} items:`, + result.map((m) => ({ + id: m.id.slice(0, 8), + role: m.role, + contentLen: m.content.length, + blocks: m.contentBlocks?.length ?? 0, + isStreaming: m.isStreaming, + })), + ); + return result; + }, [messages, streamingMessage]); + + const renderToolbar = useCallback( + (handlers: { + splitOrientation: "horizontal" | "vertical"; + onSplitPane: (e: React.MouseEvent) => void; + onClosePane: (e: React.MouseEvent) => void; + }) => ( +
+
+ + + {paneName ?? "Chat"} + +
+ +
+ ), + [paneName], + ); + + if (!sessionId) { + return ( + +
+ Session not found +
+
+ ); + } + + const hasMessages = allMessages.length > 0; + const inputDisabled = !isSessionActive || isAwaitingResponse; + + console.log( + `[ChatPane] Render state: hasMessages=${hasMessages} isAwaitingResponse=${isAwaitingResponse} isThinking=${isAwaitingResponse && !streamingMessage} inputDisabled=${inputDisabled} streamingMessage=${!!streamingMessage}`, + ); + + // Show thinking when awaiting response and no streaming response yet + const isThinking = isAwaitingResponse && !streamingMessage; + + return ( + +
+ + {hasMessages || isThinking ? ( + + {allMessages.map((msg) => ( + + ))} + {isThinking && ( + + +
+ + Thinking... +
+
+
+ )} +
+ ) : ( + } + title="Start a conversation" + description={ + isLoading + ? `Connecting... (${connectionStatus})` + : "Ask anything to get started" + } + /> + )} + +
+ +
+
+
+