diff --git a/apps/desktop2/package.json b/apps/desktop2/package.json index 458c3ccdb..2fff8d8ee 100644 --- a/apps/desktop2/package.json +++ b/apps/desktop2/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "tauri": "tauri", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build" }, "dependencies": { "@ai-sdk/openai-compatible": "^1.0.22", diff --git a/apps/desktop2/src/components/chat/body.tsx b/apps/desktop2/src/components/chat/body.tsx index c90047417..37c855554 100644 --- a/apps/desktop2/src/components/chat/body.tsx +++ b/apps/desktop2/src/components/chat/body.tsx @@ -37,7 +37,7 @@ export function ChatBody({ ref={scrollRef} className={cn([ "flex-1 overflow-y-auto", - chat.mode === "RightPanelOpen" && "border mt-1 mr-1 rounded-md rounded-b-none", + chat.mode === "RightPanelOpen" && "border mt-1 rounded-md rounded-b-none", ])} > {messages.length === 0 diff --git a/apps/desktop2/src/components/chat/header.tsx b/apps/desktop2/src/components/chat/header.tsx index 83e145d53..38585bb79 100644 --- a/apps/desktop2/src/components/chat/header.tsx +++ b/apps/desktop2/src/components/chat/header.tsx @@ -24,8 +24,8 @@ export function ChatHeader({
@@ -102,7 +102,7 @@ function ChatGroups({ return ( -
+ )} + +
+ -
+ /> + + +
- +
+ + {tabs.map((tab) => ( + setTabRef(tab, el)} + style={{ position: "relative" }} + className="h-full z-10" + layoutScroll + > + + + ))} +
+ + + + ); } @@ -171,3 +229,52 @@ function Content({ tab }: { tab: Tab }) { return null; } + +const useTabCloseHotkey = () => { + const { tabs, currentTab, close } = useTabs(); + + useHotkeys( + "mod+w", + async (e) => { + e.preventDefault(); + + if (currentTab && tabs.length > 1) { + close(currentTab); + } else { + const appWindow = getCurrentWebviewWindow(); + await appWindow.close(); + } + }, + { enableOnFormTags: true }, + [tabs, currentTab, close], + ); +}; + +function useScrollActiveTabIntoView(tabs: Tab[]) { + const tabRefsMap = useRef>(new Map()); + + useEffect(() => { + const activeTab = tabs.find((tab) => tab.active); + if (activeTab) { + const tabKey = uniqueIdfromTab(activeTab); + const tabElement = tabRefsMap.current.get(tabKey); + if (tabElement) { + tabElement.scrollIntoView({ + behavior: "smooth", + inline: "nearest", + block: "nearest", + }); + } + } + }, [tabs]); + + const setTabRef = useCallback((tab: Tab, el: HTMLDivElement | null) => { + if (el) { + tabRefsMap.current.set(uniqueIdfromTab(tab), el); + } else { + tabRefsMap.current.delete(uniqueIdfromTab(tab)); + } + }, []); + + return setTabRef; +} diff --git a/apps/desktop2/src/components/main/body/search.tsx b/apps/desktop2/src/components/main/body/search.tsx index 68664f36f..9419ab4c9 100644 --- a/apps/desktop2/src/components/main/body/search.tsx +++ b/apps/desktop2/src/components/main/body/search.tsx @@ -1,5 +1,5 @@ import { Loader2Icon, SearchIcon, XIcon } from "lucide-react"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { cn } from "@hypr/ui/lib/utils"; @@ -8,6 +8,7 @@ import { useSearch } from "../../../contexts/search/ui"; export function Search() { const { query, setQuery, isSearching, isIndexing, onFocus, onBlur } = useSearch(); const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); const showLoading = isSearching || isIndexing; @@ -49,9 +50,24 @@ export function Search() { { enableOnFormTags: true }, ); + const handleFocus = () => { + setIsFocused(true); + onFocus(); + }; + + const handleBlur = () => { + setIsFocused(false); + onBlur(); + }; + return ( -
-
+
+
{showLoading ? : } @@ -61,14 +77,14 @@ export function Search() { placeholder="Search anything..." value={query} onChange={(e) => setQuery(e.target.value)} - onFocus={onFocus} - onBlur={onBlur} + onFocus={handleFocus} + onBlur={handleBlur} className={cn([ "text-sm", - "w-full pl-9 py-2", + "w-full pl-9 h-full", query ? "pr-9" : "pr-4", - "rounded-lg bg-gray-100 border-0", - "focus:outline-none focus:bg-gray-200", + "rounded-lg bg-gray-100 border border-transparent", + "focus:outline-none focus:bg-gray-200 focus:border-black", ])} /> {query && ( diff --git a/apps/desktop2/src/components/main/body/shared.tsx b/apps/desktop2/src/components/main/body/shared.tsx index 47dc113c1..34b80bbb0 100644 --- a/apps/desktop2/src/components/main/body/shared.tsx +++ b/apps/desktop2/src/components/main/body/shared.tsx @@ -23,9 +23,8 @@ export function TabItemBase( className={clsx([ "flex items-center gap-2 cursor-pointer group", "min-w-[100px] max-w-[200px] h-full px-2", - active - ? "bg-background text-foreground rounded-lg border" - : "bg-muted/50 hover:bg-muted text-muted-foreground rounded-lg border", + "bg-color1 rounded-lg border", + active ? "text-black border-black" : "text-color3 border-transparent", ])} >
@@ -42,8 +41,8 @@ export function TabItemBase( className={clsx([ "text-xs flex-shrink-0 transition-opacity", active - ? "text-muted-foreground hover:text-foreground" - : "opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground", + ? "text-color4" + : "opacity-0 group-hover:opacity-100 text-color3", ])} > ✕ diff --git a/apps/desktop2/src/components/main/sidebar/index.tsx b/apps/desktop2/src/components/main/sidebar/index.tsx index 0a26c8e4e..69c30856f 100644 --- a/apps/desktop2/src/components/main/sidebar/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/index.tsx @@ -14,29 +14,23 @@ export function LeftSidebar() { const showSearchResults = query.trim() !== ""; return ( -
+
-
-
+
{showSearchResults ? : }
diff --git a/apps/desktop2/src/components/main/sidebar/profile/index.tsx b/apps/desktop2/src/components/main/sidebar/profile/index.tsx index 548414eca..a3022fb82 100644 --- a/apps/desktop2/src/components/main/sidebar/profile/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/profile/index.tsx @@ -64,7 +64,7 @@ export function ProfileSection() {
diff --git a/apps/desktop2/src/components/main/sidebar/search/index.tsx b/apps/desktop2/src/components/main/sidebar/search/index.tsx index 5a8d5f239..a78795e8d 100644 --- a/apps/desktop2/src/components/main/sidebar/search/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/search/index.tsx @@ -10,7 +10,7 @@ export function SearchResults() { const empty = !query || !results || results.totalResults === 0; return ( -
+
{empty ? : } diff --git a/apps/desktop2/src/components/main/sidebar/timeline.tsx b/apps/desktop2/src/components/main/sidebar/timeline.tsx index 100a5e395..39adac328 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline.tsx @@ -17,10 +17,10 @@ export function TimelineView() { const { groupedItems, sortedDates } = useTimelineData(); return ( -
+
{sortedDates.map((date) => (
-
+
{groupedItems[date].map((item) => ( diff --git a/apps/desktop2/src/routes/app/main/_layout.index.tsx b/apps/desktop2/src/routes/app/main/_layout.index.tsx index b4df15ac2..be27e9afc 100644 --- a/apps/desktop2/src/routes/app/main/_layout.index.tsx +++ b/apps/desktop2/src/routes/app/main/_layout.index.tsx @@ -16,8 +16,9 @@ function Component() { const isChatOpen = chat.mode === "RightPanelOpen"; return ( -
+
{leftsidebar.expanded && } + - + diff --git a/apps/desktop2/src/store/zustand/tabs.test.ts b/apps/desktop2/src/store/zustand/tabs.test.ts new file mode 100644 index 000000000..517a6b1e7 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { type Tab, useTabs } from "./tabs"; + +describe("Tab History Navigation", () => { + beforeEach(() => { + useTabs.setState({ + currentTab: null, + tabs: [], + history: new Map(), + canGoBack: false, + canGoNext: false, + }); + }); + + test("basic navigation: open tab1 -> open tab2 -> goBack -> goNext", () => { + const tab1: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + const tab2: Tab = { + type: "sessions", + id: "session-2", + active: true, + state: { editor: "raw" }, + }; + + useTabs.getState().openCurrent(tab1); + let state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-1" }); + expect(state.canGoBack).toBe(false); + expect(state.canGoNext).toBe(false); + + useTabs.getState().openCurrent(tab2); + state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-2" }); + expect(state.canGoBack).toBe(true); + expect(state.canGoNext).toBe(false); + + useTabs.getState().goBack(); + state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-1" }); + expect(state.canGoBack).toBe(false); + expect(state.canGoNext).toBe(true); + + useTabs.getState().goNext(); + state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-2" }); + expect(state.canGoBack).toBe(true); + expect(state.canGoNext).toBe(false); + }); + + test("truncate forward history: tab1 -> tab2 -> goBack -> tab3", () => { + const tab1: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + const tab2: Tab = { + type: "sessions", + id: "session-2", + active: true, + state: { editor: "raw" }, + }; + const tab3: Tab = { + type: "sessions", + id: "session-3", + active: true, + state: { editor: "raw" }, + }; + + useTabs.getState().openCurrent(tab1); + useTabs.getState().openCurrent(tab2); + useTabs.getState().goBack(); + + let state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-1" }); + expect(state.canGoNext).toBe(true); + + useTabs.getState().openCurrent(tab3); + + state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-3" }); + expect(state.canGoBack).toBe(true); + expect(state.canGoNext).toBe(false); + }); + + test("boundary: cannot go back at start", () => { + const tab1: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + + useTabs.getState().openCurrent(tab1); + useTabs.getState().goBack(); + + const state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-1" }); + expect(state.canGoBack).toBe(false); + }); + + test("boundary: cannot go forward at end", () => { + const tab1: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + const tab2: Tab = { + type: "sessions", + id: "session-2", + active: true, + state: { editor: "raw" }, + }; + + useTabs.getState().openCurrent(tab1); + useTabs.getState().openCurrent(tab2); + useTabs.getState().goNext(); + + const state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "session-2" }); + expect(state.canGoNext).toBe(false); + }); + + test("openCurrent with active:false in input should still track history", () => { + useTabs.getState().openCurrent({ + type: "sessions", + id: "tab-1", + active: false, + state: { editor: "raw" }, + }); + expect(useTabs.getState().canGoBack).toBe(false); + + useTabs.getState().openCurrent({ + type: "sessions", + id: "tab-2", + active: false, + state: { editor: "raw" }, + }); + expect(useTabs.getState().canGoBack).toBe(true); + + useTabs.getState().goBack(); + const state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "tab-1" }); + expect(state.canGoNext).toBe(true); + }); +}); diff --git a/apps/desktop2/src/store/zustand/tabs.ts b/apps/desktop2/src/store/zustand/tabs.ts index a54a2affb..2523fce27 100644 --- a/apps/desktop2/src/store/zustand/tabs.ts +++ b/apps/desktop2/src/store/zustand/tabs.ts @@ -3,14 +3,23 @@ import { create } from "zustand"; import { TABLES } from "../tinybase/persisted"; +type TabHistory = { + stack: Tab[]; + currentIndex: number; +}; + type State = { currentTab: Tab | null; tabs: Tab[]; + history: Map; + canGoBack: boolean; + canGoNext: boolean; }; type Actions = & TabUpdater - & TabStateUpdater; + & TabStateUpdater + & TabNavigator; type TabUpdater = { setTabs: (tabs: Tab[]) => void; @@ -26,59 +35,136 @@ type TabStateUpdater = { updateSessionTabState: (tab: Tab, state: Extract["state"]) => void; }; +type TabNavigator = { + goBack: () => void; + goNext: () => void; +}; + type Store = State & Actions; +const ACTIVE_TAB_SLOT_ID = "active-tab-history"; + +const getSlotId = (tab: Tab): string => { + return tab.active ? ACTIVE_TAB_SLOT_ID : `inactive-${uniqueIdfromTab(tab)}`; +}; + +const computeHistoryFlags = ( + history: Map, + currentTab: Tab | null, +): { canGoBack: boolean; canGoNext: boolean } => { + if (!currentTab) { + return { canGoBack: false, canGoNext: false }; + } + const slotId = getSlotId(currentTab); + const tabHistory = history.get(slotId); + if (!tabHistory) { + return { canGoBack: false, canGoNext: false }; + } + return { + canGoBack: tabHistory.currentIndex > 0, + canGoNext: tabHistory.currentIndex < tabHistory.stack.length - 1, + }; +}; + +const pushHistory = (history: Map, tab: Tab): Map => { + const newHistory = new Map(history); + const slotId = getSlotId(tab); + const existing = newHistory.get(slotId); + + if (existing) { + const newStack = existing.stack.slice(0, existing.currentIndex + 1); + newStack.push(tab); + newHistory.set(slotId, { stack: newStack, currentIndex: newStack.length - 1 }); + } else { + newHistory.set(slotId, { stack: [tab], currentIndex: 0 }); + } + + return newHistory; +}; + +const updateHistoryCurrent = (history: Map, tab: Tab): Map => { + const newHistory = new Map(history); + const slotId = getSlotId(tab); + const existing = newHistory.get(slotId); + + if (existing && existing.currentIndex >= 0) { + const newStack = [...existing.stack]; + newStack[existing.currentIndex] = tab; + newHistory.set(slotId, { ...existing, stack: newStack }); + } + + return newHistory; +}; + export const useTabs = create((set, get, _store) => ({ currentTab: null, tabs: [], + history: new Map(), + canGoBack: false, + canGoNext: false, setTabs: (tabs) => { const tabsWithDefaults = tabs.map(t => tabSchema.parse(t)); - set({ tabs: tabsWithDefaults, currentTab: tabsWithDefaults.find((t) => t.active) || null }); + const currentTab = tabsWithDefaults.find((t) => t.active) || null; + const history = new Map(); + + tabsWithDefaults.forEach((tab) => { + if (tab.active) { + history.set(getSlotId(tab), { stack: [tab], currentIndex: 0 }); + } + }); + + const flags = computeHistoryFlags(history, currentTab); + set({ tabs: tabsWithDefaults, currentTab, history, ...flags }); }, openCurrent: (newTab) => { - const { tabs } = get(); + const { tabs, history } = get(); const tabWithDefaults = tabSchema.parse(newTab); + const activeTab = { ...tabWithDefaults, active: true }; const existingTabIdx = tabs.findIndex((t) => t.active); - if (existingTabIdx === -1) { - const nextTabs = tabs + const nextTabs = existingTabIdx === -1 + ? tabs .filter((t) => !isSameTab(t, tabWithDefaults)) .map((t) => ({ ...t, active: false })) - .concat([{ ...tabWithDefaults, active: true }]); - set({ tabs: nextTabs, currentTab: tabWithDefaults }); - } else { - const nextTabs = tabs + .concat([activeTab]) + : tabs .map((t, idx) => idx === existingTabIdx - ? { ...tabWithDefaults, active: true } + ? activeTab : isSameTab(t, tabWithDefaults) ? null : { ...t, active: false } ) .filter((t): t is Tab => t !== null); - set({ tabs: nextTabs, currentTab: tabWithDefaults }); - } + + const nextHistory = pushHistory(history, activeTab); + const flags = computeHistoryFlags(nextHistory, activeTab); + set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags }); }, openNew: (tab) => { - const { tabs } = get(); + const { tabs, history } = get(); const tabWithDefaults = tabSchema.parse(tab); + const activeTab = { ...tabWithDefaults, active: true }; const nextTabs = tabs .filter((t) => !isSameTab(t, tabWithDefaults)) .map((t) => ({ ...t, active: false })) - .concat([{ ...tabWithDefaults, active: true }]); - set({ tabs: nextTabs, currentTab: tabWithDefaults }); + .concat([activeTab]); + const nextHistory = pushHistory(history, activeTab); + const flags = computeHistoryFlags(nextHistory, activeTab); + set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags }); }, select: (tab) => { - const { tabs } = get(); + const { tabs, history } = get(); const nextTabs = tabs.map((t) => ({ ...t, active: isSameTab(t, tab) })); - set({ tabs: nextTabs, currentTab: tab }); + const flags = computeHistoryFlags(history, tab); + set({ tabs: nextTabs, currentTab: tab, ...flags }); }, close: (tab) => { - const { tabs } = get(); + const { tabs, history } = get(); const remainingTabs = tabs.filter((t) => !isSameTab(t, tab)); if (remainingTabs.length === 0) { - return set({ tabs: [], currentTab: null }); + return set({ tabs: [], currentTab: null, canGoBack: false, canGoNext: false }); } const closedTabIndex = tabs.findIndex((t) => isSameTab(t, tab)); @@ -87,14 +173,22 @@ export const useTabs = create((set, get, _store) => ({ : remainingTabs.length - 1; const nextTabs = remainingTabs.map((t, idx) => ({ ...t, active: idx === nextActiveIndex })); - set({ tabs: nextTabs, currentTab: nextTabs[nextActiveIndex] }); + const nextCurrentTab = nextTabs[nextActiveIndex]; + + const nextHistory = new Map(history); + nextHistory.delete(getSlotId(tab)); + + const flags = computeHistoryFlags(nextHistory, nextCurrentTab); + set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory, ...flags }); }, reorder: (tabs) => { + const { history } = get(); const currentTab = tabs.find((t) => t.active) || null; - set({ tabs, currentTab }); + const flags = computeHistoryFlags(history, currentTab); + set({ tabs, currentTab, ...flags }); }, updateSessionTabState: (tab, state) => { - const { tabs, currentTab } = get(); + const { tabs, currentTab, history } = get(); const nextTabs = tabs.map((t) => isSameTab(t, tab) && t.type === "sessions" ? { ...t, state } @@ -103,10 +197,15 @@ export const useTabs = create((set, get, _store) => ({ const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "sessions" ? { ...currentTab, state } : currentTab; - set({ tabs: nextTabs, currentTab: nextCurrentTab }); + + const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab) + ? updateHistoryCurrent(history, nextCurrentTab) + : history; + + set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory }); }, updateContactsTabState: (tab, state) => { - const { tabs, currentTab } = get(); + const { tabs, currentTab, history } = get(); const nextTabs = tabs.map((t) => isSameTab(t, tab) && t.type === "contacts" ? { ...t, state } @@ -116,7 +215,58 @@ export const useTabs = create((set, get, _store) => ({ const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "contacts" ? { ...currentTab, state } : currentTab; - set({ tabs: nextTabs, currentTab: nextCurrentTab }); + + const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab) + ? updateHistoryCurrent(history, nextCurrentTab) + : history; + + set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory }); + }, + goBack: () => { + const { tabs, history, currentTab } = get(); + if (!currentTab) { + return; + } + + const slotId = getSlotId(currentTab); + const tabHistory = history.get(slotId); + if (!tabHistory || tabHistory.currentIndex === 0) { + return; + } + + const prevIndex = tabHistory.currentIndex - 1; + const prevTab = tabHistory.stack[prevIndex]; + + const nextTabs = tabs.map((t) => t.active ? prevTab : t); + + const nextHistory = new Map(history); + nextHistory.set(slotId, { ...tabHistory, currentIndex: prevIndex }); + + const flags = computeHistoryFlags(nextHistory, prevTab); + set({ tabs: nextTabs, currentTab: prevTab, history: nextHistory, ...flags }); + }, + goNext: () => { + const { tabs, history, currentTab } = get(); + if (!currentTab) { + return; + } + + const slotId = getSlotId(currentTab); + const tabHistory = history.get(slotId); + if (!tabHistory || tabHistory.currentIndex >= tabHistory.stack.length - 1) { + return; + } + + const nextIndex = tabHistory.currentIndex + 1; + const nextTab = tabHistory.stack[nextIndex]; + + const nextTabs = tabs.map((t) => t.active ? nextTab : t); + + const nextHistory = new Map(history); + nextHistory.set(slotId, { ...tabHistory, currentIndex: nextIndex }); + + const flags = computeHistoryFlags(nextHistory, nextTab); + set({ tabs: nextTabs, currentTab: nextTab, history: nextHistory, ...flags }); }, })); diff --git a/apps/desktop2/tailwind.config.ts b/apps/desktop2/tailwind.config.ts index 5403a4290..eb97cceed 100644 --- a/apps/desktop2/tailwind.config.ts +++ b/apps/desktop2/tailwind.config.ts @@ -10,6 +10,12 @@ const config = { fontFamily: { "racing-sans": ["Racing Sans One", "cursive"], }, + colors: { + color1: "#FBFBFB", + color2: "#F4F4F4", + color3: "#8e8e8e", + color4: "#484848", + }, }, }, plugins: [],