{/* Active indicator bar */}
+ );
+
+ if (item.path === "/ai") {
+ if (!isLgUp) {
+ return (
+
+ {inner}
+
+ );
+ }
+ return (
+
{
+ if (location === "/ai") return;
+ setAiDrawerOpen((o) => !o);
+ }}
+ onKeyDown={(e) => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ if (location === "/ai") return;
+ setAiDrawerOpen((o) => !o);
+ }}
+ >
+ {inner}
+
+ );
+ }
+
+ return (
+
+ {inner}
);
})}
diff --git a/client/src/components/DesktopAIShell.tsx b/client/src/components/DesktopAIShell.tsx
new file mode 100644
index 0000000..c961d7b
--- /dev/null
+++ b/client/src/components/DesktopAIShell.tsx
@@ -0,0 +1,121 @@
+import AIPage from "@/pages/AIPage";
+import { useAIDrawer } from "@/contexts/AIDrawerContext";
+import { useIsLgUp } from "@/hooks/useIsLgUp";
+import { cn } from "@/lib/utils";
+import { useLocation } from "wouter";
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ type ReactNode,
+ type RefObject,
+} from "react";
+
+function SplitDivider({
+ containerRef,
+ onResize,
+}: {
+ containerRef: RefObject
;
+ onResize: (aiPercent: number) => void;
+}) {
+ const dragging = useRef(false);
+
+ const onMove = useCallback(
+ (e: MouseEvent) => {
+ if (!dragging.current || !containerRef.current) return;
+ const r = containerRef.current.getBoundingClientRect();
+ const w = r.width;
+ if (w <= 0) return;
+ const aiPct = ((r.right - e.clientX) / w) * 100;
+ onResize(aiPct);
+ },
+ [containerRef, onResize],
+ );
+
+ useEffect(() => {
+ const onUp = () => {
+ dragging.current = false;
+ };
+ window.addEventListener("mouseup", onUp);
+ window.addEventListener("mousemove", onMove);
+ return () => {
+ window.removeEventListener("mouseup", onUp);
+ window.removeEventListener("mousemove", onMove);
+ };
+ }, [onMove]);
+
+ return (
+ {
+ dragging.current = true;
+ }}
+ role="separator"
+ aria-orientation="vertical"
+ aria-label="Resize panels"
+ >
+
+
+ );
+}
+
+/**
+ * lg+: optional side-by-side AI panel with draggable divider. Mobile unchanged (router only).
+ */
+export default function DesktopAIShell({ children }: { children: ReactNode }) {
+ const isLgUp = useIsLgUp();
+ const [location] = useLocation();
+ const { open, setOpen, splitPercent, setSplitPercent } = useAIDrawer();
+ const containerRef = useRef(null);
+
+ const showSplit = Boolean(isLgUp && open && location !== "/ai");
+
+ useEffect(() => {
+ if (location === "/ai" && isLgUp) setOpen(false);
+ }, [location, isLgUp, setOpen]);
+
+ return (
+
+
+ {children}
+
+ {showSplit && (
+ <>
+
setSplitPercent(aiPct)}
+ />
+
+ >
+ )}
+
+ );
+}
diff --git a/client/src/contexts/AIDrawerContext.tsx b/client/src/contexts/AIDrawerContext.tsx
new file mode 100644
index 0000000..1ef8ffa
--- /dev/null
+++ b/client/src/contexts/AIDrawerContext.tsx
@@ -0,0 +1,78 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+
+const SPLIT_STORAGE_KEY = "luxsin-ai-split-pct";
+const SPLIT_MIN = 28;
+const SPLIT_MAX = 72;
+
+function clampPct(n: number) {
+ return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, Math.round(n)));
+}
+
+function loadSplitPct(): number {
+ if (typeof window === "undefined") return 50;
+ try {
+ const v = sessionStorage.getItem(SPLIT_STORAGE_KEY);
+ if (v) {
+ const n = parseInt(v, 10);
+ if (!Number.isNaN(n)) return clampPct(n);
+ }
+ } catch {
+ /* ignore */
+ }
+ return 50;
+}
+
+type AIDrawerContextValue = {
+ /** Desktop split panel visible (only used when lg+ and route !== /ai) */
+ open: boolean;
+ setOpen: Dispatch>;
+ toggle: () => void;
+ /** Width share of the AI column (percent), used when split view is shown */
+ splitPercent: number;
+ setSplitPercent: (pct: number) => void;
+};
+
+const AIDrawerContext = createContext(null);
+
+export function AIDrawerProvider({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ const [splitPercent, setSplitPercentState] = useState(loadSplitPct);
+
+ const toggle = useCallback(() => setOpen((o) => !o), []);
+
+ const setSplitPercent = useCallback((pct: number) => {
+ const next = clampPct(pct);
+ setSplitPercentState(next);
+ try {
+ sessionStorage.setItem(SPLIT_STORAGE_KEY, String(next));
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ open, setOpen, toggle, splitPercent, setSplitPercent }),
+ [open, splitPercent, setSplitPercent, toggle],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useAIDrawer() {
+ const ctx = useContext(AIDrawerContext);
+ if (!ctx) {
+ throw new Error("useAIDrawer must be used within AIDrawerProvider");
+ }
+ return ctx;
+}
diff --git a/client/src/hooks/useIsLgUp.ts b/client/src/hooks/useIsLgUp.ts
new file mode 100644
index 0000000..29c5fd7
--- /dev/null
+++ b/client/src/hooks/useIsLgUp.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+
+/** Matches Tailwind `lg:` (1024px). Used for desktop-only UI such as the AI split panel. */
+export function useIsLgUp() {
+ const [lgUp, setLgUp] = useState(
+ () =>
+ typeof window !== "undefined" &&
+ window.matchMedia("(min-width: 1024px)").matches,
+ );
+
+ useEffect(() => {
+ const mql = window.matchMedia("(min-width: 1024px)");
+ const sync = () => setLgUp(mql.matches);
+ mql.addEventListener("change", sync);
+ sync();
+ return () => mql.removeEventListener("change", sync);
+ }, []);
+
+ return lgUp;
+}
diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json
index 8670181..2854e31 100644
--- a/client/src/locales/data-en.json
+++ b/client/src/locales/data-en.json
@@ -506,6 +506,8 @@
"ariaNewChat": "New chat",
"ariaHistory": "History",
"ariaClose": "Close",
+ "ariaFullPage": "Open full screen",
+ "ariaHidePanel": "Hide AI panel",
"clearCurrent": "Clear this chat",
"cleared": "Current chat cleared",
"clearFailed": "Clear failed: {message}",
diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json
index ecef9d2..27293f8 100644
--- a/client/src/locales/data-zh-HK.json
+++ b/client/src/locales/data-zh-HK.json
@@ -320,6 +320,8 @@
"ariaNewChat": "新增會話",
"ariaHistory": "歷史會話",
"ariaClose": "關閉",
+ "ariaFullPage": "全螢幕開啟",
+ "ariaHidePanel": "收起 AI 面板",
"clearCurrent": "清空目前會話",
"cleared": "已清空目前會話",
"clearFailed": "清空失敗:{message}",
diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json
index 3774cb6..68ad70c 100644
--- a/client/src/locales/data-zh.json
+++ b/client/src/locales/data-zh.json
@@ -321,6 +321,8 @@
"ariaNewChat": "新建会话",
"ariaHistory": "历史会话",
"ariaClose": "关闭",
+ "ariaFullPage": "全屏打开",
+ "ariaHidePanel": "收起 AI 面板",
"clearCurrent": "清空当前会话",
"cleared": "已清空当前会话",
"clearFailed": "清空失败:{message}",
diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx
index 647f08e..43f84d4 100644
--- a/client/src/pages/AIPage.tsx
+++ b/client/src/pages/AIPage.tsx
@@ -43,7 +43,10 @@ import {
RotateCcw,
Loader2,
Trash2,
+ Maximize2,
+ ChevronsRight,
} from "lucide-react";
+import { useLocation } from "wouter";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { toast } from "sonner";
@@ -134,7 +137,17 @@ function makeId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
-export default function AIPage() {
+export type AIPageVariant = "page" | "split";
+
+export interface AIPageProps {
+ variant?: AIPageVariant;
+ /** Hide side panel (desktop split) */
+ onClose?: () => void;
+}
+
+export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) {
+ const isEmbedded = variant === "split";
+ const [, setLocation] = useLocation();
const { isConnected, deviceState, api } = useDevice();
const mac = deviceState?.mac ?? "";
const language = deviceState?.language ?? 2;
@@ -610,11 +623,41 @@ export default function AIPage() {
return (
{/* ── Header ── */}
+ {isEmbedded && onClose && (
+ <>
+
+
+ >
+ )}
)}
-
+ {!isEmbedded &&
}
);
}