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:
Generated
+53
-32
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user