- add reusable `ui/sidebar` and `ui/tooltip` components for responsive navigation - optimize chat header/composer spacing and mobile-safe touch targets - add sidebar trigger in chat header and hide desktop-only controls on small screens - make session hydration reconnects silent to reduce unnecessary connecting state flicker
Unified · Split
| @@ -182,7 +182,7 @@ export default function BranchToolbar({ · 181 unmodified lines | |||
| 182 | if (!activeThread || !activeProject) return null; | 182 | if (!activeThread || !activeProject) return null; |
| 183 | 183 | ||
| 184 | return ( | 184 | return ( |
| 185 | <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1"> | 185 | <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-1 sm:px-5 sm:pb-3"> |
| 186 | <div className="flex items-center gap-2"> | 186 | <div className="flex items-center gap-2"> |
| 187 | {envLocked || activeWorktreePath ? ( | 187 | {envLocked || activeWorktreePath ? ( |
| 188 | <span className="border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs"> | 188 | <span className="border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs"> |
| @@ -119,6 +119,7 @@ import { · 118 unmodified lines | |||
| 119 | setupProjectScript, | 119 | setupProjectScript, |
| 120 | } from "~/projectScripts"; | 120 | } from "~/projectScripts"; |
| 121 | import { Toggle } from "./ui/toggle"; | 121 | import { Toggle } from "./ui/toggle"; |
| 122 | import { SidebarTrigger } from "./ui/sidebar"; | ||
| 122 | 123 | ||
| 123 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 124 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 124 | if (!duration) return formatTimestamp(createdAt); | 125 | if (!duration) return formatTimestamp(createdAt); |
| @@ -433,6 +434,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 308 unmodified lines | |||
| 433 | ); | 434 | ); |
| 434 | const phase = derivePhase(activeThread?.session ?? null); | 435 | const phase = derivePhase(activeThread?.session ?? null); |
| 435 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; | 436 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; |
| 437 | const canSend = | ||
| 438 | Boolean(api) && | ||
| 439 | Boolean(activeThread) && | ||
| 440 | !isSending && | ||
| 441 | !isConnecting && | ||
| 442 | (prompt.trim().length > 0 || composerImages.length > 0); | ||
| 436 | const nowIso = new Date(nowTick).toISOString(); | 443 | const nowIso = new Date(nowTick).toISOString(); |
| 437 | const workLogEntries = useMemo( | 444 | const workLogEntries = useMemo( |
| 438 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | 445 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| @@ -1583,7 +1590,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1144 unmodified lines | |||
| 1583 | ); | 1590 | ); |
| 1584 | 1591 | ||
| 1585 | const ensureSession = useCallback( | 1592 | const ensureSession = useCallback( |
| 1586 | async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | 1593 | async ( |
| 1594 | cwdOverride?: string, | ||
| 1595 | options?: { | ||
| 1596 | silent?: boolean; | ||
| 1597 | }, | ||
| 1598 | ): Promise<EnsuredSessionInfo | null> => { | ||
| 1587 | if (!api || !activeThread || !activeProject) return null; | 1599 | if (!api || !activeThread || !activeProject) return null; |
| 1588 | if (activeThread.session && activeThread.session.status !== "closed") { | 1600 | if (activeThread.session && activeThread.session.status !== "closed") { |
| 1589 | const sessionThreadId = activeThread.session.threadId ?? null; | 1601 | const sessionThreadId = activeThread.session.threadId ?? null; |
| @@ -1606,7 +1618,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines | |||
| 1606 | } | 1618 | } |
| 1607 | 1619 | ||
| 1608 | const priorCodexThreadId = activeThread.codexThreadId; | 1620 | const priorCodexThreadId = activeThread.codexThreadId; |
| 1609 | setIsConnecting(true); | 1621 | const shouldShowConnectingState = !options?.silent; |
| 1622 | if (shouldShowConnectingState) { | ||
| 1623 | setIsConnecting(true); | ||
| 1624 | } | ||
| 1610 | try { | 1625 | try { |
| 1611 | const session = await api.providers.startSession({ | 1626 | const session = await api.providers.startSession({ |
| 1612 | provider: "codex", | 1627 | provider: "codex", |
| @@ -1649,7 +1664,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines | |||
| 1649 | }); | 1664 | }); |
| 1650 | return null; | 1665 | return null; |
| 1651 | } finally { | 1666 | } finally { |
| 1652 | setIsConnecting(false); | 1667 | if (shouldShowConnectingState) { |
| 1668 | setIsConnecting(false); | ||
| 1669 | } | ||
| 1653 | } | 1670 | } |
| 1654 | }, | 1671 | }, |
| 1655 | [ | 1672 | [ |
| @@ -1679,7 +1696,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 23 unmodified lines | |||
| 1679 | } | 1696 | } |
| 1680 | checkpointHydrationSessionRequestRef.current.add(requestKey); | 1697 | checkpointHydrationSessionRequestRef.current.add(requestKey); |
| 1681 | setThreadError(activeThreadId, null); | 1698 | setThreadError(activeThreadId, null); |
| 1682 | void ensureSession().then( | 1699 | void ensureSession(undefined, { silent: true }).then( |
| 1683 | () => { | 1700 | () => { |
| 1684 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | 1701 | checkpointHydrationSessionRequestRef.current.delete(requestKey); |
| 1685 | }, | 1702 | }, |
| @@ -1904,6 +1921,24 @@ export default function ChatView({ threadId }: ChatViewProps) { · 218 unmodified lines | |||
| 1904 | } | 1921 | } |
| 1905 | }; | 1922 | }; |
| 1906 | 1923 | ||
| 1924 | const triggerSend = () => { | ||
| 1925 | if (!canSend) return; | ||
| 1926 | const form = textareaRef.current?.form; | ||
| 1927 | if (form) { | ||
| 1928 | if (typeof form.requestSubmit === "function") { | ||
| 1929 | form.requestSubmit(); | ||
| 1930 | } else { | ||
| 1931 | const submitEvent = new Event("submit", { bubbles: true, cancelable: true }); | ||
| 1932 | const dispatched = form.dispatchEvent(submitEvent); | ||
| 1933 | if (!dispatched) { | ||
| 1934 | return; | ||
| 1935 | } | ||
| 1936 | } | ||
| 1937 | return; | ||
| 1938 | } | ||
| 1939 | void onSend({ preventDefault: () => undefined } as FormEvent); | ||
| 1940 | }; | ||
| 1941 | |||
| 1907 | const onInterrupt = async () => { | 1942 | const onInterrupt = async () => { |
| 1908 | if (!api || !activeThread?.session) return; | 1943 | if (!api || !activeThread?.session) return; |
| 1909 | await api.providers.interruptTurn({ | 1944 | await api.providers.interruptTurn({ |
| @@ -2047,7 +2082,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 137 unmodified lines | |||
| 2047 | 2082 | ||
| 2048 | if (e.key === "Enter" && !e.shiftKey) { | 2083 | if (e.key === "Enter" && !e.shiftKey) { |
| 2049 | e.preventDefault(); | 2084 | e.preventDefault(); |
| 2050 | void onSend(e as unknown as FormEvent); | 2085 | triggerSend(); |
| 2051 | } | 2086 | } |
| 2052 | }; | 2087 | }; |
| 2053 | const onToggleWorkGroup = useCallback((groupId: string) => { | 2088 | const onToggleWorkGroup = useCallback((groupId: string) => { |
| @@ -2117,7 +2152,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 63 unmodified lines | |||
| 2117 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> | 2152 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> |
| 2118 | {/* Top bar */} | 2153 | {/* Top bar */} |
| 2119 | <header | 2154 | <header |
| 2120 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} | 2155 | className={`flex items-center justify-between border-b border-border px-3 sm:px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} |
| 2121 | > | 2156 | > |
| 2122 | <ChatHeader | 2157 | <ChatHeader |
| 2123 | activeThreadId={activeThread.id} | 2158 | activeThreadId={activeThread.id} |
| @@ -2151,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 27 unmodified lines | |||
| 2151 | {/* Messages */} | 2186 | {/* Messages */} |
| 2152 | <div | 2187 | <div |
| 2153 | ref={messagesScrollRef} | 2188 | ref={messagesScrollRef} |
| 2154 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" | 2189 | className="min-h-0 flex-1 overflow-y-auto px-3 py-3 sm:px-5 sm:py-4" |
| 2155 | onScroll={onMessagesScroll} | 2190 | onScroll={onMessagesScroll} |
| 2156 | > | 2191 | > |
| 2157 | <MessagesTimeline | 2192 | <MessagesTimeline |
| @@ -2176,7 +2211,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 18 unmodified lines | |||
| 2176 | </div> | 2211 | </div> |
| 2177 | 2212 | ||
| 2178 | {/* Input bar */} | 2213 | {/* Input bar */} |
| 2179 | <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}> | 2214 | <div |
| 2215 | className={cn( | ||
| 2216 | "px-3 pt-2 sm:px-5 max-sm:pb-[calc(0.5rem+env(safe-area-inset-bottom))]", | ||
| 2217 | isGitRepo ? "pb-1" : "pb-4", | ||
| 2218 | )} | ||
| 2219 | > | ||
| 2180 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | 2220 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> |
| 2181 | <div | 2221 | <div |
| 2182 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ | 2222 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ |
| @@ -2275,8 +2315,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 92 unmodified lines | |||
| 2275 | </div> | 2315 | </div> |
| 2276 | 2316 | ||
| 2277 | {/* Bottom toolbar */} | 2317 | {/* Bottom toolbar */} |
| 2278 | <div className="flex items-center justify-between px-3 pb-3"> | 2318 | <div className="flex items-center justify-end px-3 pb-3 sm:justify-between"> |
| 2279 | <div className="flex items-center gap-1"> | 2319 | <div className="hidden items-center gap-1 sm:flex"> |
| 2280 | {/* Model picker */} | 2320 | {/* Model picker */} |
| 2281 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> | 2321 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> |
| 2282 | 2322 | ||
| @@ -2317,7 +2357,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 34 unmodified lines | |||
| 2317 | {phase === "running" ? ( | 2357 | {phase === "running" ? ( |
| 2318 | <button | 2358 | <button |
| 2319 | type="button" | 2359 | type="button" |
| 2320 | className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105" | 2360 | className="relative z-10 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 pointer-events-auto sm:h-8 sm:w-8 sm:hover:scale-105" |
| 2321 | onClick={() => void onInterrupt()} | 2361 | onClick={() => void onInterrupt()} |
| 2322 | aria-label="Stop generation" | 2362 | aria-label="Stop generation" |
| 2323 | > | 2363 | > |
| @@ -2333,11 +2373,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines | |||
| 2333 | </button> | 2373 | </button> |
| 2334 | ) : ( | 2374 | ) : ( |
| 2335 | <button | 2375 | <button |
| 2336 | type="submit" | 2376 | type="button" |
| 2337 | className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" | 2377 | className="relative z-10 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary pointer-events-auto disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8 sm:hover:scale-105" |
| 2338 | disabled={ | 2378 | disabled={!canSend} |
| 2339 | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) | 2379 | onClick={triggerSend} |
| 2340 | } | ||
| 2341 | aria-label={ | 2380 | aria-label={ |
| 2342 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" | 2381 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |
| 2343 | } | 2382 | } |
| @@ -2387,13 +2426,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 43 unmodified lines | |||
| 2387 | </div> | 2426 | </div> |
| 2388 | 2427 | ||
| 2389 | {isGitRepo && ( | 2428 | {isGitRepo && ( |
| 2390 | <BranchToolbar | 2429 | <div className="hidden sm:block"> |
| 2391 | threadId={activeThread.id} | 2430 | <BranchToolbar |
| 2392 | envMode={envMode} | 2431 | threadId={activeThread.id} |
| 2393 | onEnvModeChange={onEnvModeChange} | 2432 | envMode={envMode} |
| 2394 | envLocked={envLocked} | 2433 | onEnvModeChange={onEnvModeChange} |
| 2395 | onComposerFocusRequest={scheduleComposerFocus} | 2434 | envLocked={envLocked} |
| 2396 | /> | 2435 | onComposerFocusRequest={scheduleComposerFocus} |
| 2436 | /> | ||
| 2437 | </div> | ||
| 2397 | )} | 2438 | )} |
| 2398 | 2439 | ||
| 2399 | {activeThread.terminalOpen && api && activeProject && ( | 2440 | {activeThread.terminalOpen && api && activeProject && ( |
| @@ -2497,28 +2538,43 @@ const ChatHeader = memo(function ChatHeader({ · 97 unmodified lines | |||
| 2497 | }: ChatHeaderProps) { | 2538 | }: ChatHeaderProps) { |
| 2498 | return ( | 2539 | return ( |
| 2499 | <> | 2540 | <> |
| 2500 | <div className="flex min-w-0 flex-1 items-center gap-3"> | 2541 | <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> |
| 2501 | <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}> | 2542 | <SidebarTrigger className="size-8 md:hidden" /> |
| 2543 | <Separator orientation="vertical" className="md:hidden data-[orientation=vertical]:h-4" /> | ||
| 2544 | <h2 | ||
| 2545 | className="truncate text-xs font-medium text-foreground sm:text-sm" | ||
| 2546 | title={activeThreadTitle} | ||
| 2547 | > | ||
| 2502 | {activeThreadTitle} | 2548 | {activeThreadTitle} |
| 2503 | </h2> | 2549 | </h2> |
| 2504 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} | 2550 | {activeProjectName && ( |
| 2551 | <Badge variant="outline" className="hidden sm:inline-flex"> | ||
| 2552 | {activeProjectName} | ||
| 2553 | </Badge> | ||
| 2554 | )} | ||
| 2505 | </div> | 2555 | </div> |
| 2506 | <div className="shrink-0 flex items-center gap-3"> | 2556 | <div className="shrink-0 flex items-center gap-1.5 sm:gap-3"> |
| 2507 | {activeProjectScripts && ( | 2557 | {activeProjectScripts && ( |
| 2508 | <ProjectScriptsControl | 2558 | <div className="hidden md:block"> |
| 2509 | scripts={activeProjectScripts} | 2559 | <ProjectScriptsControl |
| 2510 | keybindings={keybindings} | 2560 | scripts={activeProjectScripts} |
| 2511 | preferredScriptId={preferredScriptId} | 2561 | keybindings={keybindings} |
| 2512 | onRunScript={onRunProjectScript} | 2562 | preferredScriptId={preferredScriptId} |
| 2513 | onAddScript={onAddProjectScript} | 2563 | onRunScript={onRunProjectScript} |
| 2514 | onUpdateScript={onUpdateProjectScript} | 2564 | onAddScript={onAddProjectScript} |
| 2515 | /> | 2565 | onUpdateScript={onUpdateProjectScript} |
| 2566 | /> | ||
| 2567 | </div> | ||
| 2516 | )} | 2568 | )} |
| 2517 | {activeProjectName && ( | 2569 | {activeProjectName && ( |
| 2518 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> | 2570 | <div className="hidden md:block"> |
| 2571 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> | ||
| 2572 | </div> | ||
| 2519 | )} | 2573 | )} |
| 2520 | {activeProjectName && ( | 2574 | {activeProjectName && ( |
| 2521 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | 2575 | <div className="hidden md:block"> |
| 2576 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | ||
| 2577 | </div> | ||
| 2522 | )} | 2578 | )} |
| 2523 | <Toggle | 2579 | <Toggle |
| 2524 | pressed={diffOpen} | 2580 | pressed={diffOpen} |
| @@ -112,7 +112,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null · 111 unmodified lines | |||
| 112 | }; | 112 | }; |
| 113 | } | 113 | } |
| 114 | 114 | ||
| 115 | export default function Sidebar() { | 115 | interface SidebarProps { |
| 116 | embedded?: boolean; | ||
| 117 | } | ||
| 118 | |||
| 119 | export default function Sidebar({ embedded = false }: SidebarProps = {}) { | ||
| 116 | const { state, dispatch } = useStore(); | 120 | const { state, dispatch } = useStore(); |
| 117 | const api = useNativeApi(); | 121 | const api = useNativeApi(); |
| 118 | const navigate = useNavigate(); | 122 | const navigate = useNavigate(); |
| @@ -497,8 +501,12 @@ export default function Sidebar() { · 378 unmodified lines | |||
| 497 | }; | 501 | }; |
| 498 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); | 502 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); |
| 499 | 503 | ||
| 504 | const rootClassName = embedded | ||
| 505 | ? "sidebar flex h-full min-h-0 flex-col bg-card" | ||
| 506 | : "sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card max-sm:w-[36vw] max-sm:min-w-[140px] max-sm:max-w-[200px]"; | ||
| 507 | |||
| 500 | return ( | 508 | return ( |
| 501 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> | 509 | <aside className={rootClassName}> |
| 502 | {/* Branding */} | 510 | {/* Branding */} |
| 503 | <div | 511 | <div |
| 504 | className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} | 512 | className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} |
| @@ -507,7 +515,7 @@ export default function Sidebar() { · 2 unmodified lines | |||
| 507 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> | 515 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> |
| 508 | T3 <span className="font-normal text-muted-foreground">Code</span> | 516 | T3 <span className="font-normal text-muted-foreground">Code</span> |
| 509 | </span> | 517 | </span> |
| 510 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> | 518 | <span className="hidden rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60 sm:inline-block"> |
| 511 | {APP_STAGE_LABEL} | 519 | {APP_STAGE_LABEL} |
| 512 | </span> | 520 | </span> |
| 513 | </div> | 521 | </div> |
| @@ -563,7 +571,9 @@ export default function Sidebar() { · 49 unmodified lines | |||
| 563 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | 571 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 564 | {project.name} | 572 | {project.name} |
| 565 | </span> | 573 | </span> |
| 566 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | 574 | <span className="hidden text-[10px] text-muted-foreground/60 sm:inline"> |
| 575 | {threads.length} | ||
| 576 | </span> | ||
| 567 | </button> | 577 | </button> |
| 568 | 578 | ||
| 569 | {/* Threads */} | 579 | {/* Threads */} |
| @@ -627,7 +637,7 @@ export default function Sidebar() { · 57 unmodified lines | |||
| 627 | /> | 637 | /> |
| 628 | </span> | 638 | </span> |
| 629 | )} | 639 | )} |
| 630 | <span className="text-[10px] text-muted-foreground/40"> | 640 | <span className="hidden text-[10px] text-muted-foreground/40 sm:inline"> |
| 631 | {formatRelativeTime(thread.createdAt)} | 641 | {formatRelativeTime(thread.createdAt)} |
| 632 | </span> | 642 | </span> |
| 633 | </div> | 643 | </div> |
| @@ -0,0 +1,698 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { mergeProps } from "@base-ui/react/merge-props"; | ||
| 4 | import { useRender } from "@base-ui/react/use-render"; | ||
| 5 | import { cva, type VariantProps } from "class-variance-authority"; | ||
| 6 | import { PanelLeftIcon } from "lucide-react"; | ||
| 7 | import * as React from "react"; | ||
| 8 | import { cn } from "~/lib/utils"; | ||
| 9 | import { Button } from "~/components/ui/button"; | ||
| 10 | import { Input } from "~/components/ui/input"; | ||
| 11 | import { ScrollArea } from "~/components/ui/scroll-area"; | ||
| 12 | import { Separator } from "~/components/ui/separator"; | ||
| 13 | import { | ||
| 14 | Sheet, | ||
| 15 | SheetDescription, | ||
| 16 | SheetHeader, | ||
| 17 | SheetPopup, | ||
| 18 | SheetTitle, | ||
| 19 | } from "~/components/ui/sheet"; | ||
| 20 | import { Skeleton } from "~/components/ui/skeleton"; | ||
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | ||
| 22 | import { useMediaQuery } from "~/hooks/useMediaQuery"; | ||
| 23 | |||
| 24 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; | ||
| 25 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; | ||
| 26 | const SIDEBAR_WIDTH = "16rem"; | ||
| 27 | const SIDEBAR_WIDTH_MOBILE = "18rem"; | ||
| 28 | const SIDEBAR_WIDTH_ICON = "3rem"; | ||
| 29 | const SIDEBAR_KEYBOARD_SHORTCUT = "b"; | ||
| 30 | |||
| 31 | type SidebarContextProps = { | ||
| 32 | state: "expanded" | "collapsed"; | ||
| 33 | open: boolean; | ||
| 34 | setOpen: (open: boolean) => void; | ||
| 35 | openMobile: boolean; | ||
| 36 | setOpenMobile: (open: boolean) => void; | ||
| 37 | isMobile: boolean; | ||
| 38 | toggleSidebar: () => void; | ||
| 39 | }; | ||
| 40 | |||
| 41 | const SidebarContext = React.createContext<SidebarContextProps | null>(null); | ||
| 42 | |||
| 43 | function useSidebar() { | ||
| 44 | const context = React.useContext(SidebarContext); | ||
| 45 | if (!context) { | ||
| 46 | throw new Error("useSidebar must be used within a SidebarProvider."); | ||
| 47 | } | ||
| 48 | |||
| 49 | return context; | ||
| 50 | } | ||
| 51 | |||
| 52 | function SidebarProvider({ | ||
| 53 | defaultOpen = true, | ||
| 54 | open: openProp, | ||
| 55 | onOpenChange: setOpenProp, | ||
| 56 | className, | ||
| 57 | style, | ||
| 58 | children, | ||
| 59 | ...props | ||
| 60 | }: React.ComponentProps<"div"> & { | ||
| 61 | defaultOpen?: boolean; | ||
| 62 | open?: boolean; | ||
| 63 | onOpenChange?: (open: boolean) => void; | ||
| 64 | }) { | ||
| 65 | const isMobile = useMediaQuery("(max-width: 767px)"); | ||
| 66 | const [openMobile, setOpenMobile] = React.useState(false); | ||
| 67 | |||
| 68 | // This is the internal state of the sidebar. | ||
| 69 | // We use openProp and setOpenProp for control from outside the component. | ||
| 70 | const [_open, _setOpen] = React.useState(defaultOpen); | ||
| 71 | const open = openProp ?? _open; | ||
| 72 | const setOpen = React.useCallback( | ||
| 73 | async (value: boolean | ((value: boolean) => boolean)) => { | ||
| 74 | const openState = typeof value === "function" ? value(open) : value; | ||
| 75 | if (setOpenProp) { | ||
| 76 | setOpenProp(openState); | ||
| 77 | } else { | ||
| 78 | _setOpen(openState); | ||
| 79 | } | ||
| 80 | |||
| 81 | // This sets the cookie to keep the sidebar state. | ||
| 82 | await cookieStore.set({ | ||
| 83 | expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, | ||
| 84 | name: SIDEBAR_COOKIE_NAME, | ||
| 85 | path: "/", | ||
| 86 | value: String(openState), | ||
| 87 | }); | ||
| 88 | }, | ||
| 89 | [setOpenProp, open], | ||
| 90 | ); | ||
| 91 | |||
| 92 | // Helper to toggle the sidebar. | ||
| 93 | const toggleSidebar = React.useCallback(() => { | ||
| 94 | return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); | ||
| 95 | }, [isMobile, setOpen]); | ||
| 96 | |||
| 97 | // Adds a keyboard shortcut to toggle the sidebar. | ||
| 98 | React.useEffect(() => { | ||
| 99 | const handleKeyDown = (event: KeyboardEvent) => { | ||
| 100 | if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { | ||
| 101 | event.preventDefault(); | ||
| 102 | toggleSidebar(); | ||
| 103 | } | ||
| 104 | }; | ||
| 105 | |||
| 106 | window.addEventListener("keydown", handleKeyDown); | ||
| 107 | return () => window.removeEventListener("keydown", handleKeyDown); | ||
| 108 | }, [toggleSidebar]); | ||
| 109 | |||
| 110 | // We add a state so that we can do data-state="expanded" or "collapsed". | ||
| 111 | // This makes it easier to style the sidebar with Tailwind classes. | ||
| 112 | const state = open ? "expanded" : "collapsed"; | ||
| 113 | |||
| 114 | const contextValue = React.useMemo<SidebarContextProps>( | ||
| 115 | () => ({ | ||
| 116 | isMobile, | ||
| 117 | open, | ||
| 118 | openMobile, | ||
| 119 | setOpen, | ||
| 120 | setOpenMobile, | ||
| 121 | state, | ||
| 122 | toggleSidebar, | ||
| 123 | }), | ||
| 124 | [state, open, setOpen, isMobile, openMobile, toggleSidebar], | ||
| 125 | ); | ||
| 126 | |||
| 127 | return ( | ||
| 128 | <SidebarContext.Provider value={contextValue}> | ||
| 129 | <div | ||
| 130 | className={cn( | ||
| 131 | "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", | ||
| 132 | className, | ||
| 133 | )} | ||
| 134 | data-slot="sidebar-wrapper" | ||
| 135 | style={ | ||
| 136 | { | ||
| 137 | "--sidebar-width": SIDEBAR_WIDTH, | ||
| 138 | "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, | ||
| 139 | ...style, | ||
| 140 | } as React.CSSProperties | ||
| 141 | } | ||
| 142 | {...props} | ||
| 143 | > | ||
| 144 | {children} | ||
| 145 | </div> | ||
| 146 | </SidebarContext.Provider> | ||
| 147 | ); | ||
| 148 | } | ||
| 149 | |||
| 150 | function Sidebar({ | ||
| 151 | side = "left", | ||
| 152 | variant = "sidebar", | ||
| 153 | collapsible = "offcanvas", | ||
| 154 | className, | ||
| 155 | children, | ||
| 156 | ...props | ||
| 157 | }: React.ComponentProps<"div"> & { | ||
| 158 | side?: "left" | "right"; | ||
| 159 | variant?: "sidebar" | "floating" | "inset"; | ||
| 160 | collapsible?: "offcanvas" | "icon" | "none"; | ||
| 161 | }) { | ||
| 162 | const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | ||
| 163 | |||
| 164 | if (collapsible === "none") { | ||
| 165 | return ( | ||
| 166 | <div | ||
| 167 | className={cn( | ||
| 168 | "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", | ||
| 169 | className, | ||
| 170 | )} | ||
| 171 | data-slot="sidebar" | ||
| 172 | {...props} | ||
| 173 | > | ||
| 174 | {children} | ||
| 175 | </div> | ||
| 176 | ); | ||
| 177 | } | ||
| 178 | |||
| 179 | if (isMobile) { | ||
| 180 | return ( | ||
| 181 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> | ||
| 182 | <SheetPopup | ||
| 183 | className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" | ||
| 184 | data-mobile="true" | ||
| 185 | data-sidebar="sidebar" | ||
| 186 | data-slot="sidebar" | ||
| 187 | side={side} | ||
| 188 | style={ | ||
| 189 | { | ||
| 190 | "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | ||
| 191 | } as React.CSSProperties | ||
| 192 | } | ||
| 193 | > | ||
| 194 | <SheetHeader className="sr-only"> | ||
| 195 | <SheetTitle>Sidebar</SheetTitle> | ||
| 196 | <SheetDescription>Displays the mobile sidebar.</SheetDescription> | ||
| 197 | </SheetHeader> | ||
| 198 | <div className="flex h-full w-full flex-col">{children}</div> | ||
| 199 | </SheetPopup> | ||
| 200 | </Sheet> | ||
| 201 | ); | ||
| 202 | } | ||
| 203 | |||
| 204 | return ( | ||
| 205 | <div | ||
| 206 | className="group peer hidden text-sidebar-foreground md:block" | ||
| 207 | data-collapsible={state === "collapsed" ? collapsible : ""} | ||
| 208 | data-side={side} | ||
| 209 | data-slot="sidebar" | ||
| 210 | data-state={state} | ||
| 211 | data-variant={variant} | ||
| 212 | > | ||
| 213 | {/* This is what handles the sidebar gap on desktop */} | ||
| 214 | <div | ||
| 215 | className={cn( | ||
| 216 | "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", | ||
| 217 | "group-data-[collapsible=offcanvas]:w-0", | ||
| 218 | "group-data-[side=right]:rotate-180", | ||
| 219 | variant === "floating" || variant === "inset" | ||
| 220 | ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" | ||
| 221 | : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", | ||
| 222 | )} | ||
| 223 | data-slot="sidebar-gap" | ||
| 224 | /> | ||
| 225 | <div | ||
| 226 | className={cn( | ||
| 227 | "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", | ||
| 228 | side === "left" | ||
| 229 | ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" | ||
| 230 | : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", | ||
| 231 | // Adjust the padding for floating and inset variants. | ||
| 232 | variant === "floating" || variant === "inset" | ||
| 233 | ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" | ||
| 234 | : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", | ||
| 235 | className, | ||
| 236 | )} | ||
| 237 | data-slot="sidebar-container" | ||
| 238 | {...props} | ||
| 239 | > | ||
| 240 | <div | ||
| 241 | className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5" | ||
| 242 | data-sidebar="sidebar" | ||
| 243 | data-slot="sidebar-inner" | ||
| 244 | > | ||
| 245 | {children} | ||
| 246 | </div> | ||
| 247 | </div> | ||
| 248 | </div> | ||
| 249 | ); | ||
| 250 | } | ||
| 251 | |||
| 252 | function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { | ||
| 253 | const { toggleSidebar } = useSidebar(); | ||
| 254 | |||
| 255 | return ( | ||
| 256 | <Button | ||
| 257 | className={cn("size-7", className)} | ||
| 258 | data-sidebar="trigger" | ||
| 259 | data-slot="sidebar-trigger" | ||
| 260 | onClick={(event) => { | ||
| 261 | onClick?.(event); | ||
| 262 | toggleSidebar(); | ||
| 263 | }} | ||
| 264 | size="icon" | ||
| 265 | variant="ghost" | ||
| 266 | {...props} | ||
| 267 | > | ||
| 268 | <PanelLeftIcon /> | ||
| 269 | <span className="sr-only">Toggle Sidebar</span> | ||
| 270 | </Button> | ||
| 271 | ); | ||
| 272 | } | ||
| 273 | |||
| 274 | function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { | ||
| 275 | const { toggleSidebar } = useSidebar(); | ||
| 276 | |||
| 277 | return ( | ||
| 278 | <button | ||
| 279 | aria-label="Toggle Sidebar" | ||
| 280 | className={cn( | ||
| 281 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex", | ||
| 282 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | ||
| 283 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | ||
| 284 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | ||
| 285 | "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | ||
| 286 | "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | ||
| 287 | className, | ||
| 288 | )} | ||
| 289 | data-sidebar="rail" | ||
| 290 | data-slot="sidebar-rail" | ||
| 291 | onClick={toggleSidebar} | ||
| 292 | tabIndex={-1} | ||
| 293 | title="Toggle Sidebar" | ||
| 294 | type="button" | ||
| 295 | {...props} | ||
| 296 | /> | ||
| 297 | ); | ||
| 298 | } | ||
| 299 | |||
| 300 | function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { | ||
| 301 | return ( | ||
| 302 | <main | ||
| 303 | className={cn( | ||
| 304 | "relative flex w-full flex-1 flex-col bg-background", | ||
| 305 | "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5", | ||
| 306 | className, | ||
| 307 | )} | ||
| 308 | data-slot="sidebar-inset" | ||
| 309 | {...props} | ||
| 310 | /> | ||
| 311 | ); | ||
| 312 | } | ||
| 313 | |||
| 314 | function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { | ||
| 315 | return ( | ||
| 316 | <Input | ||
| 317 | className={cn("h-8 w-full bg-background shadow-none", className)} | ||
| 318 | data-sidebar="input" | ||
| 319 | data-slot="sidebar-input" | ||
| 320 | {...props} | ||
| 321 | /> | ||
| 322 | ); | ||
| 323 | } | ||
| 324 | |||
| 325 | function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { | ||
| 326 | return ( | ||
| 327 | <div | ||
| 328 | className={cn("flex flex-col gap-2 p-2", className)} | ||
| 329 | data-sidebar="header" | ||
| 330 | data-slot="sidebar-header" | ||
| 331 | {...props} | ||
| 332 | /> | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | |||
| 336 | function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { | ||
| 337 | return ( | ||
| 338 | <div | ||
| 339 | className={cn("flex flex-col gap-2 p-2", className)} | ||
| 340 | data-sidebar="footer" | ||
| 341 | data-slot="sidebar-footer" | ||
| 342 | {...props} | ||
| 343 | /> | ||
| 344 | ); | ||
| 345 | } | ||
| 346 | |||
| 347 | function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { | ||
| 348 | return ( | ||
| 349 | <Separator | ||
| 350 | className={cn("mx-2 w-auto bg-sidebar-border", className)} | ||
| 351 | data-sidebar="separator" | ||
| 352 | data-slot="sidebar-separator" | ||
| 353 | {...props} | ||
| 354 | /> | ||
| 355 | ); | ||
| 356 | } | ||
| 357 | |||
| 358 | function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { | ||
| 359 | return ( | ||
| 360 | <ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade> | ||
| 361 | <div | ||
| 362 | className={cn( | ||
| 363 | "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", | ||
| 364 | className, | ||
| 365 | )} | ||
| 366 | data-sidebar="content" | ||
| 367 | data-slot="sidebar-content" | ||
| 368 | {...props} | ||
| 369 | /> | ||
| 370 | </ScrollArea> | ||
| 371 | ); | ||
| 372 | } | ||
| 373 | |||
| 374 | function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { | ||
| 375 | return ( | ||
| 376 | <div | ||
| 377 | className={cn("relative flex w-full min-w-0 flex-col p-2", className)} | ||
| 378 | data-sidebar="group" | ||
| 379 | data-slot="sidebar-group" | ||
| 380 | {...props} | ||
| 381 | /> | ||
| 382 | ); | ||
| 383 | } | ||
| 384 | |||
| 385 | function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) { | ||
| 386 | const defaultProps = { | ||
| 387 | className: cn( | ||
| 388 | "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | ||
| 389 | "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", | ||
| 390 | className, | ||
| 391 | ), | ||
| 392 | "data-sidebar": "group-label", | ||
| 393 | "data-slot": "sidebar-group-label", | ||
| 394 | }; | ||
| 395 | |||
| 396 | return useRender({ | ||
| 397 | defaultTagName: "div", | ||
| 398 | props: mergeProps(defaultProps, props), | ||
| 399 | render, | ||
| 400 | }); | ||
| 401 | } | ||
| 402 | |||
| 403 | function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) { | ||
| 404 | const defaultProps = { | ||
| 405 | className: cn( | ||
| 406 | "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", | ||
| 407 | // Increases the hit area of the button on mobile. | ||
| 408 | "after:-inset-2 after:absolute md:after:hidden", | ||
| 409 | "group-data-[collapsible=icon]:hidden", | ||
| 410 | className, | ||
| 411 | ), | ||
| 412 | "data-sidebar": "group-action", | ||
| 413 | "data-slot": "sidebar-group-action", | ||
| 414 | }; | ||
| 415 | |||
| 416 | return useRender({ | ||
| 417 | defaultTagName: "button", | ||
| 418 | props: mergeProps(defaultProps, props), | ||
| 419 | render, | ||
| 420 | }); | ||
| 421 | } | ||
| 422 | |||
| 423 | function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { | ||
| 424 | return ( | ||
| 425 | <div | ||
| 426 | className={cn("w-full text-sm", className)} | ||
| 427 | data-sidebar="group-content" | ||
| 428 | data-slot="sidebar-group-content" | ||
| 429 | {...props} | ||
| 430 | /> | ||
| 431 | ); | ||
| 432 | } | ||
| 433 | |||
| 434 | function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { | ||
| 435 | return ( | ||
| 436 | <ul | ||
| 437 | className={cn("flex w-full min-w-0 flex-col gap-1", className)} | ||
| 438 | data-sidebar="menu" | ||
| 439 | data-slot="sidebar-menu" | ||
| 440 | {...props} | ||
| 441 | /> | ||
| 442 | ); | ||
| 443 | } | ||
| 444 | |||
| 445 | function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { | ||
| 446 | return ( | ||
| 447 | <li | ||
| 448 | className={cn("group/menu-item relative", className)} | ||
| 449 | data-sidebar="menu-item" | ||
| 450 | data-slot="sidebar-menu-item" | ||
| 451 | {...props} | ||
| 452 | /> | ||
| 453 | ); | ||
| 454 | } | ||
| 455 | |||
| 456 | const sidebarMenuButtonVariants = cva( | ||
| 457 | "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg 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:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", | ||
| 458 | { | ||
| 459 | defaultVariants: { | ||
| 460 | size: "default", | ||
| 461 | variant: "default", | ||
| 462 | }, | ||
| 463 | variants: { | ||
| 464 | size: { | ||
| 465 | default: "h-8 text-sm", | ||
| 466 | lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", | ||
| 467 | sm: "h-7 text-xs", | ||
| 468 | }, | ||
| 469 | variant: { | ||
| 470 | default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", | ||
| 471 | outline: | ||
| 472 | "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", | ||
| 473 | }, | ||
| 474 | }, | ||
| 475 | }, | ||
| 476 | ); | ||
| 477 | |||
| 478 | function SidebarMenuButton({ | ||
| 479 | isActive = false, | ||
| 480 | variant = "default", | ||
| 481 | size = "default", | ||
| 482 | tooltip, | ||
| 483 | className, | ||
| 484 | render, | ||
| 485 | ...props | ||
| 486 | }: useRender.ComponentProps<"button"> & { | ||
| 487 | isActive?: boolean; | ||
| 488 | tooltip?: string | React.ComponentProps<typeof TooltipPopup>; | ||
| 489 | } & VariantProps<typeof sidebarMenuButtonVariants>) { | ||
| 490 | const { isMobile, state } = useSidebar(); | ||
| 491 | |||
| 492 | const defaultProps = { | ||
| 493 | className: cn(sidebarMenuButtonVariants({ size, variant }), className), | ||
| 494 | "data-active": isActive, | ||
| 495 | "data-sidebar": "menu-button", | ||
| 496 | "data-size": size, | ||
| 497 | "data-slot": "sidebar-menu-button", | ||
| 498 | }; | ||
| 499 | |||
| 500 | const buttonProps = mergeProps<"button">(defaultProps, props); | ||
| 501 | |||
| 502 | const buttonElement = useRender({ | ||
| 503 | defaultTagName: "button", | ||
| 504 | props: buttonProps, | ||
| 505 | render, | ||
| 506 | }); | ||
| 507 | |||
| 508 | if (!tooltip) { | ||
| 509 | return buttonElement; | ||
| 510 | } | ||
| 511 | |||
| 512 | if (typeof tooltip === "string") { | ||
| 513 | tooltip = { | ||
| 514 | children: tooltip, | ||
| 515 | }; | ||
| 516 | } | ||
| 517 | |||
| 518 | return ( | ||
| 519 | <Tooltip> | ||
| 520 | <TooltipTrigger render={buttonElement as React.ReactElement<Record<string, unknown>>} /> | ||
| 521 | <TooltipPopup | ||
| 522 | align="center" | ||
| 523 | hidden={state !== "collapsed" || isMobile} | ||
| 524 | side="right" | ||
| 525 | {...tooltip} | ||
| 526 | /> | ||
| 527 | </Tooltip> | ||
| 528 | ); | ||
| 529 | } | ||
| 530 | |||
| 531 | function SidebarMenuAction({ | ||
| 532 | className, | ||
| 533 | showOnHover = false, | ||
| 534 | render, | ||
| 535 | ...props | ||
| 536 | }: useRender.ComponentProps<"button"> & { | ||
| 537 | showOnHover?: boolean; | ||
| 538 | }) { | ||
| 539 | const defaultProps = { | ||
| 540 | className: cn( | ||
| 541 | "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", | ||
| 542 | // Increases the hit area of the button on mobile. | ||
| 543 | "after:-inset-2 after:absolute md:after:hidden", | ||
| 544 | "peer-data-[size=sm]/menu-button:top-1", | ||
| 545 | "peer-data-[size=default]/menu-button:top-1.5", | ||
| 546 | "peer-data-[size=lg]/menu-button:top-2.5", | ||
| 547 | "group-data-[collapsible=icon]:hidden", | ||
| 548 | showOnHover && | ||
| 549 | "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", | ||
| 550 | className, | ||
| 551 | ), | ||
| 552 | "data-sidebar": "menu-action", | ||
| 553 | "data-slot": "sidebar-menu-action", | ||
| 554 | }; | ||
| 555 | |||
| 556 | return useRender({ | ||
| 557 | defaultTagName: "button", | ||
| 558 | props: mergeProps<"button">(defaultProps, props), | ||
| 559 | render, | ||
| 560 | }); | ||
| 561 | } | ||
| 562 | |||
| 563 | function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { | ||
| 564 | return ( | ||
| 565 | <div | ||
| 566 | className={cn( | ||
| 567 | "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-lg px-1 font-medium text-sidebar-foreground text-xs tabular-nums", | ||
| 568 | "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", | ||
| 569 | "peer-data-[size=sm]/menu-button:top-1", | ||
| 570 | "peer-data-[size=default]/menu-button:top-1.5", | ||
| 571 | "peer-data-[size=lg]/menu-button:top-2.5", | ||
| 572 | "group-data-[collapsible=icon]:hidden", | ||
| 573 | className, | ||
| 574 | )} | ||
| 575 | data-sidebar="menu-badge" | ||
| 576 | data-slot="sidebar-menu-badge" | ||
| 577 | {...props} | ||
| 578 | /> | ||
| 579 | ); | ||
| 580 | } | ||
| 581 | |||
| 582 | function SidebarMenuSkeleton({ | ||
| 583 | className, | ||
| 584 | showIcon = false, | ||
| 585 | ...props | ||
| 586 | }: React.ComponentProps<"div"> & { | ||
| 587 | showIcon?: boolean; | ||
| 588 | }) { | ||
| 589 | // Random width between 50 to 90%. | ||
| 590 | const width = React.useMemo(() => { | ||
| 591 | return `${Math.floor(Math.random() * 40) + 50}%`; | ||
| 592 | }, []); | ||
| 593 | |||
| 594 | return ( | ||
| 595 | <div | ||
| 596 | className={cn("flex h-8 items-center gap-2 rounded-lg px-2", className)} | ||
| 597 | data-sidebar="menu-skeleton" | ||
| 598 | data-slot="sidebar-menu-skeleton" | ||
| 599 | {...props} | ||
| 600 | > | ||
| 601 | {showIcon && <Skeleton className="size-4 rounded-lg" data-sidebar="menu-skeleton-icon" />} | ||
| 602 | <Skeleton | ||
| 603 | className="h-4 max-w-(--skeleton-width) flex-1" | ||
| 604 | data-sidebar="menu-skeleton-text" | ||
| 605 | style={ | ||
| 606 | { | ||
| 607 | "--skeleton-width": width, | ||
| 608 | } as React.CSSProperties | ||
| 609 | } | ||
| 610 | /> | ||
| 611 | </div> | ||
| 612 | ); | ||
| 613 | } | ||
| 614 | |||
| 615 | function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { | ||
| 616 | return ( | ||
| 617 | <ul | ||
| 618 | className={cn( | ||
| 619 | "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5", | ||
| 620 | "group-data-[collapsible=icon]:hidden", | ||
| 621 | className, | ||
| 622 | )} | ||
| 623 | data-sidebar="menu-sub" | ||
| 624 | data-slot="sidebar-menu-sub" | ||
| 625 | {...props} | ||
| 626 | /> | ||
| 627 | ); | ||
| 628 | } | ||
| 629 | |||
| 630 | function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { | ||
| 631 | return ( | ||
| 632 | <li | ||
| 633 | className={cn("group/menu-sub-item relative", className)} | ||
| 634 | data-sidebar="menu-sub-item" | ||
| 635 | data-slot="sidebar-menu-sub-item" | ||
| 636 | {...props} | ||
| 637 | /> | ||
| 638 | ); | ||
| 639 | } | ||
| 640 | |||
| 641 | function SidebarMenuSubButton({ | ||
| 642 | size = "md", | ||
| 643 | isActive = false, | ||
| 644 | className, | ||
| 645 | render, | ||
| 646 | ...props | ||
| 647 | }: useRender.ComponentProps<"a"> & { | ||
| 648 | size?: "sm" | "md"; | ||
| 649 | isActive?: boolean; | ||
| 650 | }) { | ||
| 651 | const defaultProps = { | ||
| 652 | className: cn( | ||
| 653 | "-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", | ||
| 654 | "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", | ||
| 655 | size === "sm" && "text-xs", | ||
| 656 | size === "md" && "text-sm", | ||
| 657 | "group-data-[collapsible=icon]:hidden", | ||
| 658 | className, | ||
| 659 | ), | ||
| 660 | "data-active": isActive, | ||
| 661 | "data-sidebar": "menu-sub-button", | ||
| 662 | "data-size": size, | ||
| 663 | "data-slot": "sidebar-menu-sub-button", | ||
| 664 | }; | ||
| 665 | |||
| 666 | return useRender({ | ||
| 667 | defaultTagName: "a", | ||
| 668 | props: mergeProps<"a">(defaultProps, props), | ||
| 669 | render, | ||
| 670 | }); | ||
| 671 | } | ||
| 672 | |||
| 673 | export { | ||
| 674 | Sidebar, | ||
| 675 | SidebarContent, | ||
| 676 | SidebarFooter, | ||
| 677 | SidebarGroup, | ||
| 678 | SidebarGroupAction, | ||
| 679 | SidebarGroupContent, | ||
| 680 | SidebarGroupLabel, | ||
| 681 | SidebarHeader, | ||
| 682 | SidebarInput, | ||
| 683 | SidebarInset, | ||
| 684 | SidebarMenu, | ||
| 685 | SidebarMenuAction, | ||
| 686 | SidebarMenuBadge, | ||
| 687 | SidebarMenuButton, | ||
| 688 | SidebarMenuItem, | ||
| 689 | SidebarMenuSkeleton, | ||
| 690 | SidebarMenuSub, | ||
| 691 | SidebarMenuSubButton, | ||
| 692 | SidebarMenuSubItem, | ||
| 693 | SidebarProvider, | ||
| 694 | SidebarRail, | ||
| 695 | SidebarSeparator, | ||
| 696 | SidebarTrigger, | ||
| 697 | useSidebar, | ||
| 698 | }; | ||
| @@ -0,0 +1,68 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; | ||
| 4 | |||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | |||
| 7 | const TooltipCreateHandle = TooltipPrimitive.createHandle; | ||
| 8 | |||
| 9 | const TooltipProvider = TooltipPrimitive.Provider; | ||
| 10 | |||
| 11 | const Tooltip = TooltipPrimitive.Root; | ||
| 12 | |||
| 13 | function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { | ||
| 14 | return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; | ||
| 15 | } | ||
| 16 | |||
| 17 | function TooltipPopup({ | ||
| 18 | className, | ||
| 19 | align = "center", | ||
| 20 | sideOffset = 4, | ||
| 21 | side = "top", | ||
| 22 | anchor, | ||
| 23 | children, | ||
| 24 | ...props | ||
| 25 | }: TooltipPrimitive.Popup.Props & { | ||
| 26 | align?: TooltipPrimitive.Positioner.Props["align"]; | ||
| 27 | side?: TooltipPrimitive.Positioner.Props["side"]; | ||
| 28 | sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; | ||
| 29 | anchor?: TooltipPrimitive.Positioner.Props["anchor"]; | ||
| 30 | }) { | ||
| 31 | return ( | ||
| 32 | <TooltipPrimitive.Portal> | ||
| 33 | <TooltipPrimitive.Positioner | ||
| 34 | align={align} | ||
| 35 | anchor={anchor} | ||
| 36 | className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none" | ||
| 37 | data-slot="tooltip-positioner" | ||
| 38 | side={side} | ||
| 39 | sideOffset={sideOffset} | ||
| 40 | > | ||
| 41 | <TooltipPrimitive.Popup | ||
| 42 | className={cn( | ||
| 43 | "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs shadow-md/5 transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | ||
| 44 | className, | ||
| 45 | )} | ||
| 46 | data-slot="tooltip-popup" | ||
| 47 | {...props} | ||
| 48 | > | ||
| 49 | <TooltipPrimitive.Viewport | ||
| 50 | className="relative size-full overflow-clip px-(--viewport-inline-padding) py-1 [--viewport-inline-padding:--spacing(2)] data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:truncate **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity" | ||
| 51 | data-slot="tooltip-viewport" | ||
| 52 | > | ||
| 53 | {children} | ||
| 54 | </TooltipPrimitive.Viewport> | ||
| 55 | </TooltipPrimitive.Popup> | ||
| 56 | </TooltipPrimitive.Positioner> | ||
| 57 | </TooltipPrimitive.Portal> | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | |||
| 61 | export { | ||
| 62 | TooltipCreateHandle, | ||
| 63 | TooltipProvider, | ||
| 64 | Tooltip, | ||
| 65 | TooltipTrigger, | ||
| 66 | TooltipPopup, | ||
| 67 | TooltipPopup as TooltipContent, | ||
| 68 | }; | ||
| @@ -13,6 +13,18 @@ import { APP_DISPLAY_NAME } from "./branding"; · 12 unmodified lines | |||
| 13 | import { isElectron } from "./env"; | 13 | import { isElectron } from "./env"; |
| 14 | import { routeTree } from "./routeTree.gen"; | 14 | import { routeTree } from "./routeTree.gen"; |
| 15 | 15 | ||
| 16 | if (!globalThis.crypto.randomUUID) { | ||
| 17 | // https://stackoverflow.com/a/2117523/2800218 | ||
| 18 | // LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode | ||
| 19 | // oxlint-disable-next-line unicorn/consistent-function-scoping | ||
| 20 | crypto.randomUUID = function randomUUID() { | ||
| 21 | // @ts-expect-error -- Some cryptic stuff that this polyfill uses | ||
| 22 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => | ||
| 23 | (c ^ (crypto.getRandomValues(new Uint8Array(1))[0]! & (15 >> (c / 4)))).toString(16), | ||
| 24 | ); | ||
| 25 | }; | ||
| 26 | } | ||
| 27 | |||
| 16 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 28 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 17 | 29 | ||
| 18 | const queryClient = new QueryClient(); | 30 | const queryClient = new QueryClient(); |
| @@ -28,7 +28,7 @@ function RootRouteView() { · 27 unmodified lines | |||
| 28 | 28 | ||
| 29 | if (!api) { | 29 | if (!api) { |
| 30 | return ( | 30 | return ( |
| 31 | <div className="flex h-screen flex-col bg-background text-foreground"> | 31 | <div className="flex h-dvh flex-col bg-background text-foreground"> |
| 32 | <div className="flex flex-1 items-center justify-center"> | 32 | <div className="flex flex-1 items-center justify-center"> |
| 33 | <p className="text-sm text-muted-foreground"> | 33 | <p className="text-sm text-muted-foreground"> |
| 34 | Connecting to {APP_DISPLAY_NAME} server... | 34 | Connecting to {APP_DISPLAY_NAME} server... |
| @@ -1,11 +1,12 @@ | |||
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | 2 | import { Suspense, lazy, type CSSProperties, type ReactNode, useCallback, useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import ChatView from "../components/ChatView"; | 4 | import ChatView from "../components/ChatView"; |
| 5 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 5 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 6 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 6 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 7 | import { useStore } from "../store"; | 7 | import { useStore } from "../store"; |
| 8 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | 8 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| 9 | import { Sidebar, SidebarProvider } from "../components/ui/sidebar"; | ||
| 9 | 10 | ||
| 10 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | 11 | const DiffPanel = lazy(() => import("../components/DiffPanel")); |
| 11 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | 12 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; |
| @@ -53,6 +54,20 @@ function ChatThreadRouteView() { · 41 unmodified lines | |||
| 53 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; | 54 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; |
| 54 | const diffOpen = search.diff === "1"; | 55 | const diffOpen = search.diff === "1"; |
| 55 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | 56 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 57 | const setDiffOpen = useCallback( | ||
| 58 | (open: boolean) => { | ||
| 59 | if (open === diffOpen) return; | ||
| 60 | void navigate({ | ||
| 61 | to: "/$threadId", | ||
| 62 | params: { threadId }, | ||
| 63 | search: (previous) => { | ||
| 64 | const rest = stripDiffSearchParams(previous); | ||
| 65 | return open ? { ...rest, diff: "1" } : rest; | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | }, | ||
| 69 | [diffOpen, navigate, threadId], | ||
| 70 | ); | ||
| 56 | const closeDiff = useCallback(() => { | 71 | const closeDiff = useCallback(() => { |
| 57 | void navigate({ | 72 | void navigate({ |
| 58 | to: "/$threadId", | 73 | to: "/$threadId", |
| @@ -83,26 +98,45 @@ function ChatThreadRouteView() { · 24 unmodified lines | |||
| 83 | return null; | 98 | return null; |
| 84 | } | 99 | } |
| 85 | 100 | ||
| 86 | const diffLoadingFallback = | 101 | const diffLoadingFallback = ( |
| 87 | !diffOpen || shouldUseDiffSheet ? ( | 102 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> |
| 88 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> | 103 | Loading diff viewer... |
| 89 | Loading diff viewer... | 104 | </div> |
| 90 | </div> | 105 | ); |
| 91 | ) : ( | 106 | |
| 92 | <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70"> | 107 | if (shouldUseDiffSheet) { |
| 93 | Loading diff viewer... | 108 | return ( |
| 94 | </aside> | 109 | <> |
| 110 | <ChatView threadId={threadId} /> | ||
| 111 | <DiffPanelWrapper sheet diffOpen={diffOpen} onCloseDiff={closeDiff}> | ||
| 112 | <Suspense fallback={diffLoadingFallback}> | ||
| 113 | <DiffPanel mode="sheet" /> | ||
| 114 | </Suspense> | ||
| 115 | </DiffPanelWrapper> | ||
| 116 | </> | ||
| 95 | ); | 117 | ); |
| 118 | } | ||
| 96 | 119 | ||
| 97 | return ( | 120 | return ( |
| 98 | <> | 121 | <div className="flex min-h-0 min-w-0 flex-1"> |
| 99 | <ChatView threadId={threadId} /> | 122 | <ChatView threadId={threadId} /> |
| 100 | <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}> | 123 | <SidebarProvider |
| 101 | <Suspense fallback={diffLoadingFallback}> | 124 | open={diffOpen} |
| 102 | <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} /> | 125 | onOpenChange={setDiffOpen} |
| 103 | </Suspense> | 126 | className="w-0 min-w-0 flex-none" |
| 104 | </DiffPanelWrapper> | 127 | style={{ "--sidebar-width": "34rem" } as CSSProperties} |
| 105 | </> | 128 | > |
| 129 | <Sidebar | ||
| 130 | side="right" | ||
| 131 | collapsible="offcanvas" | ||
| 132 | className="border-l border-border bg-card text-foreground" | ||
| 133 | > | ||
| 134 | <Suspense fallback={diffLoadingFallback}> | ||
| 135 | <DiffPanel mode="sheet" /> | ||
| 136 | </Suspense> | ||
| 137 | </Sidebar> | ||
| 138 | </SidebarProvider> | ||
| 139 | </div> | ||
| 106 | ); | 140 | ); |
| 107 | } | 141 | } |
| 108 | 142 | ||
| @@ -1,10 +1,19 @@ | |||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { isElectron } from "../env"; | 3 | import { isElectron } from "../env"; |
| 4 | import { Separator } from "../components/ui/separator"; | ||
| 5 | import { SidebarTrigger } from "../components/ui/sidebar"; | ||
| 4 | 6 | ||
| 5 | function ChatIndexRouteView() { | 7 | function ChatIndexRouteView() { |
| 6 | return ( | 8 | return ( |
| 7 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | 9 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> |
| 10 | {!isElectron && ( | ||
| 11 | <header className="sticky top-0 z-20 flex h-11 shrink-0 items-center gap-2 border-b border-border/70 bg-background/95 px-2 backdrop-blur md:hidden"> | ||
| 12 | <SidebarTrigger className="size-8" /> | ||
| 13 | <Separator orientation="vertical" className="data-[orientation=vertical]:h-4" /> | ||
| 14 | <span className="truncate text-xs text-muted-foreground/75">Threads</span> | ||
| 15 | </header> | ||
| 16 | )} | ||
| 8 | {isElectron && ( | 17 | {isElectron && ( |
| 9 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | 18 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> |
| 10 | <span className="text-xs text-muted-foreground/50">No active thread</span> | 19 | <span className="text-xs text-muted-foreground/50">No active thread</span> |
| @@ -2,7 +2,8 @@ import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; · 1 unmodified line | |||
| 2 | import { useEffect } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 5 | import Sidebar from "../components/Sidebar"; | 5 | import ThreadSidebar from "../components/Sidebar"; |
| 6 | import { Sidebar, SidebarInset, SidebarProvider } from "../components/ui/sidebar"; | ||
| 6 | 7 | ||
| 7 | function ChatRouteLayout() { | 8 | function ChatRouteLayout() { |
| 8 | const navigate = useNavigate(); | 9 | const navigate = useNavigate(); |
| @@ -24,16 +25,23 @@ function ChatRouteLayout() { · 15 unmodified lines | |||
| 24 | }, [navigate]); | 25 | }, [navigate]); |
| 25 | 26 | ||
| 26 | return ( | 27 | return ( |
| 27 | <div className="flex h-screen overflow-hidden bg-background text-foreground isolate"> | 28 | <SidebarProvider defaultOpen> |
| 28 | <Sidebar /> | 29 | <Sidebar |
| 29 | <DiffWorkerPoolProvider> | 30 | side="left" |
| 30 | <Outlet /> | 31 | collapsible="offcanvas" |
| 31 | </DiffWorkerPoolProvider> | 32 | className="border-r border-border bg-card text-foreground" |
| 32 | </div> | 33 | > |
| 34 | <ThreadSidebar embedded /> | ||
| 35 | </Sidebar> | ||
| 36 | <SidebarInset className="h-dvh min-h-0 overflow-hidden bg-background text-foreground isolate"> | ||
| 37 | <DiffWorkerPoolProvider> | ||
| 38 | <Outlet /> | ||
| 39 | </DiffWorkerPoolProvider> | ||
| 40 | </SidebarInset> | ||
| 41 | </SidebarProvider> | ||
| 33 | ); | 42 | ); |
| 34 | } | 43 | } |
| 35 | 44 | ||
| 36 | export const Route = createFileRoute("/_chat")({ | 45 | export const Route = createFileRoute("/_chat")({ |
| 37 | component: ChatRouteLayout, | 46 | component: ChatRouteLayout, |
| 38 | }); | 47 | }); |
| 39 | |||