| author | |
| committer | |
| log | ad9bc6c1df40ac41ee9f50405bb17bbea6b08674 |
| tree | 474a45d5bc744168b7aee4c5f9c800c85cbfd535 |
| parent | 76519303b9a488a0b9f4fb4c4b1968d6f9bacba8 |
- Refactor chat, header, and composer spacing/overflow for mobile-friendly behavior
- Replace custom sidebar structure with reusable collapsible/sidebar UI components
- Tighten compact controls with responsive labels, triggers, and accessibility tweaks11 files changed, 1128 insertions(+), 230 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+1-1| ... | @@ -16,7 +16,7 @@ const markdownComponents: Components = { | ... | @@ -16,7 +16,7 @@ const markdownComponents: Components = { |
| 16 | 16 | ||
| 17 | function ChatMarkdown({ text }: ChatMarkdownProps) { | 17 | function ChatMarkdown({ text }: ChatMarkdownProps) { |
| 18 | return ( | 18 | return ( |
| 19 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> | 19 | <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80"> |
| 20 | <ReactMarkdown | 20 | <ReactMarkdown |
| 21 | remarkPlugins={[remarkGfm]} | 21 | remarkPlugins={[remarkGfm]} |
| 22 | rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} | 22 | rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} |
apps/web/src/components/ChatView.tsx+45-25| ... | @@ -112,6 +112,7 @@ import { | ... | @@ -112,6 +112,7 @@ import { |
| 112 | setupProjectScript, | 112 | setupProjectScript, |
| 113 | } from "~/projectScripts"; | 113 | } from "~/projectScripts"; |
| 114 | import { Toggle } from "./ui/toggle"; | 114 | import { Toggle } from "./ui/toggle"; |
| 115 | import { SidebarTrigger } from "./ui/sidebar"; | ||
| 115 | import { | 116 | import { |
| 116 | asApprovalRequestId, | 117 | asApprovalRequestId, |
| 117 | asProjectId, | 118 | asProjectId, |
| ... | @@ -1666,6 +1667,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1666,6 +1667,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1666 | if (!activeThread) { | 1667 | if (!activeThread) { |
| 1667 | return ( | 1668 | return ( |
| 1668 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | 1669 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> |
| 1670 | {!isElectron && ( | ||
| 1671 | <header className="border-b border-border px-3 py-2 md:hidden"> | ||
| 1672 | <div className="flex items-center gap-2"> | ||
| 1673 | <SidebarTrigger className="size-7 shrink-0" /> | ||
| 1674 | <span className="text-sm font-medium text-foreground">Threads</span> | ||
| 1675 | </div> | ||
| 1676 | </header> | ||
| 1677 | )} | ||
| 1669 | {isElectron && ( | 1678 | {isElectron && ( |
| 1670 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | 1679 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> |
| 1671 | <span className="text-xs text-muted-foreground/50">No active thread</span> | 1680 | <span className="text-xs text-muted-foreground/50">No active thread</span> |
| ... | @@ -1681,10 +1690,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1681,10 +1690,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1681 | } | 1690 | } |
| 1682 | 1691 | ||
| 1683 | return ( | 1692 | return ( |
| 1684 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> | 1693 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| 1685 | {/* Top bar */} | 1694 | {/* Top bar */} |
| 1686 | <header | 1695 | <header |
| 1687 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} | 1696 | className={cn( |
| 1697 | "border-b border-border px-3 sm:px-5", | ||
| 1698 | isElectron ? "drag-region h-[52px]" : "py-2 sm:py-3", | ||
| 1699 | )} | ||
| 1688 | > | 1700 | > |
| 1689 | <ChatHeader | 1701 | <ChatHeader |
| 1690 | activeThreadId={activeThread.id} | 1702 | activeThreadId={activeThread.id} |
| ... | @@ -1718,7 +1730,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1718,7 +1730,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1718 | {/* Messages */} | 1730 | {/* Messages */} |
| 1719 | <div | 1731 | <div |
| 1720 | ref={messagesScrollRef} | 1732 | ref={messagesScrollRef} |
| 1721 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" | 1733 | className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4" |
| 1722 | onScroll={onMessagesScroll} | 1734 | onScroll={onMessagesScroll} |
| 1723 | > | 1735 | > |
| 1724 | <MessagesTimeline | 1736 | <MessagesTimeline |
| ... | @@ -1741,8 +1753,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1741,8 +1753,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1741 | </div> | 1753 | </div> |
| 1742 | 1754 | ||
| 1743 | {/* Input bar */} | 1755 | {/* Input bar */} |
| 1744 | <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}> | 1756 | <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}> |
| 1745 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | 1757 | <form onSubmit={onSend} className="mx-auto w-full max-w-3xl"> |
| 1746 | <div | 1758 | <div |
| 1747 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ | 1759 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ |
| 1748 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border" | 1760 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border" |
| ... | @@ -1753,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1753,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1753 | onDrop={onComposerDrop} | 1765 | onDrop={onComposerDrop} |
| 1754 | > | 1766 | > |
| 1755 | {/* Textarea area */} | 1767 | {/* Textarea area */} |
| 1756 | <div className="relative px-4 pt-4 pb-2"> | 1768 | <div className="relative px-3 pt-3.5 pb-2 sm:px-4 sm:pt-4"> |
| 1757 | {composerMenuOpen && ( | 1769 | {composerMenuOpen && ( |
| 1758 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | 1770 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 1759 | <ComposerCommandMenu | 1771 | <ComposerCommandMenu |
| ... | @@ -1840,24 +1852,24 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1840,24 +1852,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1840 | </div> | 1852 | </div> |
| 1841 | 1853 | ||
| 1842 | {/* Bottom toolbar */} | 1854 | {/* Bottom toolbar */} |
| 1843 | <div className="flex items-center justify-between px-3 pb-3"> | 1855 | <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3"> |
| 1844 | <div className="flex items-center gap-1"> | 1856 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:overflow-visible"> |
| 1845 | {/* Model picker */} | 1857 | {/* Model picker */} |
| 1846 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> | 1858 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> |
| 1847 | 1859 | ||
| 1848 | {/* Divider */} | 1860 | {/* Divider */} |
| 1849 | <Separator orientation="vertical" className="mx-0.5 h-4" /> | 1861 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 1850 | 1862 | ||
| 1851 | {/* Reasoning effort */} | 1863 | {/* Reasoning effort */} |
| 1852 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} /> | 1864 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} /> |
| 1853 | 1865 | ||
| 1854 | {/* Divider */} | 1866 | {/* Divider */} |
| 1855 | <Separator orientation="vertical" className="mx-0.5 h-4" /> | 1867 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 1856 | 1868 | ||
| 1857 | {/* Runtime mode toggle */} | 1869 | {/* Runtime mode toggle */} |
| 1858 | <Button | 1870 | <Button |
| 1859 | variant="ghost" | 1871 | variant="ghost" |
| 1860 | className="text-muted-foreground/70 hover:text-foreground/80" | 1872 | className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" |
| 1861 | size="sm" | 1873 | size="sm" |
| 1862 | type="button" | 1874 | type="button" |
| 1863 | disabled={isSwitchingRuntimeMode} | 1875 | disabled={isSwitchingRuntimeMode} |
| ... | @@ -1873,16 +1885,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1873,16 +1885,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1873 | } | 1885 | } |
| 1874 | > | 1886 | > |
| 1875 | {state.runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />} | 1887 | {state.runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />} |
| 1876 | {state.runtimeMode === "full-access" ? "Full access" : "Supervised"} | 1888 | <span className="sr-only sm:not-sr-only"> |
| 1889 | {state.runtimeMode === "full-access" ? "Full access" : "Supervised"} | ||
| 1890 | </span> | ||
| 1877 | </Button> | 1891 | </Button> |
| 1878 | </div> | 1892 | </div> |
| 1879 | 1893 | ||
| 1880 | {/* Right side: send / stop button */} | 1894 | {/* Right side: send / stop button */} |
| 1881 | <div className="flex items-center gap-2"> | 1895 | <div className="flex shrink-0 items-center gap-2"> |
| 1882 | {phase === "running" ? ( | 1896 | {phase === "running" ? ( |
| 1883 | <button | 1897 | <button |
| 1884 | type="button" | 1898 | type="button" |
| 1885 | 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" | 1899 | className="flex h-9 w-9 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8" |
| 1886 | onClick={() => void onInterrupt()} | 1900 | onClick={() => void onInterrupt()} |
| 1887 | aria-label="Stop generation" | 1901 | aria-label="Stop generation" |
| 1888 | > | 1902 | > |
| ... | @@ -1899,7 +1913,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1899,7 +1913,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1899 | ) : ( | 1913 | ) : ( |
| 1900 | <button | 1914 | <button |
| 1901 | type="submit" | 1915 | type="submit" |
| 1902 | 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" | 1916 | className="flex h-9 w-9 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 sm:h-8 sm:w-8" |
| 1903 | disabled={ | 1917 | disabled={ |
| 1904 | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) | 1918 | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) |
| 1905 | } | 1919 | } |
| ... | @@ -2061,14 +2075,19 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2061,14 +2075,19 @@ const ChatHeader = memo(function ChatHeader({ |
| 2061 | onToggleDiff, | 2075 | onToggleDiff, |
| 2062 | }: ChatHeaderProps) { | 2076 | }: ChatHeaderProps) { |
| 2063 | return ( | 2077 | return ( |
| 2064 | <> | 2078 | <div className="flex min-w-0 flex-1 items-center justify-between gap-2"> |
| 2065 | <div className="flex min-w-0 flex-1 items-center gap-3"> | 2079 | <div className="flex min-w-0 items-center gap-2 sm:gap-3"> |
| 2080 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | ||
| 2066 | <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}> | 2081 | <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}> |
| 2067 | {activeThreadTitle} | 2082 | {activeThreadTitle} |
| 2068 | </h2> | 2083 | </h2> |
| 2069 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} | 2084 | {activeProjectName && ( |
| 2085 | <Badge variant="outline" className="max-w-28 truncate sm:max-w-none"> | ||
| 2086 | {activeProjectName} | ||
| 2087 | </Badge> | ||
| 2088 | )} | ||
| 2070 | </div> | 2089 | </div> |
| 2071 | <div className="shrink-0 flex items-center gap-3"> | 2090 | <div className="flex shrink-0 items-center gap-2 sm:gap-3"> |
| 2072 | {activeProjectScripts && ( | 2091 | {activeProjectScripts && ( |
| 2073 | <ProjectScriptsControl | 2092 | <ProjectScriptsControl |
| 2074 | scripts={activeProjectScripts} | 2093 | scripts={activeProjectScripts} |
| ... | @@ -2086,6 +2105,7 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2086,6 +2105,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 2086 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | 2105 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> |
| 2087 | )} | 2106 | )} |
| 2088 | <Toggle | 2107 | <Toggle |
| 2108 | className="shrink-0" | ||
| 2089 | pressed={diffOpen} | 2109 | pressed={diffOpen} |
| 2090 | onPressedChange={onToggleDiff} | 2110 | onPressedChange={onToggleDiff} |
| 2091 | aria-label="Toggle diff panel" | 2111 | aria-label="Toggle diff panel" |
| ... | @@ -2095,7 +2115,7 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2095,7 +2115,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 2095 | <DiffIcon className="size-3" /> | 2115 | <DiffIcon className="size-3" /> |
| 2096 | </Toggle> | 2116 | </Toggle> |
| 2097 | </div> | 2117 | </div> |
| 2098 | </> | 2118 | </div> |
| 2099 | ); | 2119 | ); |
| 2100 | }); | 2120 | }); |
| 2101 | 2121 | ||
| ... | @@ -2309,7 +2329,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2309,7 +2329,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2309 | 2329 | ||
| 2310 | return ( | 2330 | return ( |
| 2311 | <div | 2331 | <div |
| 2312 | className="relative mx-auto max-w-3xl" | 2332 | className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" |
| 2313 | style={{ height: `${rowVirtualizer.getTotalSize()}px` }} | 2333 | style={{ height: `${rowVirtualizer.getTotalSize()}px` }} |
| 2314 | > | 2334 | > |
| 2315 | {virtualRows.map((virtualRow: VirtualItem) => { | 2335 | {virtualRows.map((virtualRow: VirtualItem) => { |
| ... | @@ -2428,7 +2448,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2428,7 +2448,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2428 | </div> | 2448 | </div> |
| 2429 | )} | 2449 | )} |
| 2430 | {row.message.text && ( | 2450 | {row.message.text && ( |
| 2431 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | 2451 | <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground"> |
| 2432 | {row.message.text} | 2452 | {row.message.text} |
| 2433 | </pre> | 2453 | </pre> |
| 2434 | )} | 2454 | )} |
| ... | @@ -2469,7 +2489,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2469,7 +2489,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2469 | <span className="h-px flex-1 bg-border" /> | 2489 | <span className="h-px flex-1 bg-border" /> |
| 2470 | </div> | 2490 | </div> |
| 2471 | )} | 2491 | )} |
| 2472 | <div className="px-1 py-0.5"> | 2492 | <div className="min-w-0 px-1 py-0.5"> |
| 2473 | <ChatMarkdown text={messageText} /> | 2493 | <ChatMarkdown text={messageText} /> |
| 2474 | {(() => { | 2494 | {(() => { |
| 2475 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( | 2495 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( |
| ... | @@ -2708,9 +2728,9 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -2708,9 +2728,9 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2708 | <Group aria-label="Subscription actions"> | 2728 | <Group aria-label="Subscription actions"> |
| 2709 | <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}> | 2729 | <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}> |
| 2710 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} | 2730 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} |
| 2711 | <span className="ml-0.5">Open</span> | 2731 | <span className="sr-only sm:not-sr-only sm:ml-0.5">Open</span> |
| 2712 | </Button> | 2732 | </Button> |
| 2713 | <GroupSeparator /> | 2733 | <GroupSeparator className="hidden sm:block" /> |
| 2714 | <Menu> | 2734 | <Menu> |
| 2715 | <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}> | 2735 | <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}> |
| 2716 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | 2736 | <ChevronDownIcon aria-hidden="true" className="size-4" /> |
apps/web/src/components/GitActionsControl.tsx+3-3| ... | @@ -501,7 +501,7 @@ export default function GitActionsControl({ | ... | @@ -501,7 +501,7 @@ export default function GitActionsControl({ |
| 501 | } | 501 | } |
| 502 | > | 502 | > |
| 503 | <GitQuickActionIcon quickAction={quickAction} /> | 503 | <GitQuickActionIcon quickAction={quickAction} /> |
| 504 | <span className="ml-0.5">{quickAction.label}</span> | 504 | <span className="sr-only sm:not-sr-only sm:ml-0.5">{quickAction.label}</span> |
| 505 | </PopoverTrigger> | 505 | </PopoverTrigger> |
| 506 | <PopoverPopup tooltipStyle side="bottom" align="start"> | 506 | <PopoverPopup tooltipStyle side="bottom" align="start"> |
| 507 | {quickActionDisabledReason} | 507 | {quickActionDisabledReason} |
| ... | @@ -515,10 +515,10 @@ export default function GitActionsControl({ | ... | @@ -515,10 +515,10 @@ export default function GitActionsControl({ |
| 515 | onClick={runQuickAction} | 515 | onClick={runQuickAction} |
| 516 | > | 516 | > |
| 517 | <GitQuickActionIcon quickAction={quickAction} /> | 517 | <GitQuickActionIcon quickAction={quickAction} /> |
| 518 | <span className="ml-0.5">{quickAction.label}</span> | 518 | <span className="sr-only sm:not-sr-only sm:ml-0.5">{quickAction.label}</span> |
| 519 | </Button> | 519 | </Button> |
| 520 | )} | 520 | )} |
| 521 | <GroupSeparator /> | 521 | <GroupSeparator className="hidden sm:block" /> |
| 522 | <Menu | 522 | <Menu |
| 523 | onOpenChange={(open) => { | 523 | onOpenChange={(open) => { |
| 524 | if (open) void invalidateGitQueries(queryClient); | 524 | if (open) void invalidateGitQueries(queryClient); |
apps/web/src/components/ProjectScriptsControl.tsx+4-4| ... | @@ -266,9 +266,9 @@ export default function ProjectScriptsControl({ | ... | @@ -266,9 +266,9 @@ export default function ProjectScriptsControl({ |
| 266 | title={`Run ${primaryScript.name}`} | 266 | title={`Run ${primaryScript.name}`} |
| 267 | > | 267 | > |
| 268 | <ScriptIcon icon={primaryScript.icon} /> | 268 | <ScriptIcon icon={primaryScript.icon} /> |
| 269 | <span className="ml-0.5">{primaryScript.name}</span> | 269 | <span className="sr-only sm:not-sr-only sm:ml-0.5">{primaryScript.name}</span> |
| 270 | </Button> | 270 | </Button> |
| 271 | <GroupSeparator /> | 271 | <GroupSeparator className="hidden sm:block" /> |
| 272 | <Menu highlightItemOnHover={false}> | 272 | <Menu highlightItemOnHover={false}> |
| 273 | <MenuTrigger | 273 | <MenuTrigger |
| 274 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} | 274 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} |
| ... | @@ -327,9 +327,9 @@ export default function ProjectScriptsControl({ | ... | @@ -327,9 +327,9 @@ export default function ProjectScriptsControl({ |
| 327 | </Menu> | 327 | </Menu> |
| 328 | </Group> | 328 | </Group> |
| 329 | ) : ( | 329 | ) : ( |
| 330 | <Button size="xs" variant="outline" onClick={openAddDialog}> | 330 | <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action"> |
| 331 | <PlusIcon className="size-3.5" /> | 331 | <PlusIcon className="size-3.5" /> |
| 332 | Add action | 332 | <span className="sr-only sm:not-sr-only sm:ml-0.5">Add action</span> |
| 333 | </Button> | 333 | </Button> |
| 334 | )} | 334 | )} |
| 335 | 335 |
apps/web/src/components/Sidebar.tsx+230-189| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { TerminalIcon } from "lucide-react"; | 1 | import { ChevronRightIcon, TerminalIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| ... | @@ -15,6 +15,22 @@ import { useNativeApi } from "../hooks/useNativeApi"; | ... | @@ -15,6 +15,22 @@ import { useNativeApi } from "../hooks/useNativeApi"; |
| 15 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 15 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; |
| 16 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 16 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 17 | import { toastManager } from "./ui/toast"; | 17 | import { toastManager } from "./ui/toast"; |
| 18 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | ||
| 19 | import { | ||
| 20 | SidebarContent, | ||
| 21 | SidebarFooter, | ||
| 22 | SidebarGroup, | ||
| 23 | SidebarHeader, | ||
| 24 | SidebarMenu, | ||
| 25 | SidebarMenuButton, | ||
| 26 | SidebarMenuItem, | ||
| 27 | SidebarMenuSub, | ||
| 28 | SidebarMenuSubButton, | ||
| 29 | SidebarMenuSubItem, | ||
| 30 | SidebarRail, | ||
| 31 | SidebarSeparator, | ||
| 32 | SidebarTrigger, | ||
| 33 | } from "./ui/sidebar"; | ||
| 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 34 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 19 | 35 | ||
| 20 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 36 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| ... | @@ -431,23 +447,22 @@ export default function Sidebar() { | ... | @@ -431,23 +447,22 @@ export default function Sidebar() { |
| 431 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); | 447 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); |
| 432 | 448 | ||
| 433 | return ( | 449 | return ( |
| 434 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> | 450 | <> |
| 435 | {/* Branding */} | 451 | <SidebarHeader |
| 436 | <div | 452 | className={`gap-3 ${isElectron ? "drag-region h-[52px] px-4 pl-[76px]" : "px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"}`} |
| 437 | className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} | ||
| 438 | > | 453 | > |
| 439 | <div className="flex min-w-0 flex-1 items-center gap-2"> | 454 | <div className="flex items-center gap-2"> |
| 440 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> | 455 | <SidebarTrigger className="shrink-0 md:hidden" /> |
| 441 | T3 <span className="font-normal text-muted-foreground">Code</span> | 456 | <div className="flex min-w-0 flex-1 items-center gap-2"> |
| 442 | </span> | 457 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> |
| 443 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> | 458 | T3 <span className="font-normal text-muted-foreground">Code</span> |
| 444 | {APP_STAGE_LABEL} | 459 | </span> |
| 445 | </span> | 460 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> |
| 461 | {APP_STAGE_LABEL} | ||
| 462 | </span> | ||
| 463 | </div> | ||
| 446 | </div> | 464 | </div> |
| 447 | </div> | ||
| 448 | 465 | ||
| 449 | {/* New thread (global) */} | ||
| 450 | <div className="px-3 pb-3"> | ||
| 451 | <button | 466 | <button |
| 452 | type="button" | 467 | type="button" |
| 453 | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" | 468 | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" |
| ... | @@ -463,182 +478,206 @@ export default function Sidebar() { | ... | @@ -463,182 +478,206 @@ export default function Sidebar() { |
| 463 | <span className="text-foreground">+</span> | 478 | <span className="text-foreground">+</span> |
| 464 | New thread | 479 | New thread |
| 465 | </button> | 480 | </button> |
| 466 | </div> | 481 | </SidebarHeader> |
| 467 | 482 | ||
| 468 | {/* Project list */} | 483 | <SidebarContent className="gap-0"> |
| 469 | <nav className="flex-1 overflow-y-auto px-2"> | 484 | <SidebarGroup className="px-2 py-2"> |
| 470 | {state.projects.map((project) => { | 485 | <SidebarMenu> |
| 471 | const threads = state.threads | 486 | {state.projects.map((project) => { |
| 472 | .filter((t) => t.projectId === project.id) | 487 | const threads = state.threads |
| 473 | .toSorted((a, b) => { | 488 | .filter((thread) => thread.projectId === project.id) |
| 474 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | 489 | .toSorted((a, b) => { |
| 475 | if (byDate !== 0) return byDate; | 490 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| 476 | return b.id.localeCompare(a.id); | 491 | if (byDate !== 0) return byDate; |
| 477 | }); | 492 | return b.id.localeCompare(a.id); |
| 478 | return ( | 493 | }); |
| 479 | <div key={project.id} className="mb-1"> | 494 | |
| 480 | {/* Project header */} | 495 | return ( |
| 496 | <Collapsible | ||
| 497 | key={project.id} | ||
| 498 | className="group/collapsible" | ||
| 499 | open={project.expanded} | ||
| 500 | onOpenChange={(open) => { | ||
| 501 | if (open === project.expanded) return; | ||
| 502 | dispatch({ type: "TOGGLE_PROJECT", projectId: project.id }); | ||
| 503 | }} | ||
| 504 | > | ||
| 505 | <SidebarMenuItem> | ||
| 506 | <CollapsibleTrigger | ||
| 507 | render={ | ||
| 508 | <SidebarMenuButton | ||
| 509 | size="sm" | ||
| 510 | className="gap-2 px-2 py-1.5 text-left hover:bg-accent" | ||
| 511 | /> | ||
| 512 | } | ||
| 513 | onContextMenu={(event) => { | ||
| 514 | event.preventDefault(); | ||
| 515 | void handleProjectContextMenu(project.id, { | ||
| 516 | x: event.clientX, | ||
| 517 | y: event.clientY, | ||
| 518 | }); | ||
| 519 | }} | ||
| 520 | > | ||
| 521 | <ChevronRightIcon | ||
| 522 | className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ | ||
| 523 | project.expanded ? "rotate-90" : "" | ||
| 524 | }`} | ||
| 525 | /> | ||
| 526 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | ||
| 527 | {project.name} | ||
| 528 | </span> | ||
| 529 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | ||
| 530 | </CollapsibleTrigger> | ||
| 531 | |||
| 532 | <CollapsibleContent> | ||
| 533 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> | ||
| 534 | {threads.map((thread) => { | ||
| 535 | const isActive = routeThreadId === thread.id; | ||
| 536 | const threadStatus = threadStatusPill( | ||
| 537 | thread, | ||
| 538 | pendingApprovalByThreadId.get(thread.id) === true, | ||
| 539 | ); | ||
| 540 | const terminalStatus = terminalStatusIndicator(thread); | ||
| 541 | |||
| 542 | return ( | ||
| 543 | <SidebarMenuSubItem key={thread.id} className="w-full"> | ||
| 544 | <SidebarMenuSubButton | ||
| 545 | render={<button type="button" />} | ||
| 546 | size="sm" | ||
| 547 | isActive={isActive} | ||
| 548 | className={`h-7 w-full translate-x-0 justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ | ||
| 549 | isActive ? "text-foreground" : "text-muted-foreground" | ||
| 550 | }`} | ||
| 551 | onClick={() => { | ||
| 552 | void navigate({ | ||
| 553 | to: "/$threadId", | ||
| 554 | params: { threadId: thread.id }, | ||
| 555 | }); | ||
| 556 | }} | ||
| 557 | onContextMenu={(event) => { | ||
| 558 | event.preventDefault(); | ||
| 559 | void handleThreadContextMenu(thread.id, { | ||
| 560 | x: event.clientX, | ||
| 561 | y: event.clientY, | ||
| 562 | }); | ||
| 563 | }} | ||
| 564 | > | ||
| 565 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> | ||
| 566 | {threadStatus && ( | ||
| 567 | <span | ||
| 568 | className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} | ||
| 569 | > | ||
| 570 | <span | ||
| 571 | className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ | ||
| 572 | threadStatus.pulse ? "animate-pulse" : "" | ||
| 573 | }`} | ||
| 574 | /> | ||
| 575 | <span className="hidden md:inline">{threadStatus.label}</span> | ||
| 576 | </span> | ||
| 577 | )} | ||
| 578 | <span className="min-w-0 flex-1 truncate text-xs"> | ||
| 579 | {thread.title} | ||
| 580 | </span> | ||
| 581 | </div> | ||
| 582 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> | ||
| 583 | {terminalStatus && ( | ||
| 584 | <span | ||
| 585 | role="img" | ||
| 586 | aria-label={terminalStatus.label} | ||
| 587 | title={terminalStatus.label} | ||
| 588 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | ||
| 589 | > | ||
| 590 | <TerminalIcon | ||
| 591 | className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} | ||
| 592 | /> | ||
| 593 | </span> | ||
| 594 | )} | ||
| 595 | <span className="text-[10px] text-muted-foreground/40"> | ||
| 596 | {formatRelativeTime(thread.createdAt)} | ||
| 597 | </span> | ||
| 598 | </div> | ||
| 599 | </SidebarMenuSubButton> | ||
| 600 | </SidebarMenuSubItem> | ||
| 601 | ); | ||
| 602 | })} | ||
| 603 | |||
| 604 | <SidebarMenuSubItem className="w-full"> | ||
| 605 | <SidebarMenuSubButton | ||
| 606 | render={<button type="button" />} | ||
| 607 | size="sm" | ||
| 608 | className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" | ||
| 609 | onClick={() => { | ||
| 610 | void handleNewThread(project.id); | ||
| 611 | }} | ||
| 612 | > | ||
| 613 | <span>+</span> | ||
| 614 | <span>New thread</span> | ||
| 615 | </SidebarMenuSubButton> | ||
| 616 | </SidebarMenuSubItem> | ||
| 617 | </SidebarMenuSub> | ||
| 618 | </CollapsibleContent> | ||
| 619 | </SidebarMenuItem> | ||
| 620 | </Collapsible> | ||
| 621 | ); | ||
| 622 | })} | ||
| 623 | </SidebarMenu> | ||
| 624 | |||
| 625 | {state.projects.length === 0 && !addingProject && ( | ||
| 626 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | ||
| 627 | No projects yet. | ||
| 628 | <br /> | ||
| 629 | Add one to get started. | ||
| 630 | </div> | ||
| 631 | )} | ||
| 632 | </SidebarGroup> | ||
| 633 | </SidebarContent> | ||
| 634 | |||
| 635 | <SidebarSeparator /> | ||
| 636 | <SidebarFooter className="gap-0 p-3"> | ||
| 637 | {addingProject ? ( | ||
| 638 | <> | ||
| 639 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 640 | Add project | ||
| 641 | </p> | ||
| 642 | <input | ||
| 643 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | ||
| 644 | placeholder="/path/to/project" | ||
| 645 | value={newCwd} | ||
| 646 | onChange={(event) => setNewCwd(event.target.value)} | ||
| 647 | onKeyDown={(event) => { | ||
| 648 | if (event.key === "Enter") handleAddProject(); | ||
| 649 | if (event.key === "Escape") setAddingProject(false); | ||
| 650 | }} | ||
| 651 | /> | ||
| 652 | {isElectron && api && ( | ||
| 481 | <button | 653 | <button |
| 482 | type="button" | 654 | type="button" |
| 483 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" | 655 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" |
| 484 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} | 656 | onClick={() => void handlePickFolder()} |
| 485 | onContextMenu={(e) => { | 657 | disabled={isPickingFolder || isAddingProject} |
| 486 | e.preventDefault(); | ||
| 487 | void handleProjectContextMenu(project.id, { | ||
| 488 | x: e.clientX, | ||
| 489 | y: e.clientY, | ||
| 490 | }); | ||
| 491 | }} | ||
| 492 | > | 658 | > |
| 493 | <span className="text-[10px] text-muted-foreground/70"> | 659 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} |
| 494 | {project.expanded ? "â–¼" : "â–¶"} | 660 | </button> |
| 495 | </span> | 661 | )} |
| 496 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | 662 | <div className="flex gap-2"> |
| 497 | {project.name} | 663 | <button |
| 498 | </span> | 664 | type="button" |
| 499 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | 665 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" |
| 666 | onClick={handleAddProject} | ||
| 667 | disabled={isAddingProject} | ||
| 668 | > | ||
| 669 | {isAddingProject ? "Adding..." : "Add"} | ||
| 670 | </button> | ||
| 671 | <button | ||
| 672 | type="button" | ||
| 673 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | ||
| 674 | onClick={() => setAddingProject(false)} | ||
| 675 | > | ||
| 676 | Cancel | ||
| 500 | </button> | 677 | </button> |
| 501 | |||
| 502 | {/* Threads */} | ||
| 503 | {project.expanded && ( | ||
| 504 | <div className="ml-2 border-l border-border/80 pl-2"> | ||
| 505 | {threads.map((thread) => { | ||
| 506 | const isActive = routeThreadId === thread.id; | ||
| 507 | const threadStatus = threadStatusPill( | ||
| 508 | thread, | ||
| 509 | pendingApprovalByThreadId.get(thread.id) === true, | ||
| 510 | ); | ||
| 511 | const terminalStatus = terminalStatusIndicator(thread); | ||
| 512 | return ( | ||
| 513 | <button | ||
| 514 | key={thread.id} | ||
| 515 | type="button" | ||
| 516 | className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ | ||
| 517 | isActive | ||
| 518 | ? "bg-accent text-foreground" | ||
| 519 | : "text-muted-foreground hover:bg-secondary" | ||
| 520 | }`} | ||
| 521 | onClick={() => { | ||
| 522 | void navigate({ | ||
| 523 | to: "/$threadId", | ||
| 524 | params: { threadId: thread.id }, | ||
| 525 | }); | ||
| 526 | }} | ||
| 527 | onContextMenu={(e) => { | ||
| 528 | e.preventDefault(); | ||
| 529 | void handleThreadContextMenu(thread.id, { | ||
| 530 | x: e.clientX, | ||
| 531 | y: e.clientY, | ||
| 532 | }); | ||
| 533 | }} | ||
| 534 | > | ||
| 535 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | ||
| 536 | {threadStatus && ( | ||
| 537 | <span | ||
| 538 | className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} | ||
| 539 | > | ||
| 540 | <span | ||
| 541 | className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ | ||
| 542 | threadStatus.pulse ? "animate-pulse" : "" | ||
| 543 | }`} | ||
| 544 | /> | ||
| 545 | <span className="hidden md:inline">{threadStatus.label}</span> | ||
| 546 | </span> | ||
| 547 | )} | ||
| 548 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | ||
| 549 | </div> | ||
| 550 | <div className="ml-2 flex shrink-0 items-center gap-1.5"> | ||
| 551 | {terminalStatus && ( | ||
| 552 | <span | ||
| 553 | role="img" | ||
| 554 | aria-label={terminalStatus.label} | ||
| 555 | title={terminalStatus.label} | ||
| 556 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | ||
| 557 | > | ||
| 558 | <TerminalIcon | ||
| 559 | className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} | ||
| 560 | /> | ||
| 561 | </span> | ||
| 562 | )} | ||
| 563 | <span className="text-[10px] text-muted-foreground/40"> | ||
| 564 | {formatRelativeTime(thread.createdAt)} | ||
| 565 | </span> | ||
| 566 | </div> | ||
| 567 | </button> | ||
| 568 | ); | ||
| 569 | })} | ||
| 570 | |||
| 571 | {/* New thread within project */} | ||
| 572 | <button | ||
| 573 | type="button" | ||
| 574 | className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 575 | onClick={() => { | ||
| 576 | void handleNewThread(project.id); | ||
| 577 | }} | ||
| 578 | > | ||
| 579 | <span>+</span> New thread | ||
| 580 | </button> | ||
| 581 | </div> | ||
| 582 | )} | ||
| 583 | </div> | 678 | </div> |
| 584 | ); | 679 | </> |
| 585 | })} | 680 | ) : ( |
| 586 | |||
| 587 | {state.projects.length === 0 && !addingProject && ( | ||
| 588 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | ||
| 589 | No projects yet. | ||
| 590 | <br /> | ||
| 591 | Add one to get started. | ||
| 592 | </div> | ||
| 593 | )} | ||
| 594 | </nav> | ||
| 595 | |||
| 596 | {/* Add project form */} | ||
| 597 | {addingProject ? ( | ||
| 598 | <div className="border-t border-border p-3"> | ||
| 599 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 600 | Add project | ||
| 601 | </p> | ||
| 602 | <input | ||
| 603 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | ||
| 604 | placeholder="/path/to/project" | ||
| 605 | value={newCwd} | ||
| 606 | onChange={(e) => setNewCwd(e.target.value)} | ||
| 607 | onKeyDown={(e) => { | ||
| 608 | if (e.key === "Enter") handleAddProject(); | ||
| 609 | if (e.key === "Escape") setAddingProject(false); | ||
| 610 | }} | ||
| 611 | /> | ||
| 612 | {isElectron && api && ( | ||
| 613 | <button | ||
| 614 | type="button" | ||
| 615 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | ||
| 616 | onClick={() => void handlePickFolder()} | ||
| 617 | disabled={isPickingFolder || isAddingProject} | ||
| 618 | > | ||
| 619 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | ||
| 620 | </button> | ||
| 621 | )} | ||
| 622 | <div className="flex gap-2"> | ||
| 623 | <button | ||
| 624 | type="button" | ||
| 625 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | ||
| 626 | onClick={handleAddProject} | ||
| 627 | disabled={isAddingProject} | ||
| 628 | > | ||
| 629 | {isAddingProject ? "Adding..." : "Add"} | ||
| 630 | </button> | ||
| 631 | <button | ||
| 632 | type="button" | ||
| 633 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | ||
| 634 | onClick={() => setAddingProject(false)} | ||
| 635 | > | ||
| 636 | Cancel | ||
| 637 | </button> | ||
| 638 | </div> | ||
| 639 | </div> | ||
| 640 | ) : ( | ||
| 641 | <div className="border-t border-border p-3"> | ||
| 642 | <button | 681 | <button |
| 643 | type="button" | 682 | type="button" |
| 644 | className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" | 683 | className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" |
| ... | @@ -646,8 +685,10 @@ export default function Sidebar() { | ... | @@ -646,8 +685,10 @@ export default function Sidebar() { |
| 646 | > | 685 | > |
| 647 | + Add project | 686 | + Add project |
| 648 | </button> | 687 | </button> |
| 649 | </div> | 688 | )} |
| 650 | )} | 689 | </SidebarFooter> |
| 651 | </aside> | 690 | |
| 691 | <SidebarRail /> | ||
| 692 | </> | ||
| 652 | ); | 693 | ); |
| 653 | } | 694 | } |
apps/web/src/components/ui/collapsible.tsx created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; | ||
| 4 | |||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | |||
| 7 | function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { | ||
| 8 | return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; | ||
| 9 | } | ||
| 10 | |||
| 11 | function CollapsibleTrigger({ | ||
| 12 | className, | ||
| 13 | ...props | ||
| 14 | }: CollapsiblePrimitive.Trigger.Props) { | ||
| 15 | return ( | ||
| 16 | <CollapsiblePrimitive.Trigger | ||
| 17 | className={cn("cursor-pointer", className)} | ||
| 18 | data-slot="collapsible-trigger" | ||
| 19 | {...props} | ||
| 20 | /> | ||
| 21 | ); | ||
| 22 | } | ||
| 23 | |||
| 24 | function CollapsiblePanel({ | ||
| 25 | className, | ||
| 26 | ...props | ||
| 27 | }: CollapsiblePrimitive.Panel.Props) { | ||
| 28 | return ( | ||
| 29 | <CollapsiblePrimitive.Panel | ||
| 30 | className={cn( | ||
| 31 | "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0", | ||
| 32 | className, | ||
| 33 | )} | ||
| 34 | data-slot="collapsible-panel" | ||
| 35 | {...props} | ||
| 36 | /> | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | export { | ||
| 41 | Collapsible, | ||
| 42 | CollapsibleTrigger, | ||
| 43 | CollapsiblePanel, | ||
| 44 | CollapsiblePanel as CollapsibleContent, | ||
| 45 | }; | ||
apps/web/src/components/ui/sidebar.tsx created+700| ... | @@ -0,0 +1,700 @@ | ||
| 1 | import { mergeProps } from "@base-ui/react/merge-props"; | ||
| 2 | import { useRender } from "@base-ui/react/use-render"; | ||
| 3 | import { cva, type VariantProps } from "class-variance-authority"; | ||
| 4 | import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react"; | ||
| 5 | import * as React from "react"; | ||
| 6 | import { cn } from "~/lib/utils"; | ||
| 7 | import { Button } from "~/components/ui/button"; | ||
| 8 | import { Input } from "~/components/ui/input"; | ||
| 9 | import { ScrollArea } from "~/components/ui/scroll-area"; | ||
| 10 | import { Separator } from "~/components/ui/separator"; | ||
| 11 | import { | ||
| 12 | Sheet, | ||
| 13 | SheetDescription, | ||
| 14 | SheetHeader, | ||
| 15 | SheetPopup, | ||
| 16 | SheetTitle, | ||
| 17 | } from "~/components/ui/sheet"; | ||
| 18 | import { Skeleton } from "~/components/ui/skeleton"; | ||
| 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | ||
| 20 | import { useMediaQuery } from "~/hooks/useMediaQuery"; | ||
| 21 | |||
| 22 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; | ||
| 23 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; | ||
| 24 | const SIDEBAR_WIDTH = "16rem"; | ||
| 25 | const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; | ||
| 26 | const SIDEBAR_WIDTH_ICON = "3rem"; | ||
| 27 | const SIDEBAR_KEYBOARD_SHORTCUT = "b"; | ||
| 28 | |||
| 29 | type SidebarContextProps = { | ||
| 30 | state: "expanded" | "collapsed"; | ||
| 31 | open: boolean; | ||
| 32 | setOpen: (open: boolean) => void; | ||
| 33 | openMobile: boolean; | ||
| 34 | setOpenMobile: (open: boolean) => void; | ||
| 35 | isMobile: boolean; | ||
| 36 | toggleSidebar: () => void; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const SidebarContext = React.createContext<SidebarContextProps | null>(null); | ||
| 40 | |||
| 41 | function useSidebar() { | ||
| 42 | const context = React.useContext(SidebarContext); | ||
| 43 | if (!context) { | ||
| 44 | throw new Error("useSidebar must be used within a SidebarProvider."); | ||
| 45 | } | ||
| 46 | |||
| 47 | return context; | ||
| 48 | } | ||
| 49 | |||
| 50 | function SidebarProvider({ | ||
| 51 | defaultOpen = true, | ||
| 52 | open: openProp, | ||
| 53 | onOpenChange: setOpenProp, | ||
| 54 | className, | ||
| 55 | style, | ||
| 56 | children, | ||
| 57 | ...props | ||
| 58 | }: React.ComponentProps<"div"> & { | ||
| 59 | defaultOpen?: boolean; | ||
| 60 | open?: boolean; | ||
| 61 | onOpenChange?: (open: boolean) => void; | ||
| 62 | }) { | ||
| 63 | const isMobile = useMediaQuery("(max-width: 767px)"); | ||
| 64 | const [openMobile, setOpenMobile] = React.useState(false); | ||
| 65 | |||
| 66 | // This is the internal state of the sidebar. | ||
| 67 | // We use openProp and setOpenProp for control from outside the component. | ||
| 68 | const [_open, _setOpen] = React.useState(defaultOpen); | ||
| 69 | const open = openProp ?? _open; | ||
| 70 | const setOpen = React.useCallback( | ||
| 71 | async (value: boolean | ((value: boolean) => boolean)) => { | ||
| 72 | const openState = typeof value === "function" ? value(open) : value; | ||
| 73 | if (setOpenProp) { | ||
| 74 | setOpenProp(openState); | ||
| 75 | } else { | ||
| 76 | _setOpen(openState); | ||
| 77 | } | ||
| 78 | |||
| 79 | // This sets the cookie to keep the sidebar state. | ||
| 80 | await cookieStore.set({ | ||
| 81 | expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, | ||
| 82 | name: SIDEBAR_COOKIE_NAME, | ||
| 83 | path: "/", | ||
| 84 | value: String(openState), | ||
| 85 | }); | ||
| 86 | }, | ||
| 87 | [setOpenProp, open], | ||
| 88 | ); | ||
| 89 | |||
| 90 | // Helper to toggle the sidebar. | ||
| 91 | const toggleSidebar = React.useCallback(() => { | ||
| 92 | return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); | ||
| 93 | }, [isMobile, setOpen]); | ||
| 94 | |||
| 95 | // Adds a keyboard shortcut to toggle the sidebar. | ||
| 96 | React.useEffect(() => { | ||
| 97 | const handleKeyDown = (event: KeyboardEvent) => { | ||
| 98 | if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { | ||
| 99 | event.preventDefault(); | ||
| 100 | toggleSidebar(); | ||
| 101 | } | ||
| 102 | }; | ||
| 103 | |||
| 104 | window.addEventListener("keydown", handleKeyDown); | ||
| 105 | return () => window.removeEventListener("keydown", handleKeyDown); | ||
| 106 | }, [toggleSidebar]); | ||
| 107 | |||
| 108 | // We add a state so that we can do data-state="expanded" or "collapsed". | ||
| 109 | // This makes it easier to style the sidebar with Tailwind classes. | ||
| 110 | const state = open ? "expanded" : "collapsed"; | ||
| 111 | |||
| 112 | const contextValue = React.useMemo<SidebarContextProps>( | ||
| 113 | () => ({ | ||
| 114 | isMobile, | ||
| 115 | open, | ||
| 116 | openMobile, | ||
| 117 | setOpen, | ||
| 118 | setOpenMobile, | ||
| 119 | state, | ||
| 120 | toggleSidebar, | ||
| 121 | }), | ||
| 122 | [state, open, setOpen, isMobile, openMobile, toggleSidebar], | ||
| 123 | ); | ||
| 124 | |||
| 125 | return ( | ||
| 126 | <SidebarContext.Provider value={contextValue}> | ||
| 127 | <div | ||
| 128 | className={cn( | ||
| 129 | "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", | ||
| 130 | className, | ||
| 131 | )} | ||
| 132 | data-slot="sidebar-wrapper" | ||
| 133 | style={ | ||
| 134 | { | ||
| 135 | "--sidebar-width": SIDEBAR_WIDTH, | ||
| 136 | "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, | ||
| 137 | ...style, | ||
| 138 | } as React.CSSProperties | ||
| 139 | } | ||
| 140 | {...props} | ||
| 141 | > | ||
| 142 | {children} | ||
| 143 | </div> | ||
| 144 | </SidebarContext.Provider> | ||
| 145 | ); | ||
| 146 | } | ||
| 147 | |||
| 148 | function Sidebar({ | ||
| 149 | side = "left", | ||
| 150 | variant = "sidebar", | ||
| 151 | collapsible = "offcanvas", | ||
| 152 | className, | ||
| 153 | children, | ||
| 154 | ...props | ||
| 155 | }: React.ComponentProps<"div"> & { | ||
| 156 | side?: "left" | "right"; | ||
| 157 | variant?: "sidebar" | "floating" | "inset"; | ||
| 158 | collapsible?: "offcanvas" | "icon" | "none"; | ||
| 159 | }) { | ||
| 160 | const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | ||
| 161 | |||
| 162 | if (collapsible === "none") { | ||
| 163 | return ( | ||
| 164 | <div | ||
| 165 | className={cn( | ||
| 166 | "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", | ||
| 167 | className, | ||
| 168 | )} | ||
| 169 | data-slot="sidebar" | ||
| 170 | {...props} | ||
| 171 | > | ||
| 172 | {children} | ||
| 173 | </div> | ||
| 174 | ); | ||
| 175 | } | ||
| 176 | |||
| 177 | if (isMobile) { | ||
| 178 | return ( | ||
| 179 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> | ||
| 180 | <SheetPopup | ||
| 181 | className={cn( | ||
| 182 | "w-(--sidebar-width) max-w-none bg-sidebar p-0 text-sidebar-foreground", | ||
| 183 | className, | ||
| 184 | )} | ||
| 185 | data-mobile="true" | ||
| 186 | data-sidebar="sidebar" | ||
| 187 | data-slot="sidebar" | ||
| 188 | showCloseButton={false} | ||
| 189 | side={side} | ||
| 190 | style={ | ||
| 191 | { | ||
| 192 | "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | ||
| 193 | } as React.CSSProperties | ||
| 194 | } | ||
| 195 | > | ||
| 196 | <SheetHeader className="sr-only"> | ||
| 197 | <SheetTitle>Sidebar</SheetTitle> | ||
| 198 | <SheetDescription>Displays the mobile sidebar.</SheetDescription> | ||
| 199 | </SheetHeader> | ||
| 200 | <div className="flex h-full w-full flex-col">{children}</div> | ||
| 201 | </SheetPopup> | ||
| 202 | </Sheet> | ||
| 203 | ); | ||
| 204 | } | ||
| 205 | |||
| 206 | return ( | ||
| 207 | <div | ||
| 208 | className="group peer hidden text-sidebar-foreground md:block" | ||
| 209 | data-collapsible={state === "collapsed" ? collapsible : ""} | ||
| 210 | data-side={side} | ||
| 211 | data-slot="sidebar" | ||
| 212 | data-state={state} | ||
| 213 | data-variant={variant} | ||
| 214 | > | ||
| 215 | {/* This is what handles the sidebar gap on desktop */} | ||
| 216 | <div | ||
| 217 | className={cn( | ||
| 218 | "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", | ||
| 219 | "group-data-[collapsible=offcanvas]:w-0", | ||
| 220 | "group-data-[side=right]:rotate-180", | ||
| 221 | variant === "floating" || variant === "inset" | ||
| 222 | ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" | ||
| 223 | : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", | ||
| 224 | )} | ||
| 225 | data-slot="sidebar-gap" | ||
| 226 | /> | ||
| 227 | <div | ||
| 228 | className={cn( | ||
| 229 | "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", | ||
| 230 | side === "left" | ||
| 231 | ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" | ||
| 232 | : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", | ||
| 233 | // Adjust the padding for floating and inset variants. | ||
| 234 | variant === "floating" || variant === "inset" | ||
| 235 | ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" | ||
| 236 | : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", | ||
| 237 | className, | ||
| 238 | )} | ||
| 239 | data-slot="sidebar-container" | ||
| 240 | {...props} | ||
| 241 | > | ||
| 242 | <div | ||
| 243 | 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" | ||
| 244 | data-sidebar="sidebar" | ||
| 245 | data-slot="sidebar-inner" | ||
| 246 | > | ||
| 247 | {children} | ||
| 248 | </div> | ||
| 249 | </div> | ||
| 250 | </div> | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { | ||
| 255 | const { toggleSidebar, openMobile } = useSidebar(); | ||
| 256 | |||
| 257 | return ( | ||
| 258 | <Button | ||
| 259 | className={cn("size-7", className)} | ||
| 260 | data-sidebar="trigger" | ||
| 261 | data-slot="sidebar-trigger" | ||
| 262 | onClick={(event) => { | ||
| 263 | onClick?.(event); | ||
| 264 | toggleSidebar(); | ||
| 265 | }} | ||
| 266 | size="icon" | ||
| 267 | variant="ghost" | ||
| 268 | {...props} | ||
| 269 | > | ||
| 270 | {openMobile ? <PanelLeftCloseIcon /> : <PanelLeftIcon />} | ||
| 271 | <span className="sr-only">Toggle Sidebar</span> | ||
| 272 | </Button> | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | |||
| 276 | function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { | ||
| 277 | const { toggleSidebar } = useSidebar(); | ||
| 278 | |||
| 279 | return ( | ||
| 280 | <button | ||
| 281 | aria-label="Toggle Sidebar" | ||
| 282 | className={cn( | ||
| 283 | "-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", | ||
| 284 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | ||
| 285 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | ||
| 286 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | ||
| 287 | "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | ||
| 288 | "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | ||
| 289 | className, | ||
| 290 | )} | ||
| 291 | data-sidebar="rail" | ||
| 292 | data-slot="sidebar-rail" | ||
| 293 | onClick={toggleSidebar} | ||
| 294 | tabIndex={-1} | ||
| 295 | title="Toggle Sidebar" | ||
| 296 | type="button" | ||
| 297 | {...props} | ||
| 298 | /> | ||
| 299 | ); | ||
| 300 | } | ||
| 301 | |||
| 302 | function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { | ||
| 303 | return ( | ||
| 304 | <main | ||
| 305 | className={cn( | ||
| 306 | "relative flex w-full flex-1 flex-col bg-background", | ||
| 307 | "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", | ||
| 308 | className, | ||
| 309 | )} | ||
| 310 | data-slot="sidebar-inset" | ||
| 311 | {...props} | ||
| 312 | /> | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | |||
| 316 | function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { | ||
| 317 | return ( | ||
| 318 | <Input | ||
| 319 | className={cn("h-8 w-full bg-background shadow-none", className)} | ||
| 320 | data-sidebar="input" | ||
| 321 | data-slot="sidebar-input" | ||
| 322 | {...props} | ||
| 323 | /> | ||
| 324 | ); | ||
| 325 | } | ||
| 326 | |||
| 327 | function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { | ||
| 328 | return ( | ||
| 329 | <div | ||
| 330 | className={cn("flex flex-col gap-2 p-2", className)} | ||
| 331 | data-sidebar="header" | ||
| 332 | data-slot="sidebar-header" | ||
| 333 | {...props} | ||
| 334 | /> | ||
| 335 | ); | ||
| 336 | } | ||
| 337 | |||
| 338 | function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { | ||
| 339 | return ( | ||
| 340 | <div | ||
| 341 | className={cn("flex flex-col gap-2 p-2", className)} | ||
| 342 | data-sidebar="footer" | ||
| 343 | data-slot="sidebar-footer" | ||
| 344 | {...props} | ||
| 345 | /> | ||
| 346 | ); | ||
| 347 | } | ||
| 348 | |||
| 349 | function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { | ||
| 350 | return ( | ||
| 351 | <Separator | ||
| 352 | className={cn("mx-2 w-auto bg-sidebar-border", className)} | ||
| 353 | data-sidebar="separator" | ||
| 354 | data-slot="sidebar-separator" | ||
| 355 | {...props} | ||
| 356 | /> | ||
| 357 | ); | ||
| 358 | } | ||
| 359 | |||
| 360 | function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { | ||
| 361 | return ( | ||
| 362 | <ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade> | ||
| 363 | <div | ||
| 364 | className={cn( | ||
| 365 | "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", | ||
| 366 | className, | ||
| 367 | )} | ||
| 368 | data-sidebar="content" | ||
| 369 | data-slot="sidebar-content" | ||
| 370 | {...props} | ||
| 371 | /> | ||
| 372 | </ScrollArea> | ||
| 373 | ); | ||
| 374 | } | ||
| 375 | |||
| 376 | function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { | ||
| 377 | return ( | ||
| 378 | <div | ||
| 379 | className={cn("relative flex w-full min-w-0 flex-col p-2", className)} | ||
| 380 | data-sidebar="group" | ||
| 381 | data-slot="sidebar-group" | ||
| 382 | {...props} | ||
| 383 | /> | ||
| 384 | ); | ||
| 385 | } | ||
| 386 | |||
| 387 | function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) { | ||
| 388 | const defaultProps = { | ||
| 389 | className: cn( | ||
| 390 | "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", | ||
| 391 | "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", | ||
| 392 | className, | ||
| 393 | ), | ||
| 394 | "data-sidebar": "group-label", | ||
| 395 | "data-slot": "sidebar-group-label", | ||
| 396 | }; | ||
| 397 | |||
| 398 | return useRender({ | ||
| 399 | defaultTagName: "div", | ||
| 400 | props: mergeProps(defaultProps, props), | ||
| 401 | render, | ||
| 402 | }); | ||
| 403 | } | ||
| 404 | |||
| 405 | function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) { | ||
| 406 | const defaultProps = { | ||
| 407 | className: cn( | ||
| 408 | "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", | ||
| 409 | // Increases the hit area of the button on mobile. | ||
| 410 | "after:-inset-2 after:absolute md:after:hidden", | ||
| 411 | "group-data-[collapsible=icon]:hidden", | ||
| 412 | className, | ||
| 413 | ), | ||
| 414 | "data-sidebar": "group-action", | ||
| 415 | "data-slot": "sidebar-group-action", | ||
| 416 | }; | ||
| 417 | |||
| 418 | return useRender({ | ||
| 419 | defaultTagName: "button", | ||
| 420 | props: mergeProps(defaultProps, props), | ||
| 421 | render, | ||
| 422 | }); | ||
| 423 | } | ||
| 424 | |||
| 425 | function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { | ||
| 426 | return ( | ||
| 427 | <div | ||
| 428 | className={cn("w-full text-sm", className)} | ||
| 429 | data-sidebar="group-content" | ||
| 430 | data-slot="sidebar-group-content" | ||
| 431 | {...props} | ||
| 432 | /> | ||
| 433 | ); | ||
| 434 | } | ||
| 435 | |||
| 436 | function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { | ||
| 437 | return ( | ||
| 438 | <ul | ||
| 439 | className={cn("flex w-full min-w-0 flex-col gap-1", className)} | ||
| 440 | data-sidebar="menu" | ||
| 441 | data-slot="sidebar-menu" | ||
| 442 | {...props} | ||
| 443 | /> | ||
| 444 | ); | ||
| 445 | } | ||
| 446 | |||
| 447 | function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { | ||
| 448 | return ( | ||
| 449 | <li | ||
| 450 | className={cn("group/menu-item relative", className)} | ||
| 451 | data-sidebar="menu-item" | ||
| 452 | data-slot="sidebar-menu-item" | ||
| 453 | {...props} | ||
| 454 | /> | ||
| 455 | ); | ||
| 456 | } | ||
| 457 | |||
| 458 | const sidebarMenuButtonVariants = cva( | ||
| 459 | "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", | ||
| 460 | { | ||
| 461 | defaultVariants: { | ||
| 462 | size: "default", | ||
| 463 | variant: "default", | ||
| 464 | }, | ||
| 465 | variants: { | ||
| 466 | size: { | ||
| 467 | default: "h-8 text-sm", | ||
| 468 | lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", | ||
| 469 | sm: "h-7 text-xs", | ||
| 470 | }, | ||
| 471 | variant: { | ||
| 472 | default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", | ||
| 473 | outline: | ||
| 474 | "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))]", | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | }, | ||
| 478 | ); | ||
| 479 | |||
| 480 | function SidebarMenuButton({ | ||
| 481 | isActive = false, | ||
| 482 | variant = "default", | ||
| 483 | size = "default", | ||
| 484 | tooltip, | ||
| 485 | className, | ||
| 486 | render, | ||
| 487 | ...props | ||
| 488 | }: useRender.ComponentProps<"button"> & { | ||
| 489 | isActive?: boolean; | ||
| 490 | tooltip?: string | React.ComponentProps<typeof TooltipPopup>; | ||
| 491 | } & VariantProps<typeof sidebarMenuButtonVariants>) { | ||
| 492 | const { isMobile, state } = useSidebar(); | ||
| 493 | |||
| 494 | const defaultProps = { | ||
| 495 | className: cn(sidebarMenuButtonVariants({ size, variant }), className), | ||
| 496 | "data-active": isActive, | ||
| 497 | "data-sidebar": "menu-button", | ||
| 498 | "data-size": size, | ||
| 499 | "data-slot": "sidebar-menu-button", | ||
| 500 | }; | ||
| 501 | |||
| 502 | const buttonProps = mergeProps<"button">(defaultProps, props); | ||
| 503 | |||
| 504 | const buttonElement = useRender({ | ||
| 505 | defaultTagName: "button", | ||
| 506 | props: buttonProps, | ||
| 507 | render, | ||
| 508 | }); | ||
| 509 | |||
| 510 | if (!tooltip) { | ||
| 511 | return buttonElement; | ||
| 512 | } | ||
| 513 | |||
| 514 | if (typeof tooltip === "string") { | ||
| 515 | tooltip = { | ||
| 516 | children: tooltip, | ||
| 517 | }; | ||
| 518 | } | ||
| 519 | |||
| 520 | return ( | ||
| 521 | <Tooltip> | ||
| 522 | <TooltipTrigger render={buttonElement as React.ReactElement<Record<string, unknown>>} /> | ||
| 523 | <TooltipPopup | ||
| 524 | align="center" | ||
| 525 | hidden={state !== "collapsed" || isMobile} | ||
| 526 | side="right" | ||
| 527 | {...tooltip} | ||
| 528 | /> | ||
| 529 | </Tooltip> | ||
| 530 | ); | ||
| 531 | } | ||
| 532 | |||
| 533 | function SidebarMenuAction({ | ||
| 534 | className, | ||
| 535 | showOnHover = false, | ||
| 536 | render, | ||
| 537 | ...props | ||
| 538 | }: useRender.ComponentProps<"button"> & { | ||
| 539 | showOnHover?: boolean; | ||
| 540 | }) { | ||
| 541 | const defaultProps = { | ||
| 542 | className: cn( | ||
| 543 | "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", | ||
| 544 | // Increases the hit area of the button on mobile. | ||
| 545 | "after:-inset-2 after:absolute md:after:hidden", | ||
| 546 | "peer-data-[size=sm]/menu-button:top-1", | ||
| 547 | "peer-data-[size=default]/menu-button:top-1.5", | ||
| 548 | "peer-data-[size=lg]/menu-button:top-2.5", | ||
| 549 | "group-data-[collapsible=icon]:hidden", | ||
| 550 | showOnHover && | ||
| 551 | "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", | ||
| 552 | className, | ||
| 553 | ), | ||
| 554 | "data-sidebar": "menu-action", | ||
| 555 | "data-slot": "sidebar-menu-action", | ||
| 556 | }; | ||
| 557 | |||
| 558 | return useRender({ | ||
| 559 | defaultTagName: "button", | ||
| 560 | props: mergeProps<"button">(defaultProps, props), | ||
| 561 | render, | ||
| 562 | }); | ||
| 563 | } | ||
| 564 | |||
| 565 | function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { | ||
| 566 | return ( | ||
| 567 | <div | ||
| 568 | className={cn( | ||
| 569 | "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", | ||
| 570 | "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", | ||
| 571 | "peer-data-[size=sm]/menu-button:top-1", | ||
| 572 | "peer-data-[size=default]/menu-button:top-1.5", | ||
| 573 | "peer-data-[size=lg]/menu-button:top-2.5", | ||
| 574 | "group-data-[collapsible=icon]:hidden", | ||
| 575 | className, | ||
| 576 | )} | ||
| 577 | data-sidebar="menu-badge" | ||
| 578 | data-slot="sidebar-menu-badge" | ||
| 579 | {...props} | ||
| 580 | /> | ||
| 581 | ); | ||
| 582 | } | ||
| 583 | |||
| 584 | function SidebarMenuSkeleton({ | ||
| 585 | className, | ||
| 586 | showIcon = false, | ||
| 587 | ...props | ||
| 588 | }: React.ComponentProps<"div"> & { | ||
| 589 | showIcon?: boolean; | ||
| 590 | }) { | ||
| 591 | // Random width between 50 to 90%. | ||
| 592 | const width = React.useMemo(() => { | ||
| 593 | return `${Math.floor(Math.random() * 40) + 50}%`; | ||
| 594 | }, []); | ||
| 595 | |||
| 596 | return ( | ||
| 597 | <div | ||
| 598 | className={cn("flex h-8 items-center gap-2 rounded-lg px-2", className)} | ||
| 599 | data-sidebar="menu-skeleton" | ||
| 600 | data-slot="sidebar-menu-skeleton" | ||
| 601 | {...props} | ||
| 602 | > | ||
| 603 | {showIcon && <Skeleton className="size-4 rounded-lg" data-sidebar="menu-skeleton-icon" />} | ||
| 604 | <Skeleton | ||
| 605 | className="h-4 max-w-(--skeleton-width) flex-1" | ||
| 606 | data-sidebar="menu-skeleton-text" | ||
| 607 | style={ | ||
| 608 | { | ||
| 609 | "--skeleton-width": width, | ||
| 610 | } as React.CSSProperties | ||
| 611 | } | ||
| 612 | /> | ||
| 613 | </div> | ||
| 614 | ); | ||
| 615 | } | ||
| 616 | |||
| 617 | function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { | ||
| 618 | return ( | ||
| 619 | <ul | ||
| 620 | className={cn( | ||
| 621 | "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", | ||
| 622 | "group-data-[collapsible=icon]:hidden", | ||
| 623 | className, | ||
| 624 | )} | ||
| 625 | data-sidebar="menu-sub" | ||
| 626 | data-slot="sidebar-menu-sub" | ||
| 627 | {...props} | ||
| 628 | /> | ||
| 629 | ); | ||
| 630 | } | ||
| 631 | |||
| 632 | function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { | ||
| 633 | return ( | ||
| 634 | <li | ||
| 635 | className={cn("group/menu-sub-item relative", className)} | ||
| 636 | data-sidebar="menu-sub-item" | ||
| 637 | data-slot="sidebar-menu-sub-item" | ||
| 638 | {...props} | ||
| 639 | /> | ||
| 640 | ); | ||
| 641 | } | ||
| 642 | |||
| 643 | function SidebarMenuSubButton({ | ||
| 644 | size = "md", | ||
| 645 | isActive = false, | ||
| 646 | className, | ||
| 647 | render, | ||
| 648 | ...props | ||
| 649 | }: useRender.ComponentProps<"a"> & { | ||
| 650 | size?: "sm" | "md"; | ||
| 651 | isActive?: boolean; | ||
| 652 | }) { | ||
| 653 | const defaultProps = { | ||
| 654 | className: cn( | ||
| 655 | "-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", | ||
| 656 | "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", | ||
| 657 | size === "sm" && "text-xs", | ||
| 658 | size === "md" && "text-sm", | ||
| 659 | "group-data-[collapsible=icon]:hidden", | ||
| 660 | className, | ||
| 661 | ), | ||
| 662 | "data-active": isActive, | ||
| 663 | "data-sidebar": "menu-sub-button", | ||
| 664 | "data-size": size, | ||
| 665 | "data-slot": "sidebar-menu-sub-button", | ||
| 666 | }; | ||
| 667 | |||
| 668 | return useRender({ | ||
| 669 | defaultTagName: "a", | ||
| 670 | props: mergeProps<"a">(defaultProps, props), | ||
| 671 | render, | ||
| 672 | }); | ||
| 673 | } | ||
| 674 | |||
| 675 | export { | ||
| 676 | Sidebar, | ||
| 677 | SidebarContent, | ||
| 678 | SidebarFooter, | ||
| 679 | SidebarGroup, | ||
| 680 | SidebarGroupAction, | ||
| 681 | SidebarGroupContent, | ||
| 682 | SidebarGroupLabel, | ||
| 683 | SidebarHeader, | ||
| 684 | SidebarInput, | ||
| 685 | SidebarInset, | ||
| 686 | SidebarMenu, | ||
| 687 | SidebarMenuAction, | ||
| 688 | SidebarMenuBadge, | ||
| 689 | SidebarMenuButton, | ||
| 690 | SidebarMenuItem, | ||
| 691 | SidebarMenuSkeleton, | ||
| 692 | SidebarMenuSub, | ||
| 693 | SidebarMenuSubButton, | ||
| 694 | SidebarMenuSubItem, | ||
| 695 | SidebarProvider, | ||
| 696 | SidebarRail, | ||
| 697 | SidebarSeparator, | ||
| 698 | SidebarTrigger, | ||
| 699 | useSidebar, | ||
| 700 | }; | ||
apps/web/src/components/ui/tooltip.tsx created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; | ||
| 2 | |||
| 3 | import { cn } from "~/lib/utils"; | ||
| 4 | |||
| 5 | const TooltipCreateHandle = TooltipPrimitive.createHandle; | ||
| 6 | |||
| 7 | const TooltipProvider = TooltipPrimitive.Provider; | ||
| 8 | |||
| 9 | const Tooltip = TooltipPrimitive.Root; | ||
| 10 | |||
| 11 | function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { | ||
| 12 | return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; | ||
| 13 | } | ||
| 14 | |||
| 15 | function TooltipPopup({ | ||
| 16 | className, | ||
| 17 | align = "center", | ||
| 18 | sideOffset = 4, | ||
| 19 | side = "top", | ||
| 20 | anchor, | ||
| 21 | children, | ||
| 22 | ...props | ||
| 23 | }: TooltipPrimitive.Popup.Props & { | ||
| 24 | align?: TooltipPrimitive.Positioner.Props["align"]; | ||
| 25 | side?: TooltipPrimitive.Positioner.Props["side"]; | ||
| 26 | sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; | ||
| 27 | anchor?: TooltipPrimitive.Positioner.Props["anchor"]; | ||
| 28 | }) { | ||
| 29 | return ( | ||
| 30 | <TooltipPrimitive.Portal> | ||
| 31 | <TooltipPrimitive.Positioner | ||
| 32 | align={align} | ||
| 33 | anchor={anchor} | ||
| 34 | className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none" | ||
| 35 | data-slot="tooltip-positioner" | ||
| 36 | side={side} | ||
| 37 | sideOffset={sideOffset} | ||
| 38 | > | ||
| 39 | <TooltipPrimitive.Popup | ||
| 40 | className={cn( | ||
| 41 | "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%)]", | ||
| 42 | className, | ||
| 43 | )} | ||
| 44 | data-slot="tooltip-popup" | ||
| 45 | {...props} | ||
| 46 | > | ||
| 47 | <TooltipPrimitive.Viewport | ||
| 48 | 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" | ||
| 49 | data-slot="tooltip-viewport" | ||
| 50 | > | ||
| 51 | {children} | ||
| 52 | </TooltipPrimitive.Viewport> | ||
| 53 | </TooltipPrimitive.Popup> | ||
| 54 | </TooltipPrimitive.Positioner> | ||
| 55 | </TooltipPrimitive.Portal> | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; | ||
apps/web/src/index.css+15| ... | @@ -133,6 +133,18 @@ body { | ... | @@ -133,6 +133,18 @@ body { |
| 133 | min-height: 100vh; | 133 | min-height: 100vh; |
| 134 | } | 134 | } |
| 135 | 135 | ||
| 136 | html, | ||
| 137 | body, | ||
| 138 | #root { | ||
| 139 | height: 100%; | ||
| 140 | width: 100%; | ||
| 141 | max-width: 100%; | ||
| 142 | overflow-y: hidden; | ||
| 143 | overflow-x: hidden; | ||
| 144 | overflow-x: clip; | ||
| 145 | overscroll-behavior-y: none; | ||
| 146 | } | ||
| 147 | |||
| 136 | body::after { | 148 | body::after { |
| 137 | content: ""; | 149 | content: ""; |
| 138 | position: fixed; | 150 | position: fixed; |
| ... | @@ -229,7 +241,9 @@ label:has(> select#reasoning-effort) select { | ... | @@ -229,7 +241,9 @@ label:has(> select#reasoning-effort) select { |
| 229 | 241 | ||
| 230 | /* Chat markdown rendering */ | 242 | /* Chat markdown rendering */ |
| 231 | .chat-markdown { | 243 | .chat-markdown { |
| 244 | min-width: 0; | ||
| 232 | overflow-wrap: anywhere; | 245 | overflow-wrap: anywhere; |
| 246 | word-break: break-word; | ||
| 233 | } | 247 | } |
| 234 | 248 | ||
| 235 | .chat-markdown > :first-child { | 249 | .chat-markdown > :first-child { |
| ... | @@ -283,6 +297,7 @@ label:has(> select#reasoning-effort) select { | ... | @@ -283,6 +297,7 @@ label:has(> select#reasoning-effort) select { |
| 283 | } | 297 | } |
| 284 | 298 | ||
| 285 | .chat-markdown pre { | 299 | .chat-markdown pre { |
| 300 | max-width: 100%; | ||
| 286 | overflow-x: auto; | 301 | overflow-x: auto; |
| 287 | border: 1px solid var(--border); | 302 | border: 1px solid var(--border); |
| 288 | border-radius: 0.75rem; | 303 | border-radius: 0.75rem; |
apps/web/src/routes/_chat.index.tsx+10| ... | @@ -1,10 +1,20 @@ | ... | @@ -1,10 +1,20 @@ |
| 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 { SidebarTrigger } from "../components/ui/sidebar"; | ||
| 4 | 5 | ||
| 5 | function ChatIndexRouteView() { | 6 | function ChatIndexRouteView() { |
| 6 | return ( | 7 | return ( |
| 7 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | 8 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> |
| 9 | {!isElectron && ( | ||
| 10 | <header className="border-b border-border px-3 py-2 md:hidden"> | ||
| 11 | <div className="flex items-center gap-2"> | ||
| 12 | <SidebarTrigger className="size-7 shrink-0" /> | ||
| 13 | <span className="text-sm font-medium text-foreground">Threads</span> | ||
| 14 | </div> | ||
| 15 | </header> | ||
| 16 | )} | ||
| 17 | |||
| 8 | {isElectron && ( | 18 | {isElectron && ( |
| 9 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | 19 | <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> | 20 | <span className="text-xs text-muted-foreground/50">No active thread</span> |
apps/web/src/routes/_chat.tsx+16-8| ... | @@ -2,7 +2,8 @@ import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; | ... | @@ -2,7 +2,8 @@ import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 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 { SidebarInset, Sidebar, 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() { | ... | @@ -24,16 +25,23 @@ function ChatRouteLayout() { |
| 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 /> | ||
| 35 | </Sidebar> | ||
| 36 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none 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 |