diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 60c21f1f9cff..a381a371e806 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -36,6 +36,7 @@ "electron-updater": "^6.7.3", "electron-window-state": "^5.0.3", "express": "^5.2.1", + "framer-motion": "^12.29.0", "goose-acp-types": "file:../acp", "katex": "^0.16.28", "lodash": "^4.17.23", @@ -237,7 +238,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -607,7 +607,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -648,7 +647,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1106,7 +1104,6 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -2740,7 +2737,6 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -3216,7 +3212,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -6541,7 +6536,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6829,7 +6825,6 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6871,7 +6866,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6882,7 +6876,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7023,7 +7016,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -7409,7 +7401,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -7658,7 +7649,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7731,7 +7721,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8272,7 +8261,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9575,7 +9563,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -9633,7 +9622,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", @@ -10641,7 +10629,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11125,7 +11112,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11574,6 +11560,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.3", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -12359,7 +12372,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -13416,7 +13428,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -14659,6 +14670,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14679,7 +14691,6 @@ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -16041,6 +16052,21 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/motion-dom": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -17055,7 +17081,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17157,6 +17182,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -17172,6 +17198,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -17527,7 +17554,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17537,7 +17563,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17559,7 +17584,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -19476,8 +19502,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -20008,7 +20033,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20433,7 +20457,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -20524,7 +20547,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -21173,7 +21195,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 25e40d6d01a1..60194b5660bc 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -70,6 +70,7 @@ "electron-updater": "^6.7.3", "electron-window-state": "^5.0.3", "express": "^5.2.1", + "framer-motion": "^12.29.0", "katex": "^0.16.28", "lodash": "^4.17.23", "lucide-react": "^0.563.0", diff --git a/ui/desktop/src/components/ApiKeyTester.tsx b/ui/desktop/src/components/ApiKeyTester.tsx index a49fed7eb52d..435ac7e5909f 100644 --- a/ui/desktop/src/components/ApiKeyTester.tsx +++ b/ui/desktop/src/components/ApiKeyTester.tsx @@ -80,7 +80,9 @@ export default function ApiKeyTester({ onSuccess, onStartTesting }: ApiKeyTester

Quick Setup with API Key

