From d8a07063b00a31d324da8a1d15b5af1835c477f9 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 19:13:06 +0900 Subject: [PATCH 01/22] feat(desktop): add sidebar timeline setting Add a persisted app setting that lets users show the timeline in the left sidebar instead of the top timeline. --- .../src/chat/components/persistent-chat.tsx | 99 ------------- apps/desktop/src/chat/state/use-chat-mode.ts | 8 +- apps/desktop/src/i18n/locales/en/messages.po | 40 +++-- apps/desktop/src/i18n/locales/en/messages.ts | 2 +- apps/desktop/src/i18n/locales/ja/messages.po | 44 ++++-- apps/desktop/src/i18n/locales/ja/messages.ts | 2 +- apps/desktop/src/i18n/locales/ko/messages.po | 44 ++++-- apps/desktop/src/i18n/locales/ko/messages.ts | 2 +- apps/desktop/src/main/body.tsx | 138 ++++++++++++++++-- apps/desktop/src/main/empty.test.tsx | 6 + apps/desktop/src/main/shell-frame.test.tsx | 100 +++++++++++++ apps/desktop/src/main/shell-frame.tsx | 27 +++- apps/desktop/src/main/shell-sidebar.tsx | 4 +- .../src/settings/general/app-settings.tsx | 90 +++++++----- apps/desktop/src/settings/general/index.tsx | 101 +++++++------ apps/desktop/src/shared/chat-cta.test.tsx | 26 ++++ apps/desktop/src/shared/chat-cta.tsx | 17 +-- apps/desktop/src/shared/main/body-frame.tsx | 13 +- apps/desktop/src/shared/main/body.test.tsx | 89 ++++++++++- .../src/shared/main/chat-panels.test.tsx | 95 +----------- apps/desktop/src/shared/main/chat-panels.tsx | 8 +- .../src/shared/main/shell-scaffold.test.tsx | 50 +++++++ .../src/shared/main/shell-scaffold.tsx | 21 ++- .../src/shared/main/shell-sidebar.test.tsx | 17 ++- apps/desktop/src/sidebar/index.test.tsx | 98 +++++++++++++ apps/desktop/src/sidebar/index.tsx | 15 +- apps/desktop/src/sidebar/timeline/index.tsx | 14 +- apps/desktop/src/sidebar/timeline/item.tsx | 3 +- .../persister/settings/persister.test.ts | 2 + .../src/store/tinybase/store/settings.ts | 5 + packages/store/src/tinybase.ts | 1 + packages/store/src/zod.ts | 1 + 32 files changed, 811 insertions(+), 371 deletions(-) delete mode 100644 apps/desktop/src/chat/components/persistent-chat.tsx create mode 100644 apps/desktop/src/main/shell-frame.test.tsx create mode 100644 apps/desktop/src/shared/chat-cta.test.tsx create mode 100644 apps/desktop/src/shared/main/shell-scaffold.test.tsx create mode 100644 apps/desktop/src/sidebar/index.test.tsx diff --git a/apps/desktop/src/chat/components/persistent-chat.tsx b/apps/desktop/src/chat/components/persistent-chat.tsx deleted file mode 100644 index 26d4e2bca7..0000000000 --- a/apps/desktop/src/chat/components/persistent-chat.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; - -import { ChatView } from "./chat-panel"; - -import { useShell } from "~/contexts/shell"; - -export function PersistentChatPanel({ - panelContainerRef, -}: { - panelContainerRef: React.RefObject; -}) { - const { chat } = useShell(); - const isVisible = chat.mode === "RightPanelOpen"; - - const [hasBeenOpened, setHasBeenOpened] = useState(false); - const [containerRect, setContainerRect] = useState(null); - const observerRef = useRef(null); - - useEffect(() => { - if (isVisible && !hasBeenOpened) { - setHasBeenOpened(true); - } - }, [isVisible, hasBeenOpened]); - - useHotkeys( - "esc", - () => chat.sendEvent({ type: "CLOSE" }), - { - enabled: isVisible, - preventDefault: true, - enableOnFormTags: true, - enableOnContentEditable: true, - }, - [chat, isVisible], - ); - - useLayoutEffect(() => { - const container = panelContainerRef.current; - - if (!isVisible || !container) { - setContainerRect(null); - return; - } - setContainerRect(container.getBoundingClientRect()); - }, [isVisible, panelContainerRef]); - - useEffect(() => { - const container = panelContainerRef.current; - - if (!isVisible || !container) { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - return; - } - - const updateRect = () => { - setContainerRect(container.getBoundingClientRect()); - }; - - observerRef.current = new ResizeObserver(updateRect); - observerRef.current.observe(container); - window.addEventListener("resize", updateRect); - window.addEventListener("scroll", updateRect, true); - - return () => { - observerRef.current?.disconnect(); - observerRef.current = null; - window.removeEventListener("resize", updateRect); - window.removeEventListener("scroll", updateRect, true); - }; - }, [isVisible, panelContainerRef]); - - if (!hasBeenOpened || !isVisible) { - return null; - } - - return ( -
-
- -
-
- ); -} diff --git a/apps/desktop/src/chat/state/use-chat-mode.ts b/apps/desktop/src/chat/state/use-chat-mode.ts index 97ad28e897..b9bb9ee307 100644 --- a/apps/desktop/src/chat/state/use-chat-mode.ts +++ b/apps/desktop/src/chat/state/use-chat-mode.ts @@ -1,5 +1,7 @@ import { useHotkeys } from "react-hotkeys-hook"; +import { commands as windowsCommands } from "@hypr/plugin-windows"; + import { useChatContext } from "./chat-context"; import { useTabs } from "~/store/zustand/tabs"; @@ -18,13 +20,15 @@ export function useChatMode() { useHotkeys( "mod+j", - () => transitionChatMode({ type: "TOGGLE" }), + () => { + windowsCommands.windowShow({ type: "composer" }).catch(console.error); + }, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, }, - [transitionChatMode], + [], ); return { diff --git a/apps/desktop/src/i18n/locales/en/messages.po b/apps/desktop/src/i18n/locales/en/messages.po index 301c659a49..f129bede37 100644 --- a/apps/desktop/src/i18n/locales/en/messages.po +++ b/apps/desktop/src/i18n/locales/en/messages.po @@ -25,27 +25,27 @@ msgstr "Add spoken language" msgid "Additional spoken languages" msgstr "Additional spoken languages" -#: src/settings/general/index.tsx:158 +#: src/settings/general/index.tsx:163 msgid "Always ready without manually launching." msgstr "Always ready without manually launching." -#: src/settings/general/app-settings.tsx:30 +#: src/settings/general/app-settings.tsx:33 msgid "App" msgstr "App" -#: src/settings/general/index.tsx:165 +#: src/settings/general/index.tsx:170 msgid "Automatically start listening when an event-backed note reaches its scheduled start time." msgstr "Automatically start listening when an event-backed note reaches its scheduled start time." -#: src/settings/general/index.tsx:175 +#: src/settings/general/index.tsx:180 msgid "Automatically stop listening when the meeting app releases the microphone." msgstr "Automatically stop listening when the meeting app releases the microphone." -#: src/settings/general/index.tsx:259 +#: src/settings/general/index.tsx:276 msgid "Data" msgstr "Data" -#: src/settings/general/index.tsx:208 +#: src/settings/general/index.tsx:225 msgid "Language & Region" msgstr "Language & Region" @@ -57,12 +57,16 @@ msgstr "Language for summaries, chats, and AI-generated responses" msgid "Main language" msgstr "Main language" +#: src/settings/general/app-settings.tsx:59 +msgid "Meetings" +msgstr "Meetings" + #: src/settings/general/main-language.tsx:64 #: src/settings/general/spoken-languages.tsx:218 msgid "No matching languages found" msgstr "No matching languages found" -#: src/settings/general/index.tsx:271 +#: src/settings/general/index.tsx:288 msgid "Notifications" msgstr "Notifications" @@ -74,7 +78,7 @@ msgstr "Search language..." msgid "Select language" msgstr "Select language" -#: src/settings/general/index.tsx:189 +#: src/settings/general/index.tsx:204 msgid "Send anonymous usage analytics to help improve Anarlog." msgstr "Send anonymous usage analytics to help improve Anarlog." @@ -82,30 +86,38 @@ msgstr "Send anonymous usage analytics to help improve Anarlog." msgid "Settings" msgstr "Settings" -#: src/settings/general/index.tsx:188 +#: src/settings/general/index.tsx:203 msgid "Share usage data" msgstr "Share usage data" -#: src/settings/general/index.tsx:181 +#: src/settings/general/index.tsx:186 msgid "Show floating bar" msgstr "Show floating bar" -#: src/settings/general/index.tsx:182 +#: src/settings/general/index.tsx:187 msgid "Show the compact floating control while listening." msgstr "Show the compact floating control while listening." -#: src/settings/general/index.tsx:157 +#: src/settings/general/index.tsx:193 +msgid "Show timeline in sidebar" +msgstr "Show timeline in sidebar" + +#: src/settings/general/index.tsx:162 msgid "Start Anarlog at login" msgstr "Start Anarlog at login" -#: src/settings/general/index.tsx:164 +#: src/settings/general/index.tsx:169 msgid "Start when meeting begins" msgstr "Start when meeting begins" -#: src/settings/general/index.tsx:174 +#: src/settings/general/index.tsx:179 msgid "Stop when meeting ends" msgstr "Stop when meeting ends" #: src/settings/general/spoken-languages.tsx:119 msgid "The main language is always included for transcription" msgstr "The main language is always included for transcription" + +#: src/settings/general/index.tsx:194 +msgid "Use the left sidebar timeline instead of the top timeline." +msgstr "Use the left sidebar timeline instead of the top timeline." diff --git a/apps/desktop/src/i18n/locales/en/messages.ts b/apps/desktop/src/i18n/locales/en/messages.ts index 4e5cdb2834..0b2b8368f0 100644 --- a/apps/desktop/src/i18n/locales/en/messages.ts +++ b/apps/desktop/src/i18n/locales/en/messages.ts @@ -1 +1 @@ -/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0L47q7\":[\"Main language\"],\"1wdDm9\":[\"Add language\"],\"4w6oyH\":[\"Start when meeting begins\"],\"6PZ_8n\":[\"The main language is always included for transcription\"],\"ADZ1Xl\":[\"Add spoken language\"],\"BgiToP\":[\"Search language...\"],\"C0rVIc\":[\"Language & Region\"],\"G4Pd27\":[\"Share usage data\"],\"HKH-W-\":[\"Data\"],\"LMUw1U\":[\"App\"],\"MsvOqZ\":[\"Automatically start listening when an event-backed note reaches its scheduled start time.\"],\"Pqpcvm\":[\"Automatically stop listening when the meeting app releases the microphone.\"],\"Tz0i8g\":[\"Settings\"],\"Wu4354\":[\"Show the compact floating control while listening.\"],\"bQjTS0\":[\"Additional spoken languages\"],\"bkVeDl\":[\"Always ready without manually launching.\"],\"etUJEW\":[\"Start Anarlog at login\"],\"iBYy7Q\":[\"Language for summaries, chats, and AI-generated responses\"],\"iDNBZe\":[\"Notifications\"],\"jzl8IQ\":[\"Stop when meeting ends\"],\"kUWjsV\":[\"No matching languages found\"],\"k_sb6z\":[\"Select language\"],\"qRSwae\":[\"Show floating bar\"],\"rcFMmv\":[\"Send anonymous usage analytics to help improve Anarlog.\"]}")as Messages; \ No newline at end of file +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0L47q7\":[\"Main language\"],\"1wdDm9\":[\"Add language\"],\"4w6oyH\":[\"Start when meeting begins\"],\"6PZ_8n\":[\"The main language is always included for transcription\"],\"ADZ1Xl\":[\"Add spoken language\"],\"BgiToP\":[\"Search language...\"],\"C0rVIc\":[\"Language & Region\"],\"G4Pd27\":[\"Share usage data\"],\"HKH-W-\":[\"Data\"],\"LMUw1U\":[\"App\"],\"MsvOqZ\":[\"Automatically start listening when an event-backed note reaches its scheduled start time.\"],\"Pqpcvm\":[\"Automatically stop listening when the meeting app releases the microphone.\"],\"Tz0i8g\":[\"Settings\"],\"Wu4354\":[\"Show the compact floating control while listening.\"],\"XDmqQW\":[\"Show timeline in sidebar\"],\"bQjTS0\":[\"Additional spoken languages\"],\"bkVeDl\":[\"Always ready without manually launching.\"],\"etUJEW\":[\"Start Anarlog at login\"],\"fn5XVd\":[\"Use the left sidebar timeline instead of the top timeline.\"],\"iBYy7Q\":[\"Language for summaries, chats, and AI-generated responses\"],\"iDNBZe\":[\"Notifications\"],\"jzl8IQ\":[\"Stop when meeting ends\"],\"jzmguI\":[\"Meetings\"],\"kUWjsV\":[\"No matching languages found\"],\"k_sb6z\":[\"Select language\"],\"qRSwae\":[\"Show floating bar\"],\"rcFMmv\":[\"Send anonymous usage analytics to help improve Anarlog.\"]}")as Messages; \ No newline at end of file diff --git a/apps/desktop/src/i18n/locales/ja/messages.po b/apps/desktop/src/i18n/locales/ja/messages.po index edead35da5..0638fef750 100644 --- a/apps/desktop/src/i18n/locales/ja/messages.po +++ b/apps/desktop/src/i18n/locales/ja/messages.po @@ -25,27 +25,27 @@ msgstr "音声言語を追加" msgid "Additional spoken languages" msgstr "追加の音声言語" -#: src/settings/general/index.tsx:158 +#: src/settings/general/index.tsx:163 msgid "Always ready without manually launching." msgstr "手動で起動しなくても常に準備できます。" -#: src/settings/general/app-settings.tsx:30 +#: src/settings/general/app-settings.tsx:33 msgid "App" msgstr "アプリ" -#: src/settings/general/index.tsx:165 +#: src/settings/general/index.tsx:170 msgid "Automatically start listening when an event-backed note reaches its scheduled start time." msgstr "予定に紐づいたノートが開始時刻になると、自動的にリスニングを開始します。" -#: src/settings/general/index.tsx:175 +#: src/settings/general/index.tsx:180 msgid "Automatically stop listening when the meeting app releases the microphone." msgstr "会議アプリがマイクを解放すると、自動的にリスニングを停止します。" -#: src/settings/general/index.tsx:259 +#: src/settings/general/index.tsx:276 msgid "Data" msgstr "データ" -#: src/settings/general/index.tsx:208 +#: src/settings/general/index.tsx:225 msgid "Language & Region" msgstr "言語と地域" @@ -57,12 +57,16 @@ msgstr "要約、チャット、AI生成応答に使用する言語" msgid "Main language" msgstr "メイン言語" +#: src/settings/general/app-settings.tsx:59 +msgid "Meetings" +msgstr "会議" + #: src/settings/general/main-language.tsx:64 #: src/settings/general/spoken-languages.tsx:218 msgid "No matching languages found" msgstr "一致する言語が見つかりません" -#: src/settings/general/index.tsx:271 +#: src/settings/general/index.tsx:288 msgid "Notifications" msgstr "通知" @@ -74,7 +78,7 @@ msgstr "言語を検索..." msgid "Select language" msgstr "言語を選択" -#: src/settings/general/index.tsx:189 +#: src/settings/general/index.tsx:204 msgid "Send anonymous usage analytics to help improve Anarlog." msgstr "Anarlog の改善に役立てるため、匿名の使用状況データを送信します。" @@ -82,30 +86,38 @@ msgstr "Anarlog の改善に役立てるため、匿名の使用状況データ msgid "Settings" msgstr "設定" -#: src/settings/general/index.tsx:188 +#: src/settings/general/index.tsx:203 msgid "Share usage data" msgstr "使用状況データを共有" -#: src/settings/general/index.tsx:181 +#: src/settings/general/index.tsx:186 msgid "Show floating bar" -msgstr "" +msgstr "フローティングバーを表示" -#: src/settings/general/index.tsx:182 +#: src/settings/general/index.tsx:187 msgid "Show the compact floating control while listening." -msgstr "" +msgstr "リスニング中にコンパクトなフローティングコントロールを表示します。" -#: src/settings/general/index.tsx:157 +#: src/settings/general/index.tsx:193 +msgid "Show timeline in sidebar" +msgstr "タイムラインをサイドバーに表示" + +#: src/settings/general/index.tsx:162 msgid "Start Anarlog at login" msgstr "ログイン時に Anarlog を起動" -#: src/settings/general/index.tsx:164 +#: src/settings/general/index.tsx:169 msgid "Start when meeting begins" msgstr "会議開始時に開始" -#: src/settings/general/index.tsx:174 +#: src/settings/general/index.tsx:179 msgid "Stop when meeting ends" msgstr "会議終了時に停止" #: src/settings/general/spoken-languages.tsx:119 msgid "The main language is always included for transcription" msgstr "メイン言語は文字起こしに常に含まれます" + +#: src/settings/general/index.tsx:194 +msgid "Use the left sidebar timeline instead of the top timeline." +msgstr "上部のタイムラインの代わりに左サイドバーのタイムラインを使用します。" diff --git a/apps/desktop/src/i18n/locales/ja/messages.ts b/apps/desktop/src/i18n/locales/ja/messages.ts index b580664285..d263cebcc0 100644 --- a/apps/desktop/src/i18n/locales/ja/messages.ts +++ b/apps/desktop/src/i18n/locales/ja/messages.ts @@ -1 +1 @@ -/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0L47q7\":[\"メイン言語\"],\"1wdDm9\":[\"言語を追加\"],\"4w6oyH\":[\"会議開始時に開始\"],\"6PZ_8n\":[\"メイン言語は文字起こしに常に含まれます\"],\"ADZ1Xl\":[\"音声言語を追加\"],\"BgiToP\":[\"言語を検索...\"],\"C0rVIc\":[\"言語と地域\"],\"G4Pd27\":[\"使用状況データを共有\"],\"HKH-W-\":[\"データ\"],\"LMUw1U\":[\"アプリ\"],\"MsvOqZ\":[\"予定に紐づいたノートが開始時刻になると、自動的にリスニングを開始します。\"],\"Pqpcvm\":[\"会議アプリがマイクを解放すると、自動的にリスニングを停止します。\"],\"Tz0i8g\":[\"設定\"],\"Wu4354\":[\"Show the compact floating control while listening.\"],\"bQjTS0\":[\"追加の音声言語\"],\"bkVeDl\":[\"手動で起動しなくても常に準備できます。\"],\"etUJEW\":[\"ログイン時に Anarlog を起動\"],\"iBYy7Q\":[\"要約、チャット、AI生成応答に使用する言語\"],\"iDNBZe\":[\"通知\"],\"jzl8IQ\":[\"会議終了時に停止\"],\"kUWjsV\":[\"一致する言語が見つかりません\"],\"k_sb6z\":[\"言語を選択\"],\"qRSwae\":[\"Show floating bar\"],\"rcFMmv\":[\"Anarlog の改善に役立てるため、匿名の使用状況データを送信します。\"]}")as Messages; \ No newline at end of file +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0L47q7\":[\"メイン言語\"],\"1wdDm9\":[\"言語を追加\"],\"4w6oyH\":[\"会議開始時に開始\"],\"6PZ_8n\":[\"メイン言語は文字起こしに常に含まれます\"],\"ADZ1Xl\":[\"音声言語を追加\"],\"BgiToP\":[\"言語を検索...\"],\"C0rVIc\":[\"言語と地域\"],\"G4Pd27\":[\"使用状況データを共有\"],\"HKH-W-\":[\"データ\"],\"LMUw1U\":[\"アプリ\"],\"MsvOqZ\":[\"予定に紐づいたノートが開始時刻になると、自動的にリスニングを開始します。\"],\"Pqpcvm\":[\"会議アプリがマイクを解放すると、自動的にリスニングを停止します。\"],\"Tz0i8g\":[\"設定\"],\"Wu4354\":[\"リスニング中にコンパクトなフローティングコントロールを表示します。\"],\"XDmqQW\":[\"タイムラインをサイドバーに表示\"],\"bQjTS0\":[\"追加の音声言語\"],\"bkVeDl\":[\"手動で起動しなくても常に準備できます。\"],\"etUJEW\":[\"ログイン時に Anarlog を起動\"],\"fn5XVd\":[\"上部のタイムラインの代わりに左サイドバーのタイムラインを使用します。\"],\"iBYy7Q\":[\"要約、チャット、AI生成応答に使用する言語\"],\"iDNBZe\":[\"通知\"],\"jzl8IQ\":[\"会議終了時に停止\"],\"jzmguI\":[\"会議\"],\"kUWjsV\":[\"一致する言語が見つかりません\"],\"k_sb6z\":[\"言語を選択\"],\"qRSwae\":[\"フローティングバーを表示\"],\"rcFMmv\":[\"Anarlog の改善に役立てるため、匿名の使用状況データを送信します。\"]}")as Messages; \ No newline at end of file diff --git a/apps/desktop/src/i18n/locales/ko/messages.po b/apps/desktop/src/i18n/locales/ko/messages.po index 490a3bdc44..c4509b34dc 100644 --- a/apps/desktop/src/i18n/locales/ko/messages.po +++ b/apps/desktop/src/i18n/locales/ko/messages.po @@ -25,27 +25,27 @@ msgstr "음성 언어 추가" msgid "Additional spoken languages" msgstr "추가 음성 언어" -#: src/settings/general/index.tsx:158 +#: src/settings/general/index.tsx:163 msgid "Always ready without manually launching." msgstr "수동으로 실행하지 않아도 항상 준비됩니다." -#: src/settings/general/app-settings.tsx:30 +#: src/settings/general/app-settings.tsx:33 msgid "App" msgstr "앱" -#: src/settings/general/index.tsx:165 +#: src/settings/general/index.tsx:170 msgid "Automatically start listening when an event-backed note reaches its scheduled start time." msgstr "일정에 연결된 노트의 시작 시간이 되면 자동으로 듣기를 시작합니다." -#: src/settings/general/index.tsx:175 +#: src/settings/general/index.tsx:180 msgid "Automatically stop listening when the meeting app releases the microphone." msgstr "회의 앱이 마이크를 해제하면 자동으로 듣기를 중지합니다." -#: src/settings/general/index.tsx:259 +#: src/settings/general/index.tsx:276 msgid "Data" msgstr "데이터" -#: src/settings/general/index.tsx:208 +#: src/settings/general/index.tsx:225 msgid "Language & Region" msgstr "언어 및 지역" @@ -57,12 +57,16 @@ msgstr "요약, 채팅, AI 생성 응답에 사용할 언어" msgid "Main language" msgstr "기본 언어" +#: src/settings/general/app-settings.tsx:59 +msgid "Meetings" +msgstr "회의" + #: src/settings/general/main-language.tsx:64 #: src/settings/general/spoken-languages.tsx:218 msgid "No matching languages found" msgstr "일치하는 언어를 찾을 수 없습니다" -#: src/settings/general/index.tsx:271 +#: src/settings/general/index.tsx:288 msgid "Notifications" msgstr "알림" @@ -74,7 +78,7 @@ msgstr "언어 검색..." msgid "Select language" msgstr "언어 선택" -#: src/settings/general/index.tsx:189 +#: src/settings/general/index.tsx:204 msgid "Send anonymous usage analytics to help improve Anarlog." msgstr "Anarlog 개선을 위해 익명 사용 분석을 보냅니다." @@ -82,30 +86,38 @@ msgstr "Anarlog 개선을 위해 익명 사용 분석을 보냅니다." msgid "Settings" msgstr "설정" -#: src/settings/general/index.tsx:188 +#: src/settings/general/index.tsx:203 msgid "Share usage data" msgstr "사용 데이터 공유" -#: src/settings/general/index.tsx:181 +#: src/settings/general/index.tsx:186 msgid "Show floating bar" -msgstr "" +msgstr "플로팅 바 표시" -#: src/settings/general/index.tsx:182 +#: src/settings/general/index.tsx:187 msgid "Show the compact floating control while listening." -msgstr "" +msgstr "듣는 동안 작은 플로팅 컨트롤을 표시합니다." -#: src/settings/general/index.tsx:157 +#: src/settings/general/index.tsx:193 +msgid "Show timeline in sidebar" +msgstr "사이드바에 타임라인 표시" + +#: src/settings/general/index.tsx:162 msgid "Start Anarlog at login" msgstr "로그인 시 Anarlog 시작" -#: src/settings/general/index.tsx:164 +#: src/settings/general/index.tsx:169 msgid "Start when meeting begins" msgstr "회의 시작 시 시작" -#: src/settings/general/index.tsx:174 +#: src/settings/general/index.tsx:179 msgid "Stop when meeting ends" msgstr "회의 종료 시 중지" #: src/settings/general/spoken-languages.tsx:119 msgid "The main language is always included for transcription" msgstr "기본 언어는 전사에 항상 포함됩니다" + +#: src/settings/general/index.tsx:194 +msgid "Use the left sidebar timeline instead of the top timeline." +msgstr "상단 타임라인 대신 왼쪽 사이드바 타임라인을 사용합니다." diff --git a/apps/desktop/src/i18n/locales/ko/messages.ts b/apps/desktop/src/i18n/locales/ko/messages.ts index 050630f21b..c2a6768979 100644 --- a/apps/desktop/src/i18n/locales/ko/messages.ts +++ b/apps/desktop/src/i18n/locales/ko/messages.ts @@ -1 +1 @@ -/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0L47q7\":[\"기본 언어\"],\"1wdDm9\":[\"언어 추가\"],\"4w6oyH\":[\"회의 시작 시 시작\"],\"6PZ_8n\":[\"기본 언어는 전사에 항상 포함됩니다\"],\"ADZ1Xl\":[\"음성 언어 추가\"],\"BgiToP\":[\"언어 검색...\"],\"C0rVIc\":[\"언어 및 지역\"],\"G4Pd27\":[\"사용 데이터 공유\"],\"HKH-W-\":[\"데이터\"],\"LMUw1U\":[\"앱\"],\"MsvOqZ\":[\"일정에 연결된 노트의 시작 시간이 되면 자동으로 듣기를 시작합니다.\"],\"Pqpcvm\":[\"회의 앱이 마이크를 해제하면 자동으로 듣기를 중지합니다.\"],\"Tz0i8g\":[\"설정\"],\"Wu4354\":[\"Show the compact floating control while listening.\"],\"bQjTS0\":[\"추가 음성 언어\"],\"bkVeDl\":[\"수동으로 실행하지 않아도 항상 준비됩니다.\"],\"etUJEW\":[\"로그인 시 Anarlog 시작\"],\"iBYy7Q\":[\"요약, 채팅, AI 생성 응답에 사용할 언어\"],\"iDNBZe\":[\"알림\"],\"jzl8IQ\":[\"회의 종료 시 중지\"],\"kUWjsV\":[\"일치하는 언어를 찾을 수 없습니다\"],\"k_sb6z\":[\"언어 선택\"],\"qRSwae\":[\"Show floating bar\"],\"rcFMmv\":[\"Anarlog 개선을 위해 익명 사용 분석을 보냅니다.\"]}")as Messages; \ No newline at end of file +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"0L47q7\":[\"기본 언어\"],\"1wdDm9\":[\"언어 추가\"],\"4w6oyH\":[\"회의 시작 시 시작\"],\"6PZ_8n\":[\"기본 언어는 전사에 항상 포함됩니다\"],\"ADZ1Xl\":[\"음성 언어 추가\"],\"BgiToP\":[\"언어 검색...\"],\"C0rVIc\":[\"언어 및 지역\"],\"G4Pd27\":[\"사용 데이터 공유\"],\"HKH-W-\":[\"데이터\"],\"LMUw1U\":[\"앱\"],\"MsvOqZ\":[\"일정에 연결된 노트의 시작 시간이 되면 자동으로 듣기를 시작합니다.\"],\"Pqpcvm\":[\"회의 앱이 마이크를 해제하면 자동으로 듣기를 중지합니다.\"],\"Tz0i8g\":[\"설정\"],\"Wu4354\":[\"듣는 동안 작은 플로팅 컨트롤을 표시합니다.\"],\"XDmqQW\":[\"사이드바에 타임라인 표시\"],\"bQjTS0\":[\"추가 음성 언어\"],\"bkVeDl\":[\"수동으로 실행하지 않아도 항상 준비됩니다.\"],\"etUJEW\":[\"로그인 시 Anarlog 시작\"],\"fn5XVd\":[\"상단 타임라인 대신 왼쪽 사이드바 타임라인을 사용합니다.\"],\"iBYy7Q\":[\"요약, 채팅, AI 생성 응답에 사용할 언어\"],\"iDNBZe\":[\"알림\"],\"jzl8IQ\":[\"회의 종료 시 중지\"],\"jzmguI\":[\"회의\"],\"kUWjsV\":[\"일치하는 언어를 찾을 수 없습니다\"],\"k_sb6z\":[\"언어 선택\"],\"qRSwae\":[\"플로팅 바 표시\"],\"rcFMmv\":[\"Anarlog 개선을 위해 익명 사용 분석을 보냅니다.\"]}")as Messages; \ No newline at end of file diff --git a/apps/desktop/src/main/body.tsx b/apps/desktop/src/main/body.tsx index fcbb67fce8..e780db6f95 100644 --- a/apps/desktop/src/main/body.tsx +++ b/apps/desktop/src/main/body.tsx @@ -1,3 +1,5 @@ +import { ArrowLeftIcon, ArrowRightIcon, CalendarDaysIcon } from "lucide-react"; + import { cn } from "@hypr/utils"; import { ClassicMainSidebar } from "./shell-sidebar"; @@ -6,6 +8,7 @@ import { TopMeetingTimeline } from "./top-meeting-timeline"; import { useClassicMainTabsShortcuts } from "./useTabsShortcuts"; import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import { ToastArea } from "~/sidebar/toast"; import { hasCustomSidebarTab } from "~/sidebar/use-custom-sidebar"; import { type Tab, uniqueIdfromTab, useTabs } from "~/store/zustand/tabs"; @@ -13,35 +16,77 @@ import { type Tab, uniqueIdfromTab, useTabs } from "~/store/zustand/tabs"; export function ClassicMainBody() { const { leftsidebar } = useShell(); const currentTab = useTabs((state) => state.currentTab); + const openNew = useTabs((state) => state.openNew); + const goBack = useTabs((state) => state.goBack); + const goNext = useTabs((state) => state.goNext); + const canGoBack = useTabs((state) => state.canGoBack); + const canGoNext = useTabs((state) => state.canGoNext); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); useClassicMainTabsShortcuts(); const isOnboarding = currentTab?.type === "onboarding"; const hasCustomSidebar = hasCustomSidebarTab(currentTab); + const showSidebarTimeline = + sidebarTimelineEnabled && + leftsidebar.expanded && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; const showTopTimeline = leftsidebar.expanded && + !showSidebarTimeline && !leftsidebar.showDevtool && !hasCustomSidebar && !isOnboarding; const showFloatingToast = - !leftsidebar.showDevtool && !hasCustomSidebar && !isOnboarding; + !showSidebarTimeline && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; + const openCalendar = () => { + openNew({ type: "calendar" }); + }; return (
-
+ {showSidebarTimeline ? (
- {showTopTimeline ? ( -
- -
- ) : null} +
+ +
-
+ ) : ( +
+
+ {showTopTimeline ? ( +
+ +
+ ) : null} +
+
+ )}
@@ -61,3 +106,72 @@ export function ClassicMainBody() {
); } + +function SidebarTimelineChrome({ + canGoBack, + canGoNext, + onBack, + onForward, + onOpenCalendar, +}: { + canGoBack: boolean; + canGoNext: boolean; + onBack: () => void; + onForward: () => void; + onOpenCalendar: () => void; +}) { + return ( +
+ + + + + + + + + +
+ ); +} + +function SidebarTimelineChromeButton({ + ariaLabel, + children, + disabled = false, + onClick, +}: { + ariaLabel: string; + children: React.ReactNode; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/main/empty.test.tsx b/apps/desktop/src/main/empty.test.tsx index 41156597f4..a1a020181b 100644 --- a/apps/desktop/src/main/empty.test.tsx +++ b/apps/desktop/src/main/empty.test.tsx @@ -22,6 +22,12 @@ vi.mock("~/shared/useNewNote", () => ({ useNewNoteAndListen: () => vi.fn(), })); +vi.mock("@hypr/plugin-windows", () => ({ + commands: { + windowShow: vi.fn(() => Promise.resolve({ status: "ok" })), + }, +})); + vi.mock("~/contexts/shell", () => ({ useShell: () => ({ chat: { diff --git a/apps/desktop/src/main/shell-frame.test.tsx b/apps/desktop/src/main/shell-frame.test.tsx new file mode 100644 index 0000000000..006dd6c633 --- /dev/null +++ b/apps/desktop/src/main/shell-frame.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + currentTab: { type: "empty" } as { type: string } | null, + leftsidebar: { + expanded: true, + showDevtool: false, + }, + sidebarTimelineEnabled: false, +})); + +vi.mock("./body", () => ({ + ClassicMainBody: () =>
, +})); + +vi.mock("~/shared/main", () => ({ + MainShellBodyFrame: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MainShellScaffold: ({ + children, + edgeToEdge, + }: { + children: React.ReactNode; + edgeToEdge?: boolean; + }) => ( +
+ {children} +
+ ), +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + leftsidebar: mocks.leftsidebar, + }), +})); + +vi.mock("~/shared/config", () => ({ + useConfigValue: () => mocks.sidebarTimelineEnabled, +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: ( + selector: (state: { currentTab: typeof mocks.currentTab }) => unknown, + ) => selector({ currentTab: mocks.currentTab }), +})); + +import { ClassicMainShellFrame } from "./shell-frame"; + +describe("ClassicMainShellFrame", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + mocks.currentTab = { type: "empty" }; + mocks.leftsidebar.expanded = true; + mocks.leftsidebar.showDevtool = false; + mocks.sidebarTimelineEnabled = false; + }); + + it("removes shell padding in top timeline mode", () => { + render(); + + expect( + screen + .getByTestId("main-shell-scaffold") + .getAttribute("data-edge-to-edge"), + ).toBe("true"); + }); + + it("keeps shell padding in sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = true; + + render(); + + expect( + screen + .getByTestId("main-shell-scaffold") + .getAttribute("data-edge-to-edge"), + ).toBe("false"); + }); + + it("keeps shell padding for custom sidebar tabs", () => { + mocks.currentTab = { type: "settings" }; + + render(); + + expect( + screen + .getByTestId("main-shell-scaffold") + .getAttribute("data-edge-to-edge"), + ).toBe("false"); + }); +}); diff --git a/apps/desktop/src/main/shell-frame.tsx b/apps/desktop/src/main/shell-frame.tsx index aafb7d1c86..baba547fef 100644 --- a/apps/desktop/src/main/shell-frame.tsx +++ b/apps/desktop/src/main/shell-frame.tsx @@ -1,11 +1,34 @@ import { ClassicMainBody } from "./body"; +import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import { MainShellBodyFrame, MainShellScaffold } from "~/shared/main"; +import { hasCustomSidebarTab } from "~/sidebar/use-custom-sidebar"; +import { useTabs } from "~/store/zustand/tabs"; export function ClassicMainShellFrame() { + const { leftsidebar } = useShell(); + const currentTab = useTabs((state) => state.currentTab); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); + + const isOnboarding = currentTab?.type === "onboarding"; + const hasCustomSidebar = hasCustomSidebarTab(currentTab); + const showSidebarTimeline = + sidebarTimelineEnabled && + leftsidebar.expanded && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; + const showTopTimeline = + leftsidebar.expanded && + !showSidebarTimeline && + !leftsidebar.showDevtool && + !hasCustomSidebar && + !isOnboarding; + return ( - - + + diff --git a/apps/desktop/src/main/shell-sidebar.tsx b/apps/desktop/src/main/shell-sidebar.tsx index 54f7c20ba2..6c5c950071 100644 --- a/apps/desktop/src/main/shell-sidebar.tsx +++ b/apps/desktop/src/main/shell-sidebar.tsx @@ -1,4 +1,5 @@ import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import { LeftSidebar } from "~/sidebar"; import { hasCustomSidebarTab, @@ -9,6 +10,7 @@ import { useTabs } from "~/store/zustand/tabs"; export function ClassicMainSidebar() { const { leftsidebar } = useShell(); const currentTab = useTabs((state) => state.currentTab); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); const isOnboarding = currentTab?.type === "onboarding"; const hasCustomSidebar = hasCustomSidebarTab(currentTab); @@ -19,7 +21,7 @@ export function ClassicMainSidebar() { return null; } - if (leftsidebar.showDevtool || hasCustomSidebar) { + if (leftsidebar.showDevtool || hasCustomSidebar || sidebarTimelineEnabled) { return ; } diff --git a/apps/desktop/src/settings/general/app-settings.tsx b/apps/desktop/src/settings/general/app-settings.tsx index d8673f0362..46a5d6505b 100644 --- a/apps/desktop/src/settings/general/app-settings.tsx +++ b/apps/desktop/src/settings/general/app-settings.tsx @@ -14,6 +14,7 @@ interface AppSettingsViewProps { autoStartScheduledMeetings: SettingItem; autoStopMeetings: SettingItem; floatingBar: SettingItem; + sidebarTimeline: SettingItem; telemetryConsent: SettingItem; } @@ -22,45 +23,62 @@ export function AppSettingsView({ autoStartScheduledMeetings, autoStopMeetings, floatingBar, + sidebarTimeline, telemetryConsent, }: AppSettingsViewProps) { return ( -
-

- App -

-
- - - - - -
+
+
+

+ App +

+
+ + + +
+
+ +
+

+ Meetings +

+
+ + + +
+
); } diff --git a/apps/desktop/src/settings/general/index.tsx b/apps/desktop/src/settings/general/index.tsx index f54890f0c6..8a7c781136 100644 --- a/apps/desktop/src/settings/general/index.tsx +++ b/apps/desktop/src/settings/general/index.tsx @@ -29,6 +29,7 @@ function useSettingsForm() { "auto_start_scheduled_meetings", "auto_stop_meetings", "floating_bar_enabled", + "sidebar_timeline_enabled", "notification_detect", "telemetry_consent", "ai_language", @@ -66,6 +67,7 @@ function useSettingsForm() { auto_start_scheduled_meetings: value.auto_start_scheduled_meetings, auto_stop_meetings: value.auto_stop_meetings, floating_bar_enabled: value.floating_bar_enabled, + sidebar_timeline_enabled: value.sidebar_timeline_enabled, notification_detect: value.notification_detect, telemetry_consent: value.telemetry_consent, ai_language: value.ai_language, @@ -109,6 +111,7 @@ function useSettingsForm() { normalizedValue.auto_start_scheduled_meetings, auto_stop_meetings: normalizedValue.auto_stop_meetings, floating_bar_enabled: normalizedValue.floating_bar_enabled, + sidebar_timeline_enabled: normalizedValue.sidebar_timeline_enabled, notification_detect: normalizedValue.notification_detect, telemetry_consent: normalizedValue.telemetry_consent, }); @@ -150,48 +153,62 @@ export function SettingsApp() { {(autoStopMeetingsField) => ( {(floatingBarEnabledField) => ( - - {(telemetryConsentField) => ( - - autostartField.handleChange(val), - }} - autoStartScheduledMeetings={{ - title: t`Start when meeting begins`, - description: t`Automatically start listening when an event-backed note reaches its scheduled start time.`, - value: - autoStartScheduledMeetingsField.state.value, - onChange: (val) => - autoStartScheduledMeetingsField.handleChange( - val, - ), - }} - autoStopMeetings={{ - title: t`Stop when meeting ends`, - description: t`Automatically stop listening when the meeting app releases the microphone.`, - value: autoStopMeetingsField.state.value, - onChange: (val) => - autoStopMeetingsField.handleChange(val), - }} - floatingBar={{ - title: t`Show floating bar`, - description: t`Show the compact floating control while listening.`, - value: floatingBarEnabledField.state.value, - onChange: (val) => - floatingBarEnabledField.handleChange(val), - }} - telemetryConsent={{ - title: t`Share usage data`, - description: t`Send anonymous usage analytics to help improve Anarlog.`, - value: telemetryConsentField.state.value, - onChange: (val) => - telemetryConsentField.handleChange(val), - }} - /> + + {(sidebarTimelineEnabledField) => ( + + {(telemetryConsentField) => ( + + autostartField.handleChange(val), + }} + autoStartScheduledMeetings={{ + title: t`Start when meeting begins`, + description: t`Automatically start listening when an event-backed note reaches its scheduled start time.`, + value: + autoStartScheduledMeetingsField.state.value, + onChange: (val) => + autoStartScheduledMeetingsField.handleChange( + val, + ), + }} + autoStopMeetings={{ + title: t`Stop when meeting ends`, + description: t`Automatically stop listening when the meeting app releases the microphone.`, + value: autoStopMeetingsField.state.value, + onChange: (val) => + autoStopMeetingsField.handleChange(val), + }} + floatingBar={{ + title: t`Show floating bar`, + description: t`Show the compact floating control while listening.`, + value: floatingBarEnabledField.state.value, + onChange: (val) => + floatingBarEnabledField.handleChange(val), + }} + sidebarTimeline={{ + title: t`Show timeline in sidebar`, + description: t`Use the left sidebar timeline instead of the top timeline.`, + value: + sidebarTimelineEnabledField.state.value, + onChange: (val) => + sidebarTimelineEnabledField.handleChange( + val, + ), + }} + telemetryConsent={{ + title: t`Share usage data`, + description: t`Send anonymous usage analytics to help improve Anarlog.`, + value: telemetryConsentField.state.value, + onChange: (val) => + telemetryConsentField.handleChange(val), + }} + /> + )} + )} )} diff --git a/apps/desktop/src/shared/chat-cta.test.tsx b/apps/desktop/src/shared/chat-cta.test.tsx new file mode 100644 index 0000000000..1c70ac07a1 --- /dev/null +++ b/apps/desktop/src/shared/chat-cta.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + windowShow: vi.fn(() => Promise.resolve({ status: "ok" })), +})); + +vi.mock("@hypr/plugin-windows", () => ({ + commands: { + windowShow: mocks.windowShow, + }, +})); + +import { ChatCTA } from "./chat-cta"; + +describe("ChatCTA", () => { + it("opens the composer window", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Ask Anarlog anything" }), + ); + + expect(mocks.windowShow).toHaveBeenCalledWith({ type: "composer" }); + }); +}); diff --git a/apps/desktop/src/shared/chat-cta.tsx b/apps/desktop/src/shared/chat-cta.tsx index 5564d48f57..5e662f48f9 100644 --- a/apps/desktop/src/shared/chat-cta.tsx +++ b/apps/desktop/src/shared/chat-cta.tsx @@ -1,30 +1,17 @@ import { MessageCircle } from "lucide-react"; +import { commands as windowsCommands } from "@hypr/plugin-windows"; import { cn } from "@hypr/utils"; -import { useShell } from "~/contexts/shell"; - export function ChatCTA({ label = "Ask Anarlog anything", }: { label?: string; }) { - const { chat } = useShell(); - const isChatOpen = chat.mode === "RightPanelOpen"; - const handleClick = () => { - if (isChatOpen) { - chat.sendEvent({ type: "TOGGLE" }); - return; - } - - chat.sendEvent({ type: "OPEN_RIGHT_PANEL" }); + windowsCommands.windowShow({ type: "composer" }).catch(console.error); }; - if (isChatOpen) { - return null; - } - return ( + )}
)} {!isTodayVisible && !isScrolledPastToday && ( - + /> )}
); } +function TimelineNowChip({ + className, + direction, + onClick, +}: { + className?: string; + direction: "up" | "down"; + onClick: () => void; +}) { + const DirectionIcon = direction === "up" ? ArrowUpIcon : ArrowDownIcon; + + return ( + + ); +} + function TodayBucket({ items, precision, diff --git a/apps/desktop/src/sidebar/timeline/realtime.tsx b/apps/desktop/src/sidebar/timeline/realtime.tsx index 88bbeb4607..1bf8f33ba5 100644 --- a/apps/desktop/src/sidebar/timeline/realtime.tsx +++ b/apps/desktop/src/sidebar/timeline/realtime.tsx @@ -37,7 +37,7 @@ export const CurrentTimeIndicator = forwardRef< } style={variant === "inside" ? { top: insideOffset } : undefined} > -
+
diff --git a/apps/desktop/src/sidebar/use-custom-sidebar.ts b/apps/desktop/src/sidebar/use-custom-sidebar.ts index 6a51c1cad7..b94b07a1f9 100644 --- a/apps/desktop/src/sidebar/use-custom-sidebar.ts +++ b/apps/desktop/src/sidebar/use-custom-sidebar.ts @@ -9,10 +9,20 @@ const CUSTOM_SIDEBAR_TYPES: Tab["type"][] = [ "templates", ]; +const LEFT_SURFACE_CUSTOM_SIDEBAR_TYPES: Tab["type"][] = [ + "calendar", + "settings", + "contacts", +]; + export function hasCustomSidebarTab(tab: Tab | null): boolean { return tab !== null && CUSTOM_SIDEBAR_TYPES.includes(tab.type); } +export function hasLeftSurfaceCustomSidebarTab(tab: Tab | null): boolean { + return tab !== null && LEFT_SURFACE_CUSTOM_SIDEBAR_TYPES.includes(tab.type); +} + export function useCustomSidebarEffect( active: boolean, leftsidebar: { diff --git a/plugins/windows/src/window/v1.rs b/plugins/windows/src/window/v1.rs index e41b1b23ab..817848b054 100644 --- a/plugins/windows/src/window/v1.rs +++ b/plugins/windows/src/window/v1.rs @@ -77,7 +77,7 @@ impl AppWindow { .decorations(true) .hidden_title(true) .theme(Some(tauri::Theme::Light)) - .traffic_light_position(tauri::LogicalPosition::new(12.0, traffic_light_y + 4.0)) + .traffic_light_position(tauri::LogicalPosition::new(12.0, traffic_light_y)) .title_bar_style(tauri::TitleBarStyle::Overlay); } From abd18f3e1168920332793afdc8e88b0be09b7e19 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 20:32:13 +0900 Subject: [PATCH 03/22] fix(desktop): use main-area chat modal Remove the right-side chat panel path and open chat in an in-window modal from the floating CTA and keyboard shortcut. --- apps/desktop/src/changelog/index.tsx | 2 +- .../src/chat/components/chat-panel.tsx | 9 +- .../src/chat/components/context-bar.test.tsx | 2 +- .../src/chat/components/context-bar.tsx | 4 +- .../src/chat/components/input/index.test.tsx | 2 +- .../src/chat/components/input/index.tsx | 24 +-- .../src/chat/components/persistent-chat.tsx | 137 ++++++++++++++++++ apps/desktop/src/chat/state/use-chat-mode.ts | 6 +- apps/desktop/src/main/empty.test.tsx | 6 - .../src/main/useTabsShortcuts.test.tsx | 6 +- .../bottom-accessory/index.test.tsx | 2 +- .../components/bottom-accessory/index.tsx | 2 +- apps/desktop/src/shared/chat-cta.test.tsx | 38 +++-- apps/desktop/src/shared/chat-cta.tsx | 12 +- .../src/shared/main/chat-panels.test.tsx | 55 ++++++- apps/desktop/src/shared/main/chat-panels.tsx | 93 ++---------- apps/desktop/src/shared/useTabsShortcuts.tsx | 4 +- .../src/store/zustand/tabs/chat-mode.test.ts | 23 ++- .../src/store/zustand/tabs/chat-mode.ts | 13 +- 19 files changed, 271 insertions(+), 169 deletions(-) create mode 100644 apps/desktop/src/chat/components/persistent-chat.tsx diff --git a/apps/desktop/src/changelog/index.tsx b/apps/desktop/src/changelog/index.tsx index e2b3d60d7f..e8cd7ffd5d 100644 --- a/apps/desktop/src/changelog/index.tsx +++ b/apps/desktop/src/changelog/index.tsx @@ -57,7 +57,7 @@ export function TabContentChangelog({ useEffect(() => { leftsidebar.setExpanded(false); - if (chat.mode === "RightPanelOpen") { + if (chat.mode === "FloatingOpen") { chat.sendEvent({ type: "CLOSE" }); } }, []); diff --git a/apps/desktop/src/chat/components/chat-panel.tsx b/apps/desktop/src/chat/components/chat-panel.tsx index bd871ab696..c6f660e1ec 100644 --- a/apps/desktop/src/chat/components/chat-panel.tsx +++ b/apps/desktop/src/chat/components/chat-panel.tsx @@ -1,8 +1,6 @@ import { platform } from "@tauri-apps/plugin-os"; import { useCallback } from "react"; -import { cn } from "@hypr/utils"; - import { ChatBody } from "./body"; import { ChatContent } from "./content"; import { ChatSession } from "./session-provider"; @@ -38,12 +36,7 @@ export function ChatView() { }); return ( -
+
({ vi.mock("~/contexts/shell", () => ({ useShell: () => ({ chat: { - mode: "RightPanelOpen", + mode: "ModalOpen", }, }), })); diff --git a/apps/desktop/src/chat/components/context-bar.tsx b/apps/desktop/src/chat/components/context-bar.tsx index a63ecd1b82..0c8d36af87 100644 --- a/apps/desktop/src/chat/components/context-bar.tsx +++ b/apps/desktop/src/chat/components/context-bar.tsx @@ -18,7 +18,6 @@ import { cn, safeParseDate } from "@hypr/utils"; import type { ContextRef } from "~/chat/context/entities"; import { type ContextChipProps, renderChip } from "~/chat/context/registry"; import type { DisplayEntity } from "~/chat/context/use-chat-context-pipeline"; -import { useShell } from "~/contexts/shell"; import { useSearchEngine } from "~/search/contexts/engine"; import { getSessionEvent } from "~/session/utils"; import * as main from "~/store/tinybase/store/main"; @@ -353,7 +352,6 @@ export function ContextBar({ onRemoveEntity?: (key: string) => void; onAddEntity?: (ref: ContextRef) => void; }) { - const { chat } = useShell(); const chips = useMemo( () => entities @@ -376,7 +374,7 @@ export function ContextBar({
diff --git a/apps/desktop/src/chat/components/input/index.test.tsx b/apps/desktop/src/chat/components/input/index.test.tsx index e72b6c846e..966798e375 100644 --- a/apps/desktop/src/chat/components/input/index.test.tsx +++ b/apps/desktop/src/chat/components/input/index.test.tsx @@ -42,7 +42,7 @@ vi.mock("@hypr/plugin-analytics", () => ({ vi.mock("~/contexts/shell", () => ({ useShell: () => ({ chat: { - mode: "RightPanelOpen", + mode: "ModalOpen", }, }), })); diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index b40d2d6947..96c73508d1 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -37,7 +37,7 @@ export function ChatMessageInput({ const editorRef = useRef(null); const disabled = typeof disabledProp === "object" ? disabledProp.disabled : disabledProp; - const shouldFocus = chat.mode === "RightPanelOpen"; + const shouldFocus = chat.mode === "FloatingOpen"; const { hasContent, initialContent, handleEditorUpdate } = useDraftState({ draftKey, @@ -56,10 +56,7 @@ export function ChatMessageInput({ const isSendDisabled = Boolean(disabled) || !hasContent; return ( - +
+
diff --git a/apps/desktop/src/chat/components/persistent-chat.tsx b/apps/desktop/src/chat/components/persistent-chat.tsx new file mode 100644 index 0000000000..03a4e95f3a --- /dev/null +++ b/apps/desktop/src/chat/components/persistent-chat.tsx @@ -0,0 +1,137 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { cn } from "@hypr/utils"; + +import { ChatView } from "./chat-panel"; + +import { useShell } from "~/contexts/shell"; + +export function PersistentChatPanel({ + floatingContainerRef, +}: { + floatingContainerRef: React.RefObject; +}) { + const { chat } = useShell(); + const isVisible = chat.mode === "FloatingOpen"; + + const [hasBeenOpened, setHasBeenOpened] = useState(false); + const [containerRect, setContainerRect] = useState(null); + const observerRef = useRef(null); + + const getActiveContainer = () => { + return ( + floatingContainerRef.current?.querySelector( + "[data-chat-floating-anchor]", + ) ?? floatingContainerRef.current + ); + }; + + useEffect(() => { + if (isVisible && !hasBeenOpened) { + setHasBeenOpened(true); + } + }, [isVisible, hasBeenOpened]); + + useHotkeys( + "esc", + () => chat.sendEvent({ type: "CLOSE" }), + { + enabled: isVisible, + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [chat, isVisible], + ); + + useLayoutEffect(() => { + const container = getActiveContainer(); + + if (!isVisible || !container) { + setContainerRect(null); + return; + } + setContainerRect(container.getBoundingClientRect()); + }, [isVisible, floatingContainerRef]); + + useEffect(() => { + const container = getActiveContainer(); + + if (!isVisible || !container) { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + return; + } + + const updateRect = () => { + setContainerRect(container.getBoundingClientRect()); + }; + + observerRef.current = new ResizeObserver(updateRect); + observerRef.current.observe(container); + window.addEventListener("resize", updateRect); + window.addEventListener("scroll", updateRect, true); + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + window.removeEventListener("resize", updateRect); + window.removeEventListener("scroll", updateRect, true); + }; + }, [isVisible, floatingContainerRef]); + + if (!hasBeenOpened) { + return null; + } + + return ( + + {isVisible && ( + +
{ + if (event.target === event.currentTarget) { + chat.sendEvent({ type: "CLOSE" }); + } + }} + > + + + +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/chat/state/use-chat-mode.ts b/apps/desktop/src/chat/state/use-chat-mode.ts index b9bb9ee307..fffba7d367 100644 --- a/apps/desktop/src/chat/state/use-chat-mode.ts +++ b/apps/desktop/src/chat/state/use-chat-mode.ts @@ -1,7 +1,5 @@ import { useHotkeys } from "react-hotkeys-hook"; -import { commands as windowsCommands } from "@hypr/plugin-windows"; - import { useChatContext } from "./chat-context"; import { useTabs } from "~/store/zustand/tabs"; @@ -21,14 +19,14 @@ export function useChatMode() { useHotkeys( "mod+j", () => { - windowsCommands.windowShow({ type: "composer" }).catch(console.error); + transitionChatMode({ type: "TOGGLE" }); }, { preventDefault: true, enableOnFormTags: true, enableOnContentEditable: true, }, - [], + [transitionChatMode], ); return { diff --git a/apps/desktop/src/main/empty.test.tsx b/apps/desktop/src/main/empty.test.tsx index a1a020181b..41156597f4 100644 --- a/apps/desktop/src/main/empty.test.tsx +++ b/apps/desktop/src/main/empty.test.tsx @@ -22,12 +22,6 @@ vi.mock("~/shared/useNewNote", () => ({ useNewNoteAndListen: () => vi.fn(), })); -vi.mock("@hypr/plugin-windows", () => ({ - commands: { - windowShow: vi.fn(() => Promise.resolve({ status: "ok" })), - }, -})); - vi.mock("~/contexts/shell", () => ({ useShell: () => ({ chat: { diff --git a/apps/desktop/src/main/useTabsShortcuts.test.tsx b/apps/desktop/src/main/useTabsShortcuts.test.tsx index 58b53eadf6..825fcece3e 100644 --- a/apps/desktop/src/main/useTabsShortcuts.test.tsx +++ b/apps/desktop/src/main/useTabsShortcuts.test.tsx @@ -2,7 +2,7 @@ import { cleanup, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ - chatMode: "FloatingClosed" as "FloatingClosed" | "RightPanelOpen", + chatMode: "FloatingClosed" as "FloatingClosed" | "ModalOpen", currentTab: null as null | { active: boolean; slotId: string; type: string }, handlers: new Map void>(), openCurrent: vi.fn(), @@ -172,8 +172,8 @@ describe("useClassicMainTabsShortcuts", () => { expect(hoisted.openCurrent).not.toHaveBeenCalled(); }); - it("closes the chat panel before going home on escape", () => { - hoisted.chatMode = "RightPanelOpen"; + it("closes the chat modal before going home on escape", () => { + hoisted.chatMode = "ModalOpen"; hoisted.currentTab = { active: true, slotId: "slot-session", diff --git a/apps/desktop/src/session/components/bottom-accessory/index.test.tsx b/apps/desktop/src/session/components/bottom-accessory/index.test.tsx index 5f485d80e9..d9e059aff5 100644 --- a/apps/desktop/src/session/components/bottom-accessory/index.test.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/index.test.tsx @@ -120,7 +120,7 @@ describe("useSessionBottomAccessory", () => { it("defers transcript escape handling while chat is open", () => { useShellMock.mockReturnValue({ chat: { - mode: "RightPanelOpen", + mode: "ModalOpen", }, }); diff --git a/apps/desktop/src/session/components/bottom-accessory/index.tsx b/apps/desktop/src/session/components/bottom-accessory/index.tsx index 8298349ad9..70e459eaaa 100644 --- a/apps/desktop/src/session/components/bottom-accessory/index.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/index.tsx @@ -42,7 +42,7 @@ export function useSessionBottomAccessory({ const canExpandLiveTranscript = showLiveAccessory; const effectiveExpanded = isLive && !canExpandLiveTranscript ? false : isExpanded; - const isChatVisible = chat.mode === "RightPanelOpen"; + const isChatVisible = chat.mode === "FloatingOpen"; const prevLive = useRef(isLive); useEffect(() => { diff --git a/apps/desktop/src/shared/chat-cta.test.tsx b/apps/desktop/src/shared/chat-cta.test.tsx index 1c70ac07a1..6b4dbf1d1c 100644 --- a/apps/desktop/src/shared/chat-cta.test.tsx +++ b/apps/desktop/src/shared/chat-cta.test.tsx @@ -1,26 +1,46 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - windowShow: vi.fn(() => Promise.resolve({ status: "ok" })), + chatMode: "FloatingClosed" as "FloatingClosed" | "ModalOpen", + sendEvent: vi.fn(), })); -vi.mock("@hypr/plugin-windows", () => ({ - commands: { - windowShow: mocks.windowShow, - }, +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + chat: { + mode: mocks.chatMode, + sendEvent: mocks.sendEvent, + }, + }), })); import { ChatCTA } from "./chat-cta"; describe("ChatCTA", () => { - it("opens the composer window", () => { + beforeEach(() => { + cleanup(); + mocks.chatMode = "FloatingClosed"; + mocks.sendEvent.mockClear(); + }); + + it("opens the chat modal", () => { render(); fireEvent.click( screen.getByRole("button", { name: "Ask Anarlog anything" }), ); - expect(mocks.windowShow).toHaveBeenCalledWith({ type: "composer" }); + expect(mocks.sendEvent).toHaveBeenCalledWith({ type: "OPEN" }); + }); + + it("hides while the chat modal is open", () => { + mocks.chatMode = "ModalOpen"; + + render(); + + expect( + screen.queryByRole("button", { name: "Ask Anarlog anything" }), + ).toBeNull(); }); }); diff --git a/apps/desktop/src/shared/chat-cta.tsx b/apps/desktop/src/shared/chat-cta.tsx index 5e662f48f9..5c97617940 100644 --- a/apps/desktop/src/shared/chat-cta.tsx +++ b/apps/desktop/src/shared/chat-cta.tsx @@ -1,17 +1,25 @@ import { MessageCircle } from "lucide-react"; -import { commands as windowsCommands } from "@hypr/plugin-windows"; import { cn } from "@hypr/utils"; +import { useShell } from "~/contexts/shell"; + export function ChatCTA({ label = "Ask Anarlog anything", }: { label?: string; }) { + const { chat } = useShell(); + const isChatOpen = chat.mode === "FloatingOpen"; + const handleClick = () => { - windowsCommands.windowShow({ type: "composer" }).catch(console.error); + chat.sendEvent({ type: "OPEN" }); }; + if (isChatOpen) { + return null; + } + return (
-
+ {onSearchChange && (
diff --git a/apps/desktop/src/sidebar/calendar.tsx b/apps/desktop/src/sidebar/calendar.tsx index 9fdcbb6c2d..82efb3403a 100644 --- a/apps/desktop/src/sidebar/calendar.tsx +++ b/apps/desktop/src/sidebar/calendar.tsx @@ -1,11 +1,11 @@ +import { CustomSidebarHeader } from "./custom-sidebar-header"; + import { CalendarSidebarContent } from "~/calendar/components/sidebar"; export function CalendarNav() { return (
-
-

Calendar

-
+
diff --git a/apps/desktop/src/sidebar/custom-sidebar-header.test.tsx b/apps/desktop/src/sidebar/custom-sidebar-header.test.tsx new file mode 100644 index 0000000000..44a1e2bb4a --- /dev/null +++ b/apps/desktop/src/sidebar/custom-sidebar-header.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + chatMode: "FloatingClosed", + currentTab: { type: "settings" } as { type: string } | null, + openCurrent: vi.fn(), + select: vi.fn(), + sendEvent: vi.fn(), + tabs: [] as { type: string }[], +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + chat: { + mode: mocks.chatMode, + sendEvent: mocks.sendEvent, + }, + }), +})); + +vi.mock("~/store/zustand/tabs", () => ({ + useTabs: (selector: (state: unknown) => unknown) => + selector({ + currentTab: mocks.currentTab, + openCurrent: mocks.openCurrent, + select: mocks.select, + tabs: mocks.tabs, + }), +})); + +import { CustomSidebarHeader } from "./custom-sidebar-header"; + +describe("CustomSidebarHeader", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + mocks.chatMode = "FloatingClosed"; + mocks.currentTab = { type: "settings" }; + mocks.openCurrent.mockClear(); + mocks.select.mockClear(); + mocks.sendEvent.mockClear(); + mocks.tabs = []; + }); + + it("opens home from the back button", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go home" })); + + expect(mocks.openCurrent).toHaveBeenCalledWith({ type: "empty" }); + }); + + it("selects an existing home tab from the back button", () => { + const homeTab = { type: "empty" }; + mocks.tabs = [homeTab]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go home" })); + + expect(mocks.select).toHaveBeenCalledWith(homeTab); + expect(mocks.openCurrent).not.toHaveBeenCalled(); + }); + + it("closes floating chat before opening home", () => { + mocks.chatMode = "FloatingOpen"; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Go home" })); + + expect(mocks.sendEvent).toHaveBeenCalledWith({ type: "CLOSE" }); + expect(mocks.openCurrent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/sidebar/custom-sidebar-header.tsx b/apps/desktop/src/sidebar/custom-sidebar-header.tsx new file mode 100644 index 0000000000..b35178f80f --- /dev/null +++ b/apps/desktop/src/sidebar/custom-sidebar-header.tsx @@ -0,0 +1,78 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { cn } from "@hypr/utils"; + +import { useShell } from "~/contexts/shell"; +import { useTabs } from "~/store/zustand/tabs"; + +export function CustomSidebarHeader({ + title, + children, +}: { + title: string; + children?: React.ReactNode; +}) { + const { chat } = useShell(); + const currentTab = useTabs((state) => state.currentTab); + const tabs = useTabs((state) => state.tabs); + const select = useTabs((state) => state.select); + const openCurrent = useTabs((state) => state.openCurrent); + + const handleBack = useCallback(() => { + if (chat.mode === "FloatingOpen") { + chat.sendEvent({ type: "CLOSE" }); + return; + } + + if (currentTab?.type === "onboarding" || currentTab?.type === "empty") { + return; + } + + const existingHomeTab = tabs.find((tab) => tab.type === "empty"); + if (existingHomeTab) { + select(existingHomeTab); + return; + } + + openCurrent({ type: "empty" }); + }, [chat, currentTab, openCurrent, select, tabs]); + + return ( +
+
+ +

+ {title} +

+
+ {children ? ( +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/sidebar/index.test.tsx b/apps/desktop/src/sidebar/index.test.tsx index 1b84c9dbc5..b99b829f86 100644 --- a/apps/desktop/src/sidebar/index.test.tsx +++ b/apps/desktop/src/sidebar/index.test.tsx @@ -91,14 +91,15 @@ describe("LeftSidebar", () => { expect(container.firstElementChild?.className).toContain("pt-0"); }); - it("keeps compact top padding for custom sidebar modes", () => { + it("keeps custom sidebar modes below the window chrome", () => { mocks.sidebarTimelineEnabled = true; mocks.currentTab = { type: "settings" }; const { container } = render(); + const classList = container.firstElementChild?.className.split(" ") ?? []; expect(screen.getByTestId("settings-nav")).toBeTruthy(); - expect(container.firstElementChild?.className).toContain("pt-1"); - expect(container.firstElementChild?.className).not.toContain("pt-0"); + expect(classList).toContain("pt-11"); + expect(classList).not.toContain("pt-0"); }); }); diff --git a/apps/desktop/src/sidebar/settings.tsx b/apps/desktop/src/sidebar/settings.tsx index fdfa77e49a..482833a11a 100644 --- a/apps/desktop/src/sidebar/settings.tsx +++ b/apps/desktop/src/sidebar/settings.tsx @@ -16,6 +16,8 @@ import { useCallback } from "react"; import { cn } from "@hypr/utils"; +import { CustomSidebarHeader } from "./custom-sidebar-header"; + import { type SettingsTab, useTabs } from "~/store/zustand/tabs"; type SettingsNavItem = @@ -110,9 +112,7 @@ export function SettingsNav() { return (
-
-

Settings

-
+
{groups.map((group) => ( From 8553b2b06cc60a8dae5b0ab955421fcf54d5fba3 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 20:59:47 +0900 Subject: [PATCH 05/22] fix(desktop): hide sidebar timeline fade at scroll ends Only fade sidebar timeline content when it can scroll past the corresponding edge. --- apps/desktop/src/sidebar/timeline/index.tsx | 26 +++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/sidebar/timeline/index.tsx b/apps/desktop/src/sidebar/timeline/index.tsx index 18a863a117..b5e9f6e082 100644 --- a/apps/desktop/src/sidebar/timeline/index.tsx +++ b/apps/desktop/src/sidebar/timeline/index.tsx @@ -65,6 +65,7 @@ export function TimelineView({ }); const [showIgnored, setShowIgnored] = useState(false); const [isScrolledToTop, setIsScrolledToTop] = useState(true); + const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); const { isIgnored } = useIgnoredEvents(); const openNew = useTabs((state) => state.openNew); @@ -174,7 +175,12 @@ export function TimelineView({ } const updateScrollPosition = () => { + const maxScrollTop = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ); setIsScrolledToTop(container.scrollTop <= 12); + setIsScrolledToBottom(maxScrollTop - container.scrollTop <= 12); }; updateScrollPosition(); @@ -185,7 +191,14 @@ export function TimelineView({ return () => { container.removeEventListener("scroll", updateScrollPosition); }; - }, [containerRef]); + }, [containerRef, buckets.length, flatItemKeys.length]); + + const scrollFadeMask = useMemo(() => { + const topFadeEnd = isScrolledToTop ? "0px" : "28px"; + const bottomFadeStart = isScrolledToBottom ? "100%" : "calc(100% - 28px)"; + + return `linear-gradient(to bottom, transparent 0, #000 ${topFadeEnd}, #000 ${bottomFadeStart}, transparent 100%)`; + }, [isScrolledToTop, isScrolledToBottom]); const todayBucketLength = useMemo(() => { const b = buckets.find((bucket) => bucket.label === "Today"); @@ -308,6 +321,10 @@ export function TimelineView({ "scrollbar-hide flex h-full flex-col overflow-y-auto", "rounded-xl", ])} + style={{ + WebkitMaskImage: scrollFadeMask, + maskImage: scrollFadeMask, + }} > {(topChromeInset || hasMoreFutureItems) && (
)} From 81ffc97a270b87b36efe0b46b96420c3a4d68279 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 21:02:44 +0900 Subject: [PATCH 06/22] feat(desktop): generate chat titles Summarize the first user request into a chat group title while keeping an immediate fallback title. --- .../desktop/src/chat/store/chat-title.test.ts | 72 +++++++++++++++ apps/desktop/src/chat/store/chat-title.ts | 88 +++++++++++++++++++ .../src/chat/store/use-chat-actions.ts | 66 +++++++++++++- 3 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/chat/store/chat-title.test.ts create mode 100644 apps/desktop/src/chat/store/chat-title.ts diff --git a/apps/desktop/src/chat/store/chat-title.test.ts b/apps/desktop/src/chat/store/chat-title.test.ts new file mode 100644 index 0000000000..a4f367b2c5 --- /dev/null +++ b/apps/desktop/src/chat/store/chat-title.test.ts @@ -0,0 +1,72 @@ +import { generateText } from "ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createFallbackChatTitle, + generateChatTitle, + normalizeGeneratedChatTitle, +} from "./chat-title"; + +vi.mock("ai", () => ({ + generateText: vi.fn(), +})); + +describe("chat title", () => { + beforeEach(() => { + vi.mocked(generateText).mockReset(); + }); + + it("creates an immediate fallback title from the initial request", () => { + const title = createFallbackChatTitle( + " Please summarize this request for tomorrow's roadmap planning and share action items ", + ); + + expect(title).toBe("Please summarize this request for tomorrow's..."); + expect(title.length).toBeLessThanOrEqual(50); + }); + + it("falls back to a default title for blank requests", () => { + expect(createFallbackChatTitle(" ")).toBe("New chat"); + }); + + it("normalizes generated titles", () => { + expect( + normalizeGeneratedChatTitle('1. "Review onboarding fixes."\nExtra text'), + ).toBe("Review onboarding fixes"); + expect(normalizeGeneratedChatTitle("")).toBeNull(); + }); + + it("summarizes the initial request with the title model", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: '"Review onboarding fixes."', + } as any); + + const model = {} as any; + const title = await generateChatTitle({ + model, + initialRequest: "Can you review the onboarding flow regressions?", + }); + + expect(title).toBe("Review onboarding fixes"); + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + model, + temperature: 0, + maxOutputTokens: 32, + prompt: expect.stringContaining( + "Can you review the onboarding flow regressions?", + ), + }), + ); + }); + + it("does not call the model for blank requests", async () => { + const title = await generateChatTitle({ + model: {} as any, + initialRequest: " ", + }); + + expect(title).toBeNull(); + expect(generateText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/chat/store/chat-title.ts b/apps/desktop/src/chat/store/chat-title.ts new file mode 100644 index 0000000000..4d2f9af195 --- /dev/null +++ b/apps/desktop/src/chat/store/chat-title.ts @@ -0,0 +1,88 @@ +import { generateText, type LanguageModel } from "ai"; + +const FALLBACK_CHAT_TITLE_MAX_LENGTH = 50; +const GENERATED_CHAT_TITLE_MAX_LENGTH = 60; +const INITIAL_REQUEST_MAX_LENGTH = 4000; + +export function createFallbackChatTitle(initialRequest: string): string { + const title = normalizeTitleText(initialRequest); + + if (!title) { + return "New chat"; + } + + return truncateTitle(title, FALLBACK_CHAT_TITLE_MAX_LENGTH); +} + +export async function generateChatTitle({ + model, + initialRequest, +}: { + model: LanguageModel; + initialRequest: string; +}): Promise { + const request = normalizeTitleText(initialRequest).slice( + 0, + INITIAL_REQUEST_MAX_LENGTH, + ); + + if (!request) { + return null; + } + + const result = await generateText({ + model, + temperature: 0, + maxRetries: 2, + maxOutputTokens: 32, + system: + "Write a concise chat title from the user's first message. Use the same language as the request. Return only the title, with no quotes, emoji, markdown, or ending punctuation. Keep it under 6 words.", + prompt: `Initial request:\n${request}`, + }); + + return normalizeGeneratedChatTitle(result.text); +} + +export function normalizeGeneratedChatTitle(text: string): string | null { + const firstLine = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) { + return null; + } + + const title = normalizeTitleText(firstLine) + .replace(/^\d+[.)]\s*/, "") + .replace(/^[-*#]\s*/, "") + .replace(/^["'`]+/, "") + .replace(/["'`]+$/, "") + .replace(/[.!?]+$/, "") + .trim(); + + if (!title) { + return null; + } + + return truncateTitle(title, GENERATED_CHAT_TITLE_MAX_LENGTH); +} + +function normalizeTitleText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function truncateTitle(title: string, maxLength: number): string { + if (title.length <= maxLength) { + return title; + } + + const truncated = title.slice(0, maxLength - 3).trimEnd(); + const lastSpace = truncated.lastIndexOf(" "); + const titlePrefix = + lastSpace > Math.floor(maxLength / 2) + ? truncated.slice(0, lastSpace) + : truncated; + + return `${titlePrefix}...`; +} diff --git a/apps/desktop/src/chat/store/use-chat-actions.ts b/apps/desktop/src/chat/store/use-chat-actions.ts index 02a8897da5..0dd13da8cc 100644 --- a/apps/desktop/src/chat/store/use-chat-actions.ts +++ b/apps/desktop/src/chat/store/use-chat-actions.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; +import { createFallbackChatTitle, generateChatTitle } from "./chat-title"; import { useCreateChatMessage } from "./useCreateChatMessage"; +import { useLanguageModel } from "~/ai/hooks"; import type { ContextRef } from "~/chat/context/entities"; import type { HyprUIMessage } from "~/chat/types"; import { id } from "~/shared/utils"; @@ -15,6 +17,8 @@ export function useChatActions({ onGroupCreated: (newGroupId: string) => void; }) { const { user_id } = main.UI.useValues(main.STORE_ID); + const store = main.UI.useStore(main.STORE_ID); + const titleModel = useLanguageModel("title"); const createChatGroup = main.UI.useSetRowCallback( "chat_groups", @@ -28,8 +32,53 @@ export function useChatActions({ main.STORE_ID, ); + const setChatGroupTitle = main.UI.useSetCellCallback( + "chat_groups", + (p: { groupId: string; title: string }) => p.groupId, + "title", + (p: { groupId: string; title: string }) => p.title, + [], + main.STORE_ID, + ); + const createChatMessage = useCreateChatMessage(); + const queueChatTitleGeneration = useCallback( + (params: { + groupId: string; + fallbackTitle: string; + initialRequest: string; + }) => { + const { groupId, fallbackTitle, initialRequest } = params; + + if (!titleModel || !initialRequest.trim()) { + return; + } + + void generateChatTitle({ + model: titleModel, + initialRequest, + }) + .then((title) => { + if (!title) { + return; + } + + const currentTitle = store?.getCell("chat_groups", groupId, "title"); + + if (currentTitle !== fallbackTitle) { + return; + } + + setChatGroupTitle({ groupId, title }); + }) + .catch((error) => { + console.error("Failed to generate chat title", error); + }); + }, + [setChatGroupTitle, store, titleModel], + ); + const handleSendMessage = useCallback( ( content: string, @@ -52,9 +101,14 @@ export function useChatActions({ let currentGroupId = groupId; if (!currentGroupId) { currentGroupId = id(); - const title = content.slice(0, 50) + (content.length > 50 ? "..." : ""); - createChatGroup({ groupId: currentGroupId, title }); + const fallbackTitle = createFallbackChatTitle(content); + createChatGroup({ groupId: currentGroupId, title: fallbackTitle }); onGroupCreated(currentGroupId); + queueChatTitleGeneration({ + groupId: currentGroupId, + fallbackTitle, + initialRequest: content, + }); } createChatMessage({ @@ -68,7 +122,13 @@ export function useChatActions({ sendMessage(uiMessage); }, - [groupId, createChatGroup, createChatMessage, onGroupCreated], + [ + groupId, + createChatGroup, + createChatMessage, + onGroupCreated, + queueChatTitleGeneration, + ], ); return { handleSendMessage }; From bfb0a433f2874eb8693a4a978b6e1e422be7e1e0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 21:03:02 +0900 Subject: [PATCH 07/22] fix(desktop): match floating chat color Use the floating CTA dark surface across the in-window chat modal and its toolbar, messages, and empty states. --- .../src/chat/components/body/empty.tsx | 52 ++++++++++++++++--- .../src/chat/components/chat-panel.tsx | 20 ++++++- .../src/chat/components/message/shared.tsx | 15 +++++- .../src/chat/components/persistent-chat.tsx | 3 +- .../src/chat/components/toolbar-controls.tsx | 34 ++++++++++-- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/chat/components/body/empty.tsx b/apps/desktop/src/chat/components/body/empty.tsx index a82143dbf3..2af44a48cc 100644 --- a/apps/desktop/src/chat/components/body/empty.tsx +++ b/apps/desktop/src/chat/components/body/empty.tsx @@ -10,6 +10,7 @@ import { useCallback } from "react"; import { cn } from "@hypr/utils"; import type { ContextRef } from "~/chat/context/entities"; +import { useShell } from "~/contexts/shell"; import { useTabs } from "~/store/zustand/tabs"; const SUGGESTIONS = [ @@ -43,7 +44,9 @@ export function ChatBodyEmpty({ contextRefs?: ContextRef[], ) => void; }) { + const { chat } = useShell(); const openNew = useTabs((state) => state.openNew); + const isFloating = chat.mode === "FloatingOpen"; const handleGoToSettings = useCallback(() => { openNew({ type: "settings", state: { tab: "intelligence" } }); @@ -61,13 +64,28 @@ export function ChatBodyEmpty({
- - + + Anarlog AI
-

+

Hi, I'm Anarlog AI. Set up a language model and I'll be ready to help.

@@ -90,13 +108,28 @@ export function ChatBodyEmpty({
- - + + Anarlog AI
-

+

Hi, I'm Anarlog AI. I can help you pull context from your notes, find key decisions, and draft what comes next.

@@ -107,8 +140,11 @@ export function ChatBodyEmpty({ key={label} onClick={() => handleSuggestionClick(prompt)} className={cn([ - "inline-flex items-center gap-1 rounded-full border border-neutral-300 bg-white px-2 py-1 text-[11px] text-neutral-700", - "transition-colors hover:bg-neutral-100", + "inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px]", + isFloating + ? "border-stone-600 bg-stone-700 text-stone-100 hover:bg-stone-600" + : "border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-100", + "transition-colors", ])} > diff --git a/apps/desktop/src/chat/components/chat-panel.tsx b/apps/desktop/src/chat/components/chat-panel.tsx index c6f660e1ec..81607b93b3 100644 --- a/apps/desktop/src/chat/components/chat-panel.tsx +++ b/apps/desktop/src/chat/components/chat-panel.tsx @@ -1,6 +1,8 @@ import { platform } from "@tauri-apps/plugin-os"; import { useCallback } from "react"; +import { cn } from "@hypr/utils"; + import { ChatBody } from "./body"; import { ChatContent } from "./content"; import { ChatSession } from "./session-provider"; @@ -15,6 +17,7 @@ import * as main from "~/store/tinybase/store/main"; export function ChatView() { const { chat } = useShell(); const { groupId, sessionId, setGroupId } = chat; + const isFloating = chat.mode === "FloatingOpen"; const currentPlatform = platform(); const chatPanelShortcutLabel = currentPlatform === "macos" ? "⌘ J" : "Ctrl J"; @@ -36,14 +39,27 @@ export function ChatView() { }); return ( -
-
+
+
chat.sendEvent({ type: "CLOSE" })} shortcutLabel={chatPanelShortcutLabel} + surface={isFloating ? "dark" : "light"} />
{user_id && ( diff --git a/apps/desktop/src/chat/components/message/shared.tsx b/apps/desktop/src/chat/components/message/shared.tsx index 9f8361159b..1046d5927a 100644 --- a/apps/desktop/src/chat/components/message/shared.tsx +++ b/apps/desktop/src/chat/components/message/shared.tsx @@ -3,6 +3,8 @@ import { type ReactNode } from "react"; import { cn } from "@hypr/utils"; +import { useShell } from "~/contexts/shell"; + export function MessageContainer({ align = "start", children, @@ -31,14 +33,23 @@ export function MessageBubble({ withActionButton?: boolean; children: ReactNode; }) { + const { chat } = useShell(); + const isFloating = chat.mode === "FloatingOpen"; + return (
void; onNewChat: () => void; onSelectChat: (chatGroupId: string) => void; shortcutLabel?: string; + surface?: "light" | "dark"; }) { + const isDark = surface === "dark"; + return (
} onClick={onNewChat} title="New chat" + className={ + isDark + ? "text-stone-300 hover:bg-stone-700 hover:text-white" + : undefined + } />
); @@ -95,11 +110,14 @@ function ChatActionButton({ function ChatGroups({ currentChatGroupId, onSelectChat, + surface = "light", }: { currentChatGroupId: string | undefined; onSelectChat: (chatGroupId: string) => void; + surface?: "light" | "dark"; }) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const isDark = surface === "dark"; const currentChatTitle = main.UI.useCell( "chat_groups", @@ -123,15 +141,23 @@ function ChatGroups({ variant="ghost" className={cn([ "group -ml-2 flex h-8 max-w-64 min-w-0 justify-start gap-2 px-2 py-0", - "text-neutral-700", + isDark + ? "text-stone-100 hover:bg-stone-700 hover:text-white" + : "text-neutral-700", ])} > -

+

{currentChatTitle || "Ask Anarlog AI anything"}

From ead7f0188695944db48d81bcab735db1d200c910 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 22:17:45 +0900 Subject: [PATCH 08/22] fix(desktop): expand floating chat modal Replace the floating chat close icon with an expand/collapse control, make the collapsed modal resizable, and add title-area padding. --- .../src/chat/components/chat-panel.tsx | 15 ++++--- .../src/chat/components/persistent-chat.tsx | 39 ++++++++++++++++--- .../src/chat/components/toolbar-controls.tsx | 38 ++++++++---------- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/chat/components/chat-panel.tsx b/apps/desktop/src/chat/components/chat-panel.tsx index 81607b93b3..6509f7081d 100644 --- a/apps/desktop/src/chat/components/chat-panel.tsx +++ b/apps/desktop/src/chat/components/chat-panel.tsx @@ -1,4 +1,3 @@ -import { platform } from "@tauri-apps/plugin-os"; import { useCallback } from "react"; import { cn } from "@hypr/utils"; @@ -14,12 +13,16 @@ import { useChatActions } from "~/chat/store/use-chat-actions"; import { useShell } from "~/contexts/shell"; import * as main from "~/store/tinybase/store/main"; -export function ChatView() { +export function ChatView({ + isExpanded = false, + onToggleExpanded, +}: { + isExpanded?: boolean; + onToggleExpanded?: () => void; +}) { const { chat } = useShell(); const { groupId, sessionId, setGroupId } = chat; const isFloating = chat.mode === "FloatingOpen"; - const currentPlatform = platform(); - const chatPanelShortcutLabel = currentPlatform === "macos" ? "⌘ J" : "Ctrl J"; const { currentSessionId } = useSessionTab(); @@ -55,10 +58,10 @@ export function ChatView() { > chat.sendEvent({ type: "CLOSE" })} - shortcutLabel={chatPanelShortcutLabel} + onToggleExpanded={onToggleExpanded} surface={isFloating ? "dark" : "light"} />
diff --git a/apps/desktop/src/chat/components/persistent-chat.tsx b/apps/desktop/src/chat/components/persistent-chat.tsx index fdc591de03..47242e02d7 100644 --- a/apps/desktop/src/chat/components/persistent-chat.tsx +++ b/apps/desktop/src/chat/components/persistent-chat.tsx @@ -18,6 +18,7 @@ export function PersistentChatPanel({ const [hasBeenOpened, setHasBeenOpened] = useState(false); const [containerRect, setContainerRect] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); const observerRef = useRef(null); const getActiveContainer = () => { @@ -109,26 +110,52 @@ export function PersistentChatPanel({ transition={{ duration: 0.2 }} >
{ - if (event.target === event.currentTarget) { + if (!isExpanded && event.target === event.currentTarget) { chat.sendEvent({ type: "CLOSE" }); } }} > - + setIsExpanded((value) => !value)} + />
diff --git a/apps/desktop/src/chat/components/toolbar-controls.tsx b/apps/desktop/src/chat/components/toolbar-controls.tsx index e4726fbe57..c03b5714f0 100644 --- a/apps/desktop/src/chat/components/toolbar-controls.tsx +++ b/apps/desktop/src/chat/components/toolbar-controls.tsx @@ -1,4 +1,10 @@ -import { ChevronDown, MessageCircle, Plus } from "lucide-react"; +import { + ChevronDown, + Maximize2, + MessageCircle, + Minimize2, + Plus, +} from "lucide-react"; import { useState } from "react"; import { Button } from "@hypr/ui/components/ui/button"; @@ -19,24 +25,24 @@ import * as main from "~/store/tinybase/store/main"; export function ChatToolbarControls({ currentChatGroupId, - onCloseChat, + isExpanded = false, onNewChat, onSelectChat, - shortcutLabel, + onToggleExpanded, surface = "light", }: { currentChatGroupId: string | undefined; - onCloseChat: () => void; + isExpanded?: boolean; onNewChat: () => void; onSelectChat: (chatGroupId: string) => void; - shortcutLabel?: string; + onToggleExpanded?: () => void; surface?: "light" | "dark"; }) { const isDark = surface === "dark"; return (
-
+
} - onClick={onCloseChat} - title="Close chat" - shortcutLabel={shortcutLabel} + icon={isExpanded ? : } + onClick={onToggleExpanded ?? (() => {})} + title={isExpanded ? "Collapse chat" : "Expand chat"} className={cn([ "absolute top-1/2 right-0 -translate-y-1/2", isDark @@ -74,13 +79,11 @@ function ChatActionButton({ icon, title, onClick, - shortcutLabel, }: { className?: string; icon: React.ReactNode; title: string; onClick: () => void; - shortcutLabel?: string; }) { return ( @@ -95,14 +98,7 @@ function ChatActionButton({ {icon} - - {title} - {shortcutLabel && ( - - {shortcutLabel} - - )} - + {title} ); } @@ -140,7 +136,7 @@ function ChatGroups({ + + {showHistoryControls ? ( + <> + + + + + + + + ) : null}

{title}

@@ -76,3 +93,36 @@ export function CustomSidebarHeader({
); } + +function CustomSidebarHeaderButton({ + children, + disabled = false, + label, + onClick, + title, +}: { + children: React.ReactNode; + disabled?: boolean; + label: string; + onClick: () => void; + title?: string; +}) { + return ( + + ); +} From de552b00c69970ecf80daabd64217f26ad3dabc1 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 23:14:01 +0900 Subject: [PATCH 11/22] fix(desktop): refine sidebar timeline calendar affordance Remove the titlebar calendar icon, restore the upward-scroll calendar chip, and start the sidebar timeline near today. --- .../src/calendar/components/calendar-view.tsx | 8 +-- apps/desktop/src/main/body.tsx | 15 +----- apps/desktop/src/shared/main/body.test.tsx | 19 +++---- apps/desktop/src/sidebar/index.test.tsx | 2 +- apps/desktop/src/sidebar/index.tsx | 5 +- apps/desktop/src/sidebar/timeline/anchor.ts | 50 +++++++++++-------- apps/desktop/src/sidebar/timeline/index.tsx | 12 ++++- 7 files changed, 55 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/calendar/components/calendar-view.tsx b/apps/desktop/src/calendar/components/calendar-view.tsx index bb33a528a1..ca45b01449 100644 --- a/apps/desktop/src/calendar/components/calendar-view.tsx +++ b/apps/desktop/src/calendar/components/calendar-view.tsx @@ -127,7 +127,7 @@ export function CalendarView() { data-tauri-drag-region className={cn([ "flex items-center justify-between", - "h-12 border-b border-neutral-200 py-2 pr-1 pl-3 select-none", + "h-12 border-b border-neutral-200 py-2 pr-3 pl-3 select-none", ])} >
@@ -140,11 +140,11 @@ export function CalendarView() {
- +
@@ -244,22 +239,14 @@ function SidebarTimelineChrome({ canGoNext, onBack, onForward, - onOpenCalendar, }: { canGoBack: boolean; canGoNext: boolean; onBack: () => void; onForward: () => void; - onOpenCalendar: () => void; }) { return (
- - - { expect(screen.queryByTestId("top-meeting-timeline")).toBeNull(); expect(screen.queryByTestId("toast-area")).toBeNull(); const sidebar = screen.getByTestId("main-sidebar"); - const calendarButton = screen.getByRole("button", { - name: "Open calendar", - }); - const topArea = calendarButton.parentElement?.parentElement?.parentElement; + const backButton = screen.getByRole("button", { name: "Go back" }); + const topArea = backButton.parentElement?.parentElement?.parentElement; expect(sidebar).toBeTruthy(); - expect(calendarButton).toBeTruthy(); - expect( - screen.getByRole("button", { name: "Go back" }).hasAttribute("disabled"), - ).toBe(true); + expect(screen.queryByRole("button", { name: "Open calendar" })).toBeNull(); + expect(backButton.hasAttribute("disabled")).toBe(true); expect( screen .getByRole("button", { name: "Go forward" }) @@ -146,25 +142,24 @@ describe("ClassicMainBody", () => { ).toBe(true); expect(topArea?.className).toContain("h-12"); expect(topArea?.className).toContain("absolute"); - expect(calendarButton.parentElement?.parentElement?.className).toContain( + expect(backButton.parentElement?.parentElement?.className).toContain( "pt-[9px]", ); expect(sidebar.parentElement?.className).toContain("flex min-h-0"); expect(sidebar.parentElement?.className).not.toContain("pt-12"); }); - it("opens calendar and navigates history from the sidebar timeline chrome", () => { + it("navigates history from the sidebar timeline chrome", () => { mocks.sidebarTimelineEnabled = true; mocks.canGoBack = true; mocks.canGoNext = true; render(); - fireEvent.click(screen.getByRole("button", { name: "Open calendar" })); fireEvent.click(screen.getByRole("button", { name: "Go back" })); fireEvent.click(screen.getByRole("button", { name: "Go forward" })); - expect(mocks.openNew).toHaveBeenCalledWith({ type: "calendar" }); + expect(mocks.openNew).not.toHaveBeenCalled(); expect(mocks.goBack).toHaveBeenCalledTimes(1); expect(mocks.goNext).toHaveBeenCalledTimes(1); }); diff --git a/apps/desktop/src/sidebar/index.test.tsx b/apps/desktop/src/sidebar/index.test.tsx index b99b829f86..1b832d671c 100644 --- a/apps/desktop/src/sidebar/index.test.tsx +++ b/apps/desktop/src/sidebar/index.test.tsx @@ -84,7 +84,7 @@ describe("LeftSidebar", () => { screen .getByTestId("timeline-view") .getAttribute("data-show-open-calendar-button"), - ).toBe("false"); + ).toBe("true"); expect( screen.getByTestId("timeline-view").getAttribute("data-top-chrome-inset"), ).toBe("true"); diff --git a/apps/desktop/src/sidebar/index.tsx b/apps/desktop/src/sidebar/index.tsx index 54c9d404dd..33711e9f14 100644 --- a/apps/desktop/src/sidebar/index.tsx +++ b/apps/desktop/src/sidebar/index.tsx @@ -53,10 +53,7 @@ export function LeftSidebar() { ) : isTemplatesMode ? ( ) : ( - + )} {!leftsidebar.showDevtool && !isSpecialMode && }
diff --git a/apps/desktop/src/sidebar/timeline/anchor.ts b/apps/desktop/src/sidebar/timeline/anchor.ts index cd919a078d..e761a0db64 100644 --- a/apps/desktop/src/sidebar/timeline/anchor.ts +++ b/apps/desktop/src/sidebar/timeline/anchor.ts @@ -18,25 +18,32 @@ export function useAnchor() { ); }, []); - const scrollToAnchor = useCallback(() => { - const container = containerRef.current; - if (!container || !anchorNode) { - return; - } + const scrollToAnchor = useCallback( + (options?: { behavior?: ScrollBehavior; viewportRatio?: number }) => { + const container = containerRef.current; + if (!container || !anchorNode) { + return; + } - const containerRect = container.getBoundingClientRect(); - const anchorRect = anchorNode.getBoundingClientRect(); - const anchorCenter = - anchorRect.top - - containerRect.top + - container.scrollTop + - anchorRect.height / 2; - const targetScrollTop = Math.max( - anchorCenter - container.clientHeight / 2, - 0, - ); - container.scrollTo({ top: targetScrollTop, behavior: "smooth" }); - }, [anchorNode]); + const containerRect = container.getBoundingClientRect(); + const anchorRect = anchorNode.getBoundingClientRect(); + const anchorCenter = + anchorRect.top - + containerRect.top + + container.scrollTop + + anchorRect.height / 2; + const viewportRatio = options?.viewportRatio ?? 0.5; + const targetScrollTop = Math.max( + anchorCenter - container.clientHeight * viewportRatio, + 0, + ); + container.scrollTo({ + top: targetScrollTop, + behavior: options?.behavior ?? "smooth", + }); + }, + [anchorNode], + ); useEffect(() => { const container = containerRef.current; @@ -79,7 +86,10 @@ export function useAutoScrollToAnchor({ anchorNode, deps = [], }: { - scrollFn: () => void; + scrollFn: (options?: { + behavior?: ScrollBehavior; + viewportRatio?: number; + }) => void; isVisible: boolean; anchorNode: HTMLDivElement | null; deps?: DependencyList; @@ -94,7 +104,7 @@ export function useAutoScrollToAnchor({ hasInitialScrolledRef.current = true; requestAnimationFrame(() => { - scrollFn(); + scrollFn({ behavior: "auto", viewportRatio: 0.15 }); }); }, [anchorNode, scrollFn]); diff --git a/apps/desktop/src/sidebar/timeline/index.tsx b/apps/desktop/src/sidebar/timeline/index.tsx index b5e9f6e082..8117186e4a 100644 --- a/apps/desktop/src/sidebar/timeline/index.tsx +++ b/apps/desktop/src/sidebar/timeline/index.tsx @@ -130,6 +130,9 @@ export function TimelineView({ [visibleTimelineEventsTable, timelineSessionsTable, timezone], ); + const reserveOpenCalendarChipSpace = + topChromeInset && showOpenCalendarButton && hasMoreFutureItems; + const hasToday = useMemo( () => buckets.some((bucket) => bucket.label === "Today"), [buckets], @@ -329,7 +332,14 @@ export function TimelineView({ {(topChromeInset || hasMoreFutureItems) && (
)} {buckets.map((bucket, index) => { From 48f095a9ebb0a6f2939aa6924d65f9c83e8709d6 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Fri, 29 May 2026 23:47:15 +0900 Subject: [PATCH 12/22] fix(desktop): show calendar back chrome Add the left-edge back navigation control to calendar mode and cover it in main shell tests. --- apps/desktop/src/main/body.tsx | 20 ++++++++++ apps/desktop/src/shared/main/body.test.tsx | 43 +++++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/body.tsx b/apps/desktop/src/main/body.tsx index 6b18302b12..318093e337 100644 --- a/apps/desktop/src/main/body.tsx +++ b/apps/desktop/src/main/body.tsx @@ -55,6 +55,7 @@ export function ClassicMainBody() { !leftsidebar.showDevtool && !hasCustomSidebar && !isOnboarding; + const showCalendarChromeBack = currentTab?.type === "calendar"; const enableMainAreaTopDrag = showSidebarTimeline || hasLeftSurfaceCustomSidebar; const mainAreaTopDrag = useMainAreaTopWindowDrag(enableMainAreaTopDrag); @@ -98,6 +99,25 @@ export function ClassicMainBody() {
)} + {showCalendarChromeBack && hasLeftSurfaceCustomSidebar ? ( +
+
+ + + +
+
+ ) : null}
({ startDragging: vi.fn().mockResolvedValue(undefined), canGoBack: false, canGoNext: false, + currentTab: { + active: true, + pinned: false, + slotId: "slot-1", + type: "empty", + }, sidebarTimelineEnabled: false, })); @@ -68,12 +74,7 @@ vi.mock("~/store/zustand/tabs", () => ({ useTabs: vi.fn((selector: (state: unknown) => unknown) => selector({ tabs: [{ active: true, pinned: false, slotId: "slot-1", type: "empty" }], - currentTab: { - active: true, - pinned: false, - slotId: "slot-1", - type: "empty", - }, + currentTab: mocks.currentTab, canGoBack: mocks.canGoBack, canGoNext: mocks.canGoNext, goBack: mocks.goBack, @@ -94,6 +95,12 @@ describe("ClassicMainBody", () => { mocks.startDragging.mockClear(); mocks.canGoBack = false; mocks.canGoNext = false; + mocks.currentTab = { + active: true, + pinned: false, + slotId: "slot-1", + type: "empty", + }; mocks.sidebarTimelineEnabled = false; }); @@ -164,6 +171,30 @@ describe("ClassicMainBody", () => { expect(mocks.goNext).toHaveBeenCalledTimes(1); }); + it("shows a back navigation button in calendar left chrome", () => { + mocks.currentTab = { + active: true, + pinned: false, + slotId: "slot-1", + type: "calendar", + }; + mocks.canGoBack = true; + + render(); + + const backButton = screen.getByRole("button", { name: "Go back" }); + const topArea = backButton.parentElement?.parentElement; + + fireEvent.click(backButton); + + expect(screen.queryByTestId("top-meeting-timeline")).toBeNull(); + expect(screen.queryByRole("button", { name: "Go forward" })).toBeNull(); + expect(backButton.hasAttribute("disabled")).toBe(false); + expect(topArea?.className).toContain("h-12"); + expect(topArea?.className).toContain("absolute"); + expect(mocks.goBack).toHaveBeenCalledTimes(1); + }); + it("starts window dragging from the top 48px of the main area in sidebar timeline mode", () => { mocks.sidebarTimelineEnabled = true; From d2d28ee4db05ee93d1a7997e7aaec3d9110ce968 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:07:39 +0900 Subject: [PATCH 13/22] fix(desktop): route calendar back through escape Use the main Escape shortcut action for the calendar left chrome back button. --- apps/desktop/src/main/body.tsx | 5 ++-- .../src/main/useTabsShortcuts.test.tsx | 14 +++++++++++ apps/desktop/src/shared/main/body.test.tsx | 12 ++++++---- apps/desktop/src/shared/useTabsShortcuts.tsx | 23 +++++++++++-------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/body.tsx b/apps/desktop/src/main/body.tsx index 318093e337..873a2fd632 100644 --- a/apps/desktop/src/main/body.tsx +++ b/apps/desktop/src/main/body.tsx @@ -34,7 +34,7 @@ export function ClassicMainBody() { const canGoBack = useTabs((state) => state.canGoBack); const canGoNext = useTabs((state) => state.canGoNext); const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); - useClassicMainTabsShortcuts(); + const { runEscapeShortcut } = useClassicMainTabsShortcuts(); const isOnboarding = currentTab?.type === "onboarding"; const hasCustomSidebar = hasCustomSidebarTab(currentTab); @@ -110,8 +110,7 @@ export function ClassicMainBody() { > diff --git a/apps/desktop/src/main/useTabsShortcuts.test.tsx b/apps/desktop/src/main/useTabsShortcuts.test.tsx index 825fcece3e..db98a6d286 100644 --- a/apps/desktop/src/main/useTabsShortcuts.test.tsx +++ b/apps/desktop/src/main/useTabsShortcuts.test.tsx @@ -130,6 +130,20 @@ describe("useClassicMainTabsShortcuts", () => { expect(hoisted.openCurrent).toHaveBeenCalledWith({ type: "empty" }); }); + it("returns the escape shortcut action", () => { + hoisted.currentTab = { + active: true, + slotId: "slot-session", + type: "sessions", + }; + + const { result } = renderHook(() => useClassicMainTabsShortcuts()); + + result.current.runEscapeShortcut(); + + expect(hoisted.openCurrent).toHaveBeenCalledWith({ type: "empty" }); + }); + it("opens the home view even when the editor stops escape propagation", () => { hoisted.currentTab = { active: true, diff --git a/apps/desktop/src/shared/main/body.test.tsx b/apps/desktop/src/shared/main/body.test.tsx index c303e53215..3b49c98c44 100644 --- a/apps/desktop/src/shared/main/body.test.tsx +++ b/apps/desktop/src/shared/main/body.test.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ openNew: vi.fn(), goBack: vi.fn(), goNext: vi.fn(), + runEscapeShortcut: vi.fn(), isTauri: vi.fn(() => true), startDragging: vi.fn().mockResolvedValue(undefined), canGoBack: false, @@ -35,7 +36,9 @@ vi.mock("@tauri-apps/api/window", () => ({ })); vi.mock("~/main/useTabsShortcuts", () => ({ - useClassicMainTabsShortcuts: vi.fn(), + useClassicMainTabsShortcuts: vi.fn(() => ({ + runEscapeShortcut: mocks.runEscapeShortcut, + })), })); vi.mock("~/main/tab-content", () => ({ @@ -91,6 +94,7 @@ describe("ClassicMainBody", () => { mocks.openNew.mockClear(); mocks.goBack.mockClear(); mocks.goNext.mockClear(); + mocks.runEscapeShortcut.mockClear(); mocks.isTauri.mockReturnValue(true); mocks.startDragging.mockClear(); mocks.canGoBack = false; @@ -171,14 +175,13 @@ describe("ClassicMainBody", () => { expect(mocks.goNext).toHaveBeenCalledTimes(1); }); - it("shows a back navigation button in calendar left chrome", () => { + it("runs the escape shortcut from the calendar left chrome back button", () => { mocks.currentTab = { active: true, pinned: false, slotId: "slot-1", type: "calendar", }; - mocks.canGoBack = true; render(); @@ -192,7 +195,8 @@ describe("ClassicMainBody", () => { expect(backButton.hasAttribute("disabled")).toBe(false); expect(topArea?.className).toContain("h-12"); expect(topArea?.className).toContain("absolute"); - expect(mocks.goBack).toHaveBeenCalledTimes(1); + expect(mocks.goBack).not.toHaveBeenCalled(); + expect(mocks.runEscapeShortcut).toHaveBeenCalledTimes(1); }); it("starts window dragging from the top 48px of the main area in sidebar timeline mode", () => { diff --git a/apps/desktop/src/shared/useTabsShortcuts.tsx b/apps/desktop/src/shared/useTabsShortcuts.tsx index 1cba6924b3..249bf7f5db 100644 --- a/apps/desktop/src/shared/useTabsShortcuts.tsx +++ b/apps/desktop/src/shared/useTabsShortcuts.tsx @@ -58,8 +58,17 @@ export function useMainTabsShortcuts({ onModT }: { onModT: () => void }) { openCurrent({ type: "empty" }); }, [currentTab, openCurrent, select, tabs]); - const escapeShortcutRef = useRef({ chat, openHome }); - escapeShortcutRef.current = { chat, openHome }; + const runEscapeShortcut = useCallback(() => { + if (chat.mode === "FloatingOpen") { + chat.sendEvent({ type: "CLOSE" }); + return; + } + + openHome(); + }, [chat, openHome]); + + const escapeShortcutRef = useRef(runEscapeShortcut); + escapeShortcutRef.current = runEscapeShortcut; useMountEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -72,13 +81,7 @@ export function useMainTabsShortcuts({ onModT }: { onModT: () => void }) { return; } - const { chat, openHome } = escapeShortcutRef.current; - if (chat.mode === "FloatingOpen") { - chat.sendEvent({ type: "CLOSE" }); - return; - } - - openHome(); + escapeShortcutRef.current(); }); }; @@ -229,7 +232,7 @@ export function useMainTabsShortcuts({ onModT }: { onModT: () => void }) { [newNoteAndListen], ); - return {}; + return { runEscapeShortcut }; } function isPersistentChatInputFocused( From 76f3946d1c1f728ea8877d4e6d23fb604d3f3aea Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:12:38 +0900 Subject: [PATCH 14/22] fix(desktop): show back chrome on custom sidebars Show the Escape-backed left chrome button on settings and contacts sidebars. --- apps/desktop/src/shared/main/body.test.tsx | 49 ++++++++++++---------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/shared/main/body.test.tsx b/apps/desktop/src/shared/main/body.test.tsx index 3b49c98c44..336f786e73 100644 --- a/apps/desktop/src/shared/main/body.test.tsx +++ b/apps/desktop/src/shared/main/body.test.tsx @@ -175,29 +175,32 @@ describe("ClassicMainBody", () => { expect(mocks.goNext).toHaveBeenCalledTimes(1); }); - it("runs the escape shortcut from the calendar left chrome back button", () => { - mocks.currentTab = { - active: true, - pinned: false, - slotId: "slot-1", - type: "calendar", - }; - - render(); - - const backButton = screen.getByRole("button", { name: "Go back" }); - const topArea = backButton.parentElement?.parentElement; - - fireEvent.click(backButton); - - expect(screen.queryByTestId("top-meeting-timeline")).toBeNull(); - expect(screen.queryByRole("button", { name: "Go forward" })).toBeNull(); - expect(backButton.hasAttribute("disabled")).toBe(false); - expect(topArea?.className).toContain("h-12"); - expect(topArea?.className).toContain("absolute"); - expect(mocks.goBack).not.toHaveBeenCalled(); - expect(mocks.runEscapeShortcut).toHaveBeenCalledTimes(1); - }); + it.each(["calendar", "settings", "contacts"])( + "runs the escape shortcut from the %s left chrome back button", + (type) => { + mocks.currentTab = { + active: true, + pinned: false, + slotId: "slot-1", + type, + }; + + render(); + + const backButton = screen.getByRole("button", { name: "Go back" }); + const topArea = backButton.parentElement?.parentElement; + + fireEvent.click(backButton); + + expect(screen.queryByTestId("top-meeting-timeline")).toBeNull(); + expect(screen.queryByRole("button", { name: "Go forward" })).toBeNull(); + expect(backButton.hasAttribute("disabled")).toBe(false); + expect(topArea?.className).toContain("h-12"); + expect(topArea?.className).toContain("absolute"); + expect(mocks.goBack).not.toHaveBeenCalled(); + expect(mocks.runEscapeShortcut).toHaveBeenCalledTimes(1); + }, + ); it("starts window dragging from the top 48px of the main area in sidebar timeline mode", () => { mocks.sidebarTimelineEnabled = true; From 3ce3aabc220ec1d600b6fdfea83c806e8bcadd46 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:16:04 +0900 Subject: [PATCH 15/22] fix(desktop): extend custom sidebar back chrome Show the Escape-backed left chrome button on settings and contacts sidebars. --- apps/desktop/src/main/body.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/body.tsx b/apps/desktop/src/main/body.tsx index 873a2fd632..c8cd34240e 100644 --- a/apps/desktop/src/main/body.tsx +++ b/apps/desktop/src/main/body.tsx @@ -55,7 +55,7 @@ export function ClassicMainBody() { !leftsidebar.showDevtool && !hasCustomSidebar && !isOnboarding; - const showCalendarChromeBack = currentTab?.type === "calendar"; + const showLeftSurfaceChromeBack = hasLeftSurfaceCustomSidebar; const enableMainAreaTopDrag = showSidebarTimeline || hasLeftSurfaceCustomSidebar; const mainAreaTopDrag = useMainAreaTopWindowDrag(enableMainAreaTopDrag); From 54cc0b08734e7493761f8bf63445f6bd13ff9e67 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:16:34 +0900 Subject: [PATCH 16/22] fix(desktop): apply custom sidebar back condition Use the shared left-surface chrome condition for the back button overlay. --- apps/desktop/src/main/body.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/body.tsx b/apps/desktop/src/main/body.tsx index c8cd34240e..8b463a5abf 100644 --- a/apps/desktop/src/main/body.tsx +++ b/apps/desktop/src/main/body.tsx @@ -13,7 +13,10 @@ import { useClassicMainTabsShortcuts } from "./useTabsShortcuts"; import { useShell } from "~/contexts/shell"; import { useConfigValue } from "~/shared/config"; import { ToastArea } from "~/sidebar/toast"; -import { hasCustomSidebarTab } from "~/sidebar/use-custom-sidebar"; +import { + hasCustomSidebarTab, + hasLeftSurfaceCustomSidebarTab, +} from "~/sidebar/use-custom-sidebar"; import { type Tab, uniqueIdfromTab, useTabs } from "~/store/zustand/tabs"; const MAIN_AREA_TOP_DRAG_HEIGHT_PX = 48; @@ -38,6 +41,8 @@ export function ClassicMainBody() { const isOnboarding = currentTab?.type === "onboarding"; const hasCustomSidebar = hasCustomSidebarTab(currentTab); + const hasLeftSurfaceCustomSidebar = + hasLeftSurfaceCustomSidebarTab(currentTab); const showSidebarTimeline = sidebarTimelineEnabled && leftsidebar.expanded && @@ -79,6 +84,11 @@ export function ClassicMainBody() { />
+ ) : hasLeftSurfaceCustomSidebar ? ( +
) : (
)} - {showCalendarChromeBack && hasLeftSurfaceCustomSidebar ? ( + {showLeftSurfaceChromeBack ? (
Date: Sat, 30 May 2026 00:17:16 +0900 Subject: [PATCH 17/22] fix(desktop): restore sidebar timeline stop control Show a Stop listening button in the session header when sidebar timeline mode hides the top timeline. --- .../components/outer-header/index.test.tsx | 111 ++++++++++++++++++ .../session/components/outer-header/index.tsx | 95 ++++++++++++++- 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/session/components/outer-header/index.test.tsx diff --git a/apps/desktop/src/session/components/outer-header/index.test.tsx b/apps/desktop/src/session/components/outer-header/index.test.tsx new file mode 100644 index 0000000000..91ffe21853 --- /dev/null +++ b/apps/desktop/src/session/components/outer-header/index.test.tsx @@ -0,0 +1,111 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EditorView } from "~/store/zustand/tabs/schema"; + +const mocks = vi.hoisted(() => ({ + leftsidebar: { + expanded: true, + showDevtool: false, + }, + sessionModes: {} as Record, + sidebarTimelineEnabled: false, + stopListening: vi.fn(), +})); + +vi.mock("./metadata", () => ({ + MetadataButton: () => , +})); + +vi.mock("./overflow", () => ({ + OverflowButton: () => , +})); + +vi.mock("@hypr/ui/components/ui/dancing-sticks", () => ({ + DancingSticks: () => , +})); + +vi.mock("~/contexts/shell", () => ({ + useShell: () => ({ + leftsidebar: mocks.leftsidebar, + }), +})); + +vi.mock("~/shared/config", () => ({ + useConfigValue: () => mocks.sidebarTimelineEnabled, +})); + +vi.mock("~/stt/contexts", () => ({ + useListener: vi.fn((selector: (state: unknown) => unknown) => + selector({ + getSessionMode: (sessionId: string) => + mocks.sessionModes[sessionId] ?? "inactive", + live: { + amplitude: { + mic: 0.5, + speaker: 0.25, + }, + degraded: null, + muted: false, + }, + stop: mocks.stopListening, + }), + ), +})); + +import { OuterHeader } from "./index"; + +describe("OuterHeader", () => { + beforeEach(() => { + mocks.leftsidebar.expanded = true; + mocks.leftsidebar.showDevtool = false; + mocks.sessionModes = {}; + mocks.sidebarTimelineEnabled = false; + mocks.stopListening.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows a stop listening button for active sessions in sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = true; + mocks.sessionModes = { "session-1": "active" }; + + render( + Session title} + />, + ); + + const stopButton = screen.getByRole("button", { + name: "Stop listening", + }); + + fireEvent.click(stopButton); + + expect(screen.getByTestId("dancing-sticks")).not.toBeNull(); + expect(stopButton.className).toContain("h-7"); + expect(stopButton.className).toContain("w-20"); + expect(stopButton.className).toContain("rounded-full"); + expect(stopButton.textContent).toContain("Stop"); + expect(mocks.stopListening).toHaveBeenCalledTimes(1); + }); + + it("keeps the dedicated stop button hidden outside sidebar timeline mode", () => { + mocks.sidebarTimelineEnabled = false; + mocks.sessionModes = { "session-1": "active" }; + + render( + Session title} + />, + ); + + expect(screen.queryByRole("button", { name: "Stop listening" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/session/components/outer-header/index.tsx b/apps/desktop/src/session/components/outer-header/index.tsx index a42744ca29..45e44e2bb8 100644 --- a/apps/desktop/src/session/components/outer-header/index.tsx +++ b/apps/desktop/src/session/components/outer-header/index.tsx @@ -1,7 +1,15 @@ +import { MicOff } from "lucide-react"; + +import { DancingSticks } from "@hypr/ui/components/ui/dancing-sticks"; +import { cn } from "@hypr/utils"; + import { MetadataButton } from "./metadata"; import { OverflowButton } from "./overflow"; +import { useShell } from "~/contexts/shell"; +import { useConfigValue } from "~/shared/config"; import type { EditorView } from "~/store/zustand/tabs/schema"; +import { useListener } from "~/stt/contexts"; export function OuterHeader({ sessionId, @@ -16,7 +24,8 @@ export function OuterHeader({
{title ?
{title}
: null} -
+
+
@@ -24,3 +33,87 @@ export function OuterHeader({
); } + +function SidebarModeStopButton({ sessionId }: { sessionId: string }) { + const { leftsidebar } = useShell(); + const sidebarTimelineEnabled = useConfigValue("sidebar_timeline_enabled"); + const { amplitude, degraded, mode, muted, stop } = useListener((state) => ({ + amplitude: state.live.amplitude, + degraded: state.live.degraded, + mode: state.getSessionMode(sessionId), + muted: state.live.muted, + stop: state.stop, + })); + const active = mode === "active" || mode === "finalizing"; + const finalizing = mode === "finalizing"; + + if ( + !sidebarTimelineEnabled || + !leftsidebar.expanded || + leftsidebar.showDevtool || + !active + ) { + return null; + } + + const accent = degraded ? "amber" : "red"; + const colors = { + red: { + button: "text-red-500 hover:text-red-600 bg-red-50 hover:bg-red-100", + sticks: "#ef4444", + stop: "bg-red-500", + }, + amber: { + button: + "text-amber-500 hover:text-amber-600 bg-amber-50 hover:bg-amber-100", + sticks: "#f59e0b", + stop: "bg-amber-500", + }, + }[accent]; + + return ( + + ); +} From 1c090b7463d93d42a2fc2a2815fcdac70f7e6e9f Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:19:15 +0900 Subject: [PATCH 18/22] fix(desktop): restore sidebar footer border chrome Keep footer accessory borders intact and only square the bottom-left corner of the main surface when bottom content attaches. --- .../components/bottom-accessory/post-session.test.tsx | 2 ++ .../src/session/components/bottom-accessory/post-session.tsx | 2 +- apps/desktop/src/shared/main/index.test.tsx | 5 +++++ apps/desktop/src/shared/main/index.tsx | 1 + apps/desktop/src/shared/main/shell-scaffold.test.tsx | 3 +++ apps/desktop/src/shared/main/shell-scaffold.tsx | 1 + 6 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx b/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx index c6b806abe0..2d8d408807 100644 --- a/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/post-session.test.tsx @@ -237,6 +237,8 @@ describe("PostSessionAccessory", () => { const transcriptCard = scrollArea?.parentElement; const transcriptSlot = transcriptCard?.parentElement; + expect(transcriptCard?.className).toContain("rounded-b-xl"); + expect(transcriptCard?.className).toContain("border"); expect(transcriptCard?.className).toContain("h-full"); expect(transcriptCard?.className).toContain("min-h-[114px]"); expect(transcriptCard?.className).not.toContain("min-h-[96px]"); diff --git a/apps/desktop/src/session/components/bottom-accessory/post-session.tsx b/apps/desktop/src/session/components/bottom-accessory/post-session.tsx index e619064672..336951182f 100644 --- a/apps/desktop/src/session/components/bottom-accessory/post-session.tsx +++ b/apps/desktop/src/session/components/bottom-accessory/post-session.tsx @@ -530,7 +530,7 @@ function TranscriptCard({ return (
{ expect(screen.getByTestId("resize-handle").dataset.className).toContain( "data-[panel-group-direction=vertical]:-mb-px", ); + expect( + document + .querySelector("[data-chat-floating-anchor]") + ?.hasAttribute("data-main-has-after-border"), + ).toBe(true); const panels = screen.getAllByTestId("panel"); expect(panels[0]?.dataset.defaultSize).toBe("78"); diff --git a/apps/desktop/src/shared/main/index.tsx b/apps/desktop/src/shared/main/index.tsx index 668c7f688c..3582ab9dff 100644 --- a/apps/desktop/src/shared/main/index.tsx +++ b/apps/desktop/src/shared/main/index.tsx @@ -146,6 +146,7 @@ function MainPanel({ >
{ expect(shell.className).toContain( "[&_[data-chat-floating-anchor]]:rounded-r-none", ); + expect(shell.className).toContain( + "[&_[data-chat-floating-anchor][data-main-has-after-border]]:rounded-bl-none", + ); expect(shell.className).toContain( "[&_[data-chat-floating-anchor]]:border-y-0", ); diff --git a/apps/desktop/src/shared/main/shell-scaffold.tsx b/apps/desktop/src/shared/main/shell-scaffold.tsx index c15ed9d0ec..6750671621 100644 --- a/apps/desktop/src/shared/main/shell-scaffold.tsx +++ b/apps/desktop/src/shared/main/shell-scaffold.tsx @@ -35,6 +35,7 @@ export function MainShellScaffold({ resolvedMainSurfaceChrome === "left" && [ "[&_[data-chat-floating-anchor]]:rounded-l-xl", "[&_[data-chat-floating-anchor]]:rounded-r-none", + "[&_[data-chat-floating-anchor][data-main-has-after-border]]:rounded-bl-none", "[&_[data-chat-floating-anchor]]:border-y-0", "[&_[data-chat-floating-anchor]]:border-r-0", "[&_[data-chat-floating-anchor]]:border-l", From 06acf129ff292c46e42cc0eae4ec1a7aa281dfcc Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:19:48 +0900 Subject: [PATCH 19/22] fix(desktop): size session header chrome Set the session header to 48px tall and increase the title field text size. --- .../components/outer-header/index.test.tsx | 26 +++++++++++++++++++ .../session/components/outer-header/index.tsx | 4 +-- .../outer-header/metadata/index.tsx | 1 + .../outer-header/overflow/index.tsx | 2 +- .../src/session/components/title-input.tsx | 6 ++--- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/session/components/outer-header/index.test.tsx b/apps/desktop/src/session/components/outer-header/index.test.tsx index 91ffe21853..8535373903 100644 --- a/apps/desktop/src/session/components/outer-header/index.test.tsx +++ b/apps/desktop/src/session/components/outer-header/index.test.tsx @@ -94,6 +94,32 @@ describe("OuterHeader", () => { expect(mocks.stopListening).toHaveBeenCalledTimes(1); }); + it("keeps the session header at 48px tall", () => { + const { container } = render( + Session title} + />, + ); + + expect(container.firstElementChild?.className).toContain("h-12"); + }); + + it("keeps the header content row full width", () => { + const { container } = render( + Session title} + />, + ); + + expect(container.firstElementChild?.firstElementChild?.className).toContain( + "w-full", + ); + }); + it("keeps the dedicated stop button hidden outside sidebar timeline mode", () => { mocks.sidebarTimelineEnabled = false; mocks.sessionModes = { "session-1": "active" }; diff --git a/apps/desktop/src/session/components/outer-header/index.tsx b/apps/desktop/src/session/components/outer-header/index.tsx index 45e44e2bb8..1fb6e95af9 100644 --- a/apps/desktop/src/session/components/outer-header/index.tsx +++ b/apps/desktop/src/session/components/outer-header/index.tsx @@ -21,8 +21,8 @@ export function OuterHeader({ title?: React.ReactNode; }) { return ( -
-
+
+
{title ?
{title}
: null}
diff --git a/apps/desktop/src/session/components/outer-header/metadata/index.tsx b/apps/desktop/src/session/components/outer-header/metadata/index.tsx index 57f373c701..8f1ec46b05 100644 --- a/apps/desktop/src/session/components/outer-header/metadata/index.tsx +++ b/apps/desktop/src/session/components/outer-header/metadata/index.tsx @@ -71,6 +71,7 @@ const TriggerInner = forwardRef< variant="ghost" size="sm" className={cn([ + "rounded-full px-3", "text-neutral-600 hover:text-black", open && "bg-neutral-100", hasEvent && "max-w-50", diff --git a/apps/desktop/src/session/components/outer-header/overflow/index.tsx b/apps/desktop/src/session/components/outer-header/overflow/index.tsx index c77f4114d0..86f2245471 100644 --- a/apps/desktop/src/session/components/outer-header/overflow/index.tsx +++ b/apps/desktop/src/session/components/outer-header/overflow/index.tsx @@ -74,7 +74,7 @@ export function OverflowButton({ diff --git a/apps/desktop/src/session/components/title-input.tsx b/apps/desktop/src/session/components/title-input.tsx index 4ccef7c16e..b700112f0b 100644 --- a/apps/desktop/src/session/components/title-input.tsx +++ b/apps/desktop/src/session/components/title-input.tsx @@ -85,7 +85,7 @@ export const TitleInput = forwardRef< if (isGenerating) { return (
- + Generating title...
@@ -95,7 +95,7 @@ export const TitleInput = forwardRef< if (showRevealAnimation && generatedTitle) { return (
- + {generatedTitle}
@@ -350,7 +350,7 @@ const TitleInputInner = memo( className={cn([ "min-w-0 flex-1 transition-opacity duration-200", "border-none bg-transparent focus:outline-hidden", - "placeholder:text-muted-foreground text-sm font-semibold", + "placeholder:text-muted-foreground text-xl font-semibold", ])} /> {onGenerateTitle && !localTitle.trim() && ( From 71dc0a800b2e0ff8c64286bc02efa278eecad11a Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 00:20:57 +0900 Subject: [PATCH 20/22] fix(desktop): align templates chrome with calendar Use the left-surface sidebar and main chrome treatment for templates. --- apps/desktop/src/shared/main/body.test.tsx | 2 +- apps/desktop/src/sidebar/index.test.tsx | 11 ++- .../desktop/src/sidebar/use-custom-sidebar.ts | 1 + .../src/templates/template-sidebar.tsx | 82 +++++++++---------- 4 files changed, 50 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/shared/main/body.test.tsx b/apps/desktop/src/shared/main/body.test.tsx index 336f786e73..2781a08a09 100644 --- a/apps/desktop/src/shared/main/body.test.tsx +++ b/apps/desktop/src/shared/main/body.test.tsx @@ -175,7 +175,7 @@ describe("ClassicMainBody", () => { expect(mocks.goNext).toHaveBeenCalledTimes(1); }); - it.each(["calendar", "settings", "contacts"])( + it.each(["calendar", "settings", "contacts", "templates"])( "runs the escape shortcut from the %s left chrome back button", (type) => { mocks.currentTab = { diff --git a/apps/desktop/src/sidebar/index.test.tsx b/apps/desktop/src/sidebar/index.test.tsx index 1b832d671c..21a23a4a43 100644 --- a/apps/desktop/src/sidebar/index.test.tsx +++ b/apps/desktop/src/sidebar/index.test.tsx @@ -91,14 +91,19 @@ describe("LeftSidebar", () => { expect(container.firstElementChild?.className).toContain("pt-0"); }); - it("keeps custom sidebar modes below the window chrome", () => { + it.each([ + ["settings", "settings-nav"], + ["calendar", "calendar-nav"], + ["contacts", "contacts-nav"], + ["templates", "templates-nav"], + ])("keeps %s below the window chrome", (type, testId) => { mocks.sidebarTimelineEnabled = true; - mocks.currentTab = { type: "settings" }; + mocks.currentTab = { type }; const { container } = render(); const classList = container.firstElementChild?.className.split(" ") ?? []; - expect(screen.getByTestId("settings-nav")).toBeTruthy(); + expect(screen.getByTestId(testId)).toBeTruthy(); expect(classList).toContain("pt-11"); expect(classList).not.toContain("pt-0"); }); diff --git a/apps/desktop/src/sidebar/use-custom-sidebar.ts b/apps/desktop/src/sidebar/use-custom-sidebar.ts index b94b07a1f9..e2d8340b21 100644 --- a/apps/desktop/src/sidebar/use-custom-sidebar.ts +++ b/apps/desktop/src/sidebar/use-custom-sidebar.ts @@ -13,6 +13,7 @@ const LEFT_SURFACE_CUSTOM_SIDEBAR_TYPES: Tab["type"][] = [ "calendar", "settings", "contacts", + "templates", ]; export function hasCustomSidebarTab(tab: Tab | null): boolean { diff --git a/apps/desktop/src/templates/template-sidebar.tsx b/apps/desktop/src/templates/template-sidebar.tsx index 0b9c5e4def..c2058395ef 100644 --- a/apps/desktop/src/templates/template-sidebar.tsx +++ b/apps/desktop/src/templates/template-sidebar.tsx @@ -16,6 +16,7 @@ import { getTemplateCopyTitle, type UserTemplate } from "./queries"; import { useTemplateTab } from "./utils"; import { useNativeContextMenu } from "~/shared/hooks/useNativeContextMenu"; +import { CustomSidebarHeader } from "~/sidebar/custom-sidebar-header"; import { type Tab } from "~/store/zustand/tabs"; type SortOption = "alphabetical" | "reverse-alphabetical"; @@ -292,52 +293,49 @@ export function TemplatesSidebarContent({ return (
-
-

Templates

-
- {userTemplates.length > 1 && ( - - - + + + + setSortOption("alphabetical")} > - - - - - - setSortOption("alphabetical")} - > - A to Z - - setSortOption("reverse-alphabetical")} - > - Z to A - - - - - )} - - -
-
+ A to Z + + setSortOption("reverse-alphabetical")} + > + Z to A + + + + + )} + + + -
+
From 4a81d99d677252fe660d7f1f80dd1dd1bc3f9ad2 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 30 May 2026 02:17:31 +0900 Subject: [PATCH 21/22] fix(desktop): refine floating chat toolbar chrome --- .../src/chat/components/chat-panel.tsx | 6 +- .../src/chat/components/persistent-chat.tsx | 243 ++++++++++++++++-- .../src/chat/components/toolbar-controls.tsx | 52 ++-- 3 files changed, 262 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/chat/components/chat-panel.tsx b/apps/desktop/src/chat/components/chat-panel.tsx index 6509f7081d..e195b55c20 100644 --- a/apps/desktop/src/chat/components/chat-panel.tsx +++ b/apps/desktop/src/chat/components/chat-panel.tsx @@ -50,10 +50,10 @@ export function ChatView({ >
(null); const [isExpanded, setIsExpanded] = useState(false); + const [floatingSize, setFloatingSize] = useState( + null, + ); + const resizeFrameRef = useRef(null); + const panelRef = useRef(null); + const resizeStateRef = useRef(null); const observerRef = useRef(null); const getActiveContainer = () => { @@ -55,7 +112,7 @@ export function PersistentChatPanel({ return; } setContainerRect(container.getBoundingClientRect()); - }, [isVisible, floatingContainerRef]); + }, [isVisible, hasBeenOpened, floatingContainerRef]); useEffect(() => { const container = getActiveContainer(); @@ -83,7 +140,7 @@ export function PersistentChatPanel({ window.removeEventListener("resize", updateRect); window.removeEventListener("scroll", updateRect, true); }; - }, [isVisible, floatingContainerRef]); + }, [isVisible, hasBeenOpened, floatingContainerRef]); if (!hasBeenOpened) { return null; @@ -108,15 +165,85 @@ export function PersistentChatPanel({ }; const panelStyle = isExpanded ? { transformOrigin: "center" } - : { - width: "min(640px, calc(100% - 2rem))", - height: "min(560px, calc(100% - 1rem))", - minWidth: "min(360px, calc(100% - 2rem))", - minHeight: "min(320px, calc(100% - 1rem))", - maxWidth: "calc(100% - 2rem)", - maxHeight: "calc(100% - 1rem)", - transformOrigin: "bottom center", - }; + : floatingSize && containerRect + ? getFloatingPanelStyle(floatingSize, containerRect) + : { + width: "min(640px, calc(100% - 2rem))", + height: "min(560px, calc(100% - 1rem))", + minWidth: "min(360px, calc(100% - 2rem))", + minHeight: "min(320px, calc(100% - 1rem))", + maxWidth: "calc(100% - 2rem)", + maxHeight: "calc(100% - 1rem)", + transformOrigin: "center", + }; + + const handleResizeStart = ( + handle: ResizeHandle, + event: PointerEvent, + ) => { + const panel = panelRef.current; + const frame = resizeFrameRef.current; + + if (!panel || !frame) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + + const panelRect = panel.getBoundingClientRect(); + const frameRect = frame.getBoundingClientRect(); + + resizeStateRef.current = { + pointerId: event.pointerId, + handle, + startX: event.clientX, + startY: event.clientY, + startSize: { + width: panelRect.width, + height: panelRect.height, + }, + containerWidth: frameRect.width, + containerHeight: frameRect.height, + }; + }; + + const handleResizeMove = (event: PointerEvent) => { + const resizeState = resizeStateRef.current; + + if (!resizeState || resizeState.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const deltaX = event.clientX - resizeState.startX; + const deltaY = event.clientY - resizeState.startY; + const nextSize = getResizedSize(resizeState, deltaX, deltaY); + + setFloatingSize( + clampFloatingPanelSize( + nextSize, + resizeState.containerWidth, + resizeState.containerHeight, + ), + ); + }; + + const handleResizeEnd = (event: PointerEvent) => { + const resizeState = resizeStateRef.current; + + if (!resizeState || resizeState.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.releasePointerCapture?.(event.pointerId); + resizeStateRef.current = null; + }; return ( @@ -139,11 +266,13 @@ export function PersistentChatPanel({ transition={{ duration: 0.2 }} >
{ if (!isExpanded && event.target === event.currentTarget) { @@ -152,6 +281,7 @@ export function PersistentChatPanel({ }} > setIsExpanded((value) => !value)} /> + {!isExpanded && + RESIZE_HANDLES.map((handle) => ( +
+ handleResizeStart(handle.id, event) + } + onPointerMove={handleResizeMove} + onPointerUp={handleResizeEnd} + onPointerCancel={handleResizeEnd} + > + {handle.id === "bottom-right" && ( + + )} +
+ ))}
@@ -181,3 +332,65 @@ export function PersistentChatPanel({
); } + +function getFloatingPanelStyle( + size: FloatingPanelSize, + containerRect: DOMRect, +): CSSProperties { + const clampedSize = clampFloatingPanelSize( + size, + containerRect.width, + containerRect.height, + ); + + return { + width: `${clampedSize.width}px`, + height: `${clampedSize.height}px`, + transformOrigin: "center", + }; +} + +function getResizedSize( + resizeState: ResizeState, + deltaX: number, + deltaY: number, +): FloatingPanelSize { + const nextSize = { ...resizeState.startSize }; + + if (resizeState.handle.includes("left")) { + nextSize.width -= deltaX * 2; + } + + if (resizeState.handle.includes("right")) { + nextSize.width += deltaX * 2; + } + + if (resizeState.handle.includes("top")) { + nextSize.height -= deltaY * 2; + } + + if (resizeState.handle.includes("bottom")) { + nextSize.height += deltaY * 2; + } + + return nextSize; +} + +function clampFloatingPanelSize( + size: FloatingPanelSize, + containerWidth: number, + containerHeight: number, +): FloatingPanelSize { + const maxWidth = Math.max(0, containerWidth - FLOATING_PANEL_MARGIN * 2); + const maxHeight = Math.max(0, containerHeight - FLOATING_PANEL_MARGIN * 2); + const minWidth = Math.min(FLOATING_PANEL_MIN_WIDTH, maxWidth); + const minHeight = Math.min(FLOATING_PANEL_MIN_HEIGHT, maxHeight); + const width = clamp(size.width, minWidth, maxWidth); + const height = clamp(size.height, minHeight, maxHeight); + + return { width, height }; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/apps/desktop/src/chat/components/toolbar-controls.tsx b/apps/desktop/src/chat/components/toolbar-controls.tsx index c03b5714f0..ecec28f759 100644 --- a/apps/desktop/src/chat/components/toolbar-controls.tsx +++ b/apps/desktop/src/chat/components/toolbar-controls.tsx @@ -41,39 +41,47 @@ export function ChatToolbarControls({ const isDark = surface === "dark"; return ( -
-
+
+
+
+
} onClick={onNewChat} title="New chat" - className={ + className={isDark ? darkToolbarButtonClassName : undefined} + /> + : } + onClick={onToggleExpanded ?? (() => {})} + title={isExpanded ? "Collapse chat" : "Expand chat"} + className={cn([ isDark - ? "text-stone-300 hover:bg-stone-700 hover:text-white" - : undefined - } + ? [ + darkToolbarButtonClassName, + "bg-white/7 text-white hover:bg-white/10", + ] + : "bg-neutral-100 text-neutral-900 hover:bg-neutral-100", + ])} />
- : } - onClick={onToggleExpanded ?? (() => {})} - title={isExpanded ? "Collapse chat" : "Expand chat"} - className={cn([ - "absolute top-1/2 right-0 -translate-y-1/2", - isDark - ? "bg-stone-700 text-white hover:bg-stone-600" - : "bg-neutral-100 text-neutral-900 hover:bg-neutral-100", - ])} - />
); } +const darkToolbarButtonClassName = + "size-8 rounded-lg text-stone-300 hover:bg-white/7 hover:text-white"; + function ChatActionButton({ className, icon, @@ -136,16 +144,18 @@ function ChatGroups({