Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
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: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ internal
.turbo

.windsurfrules
CLAUDE.md
CLAUDE.md
.cursor/
16 changes: 16 additions & 0 deletions apps/desktop/src/components/editor-area/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TemplateService } from "@/utils/template-service";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { commands as connectorCommands } from "@hypr/plugin-connector";
import { commands as dbCommands } from "@hypr/plugin-db";
import { events as localLlmEvents } from "@hypr/plugin-local-llm";
import { commands as localLlmCommands } from "@hypr/plugin-local-llm";
import { commands as miscCommands } from "@hypr/plugin-misc";
import { commands as templateCommands, type Grammar } from "@hypr/plugin-template";
Expand Down Expand Up @@ -388,6 +389,21 @@ export function useEnhanceMutation({
const [isCancelled, setIsCancelled] = useState(false);
const queryClient = useQueryClient();

useEffect(() => {
let unlisten: () => void;
localLlmEvents.llmEvent.listen(({ payload }) => {
if (payload.progress) {
setProgress(payload.progress);
}
}).then((fn) => {
unlisten = fn;
});

return () => {
unlisten();
};
}, []);
Comment on lines +392 to +405
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Prevent cleanup crash and handle 0% progress correctly; optionally scope listener to local LLM

  • unlisten is assigned asynchronously; calling it before assignment can throw.
  • if (payload.progress) skips valid 0 updates.
  • Optionally mount the listener only when using the local LLM to avoid unnecessary rerenders.
 useEffect(() => {
-  let unlisten: () => void;
-  localLlmEvents.llmEvent.listen(({ payload }) => {
-    if (payload.progress) {
-      setProgress(payload.progress);
-    }
-  }).then((fn) => {
-    unlisten = fn;
-  });
-
-  return () => {
-    unlisten();
-  };
-}, []);
+  if (!actualIsLocalLlm) return;
+  let unlisten: () => void = () => {};
+  localLlmEvents.llmEvent.listen(({ payload }) => {
+    if (typeof payload?.progress === "number") {
+      setProgress(payload.progress);
+    }
+  }).then((fn) => {
+    unlisten = fn;
+  });
+
+  return () => {
+    unlisten();
+  };
+}, [actualIsLocalLlm]);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/components/editor-area/index.tsx around lines 392 to 405,
the effect assigns unlisten asynchronously (risking calling undefined on
cleanup), treats 0 as falsy (skips valid 0 progress), and may register the
listener unnecessarily; fix by only mounting the listener when using the local
LLM (guard with the local-LLM condition if available), update the payload check
to test for progress !== undefined (so 0 is accepted), and make cleanup robust
by checking unlisten is a function before calling it (e.g., if (typeof unlisten
=== 'function') unlisten()) — also consider storing the returned unsubscribe
synchronously or cancelling registration on unmount if your event API supports
it.


// Extract H1 headers at component level (always available)
const extractH1Headers = useCallback((htmlContent: string): string[] => {
if (!htmlContent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function ChatInput(

const traverseNode = (node: any) => {
if (node.type === "mention" || node.type === "mention-@") {
if (node.attrs) {
if (node.attrs && node.attrs.type !== "selection") {
mentions.push({
id: node.attrs.id || node.attrs["data-id"],
type: node.attrs.type || node.attrs["data-type"] || "note",
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { UIMessage } from "@hypr/utils/ai";
import { useEffect, useRef, useState } from "react";
import { ChatMessage } from "./chat-message";
import { Message } from "./types";
import { UIMessageComponent } from "./ui-message";

interface ChatMessagesViewProps {
messages: Message[];
messages: UIMessage[];
sessionTitle?: string;
hasEnhancedNote?: boolean;
onApplyMarkdown?: (markdownContent: string) => void;
isGenerating?: boolean;
isStreamingText?: boolean;
isSubmitted?: boolean;
isStreaming?: boolean;
isReady?: boolean;
isError?: boolean;
}

function ThinkingIndicator() {
Expand All @@ -30,7 +32,7 @@ function ThinkingIndicator() {
}
`}
</style>
<div style={{ color: "rgb(115 115 115)", fontSize: "0.875rem", padding: "4px 0" }}>
<div style={{ color: "rgb(115 115 115)", fontSize: "0.875rem", padding: "0 0 8px 0" }}>
<span>Thinking</span>
<span className="thinking-dot">.</span>
<span className="thinking-dot">.</span>
Expand All @@ -41,27 +43,45 @@ function ThinkingIndicator() {
}

export function ChatMessagesView(
{ messages, sessionTitle, hasEnhancedNote, onApplyMarkdown, isGenerating, isStreamingText }: ChatMessagesViewProps,
{ messages, sessionTitle, hasEnhancedNote, onApplyMarkdown, isSubmitted, isStreaming, isReady, isError }:
ChatMessagesViewProps,
) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [showThinking, setShowThinking] = useState(false);
const thinkingTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const shouldShowThinking = () => {
if (!isGenerating) {
return false;
}

if (messages.length === 0) {
// Show thinking when request is submitted but not yet streaming
if (isSubmitted) {
return true;
}

const lastMessage = messages[messages.length - 1];
if (lastMessage.isUser) {
return true;
// Check if we're in transition between parts (text → tool or tool → text)
if (isStreaming && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
if (lastMessage.role === "assistant" && lastMessage.parts) {
const lastPart = lastMessage.parts[lastMessage.parts.length - 1];

// Text part finished but still streaming (tool coming)
if (lastPart?.type === "text" && !(lastPart as any).state) {
return true;
}

// Tool finished but still streaming (more text/tools coming)
if (lastPart?.type?.startsWith("tool-") || lastPart?.type === "dynamic-tool") {
const toolPart = lastPart as any;
if (
toolPart.state === "output-available"
|| toolPart.state === "output-error"
) {
return true;
}
}
}
}

if (!lastMessage.isUser && !isStreamingText) {
// Fallback for other transition states
if (!isReady && !isStreaming && !isError) {
return true;
}

Expand Down Expand Up @@ -89,16 +109,16 @@ export function ChatMessagesView(
clearTimeout(thinkingTimeoutRef.current);
}
};
}, [isGenerating, isStreamingText, messages]);
}, [isSubmitted, isStreaming, isReady, isError, messages]);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, showThinking]);

return (
<div className="flex-1 overflow-y-auto p-4 space-y-4 select-text">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6 select-text">
{messages.map((message) => (
<ChatMessage
<UIMessageComponent
key={message.id}
message={message}
sessionTitle={sessionTitle}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
export * from "./chat-history-item";
export * from "./chat-history-view";
export * from "./chat-input";
export * from "./chat-message";
export * from "./chat-messages-view";
export * from "./empty-chat-state";
export * from "./floating-action-buttons";
export { MarkdownCard } from "./markdown-card";
export { MessageContent } from "./message-content";
export * from "./types";
export { UIMessageComponent } from "./ui-message";
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function MarkdownCard(
</style>

{/* Flat card with no shadow */}
<div className="mt-4 mb-4 border border-neutral-200 rounded-lg bg-white overflow-hidden">
<div className="border border-neutral-200 rounded-lg bg-white overflow-hidden">
{/* Grey header section - Made thinner with py-1 */}
<div className="bg-neutral-50 px-4 py-1 border-b border-neutral-200 flex items-center justify-between">
<div className="text-sm text-neutral-600 flex items-center gap-2">
Expand Down
Loading
Loading