- Auto-detect your provider + + Auto-detect your provider + diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index ef16918179af..43e490f51604 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -21,7 +21,7 @@ import { Message } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; -import { useSidebar } from './ui/sidebar'; +import { useNavigationContextSafe } from './Layout/NavigationContext'; import { cn } from '../utils'; import { useChatStream } from '../hooks/useChatStream'; import { useNavigation } from '../hooks/useNavigation'; @@ -74,24 +74,17 @@ export default function BaseChat({ const navigate = useNavigate(); const scrollRef = useRef(null); const chatInputRef = useRef(null); - const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); - const isMobile = useIsMobile(); - const { state: sidebarState } = useSidebar(); + const navContext = useNavigationContextSafe(); const setView = useNavigation(); - - const contentClassName = cn( - 'pr-1 pb-10 pt-10', - (isMobile || sidebarState === 'collapsed') && 'pt-14' - ); + const isNavCollapsed = !navContext?.isNavExpanded; + const contentClassName = cn('pr-1 pb-10 pt-10', (isMobile || isNavCollapsed) && 'pt-14'); const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); - const onStreamFinish = useCallback(() => {}, []); - const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false); const { @@ -386,6 +379,7 @@ export default function BaseChat({ {/* Chat container with sticky recipe header */}
+ {/* Goose watermark - top right */}
- goose + + goose +
diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx deleted file mode 100644 index 3342f9dff6aa..000000000000 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ /dev/null @@ -1,603 +0,0 @@ -import { AppEvents } from '../../constants/events'; -import React, { useEffect, useState } from 'react'; -import { - AppWindow, - ChefHat, - ChevronRight, - Clock, - FileText, - History, - Home, - MessageSquarePlus, - Puzzle, -} from 'lucide-react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarSeparator, -} from '../ui/sidebar'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'; -import { Gear } from '../icons'; -import { View, ViewOptions } from '../../utils/navigationUtils'; -import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext'; -import { listSessions, Session, updateSessionName } from '../../api'; -import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions'; -import { useNavigation } from '../../hooks/useNavigation'; -import { SessionIndicators } from '../SessionIndicators'; -import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus'; -import { getInitialWorkingDir } from '../../utils/workingDir'; -import { useConfig } from '../ConfigContext'; -import { InlineEditText } from '../common/InlineEditText'; - -interface SidebarProps { - onSelectSession: (sessionId: string) => void; - refreshTrigger?: number; - children?: React.ReactNode; - setView?: (view: View, viewOptions?: ViewOptions) => void; - currentPath?: string; -} - -interface NavigationItem { - type: 'item'; - path: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - tooltip: string; -} - -interface NavigationSeparator { - type: 'separator'; -} - -type NavigationEntry = NavigationItem | NavigationSeparator; - -const menuItems: NavigationEntry[] = [ - { - type: 'item', - path: '/recipes', - label: 'Recipes', - icon: FileText, - tooltip: 'Browse your saved recipes', - }, - { - type: 'item', - path: '/apps', - label: 'Apps', - icon: AppWindow, - tooltip: 'MCP and custom apps', - }, - { - type: 'item', - path: '/schedules', - label: 'Scheduler', - icon: Clock, - tooltip: 'Manage scheduled runs', - }, - { - type: 'item', - path: '/extensions', - label: 'Extensions', - icon: Puzzle, - tooltip: 'Manage your extensions', - }, - { type: 'separator' }, - { - type: 'item', - path: '/settings', - label: 'Settings', - icon: Gear, - tooltip: 'Configure Goose settings', - }, -]; - -const getSessionDisplayName = (session: Session): string => { - if (session.recipe?.title) { - return session.recipe.title; - } - - if (shouldShowNewChatTitle(session)) { - return DEFAULT_CHAT_TITLE; - } - return session.name; -}; - -const SessionList = React.memo<{ - sessions: Session[]; - activeSessionId: string | undefined; - getSessionStatus: ( - sessionId: string - ) => { streamState: string; hasUnreadActivity: boolean } | undefined; - onSessionClick: (session: Session) => void; -}>( - ({ sessions, activeSessionId, getSessionStatus, onSessionClick }) => { - const sortedSessions = React.useMemo(() => { - return [...sessions].sort((a, b) => { - const aIsEmptyNew = shouldShowNewChatTitle(a); - const bIsEmptyNew = shouldShowNewChatTitle(b); - if (aIsEmptyNew && !bIsEmptyNew) return -1; - if (!aIsEmptyNew && bIsEmptyNew) return 1; - return 0; - }); - }, [sessions]); - - const handleRenameSession = async (sessionId: string, newName: string) => { - await updateSessionName({ - path: { session_id: sessionId }, - body: { name: newName }, - throwOnError: true, - }); - - // Dispatch event to update all components - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName }, - }) - ); - }; - - return ( -
- {sortedSessions.map((session, index) => { - const status = getSessionStatus(session.id); - const isStreaming = status?.streamState === 'streaming'; - const hasError = status?.streamState === 'error'; - const hasUnread = status?.hasUnreadActivity ?? false; - const displayName = getSessionDisplayName(session); - const isLast = index === sortedSessions.length - 1; - const canRename = !session.recipe?.title; - - return ( -
- {/* Vertical line segment - full height except last item stops at middle */} -
- {/* Horizontal branch line */} -
- -
- ); - })} -
- ); - }, - (prevProps, nextProps) => { - if (prevProps.sessions.length !== nextProps.sessions.length) return false; - if (prevProps.activeSessionId !== nextProps.activeSessionId) return false; - - const prevIds = prevProps.sessions.map((s) => s.id).join(','); - const nextIds = nextProps.sessions.map((s) => s.id).join(','); - if (prevIds !== nextIds) return false; - - // Check if any session name or message_count changed - for (let i = 0; i < prevProps.sessions.length; i++) { - if (prevProps.sessions[i].name !== nextProps.sessions[i].name) return false; - if (prevProps.sessions[i].message_count !== nextProps.sessions[i].message_count) return false; - } - - // Check if any session's status has changed - for (const session of prevProps.sessions) { - const prevStatus = prevProps.getSessionStatus(session.id); - const nextStatus = nextProps.getSessionStatus(session.id); - - if (prevStatus?.hasUnreadActivity !== nextStatus?.hasUnreadActivity) return false; - if (prevStatus?.streamState !== nextStatus?.streamState) return false; - } - - return true; - } -); - -SessionList.displayName = 'SessionList'; - -const AppSidebar: React.FC = ({ currentPath }) => { - const navigate = useNavigate(); - const chatContext = useChatContext(); - const configContext = useConfig(); - const setView = useNavigation(); - - const appsExtensionEnabled = !!configContext.extensionsList?.find((ext) => ext.name === 'apps') - ?.enabled; - const [searchParams] = useSearchParams(); - const [recentSessions, setRecentSessions] = useState([]); - const [isChatExpanded, setIsChatExpanded] = useState(true); - const activeSessionId = searchParams.get('resumeSessionId') ?? undefined; - const { getSessionStatus, clearUnread } = useSidebarSessionStatus(activeSessionId); - - // When activeSessionId changes, ensure it's in the recent sessions list - // This handles the case where a session is loaded from history that's older than the top 10 - useEffect(() => { - if (!activeSessionId) return; - - const isInRecentSessions = recentSessions.some((s) => s.id === activeSessionId); - if (isInRecentSessions) return; - - // Fetch the active session and add it to the top of the list - const fetchAndAddSession = async () => { - try { - const { getSession } = await import('../../api'); - const response = await getSession({ path: { session_id: activeSessionId } }); - if (response.data) { - setRecentSessions((prev) => { - // Don't add if it's already there (race condition check) - if (prev.some((s) => s.id === activeSessionId)) return prev; - // Add to the beginning and keep max 10 - return [response.data as Session, ...prev].slice(0, 10); - }); - } - } catch (error) { - console.error('Failed to fetch active session:', error); - } - }; - - fetchAndAddSession(); - }, [activeSessionId, recentSessions]); - - useEffect(() => { - const loadRecentSessions = async () => { - try { - const response = await listSessions({ throwOnError: true }); - const sessions = response.data.sessions.slice(0, 10); - setRecentSessions(sessions); - - const hasSessionWithDefaultName = sessions.some((s) => shouldShowNewChatTitle(s)); - - if (hasSessionWithDefaultName) { - window.dispatchEvent(new CustomEvent(AppEvents.SESSION_NEEDS_NAME_UPDATE)); - } - } catch (error) { - console.error('Failed to load recent sessions:', error); - } - }; - - loadRecentSessions(); - }, []); - - useEffect(() => { - let pollingTimeouts: ReturnType[] = []; - let isPolling = false; - - const handleSessionCreated = (event: Event) => { - const { session } = (event as CustomEvent<{ session?: Session }>).detail || {}; - // If session data is provided, add it immediately to the sidebar - // This is for displaying sessions that won't be returned by the API due to not having messages yet - if (session) { - setRecentSessions((prev) => { - if (prev.some((s) => s.id === session.id)) return prev; - return [session, ...prev].slice(0, 10); - }); - } - - // Poll for updates to get the generated session name - if (isPolling) { - return; - } - - isPolling = true; - const pollIntervalMs = 300; - const maxPollDurationMs = 10000; - const maxPolls = maxPollDurationMs / pollIntervalMs; - let pollCount = 0; - - const pollForUpdates = async () => { - pollCount++; - - try { - const response = await listSessions({ throwOnError: true }); - const apiSessions = response.data.sessions.slice(0, 10); - - // Merge API sessions with any locally-tracked empty sessions - setRecentSessions((prev) => { - const emptyLocalSessions = prev.filter( - (local) => - local.message_count === 0 && !apiSessions.some((api) => api.id === local.id) - ); - const merged = [...emptyLocalSessions, ...apiSessions]; - const seen = new Set(); - return merged - .filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; - }) - .slice(0, 10); - }); - - const sessionWithDefaultName = apiSessions.find((s) => shouldShowNewChatTitle(s)); - - const shouldContinue = pollCount < maxPolls && (sessionWithDefaultName || pollCount < 5); - - if (shouldContinue) { - const timeoutId = setTimeout(pollForUpdates, pollIntervalMs); - pollingTimeouts.push(timeoutId); - } else { - isPolling = false; - } - } catch { - isPolling = false; - } - }; - pollForUpdates(); - }; - - const handleSessionNeedsNameUpdate = () => { - handleSessionCreated(new CustomEvent(AppEvents.SESSION_CREATED, { detail: {} })); - }; - - const handleSessionDeleted = (event: Event) => { - const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail; - setRecentSessions((prev) => prev.filter((s) => s.id !== sessionId)); - }; - - const handleSessionRenamed = (event: Event) => { - const { sessionId, newName } = (event as CustomEvent<{ sessionId: string; newName: string }>) - .detail; - setRecentSessions((prev) => - prev.map((s) => - s.id === sessionId - ? { ...s, name: newName, message_count: Math.max(s.message_count, 1) } - : s - ) - ); - }; - - window.addEventListener(AppEvents.SESSION_CREATED, handleSessionCreated); - window.addEventListener(AppEvents.SESSION_NEEDS_NAME_UPDATE, handleSessionNeedsNameUpdate); - window.addEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted); - window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); - - return () => { - window.removeEventListener(AppEvents.SESSION_CREATED, handleSessionCreated); - window.removeEventListener(AppEvents.SESSION_NEEDS_NAME_UPDATE, handleSessionNeedsNameUpdate); - window.removeEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted); - window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); - pollingTimeouts.forEach(clearTimeout); - isPolling = false; - }; - }, []); - - useEffect(() => { - const currentItem = menuItems.find( - (item) => item.type === 'item' && item.path === currentPath - ) as NavigationItem | undefined; - - const titleBits = ['Goose']; - - if ( - currentPath === '/pair' && - chatContext?.chat?.name && - chatContext.chat.name !== DEFAULT_CHAT_TITLE - ) { - titleBits.push(chatContext.chat.name); - } else if (currentPath !== '/' && currentItem) { - titleBits.push(currentItem.label); - } - - document.title = titleBits.join(' - '); - }, [currentPath, chatContext?.chat?.name]); - - const isActivePath = (path: string) => { - return currentPath === path; - }; - - // Use a ref to access the latest recentSessions without causing re-renders or dependency issues - const recentSessionsRef = React.useRef(recentSessions); - React.useEffect(() => { - recentSessionsRef.current = recentSessions; - }, [recentSessions]); - - // Guard ref to prevent duplicate session creation from key commands - const isCreatingSessionRef = React.useRef(false); - - const handleNewChat = React.useCallback(async () => { - if (isCreatingSessionRef.current) { - return; - } - - const emptyNewSession = recentSessionsRef.current.find((s) => shouldShowNewChatTitle(s)); - - if (emptyNewSession) { - clearUnread(emptyNewSession.id); - resumeSession(emptyNewSession, setView); - } else { - isCreatingSessionRef.current = true; - try { - await startNewSession('', setView, getInitialWorkingDir(), { - allExtensions: configContext.extensionsList, - }); - } finally { - setTimeout(() => { - isCreatingSessionRef.current = false; - }, 1000); - } - } - }, [setView, clearUnread, configContext.extensionsList]); - - useEffect(() => { - const handleTriggerNewChat = () => { - handleNewChat(); - }; - - window.addEventListener(AppEvents.TRIGGER_NEW_CHAT, handleTriggerNewChat); - return () => { - window.removeEventListener(AppEvents.TRIGGER_NEW_CHAT, handleTriggerNewChat); - }; - }, [handleNewChat]); - - const handleSessionClick = React.useCallback( - async (session: Session) => { - clearUnread(session.id); - resumeSession(session, setView); - }, - [clearUnread, setView] - ); - - const handleViewAllClick = React.useCallback(() => { - navigate('/sessions'); - }, [navigate]); - - const renderMenuItem = (entry: NavigationEntry, index: number) => { - if (entry.type === 'separator') { - return ; - } - - const IconComponent = entry.icon; - - return ( - - -
- - navigate(entry.path)} - isActive={isActivePath(entry.path)} - tooltip={entry.tooltip} - className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-tertiary/50 transition-all duration-200 data-[active=true]:bg-background-tertiary" - > - - {entry.label} - - -
-
-
- ); - }; - - const visibleMenuItems = menuItems.filter((entry) => { - // Filter out Apps if extension is not enabled - if (entry.type === 'item' && entry.path === '/apps') { - return appsExtensionEnabled; - } - return true; - }); - - return ( - <> - - - {/* Home */} - - -
- - navigate('/')} - isActive={isActivePath('/')} - tooltip="Go back to the main chat screen" - className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-tertiary/50 transition-all duration-200 data-[active=true]:bg-background-tertiary" - > - - Home - - -
-
-
- - {/* Chat with Collapsible Sessions */} - - - -
- -
- - - Chat - - {recentSessions.length > 0 && ( - - - - )} -
-
-
- {recentSessions.length > 0 && ( - -
- - {/* View All Link */} - -
-
- )} -
-
-
- - - - {visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))} -
-
- - ); -}; - -export default AppSidebar; diff --git a/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx b/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx index 342a5a942008..5f64c9977201 100644 --- a/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx +++ b/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx @@ -21,12 +21,21 @@ const EnvironmentBadge: React.FC = ({ className = '' }) =
+ > +
+
+
- {tooltipText} + + {tooltipText} + ); }; diff --git a/ui/desktop/src/components/GooseSidebar/index.ts b/ui/desktop/src/components/GooseSidebar/index.ts deleted file mode 100644 index 6ca1e20a95eb..000000000000 --- a/ui/desktop/src/components/GooseSidebar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as AppSidebar } from './AppSidebar'; -export { default as EnvironmentBadge } from './EnvironmentBadge'; diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index 9d6747772ec5..7236e04ccf41 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import AppSidebar from '../GooseSidebar/AppSidebar'; -import { View, ViewOptions } from '../../utils/navigationUtils'; -import { AppWindowMac, AppWindow } from 'lucide-react'; +import { Outlet, useLocation } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { Menu } from 'lucide-react'; import { Button } from '../ui/button'; -import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar'; import ChatSessionsContainer from '../ChatSessionsContainer'; import { useChatContext } from '../../contexts/ChatContext'; +import { NavigationProvider, useNavigationContext } from './NavigationContext'; +import { Navigation } from './NavigationPanel'; +import { NAV_DIMENSIONS, Z_INDEX } from './constants'; +import { cn } from '../../utils'; import { UserInput } from '../../types/message'; interface AppLayoutContentProps { @@ -17,111 +19,181 @@ interface AppLayoutContentProps { } const AppLayoutContent: React.FC = ({ activeSessions }) => { - const navigate = useNavigate(); const location = useLocation(); const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin'; - const { isMobile, openMobile } = useSidebar(); const chatContext = useChatContext(); const isOnPairRoute = location.pathname === '/pair'; + const { + isNavExpanded, + setIsNavExpanded, + effectiveNavigationMode, + effectiveNavigationStyle, + navigationPosition, + isHorizontalNav, + isCondensedIconOnly, + } = useNavigationContext(); + if (!chatContext) { throw new Error('AppLayoutContent must be used within ChatProvider'); } const { setChat } = chatContext; - // Calculate padding based on sidebar state and macOS - const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4'; - // const headerPadding = ''; - - // Hide buttons when mobile sheet is showing - const shouldHideButtons = isMobile && openMobile; - - const setView = (view: View, viewOptions?: ViewOptions) => { - // Convert view-based navigation to route-based navigation - switch (view) { - case 'chat': - navigate('/'); - break; - case 'pair': - navigate('/pair'); - break; - case 'settings': - navigate('/settings', { state: viewOptions }); - break; - case 'extensions': - navigate('/extensions', { state: viewOptions }); - break; - case 'sessions': - navigate('/sessions'); - break; - case 'schedules': - navigate('/schedules'); - break; - case 'recipes': - navigate('/recipes'); - break; - case 'permission': - navigate('/permission', { state: viewOptions }); - break; - case 'ConfigureProviders': - navigate('/configure-providers'); - break; - case 'sharedSession': - navigate('/shared-session', { state: viewOptions }); - break; - case 'welcome': - navigate('/welcome'); - break; - default: - navigate('/'); + // Hide the titlebar drag region when nav is at the top in push mode, + // since the nav occupies that space and the drag region blocks interactions + const isPushTopNav = + effectiveNavigationMode === 'push' && navigationPosition === 'top' && isNavExpanded; + React.useEffect(() => { + const dragRegion = document.querySelector('.titlebar-drag-region') as HTMLElement | null; + if (!dragRegion) return; + if (isPushTopNav) { + dragRegion.style.display = 'none'; + } else { + dragRegion.style.display = ''; } - }; + return () => { + dragRegion.style.display = ''; + }; + }, [isPushTopNav]); - const handleSelectSession = async (sessionId: string) => { - // Navigate to chat with session data - navigate('/', { state: { sessionId } }); - }; + // Calculate padding based on macOS traffic lights + const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4'; - const handleNewWindow = () => { - window.electron.createChatWindow({ - dir: window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined, - }); + // Determine flex direction based on navigation position (for push mode) + const getLayoutClass = () => { + if (effectiveNavigationMode === 'overlay') { + return 'flex-row'; + } + + switch (navigationPosition) { + case 'top': + return 'flex-col'; + case 'bottom': + return 'flex-col-reverse'; + case 'left': + return 'flex-row'; + case 'right': + return 'flex-row-reverse'; + default: + return 'flex-row'; + } }; - return ( -
- {!shouldHideButtons && ( -
- - -
- )} - - - - + // Main content area + const mainContent = ( +
+
{/* Always render ChatSessionsContainer to keep SSE connections alive. - When navigating away from /pair */} + When navigating away from /pair, hide it with CSS */}
- +
+
+ ); + + return ( +
+ {/* Header controls */} +
+ {/* Navigation trigger */} + +
+ + {/* Main content with navigation */} +
+ {/* Push mode navigation (inline) with animation */} + {effectiveNavigationMode === 'push' && ( + + + + )} + + {/* Main content */} + {mainContent} +
+ + {/* Overlay mode navigation */} + {effectiveNavigationMode === 'overlay' && }
); }; @@ -135,8 +207,8 @@ interface AppLayoutProps { export const AppLayout: React.FC = ({ activeSessions }) => { return ( - + - + ); }; diff --git a/ui/desktop/src/components/Layout/CondensedRenderer.tsx b/ui/desktop/src/components/Layout/CondensedRenderer.tsx new file mode 100644 index 000000000000..9f1713acb41f --- /dev/null +++ b/ui/desktop/src/components/Layout/CondensedRenderer.tsx @@ -0,0 +1,352 @@ +import React, { useState } from 'react'; +import { GripVertical, ChevronDown, ChevronRight, Plus } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { cn } from '../../utils'; +import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu'; +import { ChatSessionsDropdown, SessionsList } from './navigation'; +import type { NavigationRendererProps } from './navigation/types'; + +export const CondensedRenderer: React.FC = ({ + isOverlayMode, + navigationPosition, + isCondensedIconOnly, + className, + visibleItems, + isActive, + recentSessions, + activeSessionId, + onNavClick, + onNewChat, + onSessionClick, + onFetchSessions, + getSessionStatus, + clearUnread, + isChatExpanded, + onToggleChatExpanded, + drag, + navFocusRef, +}) => { + const [chatPopoverOpen, setChatPopoverOpen] = useState(false); + + const isVertical = navigationPosition === 'left' || navigationPosition === 'right'; + const isTopPosition = navigationPosition === 'top'; + const isBottomPosition = navigationPosition === 'bottom'; + + return ( + + {/* Top spacer (vertical only) */} + {isVertical && ( +
+ )} + + {/* Left spacer (horizontal top position only) */} + {!isVertical && isTopPosition && ( +
+ )} + + {/* Navigation items */} + {isVertical ? ( +
+ {visibleItems.map((item, index) => { + const Icon = item.icon; + const active = isActive(item.path); + const isDragging = drag.draggedItem === item.id; + const isDragOver = drag.dragOverItem === item.id; + const isChatItem = item.id === 'chat'; + + return ( + drag.onDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={drag.onDragEnd} + initial={{ opacity: 0 }} + animate={{ opacity: isDragging ? 0.5 : 1 }} + transition={{ duration: 0.15, delay: index * 0.02 }} + className={cn( + 'relative cursor-move group', + isCondensedIconOnly ? 'flex-shrink-0' : 'w-full flex-shrink-0', + isDragOver && 'ring-2 ring-blue-500 rounded-lg', + isChatItem && !isCondensedIconOnly && 'overflow-visible' + )} + > +
+ {/* Chat item with dropdown in icon-only mode */} + {isChatItem && isCondensedIconOnly ? ( + + + + + onNavClick('/sessions')} + /> + + ) : ( + <> + {isChatItem && !isCondensedIconOnly ? ( +
+ +
+ +
+ + + {item.label} + +
+ {isChatExpanded ? ( + + ) : ( + + )} +
+
+ {!isChatExpanded && ( + { + e.stopPropagation(); + onNewChat(); + }} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.95 }} + className={cn( + 'absolute -right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md z-10', + 'opacity-0 group-hover:opacity-100 transition-opacity', + 'bg-background-tertiary hover:bg-background-inverse hover:text-text-inverse', + 'flex items-center justify-center' + )} + title="New Chat" + > + + + )} +
+ ) : ( + onNavClick(item.path)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={cn( + 'flex flex-row items-center gap-2', + 'relative rounded-lg transition-colors duration-200 no-drag', + isCondensedIconOnly + ? 'justify-center p-2.5' + : 'w-full pl-2 pr-4 py-2.5', + active + ? 'bg-background-inverse text-text-inverse' + : 'bg-background-primary hover:bg-background-tertiary' + )} + > + {!isCondensedIconOnly && ( +
+ +
+ )} + + {!isCondensedIconOnly && ( + + {item.label} + + )} + {!isCondensedIconOnly && item.getTag && ( +
+ + {item.getTag()} + +
+ )} +
+ )} + + )} + {isChatItem && !isCondensedIconOnly && ( + onNavClick('/sessions')} + /> + )} +
+
+ ); + })} + +
+
+ ) : ( + /* Horizontal navigation items */ + visibleItems.map((item, index) => { + const Icon = item.icon; + const active = isActive(item.path); + const isDragging = drag.draggedItem === item.id; + const isDragOver = drag.dragOverItem === item.id; + const isChatItem = item.id === 'chat'; + + return ( + drag.onDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={drag.onDragEnd} + initial={{ opacity: 0 }} + animate={{ opacity: isDragging ? 0.5 : 1 }} + transition={{ duration: 0.15, delay: index * 0.02 }} + className={cn( + 'relative cursor-move group flex-shrink-0', + isDragOver && 'ring-2 ring-blue-500 rounded-lg', + isChatItem && !isCondensedIconOnly && 'overflow-visible' + )} + > +
+ {isChatItem ? ( + + + + + + {item.label} + + + + onNavClick('/sessions')} + /> + + ) : ( + onNavClick(item.path)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={cn( + 'flex flex-row items-center gap-2 px-3 py-2.5', + 'relative rounded-lg transition-colors duration-200 no-drag', + active + ? 'bg-background-inverse text-text-inverse' + : 'bg-background-primary hover:bg-background-tertiary' + )} + > + + + {item.label} + + + )} +
+
+ ); + }) + )} + + {/* Right spacer (horizontal only) */} + {!isVertical && ( +
+ )} + + ); +}; diff --git a/ui/desktop/src/components/Layout/ExpandedRenderer.tsx b/ui/desktop/src/components/Layout/ExpandedRenderer.tsx new file mode 100644 index 000000000000..a379273632a2 --- /dev/null +++ b/ui/desktop/src/components/Layout/ExpandedRenderer.tsx @@ -0,0 +1,324 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { GripVertical } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Z_INDEX } from './constants'; +import { cn } from '../../utils'; +import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu'; +import { ChatSessionsDropdown } from './navigation'; +import type { NavigationRendererProps } from './navigation/types'; + +export const ExpandedRenderer: React.FC = ({ + isNavExpanded, + isOverlayMode, + navigationPosition, + onClose, + className, + visibleItems, + isActive, + recentSessions, + activeSessionId, + onNavClick, + onNewChat, + onSessionClick, + getSessionStatus, + clearUnread, + drag, + navFocusRef, +}) => { + const [chatDropdownOpen, setChatDropdownOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(2); + const [gridMeasured, setGridMeasured] = useState(false); + const [tilesReady, setTilesReady] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const prevIsNavExpandedRef = useRef(isNavExpanded); + const gridRef = useRef(null); + + // Detect when nav is closing + useEffect(() => { + if (prevIsNavExpandedRef.current && !isNavExpanded) { + setIsClosing(true); + setTilesReady(false); + } else if (!prevIsNavExpandedRef.current && isNavExpanded) { + setIsClosing(false); + } + prevIsNavExpandedRef.current = isNavExpanded; + }, [isNavExpanded]); + + // Delay tiles animation until panel opens + useEffect(() => { + if (!isNavExpanded) { + setTilesReady(false); + return; + } + const timeoutId = setTimeout(() => setTilesReady(true), 150); + return () => clearTimeout(timeoutId); + }, [isNavExpanded]); + + // Track grid columns for spacer tiles + useEffect(() => { + if (!isNavExpanded) { + setGridMeasured(false); + return; + } + + setGridMeasured(false); + let rafId: number; + + const updateGridColumns = () => { + if (!gridRef.current) return; + const parent = gridRef.current.parentElement; + if (!parent) return; + + const parentStyle = window.getComputedStyle(parent); + const availableWidth = + parent.clientWidth - + parseFloat(parentStyle.paddingLeft) - + parseFloat(parentStyle.paddingRight); + + const minSize = navigationPosition === 'left' || navigationPosition === 'right' ? 140 : 160; + const gap = isOverlayMode ? 12 : 2; + const cols = Math.max(1, Math.floor((availableWidth + gap) / (minSize + gap))); + + setGridColumns(cols); + setGridMeasured(true); + }; + + const timeoutId = setTimeout(() => { + rafId = requestAnimationFrame(updateGridColumns); + }, 100); + + const resizeObserver = new ResizeObserver(() => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(updateGridColumns); + }); + + const parent = gridRef.current?.parentElement; + if (parent) resizeObserver.observe(parent); + + return () => { + clearTimeout(timeoutId); + cancelAnimationFrame(rafId); + resizeObserver.disconnect(); + }; + }, [isNavExpanded, navigationPosition, isOverlayMode]); + + const isPushTopNav = !isOverlayMode && navigationPosition === 'top'; + const dragStyle = isPushTopNav ? ({ WebkitAppRegion: 'drag' } as React.CSSProperties) : undefined; + const showContent = !isClosing || isOverlayMode; + + const navContent = ( + + {showContent ? ( +
+ {visibleItems.map((item, index) => { + const Icon = item.icon; + const active = isActive(item.path); + const isDragging = drag.draggedItem === item.id; + const isDragOver = drag.dragOverItem === item.id; + const isChatItem = item.id === 'chat'; + + if (isChatItem) { + return ( + + drag.onDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={drag.onDragEnd} + initial={{ opacity: 0 }} + animate={{ opacity: tilesReady ? (isDragging ? 0.5 : 1) : 0 }} + transition={{ duration: 0.15, delay: tilesReady ? index * 0.03 : 0 }} + className={cn( + 'relative cursor-move group', + isDragOver && 'ring-2 ring-blue-500 rounded-lg' + )} + > +
+ + +
+
+ +
+ {item.getTag && ( +
+ + {item.getTag()} + +
+ )} +
+ +

{item.label}

+
+
+
+
+
+ onNavClick('/sessions')} + /> +
+
+ ); + } + + return ( + drag.onDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={drag.onDragEnd} + initial={{ opacity: 0 }} + animate={{ opacity: tilesReady ? (isDragging ? 0.5 : 1) : 0 }} + transition={{ duration: 0.15, delay: tilesReady ? index * 0.03 : 0 }} + className={cn( + 'relative cursor-move group', + isDragOver && 'ring-2 ring-blue-500 rounded-lg' + )} + > + + + + + ); + })} + + {/* Spacer tiles */} + {!isOverlayMode && + gridMeasured && + gridColumns >= 2 && + Array.from({ + length: + navigationPosition === 'left' || navigationPosition === 'right' + ? ((gridColumns - (visibleItems.length % gridColumns)) % gridColumns) + + gridColumns * 6 + : (gridColumns - (visibleItems.length % gridColumns)) % gridColumns, + }).map((_, index) => ( +
+
+
+ ))} +
+ ) : null} + + ); + + // Expanded overlay uses its own AnimatePresence + if (isOverlayMode) { + return ( + + {isNavExpanded && ( +
+ +
+
+
{navContent}
+
+
+
+ )} +
+ ); + } + + return navContent; +}; diff --git a/ui/desktop/src/components/Layout/NavigationContext.tsx b/ui/desktop/src/components/Layout/NavigationContext.tsx new file mode 100644 index 000000000000..e0f472fd95c8 --- /dev/null +++ b/ui/desktop/src/components/Layout/NavigationContext.tsx @@ -0,0 +1,226 @@ +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +export type NavigationMode = 'push' | 'overlay'; +export type NavigationStyle = 'expanded' | 'condensed'; +export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right'; + +export interface NavigationPreferences { + itemOrder: string[]; + enabledItems: string[]; +} + +export const DEFAULT_ITEM_ORDER = [ + 'home', + 'chat', + 'recipes', + 'apps', + 'scheduler', + 'extensions', + 'settings', +]; + +export const DEFAULT_ENABLED_ITEMS = [...DEFAULT_ITEM_ORDER]; + +const RESPONSIVE_BREAKPOINT = 700; + +interface NavigationContextValue { + isNavExpanded: boolean; + setIsNavExpanded: (expanded: boolean) => void; + navigationMode: NavigationMode; + setNavigationMode: (mode: NavigationMode) => void; + effectiveNavigationMode: NavigationMode; + navigationStyle: NavigationStyle; + setNavigationStyle: (style: NavigationStyle) => void; + effectiveNavigationStyle: NavigationStyle; + navigationPosition: NavigationPosition; + setNavigationPosition: (position: NavigationPosition) => void; + preferences: NavigationPreferences; + updatePreferences: (prefs: NavigationPreferences) => void; + isHorizontalNav: boolean; + isCondensedIconOnly: boolean; + isOverlayMode: boolean; + isChatExpanded: boolean; + setIsChatExpanded: (expanded: boolean) => void; +} + +const NavigationContext = createContext(null); + +export const useNavigationContext = () => { + const context = useContext(NavigationContext); + if (!context) { + throw new Error('useNavigationContext must be used within NavigationProvider'); + } + return context; +}; + +export const useNavigationContextSafe = () => { + return useContext(NavigationContext); +}; + +interface NavigationProviderProps { + children: ReactNode; +} + +export const NavigationProvider: React.FC = ({ children }) => { + const [isNavExpanded, setIsNavExpandedState] = useState(() => { + const stored = localStorage.getItem('navigation_expanded'); + return stored !== 'false'; + }); + + const [isBelowBreakpoint, setIsBelowBreakpoint] = useState( + () => window.innerWidth < RESPONSIVE_BREAKPOINT + ); + + const [navigationMode, setNavigationModeState] = useState(() => { + const stored = localStorage.getItem('navigation_mode'); + return (stored as NavigationMode) || 'push'; + }); + + const [navigationStyle, setNavigationStyleState] = useState(() => { + const stored = localStorage.getItem('navigation_style'); + return (stored as NavigationStyle) || 'condensed'; + }); + + const [navigationPosition, setNavigationPositionState] = useState(() => { + const stored = localStorage.getItem('navigation_position'); + return (stored as NavigationPosition) || 'left'; + }); + + const [preferences, setPreferences] = useState(() => { + const stored = localStorage.getItem('navigation_preferences'); + if (stored) { + try { + return JSON.parse(stored); + } catch { + console.error('Failed to parse navigation preferences'); + } + } + return { + itemOrder: DEFAULT_ITEM_ORDER, + enabledItems: DEFAULT_ENABLED_ITEMS, + }; + }); + + const [isChatExpanded, setIsChatExpandedState] = useState(() => { + const stored = localStorage.getItem('navigation_chat_expanded'); + return stored !== 'false'; + }); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${RESPONSIVE_BREAKPOINT - 1}px)`); + const onChange = () => setIsBelowBreakpoint(window.innerWidth < RESPONSIVE_BREAKPOINT); + mql.addEventListener('change', onChange); + setIsBelowBreakpoint(window.innerWidth < RESPONSIVE_BREAKPOINT); + return () => mql.removeEventListener('change', onChange); + }, []); + + const setIsNavExpanded = useCallback((expanded: boolean) => { + setIsNavExpandedState(expanded); + localStorage.setItem('navigation_expanded', String(expanded)); + }, []); + + const setNavigationMode = useCallback((mode: NavigationMode) => { + setNavigationModeState(mode); + localStorage.setItem('navigation_mode', mode); + window.dispatchEvent(new CustomEvent('navigation-mode-changed', { detail: { mode } })); + }, []); + + const setNavigationStyle = useCallback((style: NavigationStyle) => { + setNavigationStyleState(style); + localStorage.setItem('navigation_style', style); + window.dispatchEvent(new CustomEvent('navigation-style-changed', { detail: { style } })); + }, []); + + const setNavigationPosition = useCallback((position: NavigationPosition) => { + setNavigationPositionState(position); + localStorage.setItem('navigation_position', position); + window.dispatchEvent(new CustomEvent('navigation-position-changed', { detail: { position } })); + }, []); + + const updatePreferences = useCallback((newPrefs: NavigationPreferences) => { + setPreferences(newPrefs); + localStorage.setItem('navigation_preferences', JSON.stringify(newPrefs)); + window.dispatchEvent(new CustomEvent('navigation-preferences-updated', { detail: newPrefs })); + }, []); + + const setIsChatExpanded = useCallback((expanded: boolean) => { + setIsChatExpandedState(expanded); + localStorage.setItem('navigation_chat_expanded', String(expanded)); + }, []); + + const isNavExpandedRef = useRef(isNavExpanded); + useEffect(() => { + isNavExpandedRef.current = isNavExpanded; + }, [isNavExpanded]); + + useEffect(() => { + const handleToggleNavigation = () => { + setIsNavExpanded(!isNavExpandedRef.current); + }; + window.electron.on('toggle-navigation', handleToggleNavigation); + return () => { + window.electron.off('toggle-navigation', handleToggleNavigation); + }; + }, [setIsNavExpanded]); + + useEffect(() => { + const handleModeChange = (e: Event) => + setNavigationModeState((e as CustomEvent).detail.mode); + const handleStyleChange = (e: Event) => + setNavigationStyleState((e as CustomEvent).detail.style); + const handlePositionChange = (e: Event) => + setNavigationPositionState((e as CustomEvent).detail.position); + const handlePrefsChange = (e: Event) => + setPreferences((e as CustomEvent).detail); + + window.addEventListener('navigation-mode-changed', handleModeChange); + window.addEventListener('navigation-style-changed', handleStyleChange); + window.addEventListener('navigation-position-changed', handlePositionChange); + window.addEventListener('navigation-preferences-updated', handlePrefsChange); + + return () => { + window.removeEventListener('navigation-mode-changed', handleModeChange); + window.removeEventListener('navigation-style-changed', handleStyleChange); + window.removeEventListener('navigation-position-changed', handlePositionChange); + window.removeEventListener('navigation-preferences-updated', handlePrefsChange); + }; + }, []); + + const isHorizontalNav = navigationPosition === 'top' || navigationPosition === 'bottom'; + const effectiveNavigationMode: NavigationMode = + navigationStyle === 'expanded' && isBelowBreakpoint ? 'overlay' : navigationMode; + const effectiveNavigationStyle: NavigationStyle = + navigationMode === 'overlay' ? 'expanded' : navigationStyle; + const isCondensedIconOnly = !isHorizontalNav && isBelowBreakpoint; + const isOverlayMode = effectiveNavigationMode === 'overlay'; + + const value: NavigationContextValue = { + isNavExpanded, + setIsNavExpanded, + navigationMode, + setNavigationMode, + effectiveNavigationMode, + navigationStyle, + setNavigationStyle, + effectiveNavigationStyle, + navigationPosition, + setNavigationPosition, + preferences, + updatePreferences, + isHorizontalNav, + isCondensedIconOnly, + isOverlayMode, + isChatExpanded, + setIsChatExpanded, + }; + + return {children}; +}; diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx new file mode 100644 index 000000000000..f8e1b63012ba --- /dev/null +++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx @@ -0,0 +1,221 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useNavigationContext } from './NavigationContext'; +import { useConfig } from '../ConfigContext'; +import { useNavigationSessions } from '../../hooks/useNavigationSessions'; +import { getNavItemById, type NavItem } from '../../hooks/useNavigationItems'; +import { AppEvents } from '../../constants/events'; +import { CondensedRenderer } from './CondensedRenderer'; +import { ExpandedRenderer } from './ExpandedRenderer'; +import { NavigationOverlay } from './navigation'; +import type { SessionStatus, DragHandlers } from './navigation/types'; + +export const Navigation: React.FC<{ className?: string }> = ({ className }) => { + const { + isNavExpanded, + setIsNavExpanded, + navigationPosition, + preferences, + updatePreferences, + isCondensedIconOnly, + isOverlayMode, + effectiveNavigationStyle, + isChatExpanded, + setIsChatExpanded, + } = useNavigationContext(); + + const location = useLocation(); + const { extensionsList } = useConfig(); + + const appsExtensionEnabled = !!extensionsList?.find((ext) => ext.name === 'apps')?.enabled; + + const visibleItems = useMemo(() => { + return preferences.itemOrder + .filter((id) => preferences.enabledItems.includes(id)) + .map((id) => getNavItemById(id)) + .filter((item): item is NavItem => item !== undefined) + .filter((item) => { + if (item.path === '/apps') return appsExtensionEnabled; + return true; + }); + }, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled]); + + const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]); + + const { + recentSessions, + activeSessionId, + fetchSessions, + handleNavClick, + handleNewChat, + handleSessionClick, + } = useNavigationSessions({ + onNavigate: isOverlayMode ? () => setIsNavExpanded(false) : undefined, + }); + + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + + const onDragStart = useCallback((e: React.DragEvent, itemId: string) => { + setDraggedItem(itemId); + e.dataTransfer.effectAllowed = 'move'; + }, []); + + const onDragOver = useCallback( + (e: React.DragEvent, itemId: string) => { + e.preventDefault(); + if (draggedItem && draggedItem !== itemId) setDragOverItem(itemId); + }, + [draggedItem] + ); + + const onDrop = useCallback( + (e: React.DragEvent, dropItemId: string) => { + e.preventDefault(); + if (!draggedItem || draggedItem === dropItemId) return; + + const newOrder = [...preferences.itemOrder]; + const draggedIndex = newOrder.indexOf(draggedItem); + const dropIndex = newOrder.indexOf(dropItemId); + if (draggedIndex === -1 || dropIndex === -1) return; + + newOrder.splice(draggedIndex, 1); + newOrder.splice(dropIndex, 0, draggedItem); + updatePreferences({ ...preferences, itemOrder: newOrder }); + + setDraggedItem(null); + setDragOverItem(null); + }, + [draggedItem, preferences, updatePreferences] + ); + + const onDragEnd = useCallback(() => { + setDraggedItem(null); + setDragOverItem(null); + }, []); + + const drag: DragHandlers = { + draggedItem, + dragOverItem, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + }; + + const [sessionStatuses, setSessionStatuses] = useState>(new Map()); + + useEffect(() => { + const handleStatusUpdate = (event: Event) => { + const { sessionId, streamState } = (event as CustomEvent).detail; + setSessionStatuses((prev) => { + const existing = prev.get(sessionId); + const shouldMarkUnread = existing?.streamState === 'streaming' && streamState === 'idle'; + const next = new Map(prev); + next.set(sessionId, { + streamState, + hasUnreadActivity: existing?.hasUnreadActivity || shouldMarkUnread, + }); + return next; + }); + }; + + window.addEventListener(AppEvents.SESSION_STATUS_UPDATE, handleStatusUpdate); + return () => window.removeEventListener(AppEvents.SESSION_STATUS_UPDATE, handleStatusUpdate); + }, []); + + const getSessionStatus = useCallback( + (sessionId: string) => sessionStatuses.get(sessionId), + [sessionStatuses] + ); + + const clearUnread = useCallback((sessionId: string) => { + setSessionStatuses((prev) => { + const status = prev.get(sessionId); + if (status?.hasUnreadActivity) { + const next = new Map(prev); + next.set(sessionId, { ...status, hasUnreadActivity: false }); + return next; + } + return prev; + }); + }, []); + + useEffect(() => { + if (!(isOverlayMode && isNavExpanded)) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + setIsNavExpanded(false); + } + }; + + document.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [isNavExpanded, isOverlayMode, setIsNavExpanded]); + + const navFocusRef = useRef(null); + + useEffect(() => { + if (isNavExpanded) { + fetchSessions(); + requestAnimationFrame(() => navFocusRef.current?.focus()); + } + }, [isNavExpanded, fetchSessions]); + + const onToggleChatExpanded = useCallback(() => { + setIsChatExpanded(!isChatExpanded); + }, [isChatExpanded, setIsChatExpanded]); + + const onClose = useCallback(() => setIsNavExpanded(false), [setIsNavExpanded]); + + const rendererProps = { + isNavExpanded, + isOverlayMode, + navigationPosition, + isCondensedIconOnly, + onClose, + className, + visibleItems, + isActive, + recentSessions, + activeSessionId, + onNavClick: handleNavClick, + onNewChat: handleNewChat, + onSessionClick: handleSessionClick, + onFetchSessions: fetchSessions, + getSessionStatus, + clearUnread, + isChatExpanded, + onToggleChatExpanded, + drag, + navFocusRef, + }; + + const content = + effectiveNavigationStyle === 'expanded' ? ( + + ) : ( + + ); + + if (isOverlayMode) { + if (effectiveNavigationStyle === 'expanded') { + // Expanded overlay uses its own AnimatePresence layout + return content; + } + return ( + setIsNavExpanded(false)} + > + {content} + + ); + } + + if (!isNavExpanded) return null; + return content; +}; diff --git a/ui/desktop/src/components/Layout/constants.ts b/ui/desktop/src/components/Layout/constants.ts new file mode 100644 index 000000000000..5a73b631c571 --- /dev/null +++ b/ui/desktop/src/components/Layout/constants.ts @@ -0,0 +1,23 @@ +export const NAV_DIMENSIONS = { + /** Width of condensed navigation in icon-only mode */ + CONDENSED_ICON_ONLY_WIDTH: 44, + /** Width of condensed navigation with labels */ + CONDENSED_WIDTH: 200, + /** Height of expanded navigation (horizontal mode) */ + EXPANDED_HEIGHT: 180, + /** Height of condensed navigation (horizontal mode) */ + CONDENSED_HEIGHT: 46, +} as const; + +export const Z_INDEX = { + /** Header controls (menu button, etc.) */ + HEADER: 100, + /** Tooltips - should appear above most UI elements */ + TOOLTIP: 200, + /** Popover content (hover menus) */ + POPOVER: 9999, + /** Modal/overlay backdrop and content */ + OVERLAY: 10000, + /** Dropdown menus that appear above overlays */ + DROPDOWN_ABOVE_OVERLAY: 10001, +} as const; diff --git a/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx b/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx new file mode 100644 index 000000000000..fa52a6530a19 --- /dev/null +++ b/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { MessageSquare, History, Plus, ChefHat } from 'lucide-react'; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '../../ui/dropdown-menu'; +import { SessionIndicators } from '../../SessionIndicators'; +import { cn } from '../../../utils'; +import { getSessionDisplayName, truncateMessage } from '../../../hooks/useNavigationSessions'; +import type { Session } from '../../../api'; +import type { SessionStatus } from './types'; + +interface ChatSessionsDropdownProps { + sessions: Session[]; + activeSessionId?: string; + side?: 'top' | 'bottom' | 'left' | 'right'; + zIndex?: number; + getSessionStatus: (sessionId: string) => SessionStatus | undefined; + clearUnread: (sessionId: string) => void; + onNewChat: () => void; + onSessionClick: (sessionId: string) => void; + onShowAll: () => void; +} + +export const ChatSessionsDropdown: React.FC = ({ + sessions, + activeSessionId, + side = 'right', + zIndex, + getSessionStatus, + clearUnread, + onNewChat, + onSessionClick, + onShowAll, +}) => { + return ( + + + + New Chat + + + {sessions.length > 0 && } + + {sessions.map((session) => { + const status = getSessionStatus(session.id); + const isStreaming = status?.streamState === 'streaming'; + const hasError = status?.streamState === 'error'; + const hasUnread = status?.hasUnreadActivity ?? false; + const isActiveSession = session.id === activeSessionId; + + return ( + { + clearUnread(session.id); + onSessionClick(session.id); + }} + className={cn( + 'flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer', + isActiveSession && 'bg-background-tertiary' + )} + > + {session.recipe ? ( + + ) : ( + + )} + + {truncateMessage(getSessionDisplayName(session), 30)} + + + + ); + })} + + {sessions.length > 0 && ( + <> + + + + Show All + + + )} + + ); +}; diff --git a/ui/desktop/src/components/Layout/navigation/NavigationOverlay.tsx b/ui/desktop/src/components/Layout/navigation/NavigationOverlay.tsx new file mode 100644 index 000000000000..e9ac6d34f7c6 --- /dev/null +++ b/ui/desktop/src/components/Layout/navigation/NavigationOverlay.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '../../../utils'; +import { Z_INDEX } from '../constants'; + +type NavigationPosition = 'top' | 'bottom' | 'left' | 'right'; + +interface NavigationOverlayProps { + isOpen: boolean; + position: NavigationPosition; + onClose: () => void; + children: React.ReactNode; +} + +export const NavigationOverlay: React.FC = ({ + isOpen, + position, + onClose, + children, +}) => { + return ( + + {isOpen && ( +
+ {/* Backdrop */} + + + {/* Scrollable container for navigation panel */} +
+
+
{children}
+
+
+
+ )} +
+ ); +}; diff --git a/ui/desktop/src/components/Layout/navigation/SessionsList.tsx b/ui/desktop/src/components/Layout/navigation/SessionsList.tsx new file mode 100644 index 000000000000..942473678a57 --- /dev/null +++ b/ui/desktop/src/components/Layout/navigation/SessionsList.tsx @@ -0,0 +1,145 @@ +import React, { useState, useCallback } from 'react'; +import { MessageSquare, ChefHat, Plus, History } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { SessionIndicators } from '../../SessionIndicators'; +import { InlineEditText } from '../../common/InlineEditText'; +import { cn } from '../../../utils'; +import { getSessionDisplayName } from '../../../hooks/useNavigationSessions'; +import { updateSessionName } from '../../../api'; +import type { Session } from '../../../api'; +import type { SessionStatus } from './types'; + +interface SessionsListProps { + sessions: Session[]; + activeSessionId?: string; + isExpanded: boolean; + getSessionStatus: (sessionId: string) => SessionStatus | undefined; + clearUnread: (sessionId: string) => void; + onSessionClick: (sessionId: string) => void; + onSessionRenamed?: () => void; + onNewChat?: () => void; + onShowAll?: () => void; +} + +export const SessionsList: React.FC = ({ + sessions, + activeSessionId, + isExpanded, + getSessionStatus, + clearUnread, + onSessionClick, + onSessionRenamed, + onNewChat, + onShowAll, +}) => { + const [editingSessionId, setEditingSessionId] = useState(null); + + const handleSaveSessionName = useCallback( + async (sessionId: string, newName: string) => { + await updateSessionName({ + path: { session_id: sessionId }, + body: { name: newName }, + }); + onSessionRenamed?.(); + }, + [onSessionRenamed] + ); + + return ( + + {isExpanded && ( + +
+ {/* New Chat button as first item */} + {onNewChat && ( +
+
+ + Start New Chat +
+ )} + + {sessions.map((session) => { + const status = getSessionStatus(session.id); + const isStreaming = status?.streamState === 'streaming'; + const hasError = status?.streamState === 'error'; + const hasUnread = status?.hasUnreadActivity ?? false; + const isActiveSession = session.id === activeSessionId; + const isEditing = editingSessionId === session.id; + + return ( +
{ + if (!isEditing) { + clearUnread(session.id); + onSessionClick(session.id); + } + }} + className={cn( + 'w-full text-left py-1.5 px-2 text-xs rounded-md', + 'hover:bg-background-tertiary transition-colors', + 'flex items-center gap-2 cursor-pointer', + isActiveSession && 'bg-background-tertiary' + )} + > +
+ {session.recipe ? ( + + ) : ( + + )} + handleSaveSessionName(session.id, newName)} + placeholder="Untitled session" + disabled={isStreaming} + singleClickEdit={false} + className="truncate text-text-primary flex-1 !px-0 !py-0 hover:bg-transparent" + editClassName="!text-xs" + onEditStart={() => setEditingSessionId(session.id)} + onEditEnd={() => setEditingSessionId(null)} + /> + +
+ ); + })} + + {/* Show All button at bottom */} + {onShowAll && sessions.length > 0 && ( +
+
+ + Show All +
+ )} +
+ + )} + + ); +}; diff --git a/ui/desktop/src/components/Layout/navigation/index.ts b/ui/desktop/src/components/Layout/navigation/index.ts new file mode 100644 index 000000000000..63a77a7b82ca --- /dev/null +++ b/ui/desktop/src/components/Layout/navigation/index.ts @@ -0,0 +1,3 @@ +export { ChatSessionsDropdown } from './ChatSessionsDropdown'; +export { NavigationOverlay } from './NavigationOverlay'; +export { SessionsList } from './SessionsList'; diff --git a/ui/desktop/src/components/Layout/navigation/types.ts b/ui/desktop/src/components/Layout/navigation/types.ts new file mode 100644 index 000000000000..bca2cf5bbb9a --- /dev/null +++ b/ui/desktop/src/components/Layout/navigation/types.ts @@ -0,0 +1,54 @@ +import type { NavItem } from '../../../hooks/useNavigationItems'; +import type { Session } from '../../../api'; +import type { NavigationPosition } from '../NavigationContext'; + +export type StreamState = 'idle' | 'loading' | 'streaming' | 'error'; + +export interface SessionStatus { + streamState: StreamState; + hasUnreadActivity: boolean; +} + +export interface DragHandlers { + draggedItem: string | null; + dragOverItem: string | null; + onDragStart: (e: React.DragEvent, itemId: string) => void; + onDragOver: (e: React.DragEvent, itemId: string) => void; + onDrop: (e: React.DragEvent, dropItemId: string) => void; + onDragEnd: () => void; +} + +export interface NavigationRendererProps { + isNavExpanded: boolean; + isOverlayMode: boolean; + navigationPosition: NavigationPosition; + isCondensedIconOnly: boolean; + onClose: () => void; + className?: string; + + // Items + visibleItems: NavItem[]; + isActive: (path: string) => boolean; + + // Sessions + recentSessions: Session[]; + activeSessionId?: string; + onNavClick: (path: string) => void; + onNewChat: () => void; + onSessionClick: (sessionId: string) => void; + onFetchSessions: () => void; + + // Session status + getSessionStatus: (sessionId: string) => SessionStatus | undefined; + clearUnread: (sessionId: string) => void; + + // Chat expand (condensed only, but simpler to keep uniform) + isChatExpanded: boolean; + onToggleChatExpanded: () => void; + + // Drag and drop + drag: DragHandlers; + + // Ref for focus management + navFocusRef: React.RefObject; +} diff --git a/ui/desktop/src/components/ToolCallArguments.tsx b/ui/desktop/src/components/ToolCallArguments.tsx index f1a6160c47d1..fe29ffdf7ca3 100644 --- a/ui/desktop/src/components/ToolCallArguments.tsx +++ b/ui/desktop/src/components/ToolCallArguments.tsx @@ -49,7 +49,10 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
{isExpanded ? (
- +
) : (
-
+
{analysisStage}
diff --git a/ui/desktop/src/components/recipes/shared/InstructionsEditor.tsx b/ui/desktop/src/components/recipes/shared/InstructionsEditor.tsx index b2ea6eece134..96fc1f294214 100644 --- a/ui/desktop/src/components/recipes/shared/InstructionsEditor.tsx +++ b/ui/desktop/src/components/recipes/shared/InstructionsEditor.tsx @@ -99,7 +99,8 @@ Use {{parameter_name}} syntax for any user-provided values.`;

- Use {`{{parameter_name}}`}{' '} + Use{' '} + {`{{parameter_name}}`}{' '} syntax to define parameters that users can fill in

diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 07350124e12c..fac5a10c69f2 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -247,7 +247,9 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN

Schedule Not Found

-

No schedule ID provided. Return to schedules list.

+

+ No schedule ID provided. Return to schedules list. +

); } diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index 40d68d0b2488..d523686709c6 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -494,7 +494,9 @@ const SchedulesView: React.FC = ({ onClose: _onClose }) => { {!isLoading && !apiError && schedules.length === 0 && (
-

No schedules yet

+

+ No schedules yet +

)} diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx index 4a18f29b1815..229092e9e305 100644 --- a/ui/desktop/src/components/sessions/SessionsInsights.tsx +++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx @@ -345,7 +345,9 @@ export function SessionInsights() {
)) ) : ( -
No recent chat sessions found.
+
+ No recent chat sessions found. +
)}
diff --git a/ui/desktop/src/components/settings/PromptsSettingsSection.tsx b/ui/desktop/src/components/settings/PromptsSettingsSection.tsx index ae2063d37abe..a0124730162e 100644 --- a/ui/desktop/src/components/settings/PromptsSettingsSection.tsx +++ b/ui/desktop/src/components/settings/PromptsSettingsSection.tsx @@ -277,7 +277,9 @@ export default function PromptsSettingsSection() { )}
-

{prompt.description}

+

+ {prompt.description} +

+ + {isExpanded && ( + +
+

Mode

+ +
+ {!isOverlayMode && ( +
+

Style

+ +
+ )} + {!isOverlayMode && ( +
+

Position

+ +
+ )} +
+

Customize Items

+ +
+
+ )} + + ); +}; + +// Navigation Settings Card - wrapped in its own provider for settings page +const NavigationSettingsCard: React.FC = () => { + const navContext = useNavigationContextSafe(); + + // If already in a NavigationProvider context, render directly + if (navContext) { + return ; + } + + // Otherwise wrap with provider + return ( + + + + ); +}; + export default function AppSettingsSection({ scrollToSection }: AppSettingsSectionProps) { const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true); @@ -28,25 +101,19 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [showPricing, setShowPricing] = useState(true); const [isDarkMode, setIsDarkMode] = useState(false); const updateSectionRef = useRef(null); - - // Check if GOOSE_VERSION is set to determine if Updates section should be shown const shouldShowUpdates = !window.appConfig.get('GOOSE_VERSION'); - // Check if running on macOS useEffect(() => { setIsMacOS(window.electron.platform === 'darwin'); }, []); - // Detect theme changes useEffect(() => { const updateTheme = () => { setIsDarkMode(document.documentElement.classList.contains('dark')); }; - // Initial check updateTheme(); - // Listen for theme changes const observer = new MutationObserver(updateTheme); observer.observe(document.documentElement, { attributes: true, @@ -56,22 +123,18 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti return () => observer.disconnect(); }, []); - // Load show pricing setting useEffect(() => { window.electron.getSetting('showPricing').then(setShowPricing); }, []); - // Handle scrolling to update section useEffect(() => { if (scrollToSection === 'update' && updateSectionRef.current) { - // Use a timeout to ensure the DOM is ready setTimeout(() => { updateSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); } }, [scrollToSection]); - // Load menu bar and dock icon states useEffect(() => { window.electron.getMenuBarIconState().then((enabled) => { setMenuBarIconEnabled(enabled); @@ -207,7 +270,9 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti

Dock icon

-

Show goose in the dock

+

+ Show goose in the dock +

+ {/* Navigation Settings */} + + @@ -392,7 +460,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti -
); } diff --git a/ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx b/ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx new file mode 100644 index 000000000000..a7f19f2a4196 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { GripVertical, Eye, EyeOff } from 'lucide-react'; +import { + useNavigationContext, + DEFAULT_ITEM_ORDER, + DEFAULT_ENABLED_ITEMS, +} from '../../Layout/NavigationContext'; +import { cn } from '../../../utils'; + +const ITEM_LABELS: Record = { + home: 'Home', + chat: 'Chat', + recipes: 'Recipes', + apps: 'Apps', + scheduler: 'Scheduler', + extensions: 'Extensions', + settings: 'Settings', +}; + +interface NavigationCustomizationSettingsProps { + className?: string; +} + +export const NavigationCustomizationSettings: React.FC = ({ + className, +}) => { + const { preferences, updatePreferences } = useNavigationContext(); + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + + const handleDragStart = (e: React.DragEvent, itemId: string) => { + setDraggedItem(itemId); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, itemId: string) => { + e.preventDefault(); + if (draggedItem && draggedItem !== itemId) { + setDragOverItem(itemId); + } + }; + + const handleDrop = (e: React.DragEvent, dropItemId: string) => { + e.preventDefault(); + if (!draggedItem || draggedItem === dropItemId) return; + + const newOrder = [...preferences.itemOrder]; + const draggedIndex = newOrder.indexOf(draggedItem); + const dropIndex = newOrder.indexOf(dropItemId); + + if (draggedIndex === -1 || dropIndex === -1) return; + + newOrder.splice(draggedIndex, 1); + newOrder.splice(dropIndex, 0, draggedItem); + + updatePreferences({ + ...preferences, + itemOrder: newOrder, + }); + + setDraggedItem(null); + setDragOverItem(null); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + setDragOverItem(null); + }; + + const toggleItemEnabled = (itemId: string) => { + const newEnabledItems = preferences.enabledItems.includes(itemId) + ? preferences.enabledItems.filter((id) => id !== itemId) + : [...preferences.enabledItems, itemId]; + + updatePreferences({ + ...preferences, + enabledItems: newEnabledItems, + }); + }; + + const resetToDefaults = () => { + updatePreferences({ + itemOrder: DEFAULT_ITEM_ORDER, + enabledItems: DEFAULT_ENABLED_ITEMS, + }); + }; + + return ( +
+
+
+

+ Drag to reorder, click the eye icon to show/hide items +

+ +
+ + {preferences.itemOrder.map((itemId) => { + const isEnabled = preferences.enabledItems.includes(itemId); + const isDragging = draggedItem === itemId; + const isDragOver = dragOverItem === itemId; + const label = ITEM_LABELS[itemId] || itemId; + + return ( +
handleDragStart(e, itemId)} + onDragOver={(e) => handleDragOver(e, itemId)} + onDrop={(e) => handleDrop(e, itemId)} + onDragEnd={handleDragEnd} + className={cn( + 'flex items-center gap-3 p-3 rounded-lg border transition-all', + isDragging && 'opacity-50', + isDragOver + ? 'border-border-primary bg-background-tertiary' + : 'border-border-secondary bg-background-primary', + !isEnabled && 'opacity-50' + )} + > + + {label} + +
+ ); + })} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/app/NavigationModeSelector.tsx b/ui/desktop/src/components/settings/app/NavigationModeSelector.tsx new file mode 100644 index 000000000000..c3e41d7612cd --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationModeSelector.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Columns2, Layers } from 'lucide-react'; +import { useNavigationContext, NavigationMode } from '../../Layout/NavigationContext'; +import { cn } from '../../../utils'; + +interface NavigationModeSelectorProps { + className?: string; +} + +const modes: { + value: NavigationMode; + label: string; + icon: React.ReactNode; + description: string; +}[] = [ + { + value: 'push', + label: 'Push', + icon: , + description: 'Navigation pushes content', + }, + { + value: 'overlay', + label: 'Overlay', + icon: , + description: 'Full-screen overlay', + }, +]; + +export const NavigationModeSelector: React.FC = ({ className }) => { + const { navigationMode, setNavigationMode } = useNavigationContext(); + + return ( +
+
+ {modes.map((mode) => ( + + ))} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx b/ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx new file mode 100644 index 000000000000..fe2c27169024 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from 'lucide-react'; +import { useNavigationContext, NavigationPosition } from '../../Layout/NavigationContext'; +import { cn } from '../../../utils'; + +interface NavigationPositionSelectorProps { + className?: string; +} + +const positions: { value: NavigationPosition; label: string; icon: React.ReactNode }[] = [ + { value: 'top', label: 'Top', icon: }, + { value: 'bottom', label: 'Bottom', icon: }, + { value: 'left', label: 'Left', icon: }, + { value: 'right', label: 'Right', icon: }, +]; + +export const NavigationPositionSelector: React.FC = ({ + className, +}) => { + const { navigationPosition, setNavigationPosition } = useNavigationContext(); + + return ( +
+
+ {positions.map((position) => ( + + ))} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx b/ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx new file mode 100644 index 000000000000..f44dd996f0f3 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { LayoutGrid, List } from 'lucide-react'; +import { useNavigationContext, NavigationStyle } from '../../Layout/NavigationContext'; +import { cn } from '../../../utils'; + +interface NavigationStyleSelectorProps { + className?: string; +} + +const styles: { + value: NavigationStyle; + label: string; + icon: React.ReactNode; + description: string; +}[] = [ + { + value: 'expanded', + label: 'Tile', + icon: , + description: 'Enlarged tile view', + }, + { + value: 'condensed', + label: 'List', + icon: , + description: 'Classic condensed view', + }, +]; + +export const NavigationStyleSelector: React.FC = ({ className }) => { + const { navigationStyle, setNavigationStyle } = useNavigationContext(); + + return ( +
+
+ {styles.map((style) => ( + + ))} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/dictation/LocalModelManager.tsx b/ui/desktop/src/components/settings/dictation/LocalModelManager.tsx index 6fdd4da784ec..80c73f59b359 100644 --- a/ui/desktop/src/components/settings/dictation/LocalModelManager.tsx +++ b/ui/desktop/src/components/settings/dictation/LocalModelManager.tsx @@ -135,9 +135,8 @@ export const LocalModelManager = () => { const hasDownloadedNonRecommended = models.some( (model) => model.downloaded && !model.recommended ); - const displayedModels = showAllModels || hasDownloadedNonRecommended - ? models - : models.filter((m) => m.recommended); + const displayedModels = + showAllModels || hasDownloadedNonRecommended ? models : models.filter((m) => m.recommended); const hasNonRecommendedModels = models.some((m) => !m.recommended); const showToggleButton = hasNonRecommendedModels && !hasDownloadedNonRecommended; diff --git a/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx b/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx index a31e446de069..b60edf3e74f9 100644 --- a/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx +++ b/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx @@ -74,6 +74,12 @@ const shortcutConfigs: ShortcutConfig[] = [ description: 'Toggle window always on top', category: 'window', }, + { + key: 'toggleNavigation', + label: 'Toggle Navigation', + description: 'Show or hide the navigation menu', + category: 'application', + }, ]; const needsRestart = new Set([ @@ -123,7 +129,7 @@ export default function KeyboardShortcutsSection() { const loadShortcuts = useCallback(async () => { const keyboardShortcuts = await window.electron.getSetting('keyboardShortcuts'); - setShortcuts(keyboardShortcuts || defaultKeyboardShortcuts); + setShortcuts({ ...defaultKeyboardShortcuts, ...keyboardShortcuts }); }, []); useEffect(() => { diff --git a/ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx b/ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx index 33ec4c617137..a90f166763a2 100644 --- a/ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx +++ b/ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx @@ -59,7 +59,9 @@ export const ModeSelectionItem = forwardRef

{mode.label}

- {showDescription &&

{mode.description}

} + {showDescription && ( +

{mode.description}

+ )}
diff --git a/ui/desktop/src/components/settings/models/subcomponents/LeadWorkerSettings.tsx b/ui/desktop/src/components/settings/models/subcomponents/LeadWorkerSettings.tsx index 9e962d5f1d1c..2fea7c469e9d 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/LeadWorkerSettings.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/LeadWorkerSettings.tsx @@ -225,7 +225,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
-
-
@@ -347,7 +355,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps) className={`w-20 ${!isEnabled ? 'opacity-50 cursor-not-allowed' : ''}`} disabled={!isEnabled} /> -

+

Number of turns to use the lead model at the start

@@ -367,7 +377,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps) className={`w-20 ${!isEnabled ? 'opacity-50 cursor-not-allowed' : ''}`} disabled={!isEnabled} /> -

+

Consecutive failures before switching back to lead

@@ -387,7 +399,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps) className={`w-20 ${!isEnabled ? 'opacity-50 cursor-not-allowed' : ''}`} disabled={!isEnabled} /> -

+

Turns to use lead model during fallback

diff --git a/ui/desktop/src/components/ui/Tooltip.tsx b/ui/desktop/src/components/ui/Tooltip.tsx index 1a65a0ac549b..e6dd4162c327 100644 --- a/ui/desktop/src/components/ui/Tooltip.tsx +++ b/ui/desktop/src/components/ui/Tooltip.tsx @@ -30,23 +30,29 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { +}: React.ComponentProps & { arrowClassName?: string }) { return ( {children} - + ); diff --git a/ui/desktop/src/components/ui/button.tsx b/ui/desktop/src/components/ui/button.tsx index ea21f1e1967d..82fdbe48fe1a 100644 --- a/ui/desktop/src/components/ui/button.tsx +++ b/ui/desktop/src/components/ui/button.tsx @@ -12,7 +12,8 @@ const buttonVariants = cva( destructive: 'bg-background-danger text-white hover:bg-background-danger/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-background-danger/60 shadow-xs', outline: 'border hover:bg-background-secondary', - secondary: 'bg-background-secondary text-text-primary hover:bg-background-secondary/80 shadow-xs', + secondary: + 'bg-background-secondary text-text-primary hover:bg-background-secondary/80 shadow-xs', ghost: 'hover:bg-background-secondary dark:hover:bg-background-secondary/50', link: 'text-text-inverse underline-offset-4 hover:underline', }, diff --git a/ui/desktop/src/components/ui/sidebar.tsx b/ui/desktop/src/components/ui/sidebar.tsx deleted file mode 100644 index 079a8b386629..000000000000 --- a/ui/desktop/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,711 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { VariantProps, cva } from 'class-variance-authority'; -import { PanelLeftIcon } from 'lucide-react'; - -import { cn } from '../../utils'; -import { Button } from './button'; -import { Input } from './input'; -import { Separator } from './separator'; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet'; -import { Skeleton } from './skeleton'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './Tooltip'; -import { useIsMobile } from '../../hooks/use-mobile'; - -const SIDEBAR_COOKIE_NAME = 'sidebar_state'; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = '12rem'; -const SIDEBAR_WIDTH_MOBILE = 'fit-content'; -const SIDEBAR_WIDTH_ICON = '38px'; -const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; - -type SidebarContextProps = { - state: 'expanded' | 'collapsed'; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error('useSidebar must be used within a SidebarProvider.'); - } - - return context; -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<'div'> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === 'function' ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open] - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? 'expanded' : 'collapsed'; - - const contextValue = React.useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ); - - return ( - - -
- {children} -
-
-
- ); -} - -function Sidebar({ - side = 'left', - variant = 'sidebar', - collapsible = 'offcanvas', - className, - children, - ...props -}: React.ComponentProps<'div'> & { - side?: 'left' | 'right'; - variant?: 'sidebar' | 'floating' | 'inset'; - collapsible?: 'offcanvas' | 'icon' | 'none'; -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - - if (collapsible === 'none') { - return ( -
- {children} -
- ); - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ); - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ); -} - -function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { - const { toggleSidebar } = useSidebar(); - - return ( - - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { - const { toggleSidebar } = useSidebar(); - - return ( -