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 */}
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 */}
-
-
onSessionClick(session)}
- className={`w-full text-left ml-3 px-1.5 py-1.5 pr-2 rounded-md text-sm transition-colors flex items-center gap-1 min-w-0 ${
- activeSessionId === session.id
- ? 'bg-background-tertiary text-text-primary'
- : 'text-text-secondary hover:bg-background-tertiary/50 hover:text-text-primary'
- }`}
- title={displayName}
- >
- {session.recipe && }
-
- {canRename ? (
- handleRenameSession(session.id, newName)}
- className="text-sm -mx-2 -my-1"
- editClassName="text-sm"
- singleClickEdit={false}
- />
- ) : (
- {displayName}
- )}
-
-
-
-
- );
- })}
-
- );
- },
- (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 */}
-
-
- View All
-
-
-
- )}
-
-
-
-
-
-
- {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 && (
-
-
-
- {safeIsMacOS ? : }
-
-
- )}
-
-
-
-
+ // 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 */}
+ setIsNavExpanded(!isNavExpanded)}
+ className="no-drag hover:!bg-background-tertiary"
+ variant="ghost"
+ size="xs"
+ title={isNavExpanded ? 'Close navigation' : 'Open navigation'}
+ >
+
+
+
+
+ {/* 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'
+ )}
+ >
+
+ onNavClick(item.path)}
+ className="flex-1 flex flex-col items-start justify-between p-5 no-drag text-left"
+ >
+
+
+
+ {item.getTag && (
+
+
+ {item.getTag()}
+
+
+ )}
+
+
+
{item.label}
+
+
+
+
+ );
+ })}
+
+ {/* 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 && (
+
+ )}
+
+ );
+ }
+
+ 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 */}
+
+
+ )}
+
+ );
+};
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 && (
+
+ )}
+
+ {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 && (
+
+ )}
+
+
+ )}
+
+ );
+};
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}
+
{
+ const [isExpanded, setIsExpanded] = useState(false);
+ const navContext = useNavigationContextSafe();
+ const isOverlayMode = navContext?.navigationMode === 'overlay';
+
+ return (
+
+
+ setIsExpanded(!isExpanded)}
+ className="w-full flex items-center justify-between text-left"
+ >
+
+ Navigation
+ Customize navigation layout and behavior
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {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
+
+
+ Reset to defaults
+
+
+
+ {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}
+ toggleItemEnabled(itemId)}
+ className="p-1 rounded hover:bg-background-tertiary transition-colors flex-shrink-0"
+ title={isEnabled ? 'Hide item' : 'Show item'}
+ >
+ {isEnabled ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+};
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) => (
+
setNavigationMode(mode.value)}
+ className={cn(
+ 'flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all',
+ navigationMode === mode.value
+ ? 'border-border-primary bg-background-tertiary'
+ : 'border-border-secondary bg-background-primary hover:border-border-medium'
+ )}
+ >
+ {mode.icon}
+
+
{mode.label}
+
{mode.description}
+
+
+ ))}
+
+
+ );
+};
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) => (
+
setNavigationPosition(position.value)}
+ className={cn(
+ 'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all',
+ navigationPosition === position.value
+ ? 'border-border-primary bg-background-tertiary'
+ : 'border-border-secondary bg-background-primary hover:border-border-medium'
+ )}
+ >
+ {position.icon}
+ {position.label}
+
+ ))}
+
+
+ );
+};
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) => (
+
setNavigationStyle(style.value)}
+ className={cn(
+ 'flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all',
+ navigationStyle === style.value
+ ? 'border-border-primary bg-background-tertiary'
+ : 'border-border-secondary bg-background-primary hover:border-border-medium'
+ )}
+ >
+ {style.icon}
+
+
{style.label}
+
{style.description}
+
+
+ ))}
+
+
+ );
+};
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)
-
+
Lead Model
{isLeadCustomModel && (
@@ -270,14 +272,18 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
disabled={!isEnabled}
/>
)}
-
+
Strong model for initial planning and fallback recovery
-
+
Worker Model
{isWorkerCustomModel && (
@@ -324,7 +330,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
disabled={!isEnabled}
/>
)}
-
+
Fast model for routine execution tasks
@@ -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 (
- {
- onClick?.(event);
- toggleSidebar();
- }}
- {...props}
- >
-
- Toggle Sidebar
-
- );
-}
-
-function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
- const { toggleSidebar } = useSidebar();
-
- return (
-
- );
-}
-
-function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
- return (
-
- );
-}
-
-function SidebarInput({ className, ...props }: React.ComponentProps) {
- return (
-
- );
-}
-
-function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- );
-}
-
-function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- );
-}
-
-function SidebarSeparator({ className, ...props }: React.ComponentProps) {
- return (
-
- );
-}
-
-function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- );
-}
-
-function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- );
-}
-
-function SidebarGroupLabel({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<'div'> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : 'div';
-
- return (
- svg]:size-4 [&>svg]:shrink-0',
- 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
- className
- )}
- {...props}
- />
- );
-}
-
-function SidebarGroupAction({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<'button'> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : 'button';
-
- return (
- svg]:size-4 [&>svg]:shrink-0',
- // Increases the hit area of the button on mobile.
- 'after:absolute after:-inset-2 md:after:hidden',
- 'group-data-[collapsible=icon]:hidden',
- className
- )}
- {...props}
- />
- );
-}
-
-function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- );
-}
-
-function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
- return (
-
- );
-}
-
-function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
- return (
-
- );
-}
-
-const sidebarMenuButtonVariants = cva(
- 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
- {
- variants: {
- variant: {
- default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
- outline: 'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
- },
- size: {
- default: 'h-8 text-sm',
- sm: 'h-7 text-xs',
- lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
- },
- },
- defaultVariants: {
- variant: 'default',
- size: 'default',
- },
- }
-);
-
-function SidebarMenuButton({
- asChild = false,
- isActive = false,
- variant = 'default',
- size = 'default',
- tooltip,
- className,
- onClick,
- ...props
-}: React.ComponentProps<'button'> & {
- asChild?: boolean;
- isActive?: boolean;
- tooltip?: string | React.ComponentProps;
-} & VariantProps) {
- const Comp = asChild ? Slot : 'button';
- const { isMobile, state, setOpenMobile } = useSidebar();
-
- const handleClick = React.useCallback(
- (event: React.MouseEvent) => {
- // Call the original onClick handler if provided
- onClick?.(event);
-
- // Auto-close mobile sidebar when menu item is clicked
- if (isMobile) {
- setOpenMobile(false);
- }
- },
- [onClick, isMobile, setOpenMobile]
- );
-
- const button = (
-
- );
-
- if (!tooltip) {
- return button;
- }
-
- if (typeof tooltip === 'string') {
- tooltip = {
- children: tooltip,
- };
- }
-
- return (
-
- {button}
-
-
- );
-}
-
-function SidebarMenuAction({
- className,
- asChild = false,
- showOnHover = false,
- ...props
-}: React.ComponentProps<'button'> & {
- asChild?: boolean;
- showOnHover?: boolean;
-}) {
- const Comp = asChild ? Slot : 'button';
-
- return (
- svg]:size-4 [&>svg]:shrink-0',
- // Increases the hit area of the button on mobile.
- 'after:absolute after:-inset-2 md:after:hidden',
- 'peer-data-[size=sm]/menu-button:top-1',
- 'peer-data-[size=default]/menu-button:top-1.5',
- 'peer-data-[size=lg]/menu-button:top-2.5',
- 'group-data-[collapsible=icon]:hidden',
- showOnHover &&
- 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
- className
- )}
- {...props}
- />
- );
-}
-
-function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- );
-}
-
-function SidebarMenuSkeleton({
- className,
- showIcon = false,
- ...props
-}: React.ComponentProps<'div'> & {
- showIcon?: boolean;
-}) {
- // Random width between 50 to 90%.
- const width = React.useMemo(() => {
- return `${Math.floor(Math.random() * 40) + 50}%`;
- }, []);
-
- return (
-
- {showIcon && }
-
-
- );
-}
-
-function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
- return (
-
- );
-}
-
-function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
- return (
-
- );
-}
-
-function SidebarMenuSubButton({
- asChild = false,
- size = 'md',
- isActive = false,
- className,
- ...props
-}: React.ComponentProps<'a'> & {
- asChild?: boolean;
- size?: 'sm' | 'md';
- isActive?: boolean;
-}) {
- const Comp = asChild ? Slot : 'a';
-
- return (
- svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
- 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
- size === 'sm' && 'text-xs',
- size === 'md' && 'text-sm',
- 'group-data-[collapsible=icon]:hidden',
- className
- )}
- {...props}
- />
- );
-}
-
-export {
- Sidebar,
- SidebarContent,
- SidebarFooter,
- SidebarGroup,
- SidebarGroupAction,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarHeader,
- SidebarInput,
- SidebarInset,
- SidebarMenu,
- SidebarMenuAction,
- SidebarMenuBadge,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarMenuSkeleton,
- SidebarMenuSub,
- SidebarMenuSubButton,
- SidebarMenuSubItem,
- SidebarProvider,
- SidebarRail,
- SidebarSeparator,
- SidebarTrigger,
- useSidebar,
-};
diff --git a/ui/desktop/src/hooks/useNavigationItems.ts b/ui/desktop/src/hooks/useNavigationItems.ts
new file mode 100644
index 000000000000..d8d44fcf8bc2
--- /dev/null
+++ b/ui/desktop/src/hooks/useNavigationItems.ts
@@ -0,0 +1,26 @@
+import { Home, MessageSquare, FileText, AppWindow, Clock, Puzzle, Settings } from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
+
+export interface NavItem {
+ id: string;
+ path: string;
+ label: string;
+ icon: LucideIcon;
+ getTag?: () => string;
+ tagAlign?: 'left' | 'right';
+ hasSubItems?: boolean;
+}
+
+export const NAV_ITEMS: NavItem[] = [
+ { id: 'home', path: '/', label: 'Home', icon: Home },
+ { id: 'chat', path: '/pair', label: 'Chat', icon: MessageSquare, hasSubItems: true },
+ { id: 'recipes', path: '/recipes', label: 'Recipes', icon: FileText },
+ { id: 'apps', path: '/apps', label: 'Apps', icon: AppWindow },
+ { id: 'scheduler', path: '/schedules', label: 'Scheduler', icon: Clock },
+ { id: 'extensions', path: '/extensions', label: 'Extensions', icon: Puzzle },
+ { id: 'settings', path: '/settings', label: 'Settings', icon: Settings },
+];
+
+export function getNavItemById(id: string): NavItem | undefined {
+ return NAV_ITEMS.find((item) => item.id === id);
+}
diff --git a/ui/desktop/src/hooks/useNavigationSessions.ts b/ui/desktop/src/hooks/useNavigationSessions.ts
new file mode 100644
index 000000000000..1dacbe603cb9
--- /dev/null
+++ b/ui/desktop/src/hooks/useNavigationSessions.ts
@@ -0,0 +1,200 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
+import { listSessions } from '../api';
+import { useChatContext } from '../contexts/ChatContext';
+import { useConfig } from '../components/ConfigContext';
+import { useNavigation } from './useNavigation';
+import { startNewSession, resumeSession, shouldShowNewChatTitle } from '../sessions';
+import { getInitialWorkingDir } from '../utils/workingDir';
+import { AppEvents } from '../constants/events';
+import type { Session } from '../api';
+
+const MAX_RECENT_SESSIONS = 5;
+
+interface UseNavigationSessionsOptions {
+ onNavigate?: () => void;
+ fetchOnMount?: boolean;
+}
+
+export function useNavigationSessions(options: UseNavigationSessionsOptions = {}) {
+ const { onNavigate, fetchOnMount = false } = options;
+
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
+ const chatContext = useChatContext();
+ const { extensionsList } = useConfig();
+ const setView = useNavigation();
+
+ const [recentSessions, setRecentSessions] = useState([]);
+ const sessionsRef = useRef([]);
+ const lastSessionIdRef = useRef(null);
+ const isCreatingSessionRef = useRef(false);
+
+ const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
+ const currentSessionId =
+ location.pathname === '/pair' ? searchParams.get('resumeSessionId') : null;
+
+ useEffect(() => {
+ sessionsRef.current = recentSessions;
+ }, [recentSessions]);
+
+ useEffect(() => {
+ if (currentSessionId) {
+ lastSessionIdRef.current = currentSessionId;
+ }
+ }, [currentSessionId]);
+
+ const fetchSessions = useCallback(async () => {
+ try {
+ const response = await listSessions({ throwOnError: false });
+ if (response.data) {
+ const sorted = [...response.data.sessions]
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
+ .slice(0, MAX_RECENT_SESSIONS);
+ setRecentSessions(sorted);
+ sessionsRef.current = response.data.sessions;
+ }
+ } catch (error) {
+ console.error('Failed to fetch sessions:', error);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (fetchOnMount) {
+ fetchSessions();
+ }
+ }, [fetchOnMount, fetchSessions]);
+
+ useEffect(() => {
+ let pollingTimeouts: ReturnType[] = [];
+ let isPolling = false;
+
+ const handleSessionCreated = (event: Event) => {
+ const { session } = (event as CustomEvent<{ session?: Session }>).detail || {};
+ if (session) {
+ setRecentSessions((prev) => {
+ if (prev.some((s) => s.id === session.id)) return prev;
+ return [session, ...prev].slice(0, MAX_RECENT_SESSIONS);
+ });
+ sessionsRef.current = [session, ...sessionsRef.current.filter((s) => s.id !== session.id)];
+ }
+
+ 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: false });
+ if (response.data) {
+ const apiSessions = response.data.sessions.slice(0, MAX_RECENT_SESSIONS);
+ setRecentSessions((prev) => {
+ const emptyLocalSessions = prev.filter(
+ (local) =>
+ local.message_count === 0 && !apiSessions.some((api) => api.id === local.id)
+ );
+ return [...emptyLocalSessions, ...apiSessions].slice(0, MAX_RECENT_SESSIONS);
+ });
+ sessionsRef.current = response.data.sessions;
+ }
+ } catch (error) {
+ console.error('Failed to poll sessions:', error);
+ }
+
+ if (pollCount < maxPolls) {
+ const timeout = setTimeout(pollForUpdates, pollIntervalMs);
+ pollingTimeouts.push(timeout);
+ } else {
+ isPolling = false;
+ }
+ };
+
+ pollForUpdates();
+ };
+
+ window.addEventListener(AppEvents.SESSION_CREATED, handleSessionCreated);
+ return () => {
+ window.removeEventListener(AppEvents.SESSION_CREATED, handleSessionCreated);
+ pollingTimeouts.forEach(clearTimeout);
+ };
+ }, []);
+
+ const handleNavClick = useCallback(
+ (path: string) => {
+ if (path === '/pair') {
+ const sessionId =
+ currentSessionId || lastSessionIdRef.current || chatContext?.chat?.sessionId;
+ if (sessionId && sessionId.length > 0) {
+ navigate(`/pair?resumeSessionId=${sessionId}`);
+ } else {
+ navigate('/');
+ }
+ } else {
+ navigate(path);
+ }
+ onNavigate?.();
+ },
+ [navigate, currentSessionId, chatContext?.chat?.sessionId, onNavigate]
+ );
+
+ const handleNewChat = useCallback(async () => {
+ if (isCreatingSessionRef.current) return;
+
+ const emptyNewSession = sessionsRef.current.find((s) => shouldShowNewChatTitle(s));
+
+ if (emptyNewSession) {
+ resumeSession(emptyNewSession, setView);
+ } else {
+ isCreatingSessionRef.current = true;
+ try {
+ await startNewSession('', setView, getInitialWorkingDir(), {
+ allExtensions: extensionsList,
+ });
+ } finally {
+ setTimeout(() => {
+ isCreatingSessionRef.current = false;
+ }, 1000);
+ }
+ }
+ onNavigate?.();
+ }, [setView, onNavigate, extensionsList]);
+
+ const handleSessionClick = useCallback(
+ (sessionId: string) => {
+ navigate(`/pair?resumeSessionId=${sessionId}`);
+ onNavigate?.();
+ },
+ [navigate, onNavigate]
+ );
+
+ return {
+ recentSessions,
+ activeSessionId,
+ currentSessionId,
+ fetchSessions,
+ handleNavClick,
+ handleNewChat,
+ handleSessionClick,
+ };
+}
+
+export function getSessionDisplayName(session: Session): string {
+ if (session.recipe?.title) {
+ return session.recipe.title;
+ }
+ if (shouldShowNewChatTitle(session)) {
+ return 'New Chat';
+ }
+ return session.name;
+}
+
+export function truncateMessage(msg?: string, maxLen = 20): string {
+ if (!msg) return 'New Chat';
+ return msg.length > maxLen ? msg.substring(0, maxLen) + '...' : msg;
+}
diff --git a/ui/desktop/src/hooks/useSidebarSessionStatus.ts b/ui/desktop/src/hooks/useSidebarSessionStatus.ts
deleted file mode 100644
index cc9c84d15059..000000000000
--- a/ui/desktop/src/hooks/useSidebarSessionStatus.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { AppEvents } from '../constants/events';
-import { useState, useCallback, useRef, useEffect } from 'react';
-
-type StreamState = 'idle' | 'streaming' | 'error';
-
-interface SessionStatus {
- streamState: StreamState;
- hasUnreadActivity: boolean;
-}
-
-/**
- * Simple hook to track session status for the sidebar.
- * Listens to session-status-update events from BaseChat components.
- */
-export function useSidebarSessionStatus(activeSessionId: string | undefined) {
- const [statuses, setStatuses] = useState>(new Map());
- const activeSessionIdRef = useRef(activeSessionId);
-
- // Keep ref in sync
- useEffect(() => {
- activeSessionIdRef.current = activeSessionId;
- }, [activeSessionId]);
-
- // Clear unread when active session changes
- useEffect(() => {
- if (activeSessionId) {
- setStatuses((prev) => {
- const status = prev.get(activeSessionId);
- if (status?.hasUnreadActivity) {
- const next = new Map(prev);
- next.set(activeSessionId, { ...status, hasUnreadActivity: false });
- return next;
- }
- return prev;
- });
- }
- }, [activeSessionId]);
-
- // Listen for status updates from BaseChat
- useEffect(() => {
- const handleStatusUpdate = (event: Event) => {
- const { sessionId, streamState } = (event as CustomEvent).detail;
-
- setStatuses((prev) => {
- const existing = prev.get(sessionId);
- const wasStreaming = existing?.streamState === 'streaming';
- const isNowIdle = streamState === 'idle';
- const isBackground = sessionId !== activeSessionIdRef.current;
-
- // Mark unread if streaming just finished in a background session
- const shouldMarkUnread = isBackground && wasStreaming && isNowIdle;
-
- 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): SessionStatus | undefined => {
- return statuses.get(sessionId);
- },
- [statuses]
- );
-
- const clearUnread = useCallback((sessionId: string) => {
- setStatuses((prev) => {
- const status = prev.get(sessionId);
- if (status?.hasUnreadActivity) {
- const next = new Map(prev);
- next.set(sessionId, { ...status, hasUnreadActivity: false });
- return next;
- }
- return prev;
- });
- }, []);
-
- return { getSessionStatus, clearUnread };
-}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 5ea4444f642b..2f3ca9c4d28c 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -1971,6 +1971,23 @@ async function appMain() {
);
}
}
+
+ const viewMenu = menu.items.find((item) => item.label === 'View');
+ if (viewMenu?.submenu && shortcuts.toggleNavigation) {
+ viewMenu.submenu.append(new MenuItem({ type: 'separator' }));
+ viewMenu.submenu.append(
+ new MenuItem({
+ label: 'Toggle Navigation',
+ accelerator: shortcuts.toggleNavigation,
+ click() {
+ const focusedWindow = BrowserWindow.getFocusedWindow();
+ if (focusedWindow) {
+ focusedWindow.webContents.send('toggle-navigation');
+ }
+ },
+ })
+ );
+ }
}
// on macOS, the topbar is hidden
diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts
index b4ddcc4c3542..bf1a620c3b98 100644
--- a/ui/desktop/src/preload.ts
+++ b/ui/desktop/src/preload.ts
@@ -274,7 +274,12 @@ const electronAPI: ElectronAPI = {
emit: (channel: string, ...args: unknown[]) => {
ipcRenderer.emit(channel, ...args);
},
- broadcastThemeChange: (themeData: { mode: string; useSystemTheme: boolean; theme: string; tokensUpdated?: boolean }) => {
+ broadcastThemeChange: (themeData: {
+ mode: string;
+ useSystemTheme: boolean;
+ theme: string;
+ tokensUpdated?: boolean;
+ }) => {
ipcRenderer.send('broadcast-theme-change', themeData);
},
openExternal: (url: string): Promise => {
diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css
index f3b193d5d3e0..1d9e181cc92e 100644
--- a/ui/desktop/src/styles/main.css
+++ b/ui/desktop/src/styles/main.css
@@ -591,7 +591,6 @@ p > code.bg-inline-code {
/* Sidebar scrollbar styles */
.sidebar-scrollbar {
- scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
diff --git a/ui/desktop/src/theme/theme-tokens.ts b/ui/desktop/src/theme/theme-tokens.ts
index 07231674caa4..fa1d2d5d622a 100644
--- a/ui/desktop/src/theme/theme-tokens.ts
+++ b/ui/desktop/src/theme/theme-tokens.ts
@@ -24,9 +24,7 @@ type ThemeTokens = Record;
// Subset of keys that are the same across both themes.
type BaseTokenKey = Extract<
McpUiStyleVariableKey,
- | `--font-${string}`
- | `--border-radius-${string}`
- | `--border-width-${string}`
+ `--font-${string}` | `--border-radius-${string}` | `--border-width-${string}`
>;
type ColorTokenKey = Exclude;
@@ -143,8 +141,7 @@ const lightColorTokens: ColorTokens = {
'--shadow-hairline': '0 0 0 1px rgba(0, 0, 0, 0.05)',
'--shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'--shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
- '--shadow-lg':
- '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
+ '--shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
};
// ---------------------------------------------------------------------------
@@ -200,8 +197,7 @@ const darkColorTokens: ColorTokens = {
'--shadow-hairline': '0 0 0 1px rgba(0, 0, 0, 0.2)',
'--shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.2)',
'--shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2)',
- '--shadow-lg':
- '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2)',
+ '--shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2)',
};
// ---------------------------------------------------------------------------
diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts
index 996e81d4de5d..31226f068585 100644
--- a/ui/desktop/src/utils/settings.ts
+++ b/ui/desktop/src/utils/settings.ts
@@ -15,6 +15,7 @@ export interface KeyboardShortcuts {
findNext: string | null;
findPrevious: string | null;
alwaysOnTop: string | null;
+ toggleNavigation: string | null;
}
export type DefaultKeyboardShortcuts = {
@@ -58,6 +59,7 @@ export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = {
findNext: 'CommandOrControl+G',
findPrevious: 'CommandOrControl+Shift+G',
alwaysOnTop: 'CommandOrControl+Shift+T',
+ toggleNavigation: 'CommandOrControl+/',
};
export const defaultSettings: Settings = {
@@ -104,5 +106,5 @@ export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {
quickLauncher: launcherShortcut,
};
}
- return settings.keyboardShortcuts || defaultKeyboardShortcuts;
+ return { ...defaultKeyboardShortcuts, ...settings.keyboardShortcuts };
}