New navigation settings layout options and styling (#6645)

Co-authored-by: Zane Staggs <zane@squareup.com>
Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
This commit is contained in:
Spence
2026-02-23 11:56:51 -05:00
committed by GitHub
parent c73bad9a9c
commit 5349dd6098
45 changed files with 2446 additions and 1585 deletions
+53 -32
View File
@@ -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"
}
+1
View File
@@ -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",
+3 -1
View File
@@ -80,7 +80,9 @@ export default function ApiKeyTester({ onSuccess, onStartTesting }: ApiKeyTester
<h3 className="font-medium text-text-primary text-sm sm:text-base">
Quick Setup with API Key
</h3>
<span className="text-text-secondary text-xs sm:text-sm">Auto-detect your provider</span>
<span className="text-text-secondary text-xs sm:text-sm">
Auto-detect your provider
</span>
</div>
</div>
+8 -12
View File
@@ -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<ScrollAreaHandle>(null);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const disableAnimation = location.state?.disableAnimation || false;
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
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 */}
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
{/* Goose watermark - top right */}
<div className="absolute top-3 right-4 z-[60] flex flex-row items-center gap-1">
<a
href="https://block.github.io/goose"
@@ -394,7 +388,9 @@ export default function BaseChat({
className="no-drag flex flex-row items-center gap-1 hover:opacity-80 transition-opacity"
>
<Goose className="size-5 goose-icon-animation" />
<span className="text-sm leading-none text-text-secondary -translate-y-px">goose</span>
<span className="text-sm leading-none text-text-secondary -translate-y-px">
goose
</span>
</a>
<EnvironmentBadge className="translate-y-px" />
</div>
@@ -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 (
<div className="relative ml-3">
{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 (
<div key={session.id} className="relative flex items-center">
{/* Vertical line segment - full height except last item stops at middle */}
<div
className={`absolute left-0 w-px bg-border-secondary ${
isLast ? 'top-0 h-1/2' : 'top-0 h-full'
}`}
/>
{/* Horizontal branch line */}
<div className="absolute left-0 w-2 h-px bg-border-secondary top-1/2" />
<button
onClick={() => 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 && <ChefHat className="w-3.5 h-3.5 flex-shrink-0" />}
<div className="flex-1 min-w-0">
{canRename ? (
<InlineEditText
value={displayName}
onSave={(newName) => handleRenameSession(session.id, newName)}
className="text-sm -mx-2 -my-1"
editClassName="text-sm"
singleClickEdit={false}
/>
) : (
<span className="truncate block">{displayName}</span>
)}
</div>
<SessionIndicators
isStreaming={isStreaming}
hasUnread={hasUnread}
hasError={hasError}
/>
</button>
</div>
);
})}
</div>
);
},
(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<SidebarProps> = ({ 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<Session[]>([]);
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<true>({ 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<typeof setTimeout>[] = [];
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<true>({ 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<string>();
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 <SidebarSeparator key={index} />;
}
const IconComponent = entry.icon;
return (
<SidebarGroup key={entry.path} className="px-2">
<SidebarGroupContent className="space-y-1">
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
data-testid={`sidebar-${entry.label.toLowerCase()}-button`}
onClick={() => 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"
>
<IconComponent className="w-4 h-4" />
<span>{entry.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarGroupContent>
</SidebarGroup>
);
};
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 (
<>
<SidebarContent className="pt-12">
<SidebarMenu>
{/* Home */}
<SidebarGroup className="px-2">
<SidebarGroupContent className="space-y-1">
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="sidebar-home-button"
onClick={() => 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 className="w-4 h-4" />
<span>Home</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarGroupContent>
</SidebarGroup>
{/* Chat with Collapsible Sessions */}
<SidebarGroup className="px-2">
<SidebarGroupContent className="space-y-1">
<Collapsible open={isChatExpanded} onOpenChange={setIsChatExpanded}>
<div className="sidebar-item">
<SidebarMenuItem>
<div className="flex items-center w-full">
<SidebarMenuButton
data-testid="sidebar-new-chat-button"
onClick={handleNewChat}
tooltip="Start a new chat"
className="flex-1 justify-start px-3 rounded-lg h-fit hover:bg-background-tertiary/50 transition-all duration-200"
>
<MessageSquarePlus className="w-4 h-4" />
<span>Chat</span>
</SidebarMenuButton>
{recentSessions.length > 0 && (
<CollapsibleTrigger asChild>
<button
className="flex items-center justify-center w-6 h-8 hover:bg-background-tertiary/50 rounded-md transition-colors"
aria-label={
isChatExpanded ? 'Collapse chat sessions' : 'Expand chat sessions'
}
>
<ChevronRight
className={`w-4 h-4 text-text-secondary transition-transform duration-200 ${
isChatExpanded ? 'rotate-90' : ''
}`}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</SidebarMenuItem>
</div>
{recentSessions.length > 0 && (
<CollapsibleContent className="overflow-hidden transition-all data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0">
<div className="mt-1 space-y-1">
<SessionList
sessions={recentSessions}
activeSessionId={activeSessionId}
getSessionStatus={getSessionStatus}
onSessionClick={handleSessionClick}
/>
{/* View All Link */}
<button
onClick={handleViewAllClick}
className="w-full text-left px-3 py-1.5 rounded-md text-sm text-text-secondary hover:bg-background-tertiary/50 hover:text-text-primary transition-colors flex items-center gap-2"
>
<History className="w-4 h-4" />
<span>View All</span>
</button>
</div>
</CollapsibleContent>
)}
</Collapsible>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))}
</SidebarMenu>
</SidebarContent>
</>
);
};
export default AppSidebar;
@@ -21,12 +21,21 @@ const EnvironmentBadge: React.FC<EnvironmentBadgeProps> = ({ className = '' }) =
<Tooltip>
<TooltipTrigger asChild>
<div
className={`${bgColor} w-2 h-2 rounded-full cursor-default ${className}`}
className={`relative cursor-default no-drag ${className}`}
data-testid="environment-badge"
aria-label={tooltipText}
/>
>
<div className="absolute -inset-1" />
<div className={`${bgColor} w-2 h-2 rounded-full`} />
</div>
</TooltipTrigger>
<TooltipContent side="right">{tooltipText}</TooltipContent>
<TooltipContent
side="bottom"
className={bgColor}
arrowClassName={isAlpha ? 'fill-purple-600 bg-purple-600' : 'fill-orange-400 bg-orange-400'}
>
{tooltipText}
</TooltipContent>
</Tooltip>
);
};
@@ -1,2 +0,0 @@
export { default as AppSidebar } from './AppSidebar';
export { default as EnvironmentBadge } from './EnvironmentBadge';
+161 -89
View File
@@ -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<AppLayoutContentProps> = ({ 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
// 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]);
// Calculate padding based on macOS traffic lights
const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4';
// const headerPadding = '';
// Hide buttons when mobile sheet is showing
const shouldHideButtons = isMobile && openMobile;
// Determine flex direction based on navigation position (for push mode)
const getLayoutClass = () => {
if (effectiveNavigationMode === 'overlay') {
return 'flex-row';
}
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;
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:
navigate('/');
return 'flex-row';
}
};
const handleSelectSession = async (sessionId: string) => {
// Navigate to chat with session data
navigate('/', { state: { sessionId } });
};
const handleNewWindow = () => {
window.electron.createChatWindow({
dir: window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined,
});
};
return (
<div className="flex flex-1 w-full min-h-0 relative animate-fade-in">
{!shouldHideButtons && (
<div className={`${headerPadding} absolute top-3 z-100 flex items-center`}>
<SidebarTrigger
className={`no-drag hover:border-border-secondary hover:text-text-primary hover:!bg-background-tertiary hover:scale-105`}
/>
<Button
onClick={handleNewWindow}
className="no-drag hover:!bg-background-tertiary"
variant="ghost"
size="xs"
title="Start a new session in a new window"
>
{safeIsMacOS ? <AppWindowMac className="w-4 h-4" /> : <AppWindow className="w-4 h-4" />}
</Button>
</div>
)}
<Sidebar variant="inset" collapsible="offcanvas">
<AppSidebar
onSelectSession={handleSelectSession}
setView={setView}
currentPath={location.pathname}
/>
</Sidebar>
<SidebarInset>
// Main content area
const mainContent = (
<div className="flex-1 overflow-hidden min-h-0">
<div className="h-full w-full bg-background-primary rounded-lg overflow-hidden">
<Outlet />
{/* Always render ChatSessionsContainer to keep SSE connections alive.
When navigating away from /pair */}
When navigating away from /pair, hide it with CSS */}
<div className={isOnPairRoute ? 'contents' : 'hidden'}>
<ChatSessionsContainer setChat={setChat} activeSessions={activeSessions} />
</div>
</SidebarInset>
</div>
</div>
);
return (
<div
className={cn(
'flex flex-1 w-full h-full relative animate-fade-in bg-background-secondary',
getLayoutClass()
)}
>
{/* Header controls */}
<div
style={{ zIndex: Z_INDEX.HEADER }}
className={cn(
'absolute flex items-center gap-1',
effectiveNavigationStyle === 'condensed' &&
navigationPosition === 'bottom' &&
effectiveNavigationMode === 'push'
? 'bottom-4 right-6'
: cn(
headerPadding,
'top-[11px]',
navigationPosition === 'right' ? 'right-6 left-auto' : 'ml-1.5'
)
)}
>
{/* Navigation trigger */}
<Button
onClick={() => setIsNavExpanded(!isNavExpanded)}
className="no-drag hover:!bg-background-tertiary"
variant="ghost"
size="xs"
title={isNavExpanded ? 'Close navigation' : 'Open navigation'}
>
<Menu className="w-5 h-5" />
</Button>
</div>
{/* Main content with navigation */}
<div className={cn('flex flex-1 w-full h-full min-h-0 p-[2px]', getLayoutClass())}>
{/* Push mode navigation (inline) with animation */}
{effectiveNavigationMode === 'push' && (
<motion.div
key="push-nav"
initial={false}
animate={{
width: isHorizontalNav
? '100%'
: isNavExpanded
? effectiveNavigationStyle === 'expanded'
? '30%'
: isCondensedIconOnly
? NAV_DIMENSIONS.CONDENSED_ICON_ONLY_WIDTH
: NAV_DIMENSIONS.CONDENSED_WIDTH
: 0,
height: isHorizontalNav
? isNavExpanded
? effectiveNavigationStyle === 'expanded'
? NAV_DIMENSIONS.EXPANDED_HEIGHT
: NAV_DIMENSIONS.CONDENSED_HEIGHT
: 0
: '100%',
}}
transition={{
type: 'spring',
stiffness: 400,
damping: 40,
}}
style={{
maxWidth:
!isHorizontalNav && effectiveNavigationStyle === 'expanded' ? '400px' : undefined,
minWidth:
!isHorizontalNav && effectiveNavigationStyle === 'condensed' && isNavExpanded
? isCondensedIconOnly
? NAV_DIMENSIONS.CONDENSED_ICON_ONLY_WIDTH
: NAV_DIMENSIONS.CONDENSED_WIDTH
: undefined,
minHeight:
isHorizontalNav && isNavExpanded
? effectiveNavigationStyle === 'expanded'
? NAV_DIMENSIONS.EXPANDED_HEIGHT
: NAV_DIMENSIONS.CONDENSED_HEIGHT
: undefined,
height: !isHorizontalNav ? '100%' : undefined,
}}
className={cn(
'flex-shrink-0',
effectiveNavigationStyle === 'condensed' && !isHorizontalNav
? 'overflow-visible'
: 'overflow-hidden',
isHorizontalNav ? 'w-full' : 'h-full'
)}
>
<Navigation />
</motion.div>
)}
{/* Main content */}
{mainContent}
</div>
{/* Overlay mode navigation */}
{effectiveNavigationMode === 'overlay' && <Navigation />}
</div>
);
};
@@ -135,8 +207,8 @@ interface AppLayoutProps {
export const AppLayout: React.FC<AppLayoutProps> = ({ activeSessions }) => {
return (
<SidebarProvider>
<NavigationProvider>
<AppLayoutContent activeSessions={activeSessions} />
</SidebarProvider>
</NavigationProvider>
);
};
@@ -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<NavigationRendererProps> = ({
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 (
<motion.div
ref={navFocusRef}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'bg-app outline-none',
isOverlayMode && 'rounded-xl backdrop-blur-md shadow-lg p-2',
isVertical ? 'flex flex-col gap-[2px] h-full' : 'flex flex-row items-stretch gap-[2px]',
!isOverlayMode && navigationPosition === 'left' && !isCondensedIconOnly && 'pr-[2px]',
!isOverlayMode && navigationPosition === 'right' && !isCondensedIconOnly && 'pl-[2px]',
!isOverlayMode && isTopPosition && 'pb-[2px] pt-0',
!isOverlayMode && isBottomPosition && 'pt-[2px] pb-0',
!isCondensedIconOnly && 'overflow-visible',
className
)}
>
{/* Top spacer (vertical only) */}
{isVertical && (
<div
className={cn(
'bg-background-primary rounded-lg flex-shrink-0',
isCondensedIconOnly ? 'h-[80px] w-[40px]' : 'h-[48px] w-full'
)}
/>
)}
{/* Left spacer (horizontal top position only) */}
{!isVertical && isTopPosition && (
<div className="bg-background-primary rounded-lg self-stretch w-[160px] flex-shrink-0" />
)}
{/* Navigation items */}
{isVertical ? (
<div className="flex-1 min-h-0 flex flex-col gap-[2px]">
{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 (
<motion.div
key={item.id}
draggable
onDragStart={(e) => 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'
)}
>
<div
className={cn(
'flex flex-col',
isCondensedIconOnly ? 'items-start' : 'w-full',
isChatItem && !isCondensedIconOnly && 'overflow-visible'
)}
>
{/* Chat item with dropdown in icon-only mode */}
{isChatItem && isCondensedIconOnly ? (
<DropdownMenu open={chatPopoverOpen} onOpenChange={setChatPopoverOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center justify-center',
'rounded-lg transition-colors duration-200 no-drag',
'p-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<Icon className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<ChatSessionsDropdown
sessions={recentSessions}
activeSessionId={activeSessionId}
side={navigationPosition === 'left' ? 'right' : 'left'}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onNewChat={onNewChat}
onSessionClick={onSessionClick}
onShowAll={() => onNavClick('/sessions')}
/>
</DropdownMenu>
) : (
<>
{isChatItem && !isCondensedIconOnly ? (
<div className="relative">
<motion.button
onClick={onToggleChatExpanded}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
'flex flex-row items-center gap-2 outline-none',
'relative rounded-lg transition-colors duration-200 no-drag',
'w-full pl-2 pr-4 py-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left flex-1">
{item.label}
</span>
<div className="flex-shrink-0">
{isChatExpanded ? (
<ChevronDown className="w-3 h-3 text-text-secondary" />
) : (
<ChevronRight className="w-3 h-3 text-text-secondary" />
)}
</div>
</motion.button>
{!isChatExpanded && (
<motion.button
onClick={(e) => {
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"
>
<Plus className="w-4 h-4" />
</motion.button>
)}
</div>
) : (
<motion.button
onClick={() => 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 && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
)}
<Icon className="w-5 h-5 flex-shrink-0" />
{!isCondensedIconOnly && (
<span className="text-sm font-medium text-left flex-1">
{item.label}
</span>
)}
{!isCondensedIconOnly && item.getTag && (
<div className="flex items-center gap-1 flex-shrink-0">
<span
className={cn(
'text-xs font-mono px-2 py-0.5 rounded-full',
active
? 'bg-background-primary/20 text-text-inverse/80'
: 'bg-background-secondary text-text-secondary'
)}
>
{item.getTag()}
</span>
</div>
)}
</motion.button>
)}
</>
)}
{isChatItem && !isCondensedIconOnly && (
<SessionsList
sessions={recentSessions}
activeSessionId={activeSessionId}
isExpanded={isChatExpanded}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onSessionClick={onSessionClick}
onSessionRenamed={onFetchSessions}
onNewChat={onNewChat}
onShowAll={() => onNavClick('/sessions')}
/>
)}
</div>
</motion.div>
);
})}
<div
className={cn(
'bg-background-primary rounded-lg flex-1 min-h-[40px]',
isCondensedIconOnly ? 'w-[40px]' : 'w-full'
)}
/>
</div>
) : (
/* 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 (
<motion.div
key={item.id}
draggable
onDragStart={(e) => 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'
)}
>
<div className="flex flex-col">
{isChatItem ? (
<DropdownMenu open={chatPopoverOpen} onOpenChange={setChatPopoverOpen}>
<DropdownMenuTrigger asChild>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
'flex flex-row items-center justify-center gap-2',
'relative rounded-lg transition-colors duration-200 no-drag',
'px-3 py-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
</span>
</motion.button>
</DropdownMenuTrigger>
<ChatSessionsDropdown
sessions={recentSessions}
activeSessionId={activeSessionId}
side={isTopPosition ? 'bottom' : 'top'}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onNewChat={onNewChat}
onSessionClick={onSessionClick}
onShowAll={() => onNavClick('/sessions')}
/>
</DropdownMenu>
) : (
<motion.button
onClick={() => 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'
)}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
</span>
</motion.button>
)}
</div>
</motion.div>
);
})
)}
{/* Right spacer (horizontal only) */}
{!isVertical && (
<div
className="bg-background-primary rounded-lg self-stretch flex-1 min-w-[40px]"
style={
!isOverlayMode && isTopPosition
? ({ WebkitAppRegion: 'drag' } as React.CSSProperties)
: undefined
}
/>
)}
</motion.div>
);
};
@@ -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<NavigationRendererProps> = ({
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<HTMLDivElement>(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 = (
<motion.div
ref={navFocusRef}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'bg-app h-full overflow-hidden outline-none',
isOverlayMode && 'backdrop-blur-md shadow-2xl rounded-lg p-4',
!isOverlayMode && navigationPosition === 'top' && 'pb-[2px]',
!isOverlayMode && navigationPosition === 'bottom' && 'pt-[2px]',
!isOverlayMode && navigationPosition === 'left' && 'pr-[2px]',
!isOverlayMode && navigationPosition === 'right' && 'pl-[2px]',
className
)}
>
{showContent ? (
<div
ref={gridRef}
className={cn(
'grid gap-[2px] overflow-y-auto overflow-x-hidden h-full',
isOverlayMode && 'gap-3'
)}
style={{
...(dragStyle || {}),
gridTemplateColumns: isOverlayMode
? 'repeat(auto-fit, minmax(120px, 1fr))'
: navigationPosition === 'left' || navigationPosition === 'right'
? 'repeat(auto-fit, minmax(140px, 1fr))'
: 'repeat(auto-fit, minmax(160px, 1fr))',
alignContent: 'start',
}}
>
{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 (
<DropdownMenu
key={item.id}
open={chatDropdownOpen}
onOpenChange={setChatDropdownOpen}
>
<motion.div
draggable
onDragStart={(e) => 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'
)}
>
<div className="relative">
<DropdownMenuTrigger asChild>
<motion.div
className={cn(
'w-full relative flex flex-col rounded-lg',
'transition-colors duration-200 aspect-square cursor-pointer',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<div className="flex-1 flex flex-col items-start justify-between p-5 no-drag text-left">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
{item.getTag && (
<div
className={cn(
'absolute top-3 px-2 py-1 rounded-full',
item.tagAlign === 'left' ? 'left-8' : 'right-8',
'bg-background-secondary'
)}
>
<span className="text-xs font-mono text-text-secondary">
{item.getTag()}
</span>
</div>
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
</div>
</div>
</motion.div>
</DropdownMenuTrigger>
</div>
<ChatSessionsDropdown
sessions={recentSessions}
activeSessionId={activeSessionId}
side="right"
zIndex={Z_INDEX.DROPDOWN_ABOVE_OVERLAY}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onNewChat={onNewChat}
onSessionClick={onSessionClick}
onShowAll={() => onNavClick('/sessions')}
/>
</motion.div>
</DropdownMenu>
);
}
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => 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'
)}
>
<motion.div
className={cn(
'w-full relative flex flex-col rounded-lg',
'transition-colors duration-200 aspect-square',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<button
onClick={() => onNavClick(item.path)}
className="flex-1 flex flex-col items-start justify-between p-5 no-drag text-left"
>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
{item.getTag && (
<div
className={cn(
'absolute top-3 px-2 py-1 rounded-full',
item.tagAlign === 'left' ? 'left-8' : 'right-8',
'bg-background-secondary'
)}
>
<span className="text-xs font-mono text-text-secondary">
{item.getTag()}
</span>
</div>
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
</div>
</button>
</motion.div>
</motion.div>
);
})}
{/* 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) => (
<div key={`spacer-${index}`} className="relative">
<div className="w-full aspect-square rounded-lg bg-background-primary" />
</div>
))}
</div>
) : null}
</motion.div>
);
// Expanded overlay uses its own AnimatePresence
if (isOverlayMode) {
return (
<AnimatePresence>
{isNavExpanded && (
<div className="fixed inset-0" style={{ zIndex: Z_INDEX.OVERLAY }}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-0 overflow-y-auto pointer-events-none">
<div className="min-h-full flex items-center justify-center p-8">
<div className="pointer-events-auto max-w-3xl w-full">{navContent}</div>
</div>
</div>
</div>
)}
</AnimatePresence>
);
}
return navContent;
};
@@ -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<NavigationContextValue | null>(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<NavigationProviderProps> = ({ children }) => {
const [isNavExpanded, setIsNavExpandedState] = useState<boolean>(() => {
const stored = localStorage.getItem('navigation_expanded');
return stored !== 'false';
});
const [isBelowBreakpoint, setIsBelowBreakpoint] = useState<boolean>(
() => window.innerWidth < RESPONSIVE_BREAKPOINT
);
const [navigationMode, setNavigationModeState] = useState<NavigationMode>(() => {
const stored = localStorage.getItem('navigation_mode');
return (stored as NavigationMode) || 'push';
});
const [navigationStyle, setNavigationStyleState] = useState<NavigationStyle>(() => {
const stored = localStorage.getItem('navigation_style');
return (stored as NavigationStyle) || 'condensed';
});
const [navigationPosition, setNavigationPositionState] = useState<NavigationPosition>(() => {
const stored = localStorage.getItem('navigation_position');
return (stored as NavigationPosition) || 'left';
});
const [preferences, setPreferences] = useState<NavigationPreferences>(() => {
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<boolean>(() => {
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 <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
};
@@ -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<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(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<Map<string, SessionStatus>>(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<HTMLDivElement>(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' ? (
<ExpandedRenderer {...rendererProps} />
) : (
<CondensedRenderer {...rendererProps} />
);
if (isOverlayMode) {
if (effectiveNavigationStyle === 'expanded') {
// Expanded overlay uses its own AnimatePresence layout
return content;
}
return (
<NavigationOverlay
isOpen={isNavExpanded}
position={navigationPosition}
onClose={() => setIsNavExpanded(false)}
>
{content}
</NavigationOverlay>
);
}
if (!isNavExpanded) return null;
return content;
};
@@ -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;
@@ -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<ChatSessionsDropdownProps> = ({
sessions,
activeSessionId,
side = 'right',
zIndex,
getSessionStatus,
clearUnread,
onNewChat,
onSessionClick,
onShowAll,
}) => {
return (
<DropdownMenuContent
className="w-64 p-1 bg-background-primary border-border-secondary rounded-lg shadow-lg"
side={side}
align="start"
sideOffset={8}
style={zIndex ? { zIndex } : undefined}
>
<DropdownMenuItem
onClick={onNewChat}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer"
>
<Plus className="w-4 h-4 flex-shrink-0" />
<span>New Chat</span>
</DropdownMenuItem>
{sessions.length > 0 && <DropdownMenuSeparator className="my-1" />}
{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 (
<DropdownMenuItem
key={session.id}
onClick={() => {
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 ? (
<ChefHat className="w-4 h-4 flex-shrink-0 text-text-secondary" />
) : (
<MessageSquare className="w-4 h-4 flex-shrink-0 text-text-secondary" />
)}
<span className="truncate flex-1">
{truncateMessage(getSessionDisplayName(session), 30)}
</span>
<SessionIndicators
isStreaming={isStreaming}
hasUnread={hasUnread}
hasError={hasError}
/>
</DropdownMenuItem>
);
})}
{sessions.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={onShowAll}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer text-text-secondary"
>
<History className="w-4 h-4 flex-shrink-0" />
<span>Show All</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
);
};
@@ -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<NavigationOverlayProps> = ({
isOpen,
position,
onClose,
children,
}) => {
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0" style={{ zIndex: Z_INDEX.OVERLAY }}>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
{/* Scrollable container for navigation panel */}
<div className="absolute inset-0 overflow-y-auto pointer-events-none">
<div
className={cn(
'min-h-full flex p-4',
position === 'top' && 'items-start justify-center pt-16',
position === 'bottom' && 'items-end justify-center pb-8',
position === 'left' && 'items-center justify-start pl-4',
position === 'right' && 'items-center justify-end pr-4'
)}
>
<div className="pointer-events-auto">{children}</div>
</div>
</div>
</div>
)}
</AnimatePresence>
);
};
@@ -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<SessionsListProps> = ({
sessions,
activeSessionId,
isExpanded,
getSessionStatus,
clearUnread,
onSessionClick,
onSessionRenamed,
onNewChat,
onShowAll,
}) => {
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const handleSaveSessionName = useCallback(
async (sessionId: string, newName: string) => {
await updateSessionName({
path: { session_id: sessionId },
body: { name: newName },
});
onSessionRenamed?.();
},
[onSessionRenamed]
);
return (
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden mt-[2px]"
>
<div className="bg-background-primary rounded-lg py-1 flex flex-col gap-[2px]">
{/* New Chat button as first item */}
{onNewChat && (
<div
onClick={onNewChat}
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'
)}
>
<div className="w-4 flex-shrink-0" />
<Plus className="w-4 h-4 flex-shrink-0 text-text-secondary" />
<span className="text-text-primary">Start New Chat</span>
</div>
)}
{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 (
<div
key={session.id}
onClick={() => {
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'
)}
>
<div className="w-4 flex-shrink-0" />
{session.recipe ? (
<ChefHat className="w-4 h-4 flex-shrink-0 text-text-secondary" />
) : (
<MessageSquare className="w-4 h-4 flex-shrink-0 text-text-secondary" />
)}
<InlineEditText
value={getSessionDisplayName(session)}
onSave={(newName) => 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)}
/>
<SessionIndicators
isStreaming={isStreaming}
hasUnread={hasUnread}
hasError={hasError}
/>
</div>
);
})}
{/* Show All button at bottom */}
{onShowAll && sessions.length > 0 && (
<div
onClick={onShowAll}
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 text-text-secondary'
)}
>
<div className="w-4 flex-shrink-0" />
<History className="w-4 h-4 flex-shrink-0" />
<span>Show All</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,3 @@
export { ChatSessionsDropdown } from './ChatSessionsDropdown';
export { NavigationOverlay } from './NavigationOverlay';
export { SessionsList } from './SessionsList';
@@ -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<HTMLDivElement | null>;
}
@@ -49,7 +49,10 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
<div className={`w-full flex items-stretch ${isExpanded ? '' : 'truncate min-w-0'}`}>
{isExpanded ? (
<div>
<MarkdownContent content={value} className="font-sans text-sm text-text-secondary" />
<MarkdownContent
content={value}
className="font-sans text-sm text-text-secondary"
/>
</div>
) : (
<button
@@ -258,7 +258,10 @@ export default function CreateRecipeFromSessionModal({
Analyzing your conversation
</div>
</div>
<div className="text-text-secondary text-center max-w-md" data-testid="analysis-stage">
<div
className="text-text-secondary text-center max-w-md"
data-testid="analysis-stage"
>
{analysisStage}
</div>
<div className="flex items-center space-x-2 text-text-secondary">
@@ -99,7 +99,8 @@ Use {{parameter_name}} syntax for any user-provided values.`;
</Button>
</div>
<p className="text-xs text-text-secondary mb-3">
Use <code className="bg-background-secondary px-1 rounded">{`{{parameter_name}}`}</code>{' '}
Use{' '}
<code className="bg-background-secondary px-1 rounded">{`{{parameter_name}}`}</code>{' '}
syntax to define parameters that users can fill in
</p>
</div>
@@ -247,7 +247,9 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
<div className="h-screen w-full flex flex-col items-center justify-center bg-white dark:bg-gray-900 text-text-primary p-8">
<BackButton onClick={onNavigateBack} />
<h1 className="text-2xl font-medium text-text-primary mt-4">Schedule Not Found</h1>
<p className="text-text-secondary mt-2">No schedule ID provided. Return to schedules list.</p>
<p className="text-text-secondary mt-2">
No schedule ID provided. Return to schedules list.
</p>
</div>
);
}
@@ -494,7 +494,9 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
{!isLoading && !apiError && schedules.length === 0 && (
<div className="flex flex-col pt-4 pb-12">
<CircleDotDashed className="h-5 w-5 text-text-secondary mb-3.5" />
<p className="text-base text-text-secondary font-light mb-2">No schedules yet</p>
<p className="text-base text-text-secondary font-light mb-2">
No schedules yet
</p>
</div>
)}
@@ -345,7 +345,9 @@ export function SessionInsights() {
</div>
))
) : (
<div className="text-text-secondary text-sm py-2">No recent chat sessions found.</div>
<div className="text-text-secondary text-sm py-2">
No recent chat sessions found.
</div>
)}
</div>
</CardContent>
@@ -277,7 +277,9 @@ export default function PromptsSettingsSection() {
</span>
)}
</div>
<p className="text-sm text-text-secondary mt-0.5 truncate">{prompt.description}</p>
<p className="text-sm text-text-secondary mt-0.5 truncate">
{prompt.description}
</p>
</div>
<Button
variant="outline"
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Switch } from '../../ui/switch';
import { Button } from '../../ui/button';
import { Settings } from 'lucide-react';
import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
import UpdateSection from './UpdateSection';
import TunnelSection from '../tunnel/TunnelSection';
@@ -13,11 +13,84 @@ import BlockLogoBlack from './icons/block-lockup_black.png';
import BlockLogoWhite from './icons/block-lockup_white.png';
import TelemetrySettings from './TelemetrySettings';
import { trackSettingToggled } from '../../../utils/analytics';
import { NavigationModeSelector } from './NavigationModeSelector';
import { NavigationStyleSelector } from './NavigationStyleSelector';
import { NavigationPositionSelector } from './NavigationPositionSelector';
import { NavigationCustomizationSettings } from './NavigationCustomizationSettings';
import { NavigationProvider, useNavigationContextSafe } from '../../Layout/NavigationContext';
interface AppSettingsSectionProps {
scrollToSection?: string;
}
const NavigationSettingsContent: React.FC = () => {
const [isExpanded, setIsExpanded] = useState(false);
const navContext = useNavigationContextSafe();
const isOverlayMode = navContext?.navigationMode === 'overlay';
return (
<Card className="rounded-lg">
<CardHeader className="pb-0">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-left"
>
<div>
<CardTitle className="mb-1">Navigation</CardTitle>
<CardDescription>Customize navigation layout and behavior</CardDescription>
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-text-secondary" />
) : (
<ChevronDown className="w-5 h-5 text-text-secondary" />
)}
</button>
</CardHeader>
{isExpanded && (
<CardContent className="pt-4 px-4 space-y-6">
<div>
<h3 className="text-sm font-medium text-text-primary mb-3">Mode</h3>
<NavigationModeSelector />
</div>
{!isOverlayMode && (
<div>
<h3 className="text-sm font-medium text-text-primary mb-3">Style</h3>
<NavigationStyleSelector />
</div>
)}
{!isOverlayMode && (
<div>
<h3 className="text-sm font-medium text-text-primary mb-3">Position</h3>
<NavigationPositionSelector />
</div>
)}
<div>
<h3 className="text-sm font-medium text-text-primary mb-3">Customize Items</h3>
<NavigationCustomizationSettings />
</div>
</CardContent>
)}
</Card>
);
};
// 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 <NavigationSettingsContent />;
}
// Otherwise wrap with provider
return (
<NavigationProvider>
<NavigationSettingsContent />
</NavigationProvider>
);
};
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<HTMLDivElement>(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
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-primary text-xs">Dock icon</h3>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">Show goose in the dock</p>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
Show goose in the dock
</p>
</div>
<div className="flex items-center">
<Switch
@@ -268,6 +333,9 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</CardContent>
</Card>
{/* Navigation Settings */}
<NavigationSettingsCard />
<TunnelSection />
<TelemetrySettings isWelcome={false} />
@@ -392,7 +460,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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<string, string> = {
home: 'Home',
chat: 'Chat',
recipes: 'Recipes',
apps: 'Apps',
scheduler: 'Scheduler',
extensions: 'Extensions',
settings: 'Settings',
};
interface NavigationCustomizationSettingsProps {
className?: string;
}
export const NavigationCustomizationSettings: React.FC<NavigationCustomizationSettingsProps> = ({
className,
}) => {
const { preferences, updatePreferences } = useNavigationContext();
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(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 (
<div className={className}>
<div className="space-y-3">
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-text-secondary">
Drag to reorder, click the eye icon to show/hide items
</p>
<button
onClick={resetToDefaults}
className="text-xs text-text-secondary hover:text-text-primary transition-colors"
>
Reset to defaults
</button>
</div>
{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 (
<div
key={itemId}
draggable
onDragStart={(e) => 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'
)}
>
<GripVertical className="w-4 h-4 text-text-secondary cursor-move flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary">{label}</span>
<button
onClick={() => toggleItemEnabled(itemId)}
className="p-1 rounded hover:bg-background-tertiary transition-colors flex-shrink-0"
title={isEnabled ? 'Hide item' : 'Show item'}
>
{isEnabled ? (
<Eye className="w-4 h-4 text-text-primary" />
) : (
<EyeOff className="w-4 h-4 text-text-secondary" />
)}
</button>
</div>
);
})}
</div>
</div>
);
};
@@ -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: <Columns2 className="w-5 h-5" />,
description: 'Navigation pushes content',
},
{
value: 'overlay',
label: 'Overlay',
icon: <Layers className="w-5 h-5" />,
description: 'Full-screen overlay',
},
];
export const NavigationModeSelector: React.FC<NavigationModeSelectorProps> = ({ className }) => {
const { navigationMode, setNavigationMode } = useNavigationContext();
return (
<div className={className}>
<div className="grid grid-cols-2 gap-3">
{modes.map((mode) => (
<button
key={mode.value}
onClick={() => 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'
)}
>
<div className="text-text-primary">{mode.icon}</div>
<div className="text-center">
<div className="text-sm font-medium text-text-primary">{mode.label}</div>
<div className="text-xs text-text-secondary mt-1">{mode.description}</div>
</div>
</button>
))}
</div>
</div>
);
};
@@ -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: <ArrowUp className="w-5 h-5" /> },
{ value: 'bottom', label: 'Bottom', icon: <ArrowDown className="w-5 h-5" /> },
{ value: 'left', label: 'Left', icon: <ArrowLeft className="w-5 h-5" /> },
{ value: 'right', label: 'Right', icon: <ArrowRight className="w-5 h-5" /> },
];
export const NavigationPositionSelector: React.FC<NavigationPositionSelectorProps> = ({
className,
}) => {
const { navigationPosition, setNavigationPosition } = useNavigationContext();
return (
<div className={className}>
<div className="grid grid-cols-4 gap-3">
{positions.map((position) => (
<button
key={position.value}
onClick={() => 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'
)}
>
<div className="text-text-primary">{position.icon}</div>
<div className="text-xs font-medium text-text-primary">{position.label}</div>
</button>
))}
</div>
</div>
);
};
@@ -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: <LayoutGrid className="w-5 h-5" />,
description: 'Enlarged tile view',
},
{
value: 'condensed',
label: 'List',
icon: <List className="w-5 h-5" />,
description: 'Classic condensed view',
},
];
export const NavigationStyleSelector: React.FC<NavigationStyleSelectorProps> = ({ className }) => {
const { navigationStyle, setNavigationStyle } = useNavigationContext();
return (
<div className={className}>
<div className="grid grid-cols-2 gap-3">
{styles.map((style) => (
<button
key={style.value}
onClick={() => 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'
)}
>
<div className="text-text-primary">{style.icon}</div>
<div className="text-center">
<div className="text-sm font-medium text-text-primary">{style.label}</div>
<div className="text-xs text-text-secondary mt-1">{style.description}</div>
</div>
</button>
))}
</div>
</div>
);
};
@@ -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;
@@ -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<keyof KeyboardShortcuts>([
@@ -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(() => {
@@ -59,7 +59,9 @@ export const ModeSelectionItem = forwardRef<HTMLDivElement, ModeSelectionItemPro
<div className="flex">
<div>
<h3 className="text-text-primary">{mode.label}</h3>
{showDescription && <p className="text-text-secondary mt-[2px]">{mode.description}</p>}
{showDescription && (
<p className="text-text-secondary mt-[2px]">{mode.description}</p>
)}
</div>
</div>
@@ -225,7 +225,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className={`text-sm ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<label
className={`text-sm ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Lead Model
</label>
{isLeadCustomModel && (
@@ -270,14 +272,18 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
disabled={!isEnabled}
/>
)}
<p className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<p
className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Strong model for initial planning and fallback recovery
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className={`text-sm ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<label
className={`text-sm ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Worker Model
</label>
{isWorkerCustomModel && (
@@ -324,7 +330,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
disabled={!isEnabled}
/>
)}
<p className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<p
className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Fast model for routine execution tasks
</p>
</div>
@@ -347,7 +355,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
className={`w-20 ${!isEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={!isEnabled}
/>
<p className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<p
className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Number of turns to use the lead model at the start
</p>
</div>
@@ -367,7 +377,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
className={`w-20 ${!isEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={!isEnabled}
/>
<p className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<p
className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Consecutive failures before switching back to lead
</p>
</div>
@@ -387,7 +399,9 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
className={`w-20 ${!isEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={!isEnabled}
/>
<p className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}>
<p
className={`text-xs ${!isEnabled ? 'text-text-secondary' : 'text-text-secondary'}`}
>
Turns to use lead model during fallback
</p>
</div>
+9 -3
View File
@@ -30,23 +30,29 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
function TooltipContent({
className,
arrowClassName,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & { arrowClassName?: string }) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-background-inverse text-text-inverse animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
'bg-background-inverse text-text-inverse animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[200] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-background-inverse fill-background-inverse z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45" />
<TooltipPrimitive.Arrow
className={cn(
'bg-background-inverse fill-background-inverse z-[200] size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45',
arrowClassName
)}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
+2 -1
View File
@@ -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',
},
-711
View File
@@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh min-h-0 w-full overflow-hidden',
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
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 (
<div
data-slot="sidebar"
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) px-4 py-0 [&>button]:hidden !w-fit !max-w-none"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col overflow-hidden">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-300 ease-out will-change-[width]',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-transform duration-300 ease-out will-change-transform md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:translate-x-[-100%]'
: 'right-0 group-data-[collapsible=offcanvas]:translate-x-[100%]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'py-2 pl-2 pr-0 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
style={{
willChange: state === 'collapsed' || state === 'expanded' ? 'transform' : 'auto',
}}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar flex h-full w-full flex-col overflow-hidden group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:border"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="xs"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'hover:after:border-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col min-w-0 min-h-0',
// For inset variant (used in the app)
'md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
// For offcanvas variant - ensure content doesn't go under sidebar
'md:peer-data-[collapsible=offcanvas]:peer-data-[state=expanded]:ml-[var(--sidebar-width)]',
'md:peer-data-[collapsible=offcanvas]:peer-data-[state=collapsed]:ml-0',
// Smooth transition when sidebar state changes
'transition-[margin-left] duration-300 ease-out',
className
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 px-2 pt-1', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('bg-border-secondary ml-5 my-2 !w-8', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden sidebar-scrollbar',
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>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 (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>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 (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
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<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state, setOpenMobile } = useSidebar();
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// 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 = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
onClick={handleClick}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>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 (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'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',
className
)}
{...props}
/>
);
}
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 (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
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 (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>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,
};
@@ -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);
}
@@ -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<Session[]>([]);
const sessionsRef = useRef<Session[]>([]);
const lastSessionIdRef = useRef<string | null>(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<typeof setTimeout>[] = [];
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;
}
@@ -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<Map<string, SessionStatus>>(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 };
}
+17
View File
@@ -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
+6 -1
View File
@@ -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<void> => {
-1
View File
@@ -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;
}
+3 -7
View File
@@ -24,9 +24,7 @@ type ThemeTokens = Record<McpUiStyleVariableKey, string>;
// 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<McpUiStyleVariableKey, BaseTokenKey>;
@@ -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)',
};
// ---------------------------------------------------------------------------
+3 -1
View File
@@ -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 };
}