diff --git a/apps/desktop/src/chat/components/chat-panel.tsx b/apps/desktop/src/chat/components/chat-panel.tsx index ee39a2579d..055edcf2fc 100644 --- a/apps/desktop/src/chat/components/chat-panel.tsx +++ b/apps/desktop/src/chat/components/chat-panel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { cn } from "@hypr/utils"; @@ -11,28 +11,19 @@ import { useSessionTab } from "./use-session-tab"; import { useLanguageModel } from "~/ai/hooks"; import { useChatActions } from "~/chat/store/use-chat-actions"; import { useShell } from "~/contexts/shell"; -import { id } from "~/shared/utils"; import * as main from "~/store/tinybase/store/main"; export function ChatView() { const { chat } = useShell(); - const { groupId, setGroupId } = chat; + const { groupId, sessionId, setGroupId, startNewChat, selectChat } = chat; const { currentSessionId } = useSessionTab(); - // sessionId drives the ChatSession key and useChat id. - // It is managed explicitly — not derived from groupId — so that we can distinguish: - // handleNewChat: new random ID → fresh useChat instance - // handleSelectChat: set to groupId → forces ChatSession remount to load history - // onGroupCreated: groupId changes but sessionId stays stable → keeps useChat alive for the in-flight stream - const [sessionId, setSessionId] = useState(() => groupId ?? id()); - const model = useLanguageModel("chat"); const { user_id } = main.UI.useValues(main.STORE_ID); const handleGroupCreated = useCallback( (newGroupId: string) => { - // Don't update sessionId — keep current one so useChat stays alive for the in-flight stream setGroupId(newGroupId); }, [setGroupId], @@ -43,19 +34,6 @@ export function ChatView() { onGroupCreated: handleGroupCreated, }); - const handleNewChat = useCallback(() => { - setGroupId(undefined); - setSessionId(id()); - }, [setGroupId]); - - const handleSelectChat = useCallback( - (selectedGroupId: string) => { - setGroupId(selectedGroupId); - setSessionId(selectedGroupId); - }, - [setGroupId], - ); - return (
chat.sendEvent({ type: "CLOSE" })} /> {user_id && ( diff --git a/apps/desktop/src/chat/components/context-bar.tsx b/apps/desktop/src/chat/components/context-bar.tsx index 73f5439a0d..d90e1784be 100644 --- a/apps/desktop/src/chat/components/context-bar.tsx +++ b/apps/desktop/src/chat/components/context-bar.tsx @@ -281,7 +281,7 @@ export function ContextBar({ [entities], ); - if (chips.length === 0 && !onAddEntity) { + if (chips.length === 0) { return null; } diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index 3ea3caa255..ecdd00c996 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -65,6 +65,7 @@ export function ChatMessageInput({ isRightPanel={chat.mode === "RightPanelOpen"} >
{children} diff --git a/apps/desktop/src/chat/components/message/normal.tsx b/apps/desktop/src/chat/components/message/normal.tsx index 3a4695281a..a4bc42988c 100644 --- a/apps/desktop/src/chat/components/message/normal.tsx +++ b/apps/desktop/src/chat/components/message/normal.tsx @@ -113,7 +113,13 @@ function Part({ part }: { part: Part }) { } function Reasoning({ part }: { part: Extract }) { - const cleaned = part.text + const raw = part.text.trim(); + + if (!raw) { + return null; + } + + const cleaned = raw .replace(/[\n`*#"]/g, " ") .replace(/\s+/g, " ") .trim(); @@ -121,6 +127,10 @@ function Reasoning({ part }: { part: Extract }) { const streaming = part.state !== "done"; const title = streaming ? cleaned.slice(-150) : cleaned; + if (!title) { + return null; + } + return ( } diff --git a/apps/desktop/src/chat/components/shared.test.ts b/apps/desktop/src/chat/components/shared.test.ts new file mode 100644 index 0000000000..e955e2b1d6 --- /dev/null +++ b/apps/desktop/src/chat/components/shared.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { hasRenderableContent } from "./shared"; + +describe("hasRenderableContent", () => { + test("returns false for blank reasoning-only messages", () => { + expect( + hasRenderableContent({ + id: "message-1", + role: "assistant", + parts: [{ type: "reasoning", text: " ", state: "done" }], + }), + ).toBe(false); + }); + + test("returns true for non-empty reasoning messages", () => { + expect( + hasRenderableContent({ + id: "message-2", + role: "assistant", + parts: [{ type: "reasoning", text: "Thinking", state: "done" }], + }), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/chat/components/shared.ts b/apps/desktop/src/chat/components/shared.ts index 274c1dffbf..9eac22ff04 100644 --- a/apps/desktop/src/chat/components/shared.ts +++ b/apps/desktop/src/chat/components/shared.ts @@ -1,5 +1,15 @@ import type { HyprUIMessage } from "~/chat/types"; export function hasRenderableContent(message: HyprUIMessage): boolean { - return message.parts.some((part) => part.type !== "step-start"); + return message.parts.some((part) => { + if (part.type === "step-start") { + return false; + } + + if (part.type === "reasoning") { + return part.text.trim().length > 0; + } + + return true; + }); } diff --git a/apps/desktop/src/chat/state/chat-context.test.ts b/apps/desktop/src/chat/state/chat-context.test.ts new file mode 100644 index 0000000000..d3ca6d11b9 --- /dev/null +++ b/apps/desktop/src/chat/state/chat-context.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { useChatContext } from "./chat-context"; + +describe("chat context", () => { + beforeEach(() => { + useChatContext.setState({ + groupId: undefined, + sessionId: "session-initial", + }); + }); + + test("startNewChat resets the group and rotates the session id", () => { + useChatContext.setState({ + groupId: "group-1", + sessionId: "session-1", + }); + + useChatContext.getState().startNewChat(); + + const state = useChatContext.getState(); + expect(state.groupId).toBeUndefined(); + expect(state.sessionId).not.toBe("session-1"); + }); + + test("selectChat syncs the selected group and session id", () => { + useChatContext.getState().selectChat("group-2"); + + const state = useChatContext.getState(); + expect(state.groupId).toBe("group-2"); + expect(state.sessionId).toBe("group-2"); + }); +}); diff --git a/apps/desktop/src/chat/state/chat-context.ts b/apps/desktop/src/chat/state/chat-context.ts index 903a086bf3..74ab4ec3ca 100644 --- a/apps/desktop/src/chat/state/chat-context.ts +++ b/apps/desktop/src/chat/state/chat-context.ts @@ -1,16 +1,24 @@ import { create } from "zustand"; +import { id } from "~/shared/utils"; + interface ChatContextState { groupId: string | undefined; + sessionId: string; } interface ChatContextActions { setGroupId: (groupId: string | undefined) => void; + startNewChat: () => void; + selectChat: (groupId: string) => void; } export const useChatContext = create( (set) => ({ groupId: undefined, + sessionId: id(), setGroupId: (groupId) => set({ groupId }), + startNewChat: () => set({ groupId: undefined, sessionId: id() }), + selectChat: (groupId) => set({ groupId, sessionId: groupId }), }), ); diff --git a/apps/desktop/src/chat/state/use-chat-mode.ts b/apps/desktop/src/chat/state/use-chat-mode.ts index 9e1e52f4ab..97ad28e897 100644 --- a/apps/desktop/src/chat/state/use-chat-mode.ts +++ b/apps/desktop/src/chat/state/use-chat-mode.ts @@ -11,7 +11,10 @@ export function useChatMode() { const transitionChatMode = useTabs((state) => state.transitionChatMode); const groupId = useChatContext((state) => state.groupId); + const sessionId = useChatContext((state) => state.sessionId); const setGroupId = useChatContext((state) => state.setGroupId); + const startNewChat = useChatContext((state) => state.startNewChat); + const selectChat = useChatContext((state) => state.selectChat); useHotkeys( "mod+j", @@ -28,6 +31,9 @@ export function useChatMode() { mode, sendEvent: transitionChatMode, groupId, + sessionId, setGroupId, + startNewChat, + selectChat, }; } diff --git a/apps/desktop/src/shared/main/index.tsx b/apps/desktop/src/shared/main/index.tsx index 681ca89783..a256c68c3b 100644 --- a/apps/desktop/src/shared/main/index.tsx +++ b/apps/desktop/src/shared/main/index.tsx @@ -937,6 +937,11 @@ function useTabsShortcuts() { useHotkeys( "mod+n", () => { + if (isPersistentChatInputFocused(chat.mode)) { + chat.startNewChat(); + return; + } + if (currentTab?.type === "empty") { newNoteCurrent(); } else { @@ -948,7 +953,7 @@ function useTabsShortcuts() { enableOnFormTags: true, enableOnContentEditable: true, }, - [currentTab, newNote, newNoteCurrent], + [chat, currentTab, newNote, newNoteCurrent], ); useHotkeys( @@ -1129,3 +1134,22 @@ function useNewEmptyTab() { return handler; } + +function isPersistentChatInputFocused( + mode: ReturnType["chat"]["mode"], +) { + if (mode !== "FloatingOpen" && mode !== "RightPanelOpen") { + return false; + } + + if (typeof document === "undefined") { + return false; + } + + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) { + return false; + } + + return activeElement.closest("[data-chat-message-input]") !== null; +}